Shipping a Scratch Container to a Real Subdomain
Phase 6 was the polish pass — the unglamorous bits that make a backend feel finished rather than "mostly works on localhost." Config that still lived only in my head got wired properly: CORS allowed origins, the static file directory, and the public vault hostname used to build share URLs. None of it is exciting. All of it was necessary.
The interesting work was two pieces of middleware.
First, CORS. FastAPI's CORSMiddleware has a specific, easy-to-miss trap that the browser CORS spec actually forbids: allowing credentialed cross-origin requests and responding with Access-Control-Allow-Origin: *. Browsers silently reject that combination. The fix — used by Starlette and by the Go middleware I wrote to match it — is never to emit a literal wildcard when credentials are allowed. Echo back the request's specific Origin header, but only after confirming it's on the allow-list. Same behavior as the Python app. Same trap avoided.
Second, panic recovery. Chi's default recovery middleware returns a plain-text crash message. Every other error path in this API already speaks {"detail": "..."}. A JSON-only client that hits a panicking handler and gets back prose is a bad client experience, so I replaced the default with a hand-written one that keeps the response shape consistent. Purely cosmetic from the server's point of view. Not cosmetic from the client's.
I deliberately did not write the SPA static-file-serving fallback in this phase, even though the equivalent sits in the Python app's main.py and would have been easy to bang out. There was no frontend build in the repository to test against. Earlier in this project, two real bugs only showed up because I insisted on testing against something real instead of trusting code that looked right. Writing an untested static-file handler — with a security-sensitive path-traversal guard, no less — felt like exactly the wrong moment to skip verification. I wrote it down as deferred instead of quietly leaving a gap.
Phase 7 is where that deferred piece landed, once a real frontend build existed.
I copied the exact same production frontend the live Python app already serves — not rebuilt from source. Identical bytes, on purpose, so a later side-by-side comparison isn't confounded by two different frontend versions. The static-file handler serves that build with a fallback to the SPA shell for unknown client-side routes. The path-traversal guard got tested for real: I planted a secret file just outside the static directory, then threw ../, ../../, and a URL-encoded %2e%2e variant at the server. None of them could reach it. All of them fell back to the ordinary SPA shell, not an error that reveals anything about the filesystem.
Then the Dockerfile. Two stages: a Go build stage with CGO_ENABLED=0 (the SQLite driver is pure Go — no C dependencies at all), then a final runtime stage based on scratch. Literally no operating system. Not even a shell.
Getting a fixed non-root user id was a small realization. The Python image uses UID 1000 by convention, so a bind-mounted data directory's ownership means the same thing either way. Linux doesn't actually need a /etc/passwd entry to run a process as a given numeric uid — only to resolve that id to a human-readable name, which nothing here needed. USER 1000:1000 written as a number. No useradd. There's no shell in scratch to run useradd with anyway.
The resulting image: 30.6MB, versus the existing Python image's 313MB. That's not a vague "Go is smaller" story. It's a direct consequence of two earlier decisions — the pure-Go SQLite driver back in the data-layer phase, and scratch here.
Deployed it for real, side by side with the live Python app. Built the image, ran it via docker-compose on its own port, added a brand new subdomain through the project's existing Caddy automation. Then re-ran the entire verification pass a second time over real HTTPS through the new subdomain — not just against the raw container port. Health check, register, put/get a document, SPA shell loading, CORS headers. All green.
As a slightly funny concrete proof that scratch really contains nothing but the one binary: a shell command that tried to open a shell inside the running container failed with "executable file not found."
The live Python app was never touched. Different container, different port, different subdomain, own separate SQLite database with its own test user accounts. No shared data at all. Zero risk to production during any of this.
Both backends are live now. Phase 8 is the obvious next move: head-to-head benchmarks, now that there's something real on both sides to measure.