Skip to content

Deploying the app (Fly.io)

The app runs on Fly.io as a single VM in lhr (London) with a 1 GB volume mounted at /data for SQLite + uploaded media.

The Fly account is personal ([email protected]) — not the Torchbox account. Always confirm before deploying:

fly auth whoami

If it doesn't say [email protected], run fly auth logout && fly auth login and pick the right account.

How deploys happen

GitHub Actions runs the deploy on every push to main. The workflow file is .github/workflows/deploy.yml. It:

  1. Runs the test suite.
  2. Builds the Docker image (multi-stage; Pillow + zlib at build time, smaller libjpeg at runtime).
  3. Pushes to Fly.

You don't normally run fly deploy by hand — merge to main, wait ~2 minutes, the new build is live.

Migrations

Migrations run on container boot via entrypoint.sh, not via Fly's release_command. The reason matters: release_command runs on a separate ephemeral machine that doesn't mount the SQLite volume, so manage.py migrate there ran against an empty DB and silently no-op'd in prod for ages. We caught it the third time it bit us.

So: on every machine boot, entrypoint.sh runs python manage.py migrate --noinput against the volume-backed DB, then starts gunicorn and python manage.py db_worker as co-processes. Single-machine deploy means no race; migrate is idempotent.

Processes on the machine

One Fly machine runs two processes (managed by entrypoint.sh, not Fly's [processes] block):

  • gunicorn — serves HTTP on port 8000.
  • db_worker — polls the django-tasks-db table for queued background tasks (~1s interval) and executes them.

Both processes live on the same machine because the SQLite volume is single-attach — Fly volumes can't be mounted to more than one machine. When the Postgres migration lands (infra backlog), db_worker splits out onto its own machine via a [processes] block; the application code doesn't change. See the "Durable scan queue" entry on the roadmap for the worker-topology rationale.

If either process dies, entrypoint.sh brings the machine down so Fly restarts it cleanly — half-running states aren't useful.

Machine scaling and cold starts

fly.toml keeps one machine warm at all times (min_machines_running = 1). This avoids the cold-start penalty: with scale-to-zero, the first request after an idle period had to boot the machine, run migrate, and start gunicorn + db_worker before responding — several seconds of perceived slowness. A single shared-cpu-1x / 512 MB machine running 24/7 is roughly $2/month.

auto_start_machines stays on, so Fly can still burst a second machine when the concurrency soft limit (20 requests) is hit; auto_stop_machines = 'stop' reaps those extras back down to the one warm machine when load drops.

There's one subtlety: min_machines_running stops Fly from reaping the last machine, but it doesn't proactively start a stopped one. After a deploy the machine is left stopped, so without anything pinging it the first real visitor still pays the cold-boot cost. The obvious fix is an [[http_service.checks]] block pointing Fly's proxy at a /healthz/ URL — but don't add one naively.

Outage post-mortem (2026-06-10). We added an [[http_service.checks]] block hitting /healthz/. Fly's health checker calls the path with an internal Host header that isn't in ALLOWED_HOSTS (maud.fly.dev,localhost), so Django returned 400 before the view ran. Fly read 400 as unhealthy, marked the machine critical, and the proxy served 503 to every user ("no known healthy instances"). The site was down until we removed the check and redeployed.

To reintroduce a Fly health check safely, the probe must answer before Django's host validation — i.e. a middleware placed first in MIDDLEWARE that returns 200 for the health path regardless of Host (a normal URL+view won't do, because ALLOWED_HOSTS is enforced upstream of the view). There's a healthz view/URL (apps/common/views.healthz, /healthz/) kept for that future fix, but no Fly check currently points at it.

SQLite tuning

gunicorn (2 workers × 4 threads) and db_worker all write to the same /data/db.sqlite3. To keep them from blocking each other, config/settings.py sets PRAGMAs on the SQLite connection: journal_mode=WAL (readers proceed while the writer holds the lock), busy_timeout=5000 (wait out a held write lock instead of erroring "database is locked"), and synchronous=NORMAL (safe under WAL). Connections are reused across requests via CONN_MAX_AGE = 60. These only apply to the SQLite backend and drop away once the Postgres migration lands.

