Files
RosterChief/DEPLOYMENT.md
Bernard Siebens c0a44093d9 Add a health check and the deployment runbook
/healthz checks the database and does a cache ROUND TRIP, not a ping. Both matter:
a node that cannot reach Postgres serves nothing, and a cache that accepts writes
and returns nothing would have waffle read every feature flag as unset -- so
"healthy" has to mean more than "the process is listening", or the load balancer
will keep feeding traffic to a node that only looks alive.

No auth and no tenant on it: the proxy, and later a load balancer, must reach it on
any host.

DEPLOYMENT.md is the runbook, and leads with the five things that make this app not
a generic Django deploy: the wildcard cert forces DNS-01 (Let's Encrypt will not
issue a wildcard over HTTP-01); Redis is required on one server, not two, because
of the per-process flag cache; SECURE_PROXY_SSL_HEADER plus Caddy's
X-Forwarded-Proto or WebAuthn and the SSL redirect both break; uploads must reach
object storage BEFORE the second app server, not during; and invoices need native
pango.

Also documents why the archive job ships with --commit off, why migrations are run
explicitly rather than from the entrypoint, and how to test the restore before the
day you need it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 09:45:21 +02:00

6.3 KiB

Deploying RosterChief

One server today, several later, with no code changes in between — only environment variables. This document is the runbook and, more usefully, the list of things that are specific to this app and will bite you if you treat it as a generic Django deploy.

The five things that make this deployment unusual

1. You need a wildcard TLS certificate, and that forces DNS-01. Tenancy is subdomain-based (ajax.rosterchief.app), so the certificate must cover *.rosterchief.app. Let's Encrypt will not issue a wildcard over HTTP-01 — only over DNS-01, which means the TLS terminator needs API access to your DNS zone. That is why deploy/caddy/Dockerfile builds Caddy with a DNS provider plugin, and why CLOUDFLARE_API_TOKEN is a required variable rather than a nicety. Swap the plugin (caddy-dns/route53, caddy-dns/digitalocean, …) if your DNS lives elsewhere.

DNS needs two records, both pointing at the server:

A   rosterchief.app    -> <server ip>
A   *.rosterchief.app  -> <server ip>

2. Redis is not optional, even on one server. waffle caches each feature flag's targeting in the Django cache, and LocMemCache is private to a single process. Under several gunicorn workers, toggling a feature in the control panel flushes one worker's cache while the others keep serving the stale flag — a feature that "sometimes doesn't turn on". A shared cache is the fix.

3. SECURE_PROXY_SSL_HEADER must be set, and Caddy must send the header. Caddy terminates TLS, so without it Django believes every request is plain HTTP: request.is_secure() goes false, WebAuthn disagrees with the browser about the origin, and SECURE_SSL_REDIRECT becomes a redirect loop. Both halves are already wired (settings + header_up X-Forwarded-Proto); don't remove either.

4. Uploads must move to object storage before the second app server. Club logos go to MEDIA_ROOT on local disk. On one box that is fine. On two, a logo uploaded to node A is a 404 on node B. Setting AWS_STORAGE_BUCKET_NAME switches the default storage to S3 — do it before you scale, not during.

5. PDF invoices need native libraries. WeasyPrint binds to pango/cairo. The image installs them; a bare-metal deploy would need them too, and a Mac needs Homebrew. This is the main reason to run the container even in development if you touch invoicing.

First deploy

# 1. Configure
cp .env.compose.example .env                 # read by docker compose
cp .env.production.example .env.production   # read by Django
python -c "import secrets; print(secrets.token_urlsafe(64))"   # -> DJANGO_SECRET_KEY

# 2. Build and start
docker compose build
docker compose up -d db redis
docker compose run --rm web python manage.py migrate
docker compose run --rm web python manage.py createsuperuser
docker compose up -d

# 3. Verify
curl -fsS https://rosterchief.app/healthz          # {"status": "ok", ...}
docker compose run --rm web python manage.py check --deploy

check --deploy is what catches an env file that forgot the HTTPS flags: they default to off in code, because defaulting them to not DEBUG would redirect every test request to https and break the suite anywhere DEBUG is unset.

The first docker compose up will take a minute or two: Caddy is provisioning the wildcard certificate over DNS-01, and DNS propagation is not instant. Watch it with docker compose logs -f caddy.

Migrations

Deliberately not run by the container's entrypoint. With more than one web container they would race, and a starting gunicorn worker is a bad place to discover a failed migration. Run them once, explicitly, as part of the deploy:

docker compose build
docker compose run --rm web python manage.py migrate
docker compose up -d --no-deps web

Scheduled jobs

Two commands need to run on a schedule. Put them on the host, not in a container, and on exactly one node when you have several — three nodes archiving the same club is three emails to the same club.

# Bill: archive clubs unpaid past their grace period.
# Run it WITHOUT --commit for the first week and read the output. The flag exists because
# this switches off paying customers: a bad clock or a bad cron should cost you an email,
# not a morning of angry clubs.
0 6 * * *  cd /srv/rosterchief && docker compose run --rm web python manage.py archive_overdue_clubs --commit

# Events: extend recurring series so the calendar never runs dry.
0 3 * * *  cd /srv/rosterchief && docker compose run --rm web python manage.py extend_event_series

Backups

Two things carry state: Postgres and the uploads.

# Database
docker compose exec -T db pg_dump -U rosterchief rosterchief | gzip > rosterchief-$(date +%F).sql.gz

# Uploads — until they are on S3, in which case the bucket's own versioning is the backup.
docker compose cp web:/app/media ./media-backup

Restore is gunzip -c dump.sql.gz | docker compose exec -T db psql -U rosterchief rosterchief. Test it once, now, rather than the first time you need it.

Going multi-server

Nothing in the code changes. What changes is where the services live:

one server several
Postgres db container DJANGO_DATABASE_URL → your central Postgres
Cache / flags redis container managed Redis (or your existing one)
Uploads local disk S3 bucket (AWS_STORAGE_BUCKET_NAME)
Static files WhiteNoise, in the image unchanged — that is why WhiteNoise is there
Cron host crontab one node only
TLS Caddy on the box load balancer, or Caddy on each node

Drop db and redis from compose.yaml, point the URLs at the central services, and run web on as many nodes as you like behind a load balancer pointed at /healthz.

The health check tests the database and a cache round trip, not just that the process is listening — a node that cannot reach Postgres, or whose cache silently swallows writes, is not healthy, and a load balancer must not keep feeding it traffic.

Rollback

Images are the unit of rollback. Tag on build, keep the last few, and:

docker compose up -d --no-deps web   # with the previous image tag

Migrations are the exception: they don't roll back with the image. Prefer additive migrations (add a column, deploy, backfill, then stop writing the old one) so that yesterday's image still runs against today's schema.