Skip to content

Production Deployment

Writ edited this page Aug 5, 2026 · 2 revisions

Production deployment

Running Writ on a public server, and keeping it running.

The reference material — every setting, the bring-your-own-proxy config, the backup checklist — lives in docs/DEPLOYMENT.md in the repo, so it versions with the code that reads it. This page is the part that does not: what to do before you deploy, what breaks, and how to operate it over time.

Before you run anything

Sizing. The coordinator is a single Python process over one SQLite file. It is not the expensive part — 1 vCPU and 1 GB of RAM runs it comfortably. The browsers are the expensive part, and they run in your agents, which do not have to be on this machine. Put the coordinator on something small and put agents wherever you have cores.

DNS first. Create the A record and let it propagate before you deploy. Let's Encrypt rate-limits failed issuance (5 failures per account per hostname per hour), so deploying against a name that does not resolve yet costs you attempts. dig +short writ.example.com should print your server's IP.

Ports. Inbound 80 and 443 must be open. Port 80 is not optional — the ACME HTTP-01 challenge is served on it, and Caddy also uses it for the HTTP→HTTPS redirect. If a distro nginx or Apache is already listening there, stop and disable it first.

Nothing else needs to be open. The coordinator publishes only on 127.0.0.1:8000; agents dial out over WebSocket, so they need no inbound port and work fine behind NAT.

Deploy

git clone https://github.com/usewrit/writ.git && cd writ
./scripts/deploy.sh writ.example.com you@example.com

Then open the URL it prints and create your account. Onboarding confirms the public address as its second step — it should already show the https URL you just deployed.

Upgrading

git pull
docker compose --profile tls up -d --build

Migrations run automatically on start (alembic upgrade head in the container entrypoint). Your .env, the writ-data volume and the caddy-data volume are untouched by a rebuild.

Back up before a major upgrade. The SQLite file is a single file; copying it while the coordinator is stopped is a complete, consistent backup:

docker compose --profile tls down
docker run --rm -v writ_writ-data:/data -v "$PWD":/backup alpine \
  tar czf /backup/writ-backup-$(date +%F).tar.gz -C /data .
docker compose --profile tls up -d

Operating it

Watch these. GET /health on the coordinator is the liveness check the container already uses. For metrics, set ENABLE_METRICS=true and scrape /metrics — it is unauthenticated by design (that is what Prometheus expects), so keep it on a private network or behind your proxy's own auth.

Turn on admin MFA. Enroll a second factor (TOTP or a passkey) for every platform admin, then set REQUIRE_ADMIN_MFA=true. Doing it in the other order locks you out of the admin panel. The coordinator warns about this on every production boot until you do.

Fleet capacity. An agent self-reports how many concurrent sessions it will take, and the stock writ-agent says 2 — which is conservative for most hardware. Fleet → click an agent's slot meter shows you all three numbers (what the agent reports, what its token allows, what is in effect) and lets you pin your own. Raise it if the machine has the cores and RAM.

When it goes wrong

The certificate never arrives. In order of likelihood:

docker compose --profile tls logs -f caddy
  1. DNS does not point here yet, or points at a proxy in front. Check with dig +short <domain> from the server.
  2. A firewall or cloud security group blocks inbound 80. ACME dials back in on it; outbound-only rules are not enough.
  3. Something else holds port 80 (ss -ltnp 'sport = :80').
  4. You burned the rate limit debugging. Re-run with --staging until it works, then once without.

Every request returns 400 "Invalid host header". The Host you are dialing is not on the allowlist. The response names the rejected hostname. Set WRIT_PUBLIC_URL to the URL people actually open and restart, or add the extra name under Settings → Network → Trusted hosts (applies live, no restart).

Everyone gets rate-limited at once, or one person's failed logins lock out everybody. FORWARDED_ALLOW_IPS does not cover the address your proxy connects from, so every request looks like it came from the proxy and they all share one bucket. With the bundled Caddy that value is 127.0.0.1,172.16.0.0/12; with your own proxy it is that proxy's address.

Agents connect and then go idle, or never connect. Almost always WRIT_PUBLIC_URL. An agent enrolled while it was http://localhost:8000 holds that address. Fix the setting, then re-enrol the agent from Fleet → Connect a new agent — the install one-liner is generated from the current value.

Agents drop after about a minute. Your reverse proxy is timing out the WebSocket. On nginx add proxy_read_timeout 300s and the Upgrade/Connection headers. The bundled Caddy does not have this problem.

The coordinator will not boot. It fails closed on purpose and the error names the variable. The two common ones: a secret left blank in .env (regenerate with ./scripts/gen-env.sh --force — note this rotates them, invalidating sessions and agent tokens), and WRIT_PUBLIC_URL unset in production.

Losing things

If you lose What happens
SECRET_ENCRYPTION_KEY Unrecoverable. Stored credentials cannot be decrypted, even with a full database backup. Keep it off this server.
the writ-data volume Everything: workflows, runs, users, files. Restore from backup.
the caddy-data volume Just certificates. Caddy re-issues on next start — but Let's Encrypt allows 5 duplicates per week, so do not lose it repeatedly.
the admin password Recoverable: ./reset-admin-password.sh.

Clone this wiki locally