If you ever need to migrate manually:

fly ssh console -a maud -C 'python manage.py migrate'

First-time setup (if you're forking)

brew install flyctl     # macOS
fly auth login          # opens a browser

cd maud/

# 1. Launch the app on Fly.
fly launch --no-deploy --copy-config

# 2. Volume — one-off, holds the SQLite DB and uploaded media.
fly volumes create maud_data --region lhr --size 1

# 3. Required secrets.
fly secrets set \
  SECRET_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(64))')" \
  EMAIL_TOKEN_KEY="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')" \
  ANTHROPIC_API_KEY="sk-ant-..."

# 4. Optional secrets (DVLA + DVSA, Google OAuth — see relevant docs).
fly secrets set \
  GOOGLE_OAUTH_CLIENT_ID="..." GOOGLE_OAUTH_CLIENT_SECRET="..." \
  DVLA_VES_API_KEY="..." \
  DVSA_MOT_CLIENT_ID="..." DVSA_MOT_CLIENT_SECRET="..." \
  DVSA_MOT_TENANT_ID="..." DVSA_MOT_API_KEY="..." \
  VEHICLE_LOOKUP_MODE="live"

# 5. First deploy.
fly deploy

Weekly Gmail-scan cron

.github/workflows/weekly-scan.yml fires the Gmail scan for every active household once a week (Mondays 06:00 UTC) plus workflow_dispatch for manual re-runs. It POSTs to /internal/cron/scan-all/ and polls /internal/cron/scan-status/ every 30s until in-flight scans finish — the polling keeps the Fly machine awake while db_worker drains the queue (autostop only watches HTTP traffic, not the worker process).

Two paired secrets (both required, must match):

# 1. On Fly — the endpoint compares X-Cron-Key against this with constant-time
# hmac. Empty value refuses every request.
fly secrets set CRON_SECRET="$(python3 -c 'import secrets; print(secrets.token_urlsafe(32))')"

# 2. On GitHub — repo Settings → Secrets and variables → Actions → New repository secret.
#    Name: CRON_SECRET     (same value as the Fly secret above)
#    Name: CRON_BASE_URL   (e.g. https://maud.fly.dev — no trailing slash)

To verify end-to-end after merging PR 2:

# From the GitHub UI: Actions → Weekly Gmail scan → "Run workflow".
# Or from the CLI:
gh workflow run weekly-scan.yml
gh run watch

The workflow log will show enqueued: N, skipped_running: 0 and then poll messages until running: 0.

ALLOWED_HOSTS / custom domain

ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS live in fly.toml [env]. If you change the app name or add a custom domain, edit them there and fly deploy.

Day-two ops

fly logs -a maud                              # tail
fly ssh console -a maud                       # shell into the running VM
fly ssh console -a maud -C 'python manage.py shell'    # Django shell
fly ssh console -a maud -C 'python manage.py createsuperuser'
fly status -a maud                            # machine state, recent deploys
fly secrets list -a maud                      # env-var names (values not shown)

Reset a user's household to a fresh sandbox — useful after pipeline / classifier prompt changes, or for testing on a dedicated sandbox account:

fly ssh console -a maud -C 'python manage.py wipe_household --user <username> --dry-run'
fly ssh console -a maud -C 'python manage.py wipe_household --user <username>'

Wipes Documents (and their files), Findings, NamedItems, Profiles, and ActivityEvents for the named user's household. Keeps the user, household membership, and Gmail OAuth connection (scan timestamps reset).

Rollback

fly releases -a maud             # list past releases
fly deploy --image registry.fly.io/maud:deployment-<id>

…or just revert the offending PR on GitHub and let the next CI deploy roll forward.

Costs

  • VM: shared-cpu-1x, 512 MB. Auto-stops when idle, auto-starts on request. Free tier covers the development load.
  • Volume: 1 GB. ~$0.15/month.
  • Egress: free up to 100 GB/month.

Nothing's costing real money today.