A coding agent can emit a plausible backend in one sitting and still leave a solo founder with nothing that runs on one machine. Queues, workers, and extra datastores show up as “production hygiene.” They are unpaid platform work. A single-process gate blocks that topology before the first public URL, so the weekend build can sit on a free server with a bill of zero.
This article is a worked example, not a production postmortem. The gate is a YAML pin, a Python checker, and a stdlib smoke test. It does not measure latency or conversion. It answers one operational question: can this MVP serve HTTP and persist rows inside one OS process.
Why agents invent a platform team
Agentic coding tools fill unspecified gaps. A prompt that says “ship a waitlist and an admin view” does not name a process model. The model then reaches for Redis, a mail worker, object storage, and a compose file with four services. Each piece is common in large apps. None of it is required for a first cohort.
Indie constraints are different. There is no on-call rotation. There is no budget for managed queues. A free server, when one is available, usually means one public port and one long-running process. Extra services fail that shape even when the Python or Node tests pass.
Cheap generation makes the failure mode worse. Files appear faster than the founder can operate them. Technical debt used to wait for headcount. It now arrives on Saturday afternoon as a second Dockerfile.
The rule
The weekend MVP is one process, one listen port, and one file-backed store. Background work runs in-request or not at all. Mail, webhooks, and image pipelines wait until a paying path exists. If a feature needs a second process, it is out of scope for the slice.
That rule is stricter than a cloud-vendor denylist. Redis on localhost still splits the runtime. A local worker still needs a supervisor. The gate cares about process topology, not logo choice.
Artifact: process.lock.yml
Keep the pin next to the repo root. The agent reads it on every turn. The checker fails CI when the tree drifts.
# process.lock.yml — pin for a solo, zero-bill slice
version: 1
process:
count: 1
listen_port: 8080
store: sqlite
store_path: ./data/app.db
allow_imports:
- sqlite3
- http.server
- json
- pathlib
- urllib.parse
- html
- os
- time
- secrets
forbid_tokens:
- celery
- dramatiq
- rq.Queue
- redis
- kafka
- pika
- boto3
- aiobotocore
- docker-compose
- kubernetes
- multiprocessing.Pool
- background_tasks
- APScheduler
compose:
allowed_services: []
notes:
- No worker containers.
- No second datastore.
- Email is a row in SQLite, not a mail daemon.
The allow-list is intentionally small. A later slice can add a template engine. It cannot add a broker. The empty allowed_services list means a compose file is a hard fail, not a warning.
Artifact: the checker
The script below is a complete, runnable gate. It walks *.py, *.yml, *.yaml, and *.toml under the repo, skipping virtualenvs. It is a heuristic. It will miss a clever import alias. It will catch the defaults agents actually emit.
#!/usr/bin/env python3
"""Fail the build when the tree outgrows one process."""
from __future__ import annotations
import sys
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
PIN = ROOT / "process.lock.yml"
SKIP_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__"}
SCAN_SUFFIX = {".py", ".yml", ".yaml", ".toml", ".md"}
def load_pin() -> dict:
data = yaml.safe_load(PIN.read_text(encoding="utf-8"))
if data.get("process", {}).get("count") != 1:
raise SystemExit("process.count must be 1 for this slice")
return data
def iter_files():
for path in ROOT.rglob("*"):
if not path.is_file():
continue
if any(part in SKIP_DIRS for part in path.parts):
continue
if path.suffix.lower() not in SCAN_SUFFIX:
continue
if path.name == "process.lock.yml":
continue
yield path
def main() -> int:
pin = load_pin()
forbidden = tuple(pin["forbid_tokens"])
hits = []
for path in iter_files():
text = path.read_text(encoding="utf-8", errors="ignore")
for token in forbidden:
if token in text:
hits.append(f"{path.relative_to(ROOT)}: {token}")
compose = ROOT / "docker-compose.yml"
if compose.exists() and not pin.get("compose", {}).get("allowed_services"):
hits.append("docker-compose.yml: extra services are out of scope")
if hits:
print("process gate failed:")
for row in hits:
print(f" - {row}")
return 1
print("process gate ok: one process, sqlite, no broker")
return 0
if __name__ == "__main__":
sys.exit(main())
Install PyYAML in the dev environment only. The app runtime does not need it. Run the gate before any deploy command.
python3 -m pip install pyyaml
python3 tools/check_process_gate.py
Artifact: a backend that can pass
The following server uses the standard library and SQLite. It is enough for an email capture page. It is not a framework showcase. Label it as a slice, not a platform.
#!/usr/bin/env python3
"""One-process waitlist. Unexecuted until you run it locally."""
from __future__ import annotations
import json
import sqlite3
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs
DB = Path("data/app.db")
PORT = 8080
FORM = b"""<!doctype html><meta charset=utf-8>
<title>Waitlist</title>
<form method=post action=/join>
<input name=email type=email required>
<button>Join</button>
</form>"""
def init_db() -> None:
DB.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(DB) as con:
con.execute(
"CREATE TABLE IF NOT EXISTS waitlist("
"id INTEGER PRIMARY KEY, email TEXT UNIQUE, created_at TEXT)"
)
class Handler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.path != "/":
self.send_error(404)
return
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.end_headers()
self.wfile.write(FORM)
def do_POST(self) -> None:
if self.path != "/join":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0"))
body = self.rfile.read(length).decode("utf-8")
email = (parse_qs(body).get("email") or [""])[0].strip().lower()
if "@" not in email:
self.send_error(400, "bad email")
return
with sqlite3.connect(DB) as con:
con.execute(
"INSERT OR IGNORE INTO waitlist(email, created_at) "
"VALUES(?, datetime('now'))",
(email,),
)
self.send_response(303)
self.send_header("Location", "/")
self.end_headers()
def log_message(self, fmt: str, *args) -> None:
sys_stderr = __import__("sys").stderr
sys_stderr.write("%s - %s\n" % (self.address_string(), fmt % args))
def main() -> None:
init_db()
httpd = ThreadingHTTPServer(("0.0.0.0", PORT), Handler)
print(json.dumps({"listen": PORT, "db": str(DB)}))
httpd.serve_forever()
if __name__ == "__main__":
main()
Threading inside one process is allowed. A second OS process is not. ThreadingHTTPServer keeps the gate honest: concurrency is in-process, persistence is a file.
Smoke test the founder can re-run
Do not trust the agent’s claim that the server “should work.” The test starts the process, posts one email, and checks SQLite. Stop the process afterward. This is a local check, not a load test.
#!/usr/bin/env python3
import sqlite3
import subprocess
import time
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DB = ROOT / "data" / "app.db"
def main() -> None:
proc = subprocess.Popen(["python3", str(ROOT / "app.py")], cwd=ROOT)
try:
time.sleep(0.4)
req = urllib.request.Request(
"http://127.0.0.1:8080/join",
data=b"email=founder@example.com",
method="POST",
)
urllib.request.urlopen(req, timeout=2)
with sqlite3.connect(DB) as con:
n = con.execute("select count(*) from waitlist").fetchone()[0]
if n < 1:
raise SystemExit("smoke failed: no row")
print("smoke ok", n)
finally:
proc.terminate()
proc.wait(timeout=3)
if __name__ == "__main__":
main()
python3 tools/check_process_gate.py
python3 tests/smoke_waitlist.py
If either command fails, the slice is not shippable. More generated files will not fix a topology violation. Delete the extra service instead of prompting for “production hardening.”
Numbered workflow for a same-day ship
- Write
process.lock.ymlbefore the first generation prompt. Paste the pin into the agent context. State that any extra process is a defect, not a stretch goal. - Generate only the HTTP handler, the schema, and the HTML form. Refuse refactors that add task queues “for later.”
- Run the gate. Read every hit. Agents often hide Redis behind a cache helper. The token still appears.
- Run the smoke test against
127.0.0.1. Confirm a row exists without opening a dashboard product. - Copy
app.py,process.lock.yml, anddata/to a single host. Bind port 8080. Use a reverse proxy if the host already has one. Do not add a second app container to make that proxy feel official.
A coding assistant with free model access can do steps 2 and 3 if the pin is in the prompt. Disclosure: This article was prepared as part of MonkeyCode's product outreach. MonkeyCode is an open-source project that, per the operator, offers free model access and a free server option. This workflow uses those two facts only. It does not claim a quota, a model name, a hardware profile, or a durability guarantee. The gate still runs if another tool generates the files.
The free server option matters after the gate is green. A topology that needs four services will not become cheaper on a complimentary host. It will fail in a more confusing way. Keep the process count at one, then put that process on the host.
What the gate will not save
The checker is string-based. An agent can rename celery to a local module and smuggle a broker through a socket. Read the diff. The pin is a seatbelt, not a proof of correctness.
SQLite will serialize writes. That is acceptable for a first hundred signups. It is the wrong store for a write-heavy game loop. Moving to a hosted database is a later decision, made with revenue, not with an agent’s fear of “not being production ready.”
In-request work will block a thread. A burst of slow outbound HTTP will stall the waitlist form. The indie response is to drop the outbound call, not to add a worker pool on day one.
Free model access can still invent APIs that do not exist. The smoke test is the backstop. If the handler imports a package that is not on the allow-list, install nothing. Change the code.
A free server is not an SLA. Back up data/app.db with sqlite3 .dump on a schedule the founder actually runs. Treat the host as replaceable. The pin should make replacement boring: one process, one file.
Who should not use this
Skip the single-process gate when the product already has a real queue, a compliance boundary, or a traffic shape that needs more than one machine. Skip it for multi-tenant billing where a blocked writer is a contract breach. Skip it when the founder is practicing distributed systems on purpose.
Teams with platform engineers do not need this article. They already have a topology. The failure mode here is specific: one person, one weekend, an agent that wants to hire an imaginary infra team.
Close
Ship the slice that fits in one process. Keep the bill at zero by refusing services the cohort does not prove. Accept the limits: serialized writes, no mail daemon, no sidecar cache. If the gate stays green, a free server option is enough to put the handler on the internet for a first list of emails. Widen the pin only after those emails turn into a reason to pay.
Top comments (0)