One binary, the ephemeral filesystem, and the prod guards
Twelve lessons of SignFlow run on your laptop. This lesson ships it — to Railway, on Postgres, reachable at a real URL. Most of the work is already done: the binary embeds its own migrations and assets, and the production guards are code you wrote lessons ago. The one genuine decision is where uploaded files live, and getting it wrong is the classic production surprise.
One static binary, on distroless. A multi-stage Dockerfile builds the app in a full Go image, then copies just the resulting binary into gcr.io/distroless/static — no shell, no package manager, no OS cruft, a few MB. Because migrations (//go:embed) and static assets ship inside the binary, the image contains nothing but the binary. And the binary runs its own goose migrations on startup — a fresh deploy migrates itself; there is no separate migration step to forget.
Railway + Postgres, wired by config. railway.json points Railway at the Dockerfile and sets a /healthz healthcheck (the endpoint from lesson 1 — it pings the DB, so an unreachable database reports unhealthy instead of serving 500s). A Postgres addon provides DATABASE_URL. Railway injects PORT. The app reads all of it through the config package.
The lesson: Railway's filesystem is ephemeral. A container's local disk is wiped on every deploy and restart. LocalStore writing to ./uploads is perfect in dev — and a trap in prod: every uploaded document would vanish on your next deploy. A deployment that works until the first redeploy is exactly the kind of surprise that reaches production. The fix is a Railway Volume: a persistent disk mounted at /data, with UPLOAD_DIR=/data/uploads. Chosen over S3/R2 because it is one config change — no new SDK, no credentials — and the storage.Store interface from lesson 8 already makes object storage a clean swap later if you ever scale to multiple instances (a Volume binds to one). You will prove the fix in step 3: upload, redeploy, download, and hash — byte-identical.
The production guards are already in your code. You don't add prod hardening here; you turn it on with APP_ENV=prod, and the guards you wrote earlier activate:
config.Loadrefuses to boot withoutSESSION_SECRETandBASE_URLin prod — no silent insecure defaults (lessons 6–7).- The session cookie gets its
Secureflag (lesson 4). - gorilla/csrf's strict Origin/Referer check is active — the plaintext bypass from lesson 6 was wrapped in
if !IsProd(), so it is dev-only. In prod a cross-origin POST is rejected.
That is the payoff of never hard-coding a secret and never defaulting your way past a security check: production readiness is a flag, not a rewrite.
Console email stays the prod default — on purpose. EMAIL_SENDER defaults to console even in production. That is a deliberate teaching choice, not laziness: it lets you deploy SignFlow and run the entire flow — registration, password reset, signer invites — with no API key and no email account. The links print to the Railway deploy logs; you read them there. Set EMAIL_SENDER=resend + RESEND_API_KEY when you want real email. The email.Sender interface from lesson 7 makes that a one-variable switch.
This lesson:
- The Dockerfile,
.dockerignore,railway.json, and.env.example. - Deploy to Railway with a Postgres addon and a Volume — then prove the Volume works by surviving a redeploy.
Live reference: https://signflow-production-67f3.up.railway.app