Commit Graph

5 Commits

Author SHA1 Message Date
5d42691a10 Fix the image build: fetch the git dependency in its own stage
The build died at `uv sync`: django-lucide is our fork, declared as a git source
and pinned by the lock to a commit, so uv shells out to `git` to fetch it — and
python:3.14-slim has no git.

Installing git in the runtime image would have fixed it and left a build tool, plus
its dependency tree, in production for the sake of one package that is already
vendored into the venv by then. So the virtualenv is now built in a stage that has
git, and the finished .venv is copied into a runtime stage that does not. Same base
image, so the compiled wheels inside it stay ABI compatible.

Also drops the second `uv sync`, which installed the project itself: there is no
[build-system] and rosterchief is not a package — gunicorn imports it from the
working directory, exactly as it does locally.

Unverified end to end: still no container runtime on this machine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:07:54 +02:00
534d01d6fe Size the server, and cost an AWS three-node layout
For 1-5 clubs / 1000 members / 10 events per club per week: 2 vCPU, 4 GB, 40 GB.

The data does not size this box. Computed from the real schema, attendance
dominates (every event invites a squad, so one event is ~20 rows) and the whole
thing comes to ~40 MB/year -- 0.2 GB after five years. Invoices are rendered on
demand and never stored.

What sizes it is the processes, measured rather than guessed: gunicorn master plus
three workers is ~270 MB (~54 MB each), and the whole stack idles around 1.0-1.2 GB.
2 GB would run it; 4 GB is the recommendation because `docker compose build` is the
memory spike, not serving -- npm, uv and collectstatic together will OOM a 2 GB box
that is also running Postgres. Rendering an invoice adds ~50-100 MB to one worker
the first time, since WeasyPrint is imported lazily.

Also adds the AWS three-node layout for fun, with a cost table. Two things worth
knowing there: ACM issues the wildcard certificate free with Route 53 validation, so
the entire DNS-01 dance disappears; and a NAT Gateway would cost more than the
compute (~$32/month per AZ) if the tasks sit in private subnets.

The honest line at the end: ~$110-130/month on AWS against ~EUR 5 on a VPS, for a
database that is 200 MB after five years. The money buys resilience, not capacity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:25:40 +02:00
98e5f22873 Document running RosterChief beside another domain on one Caddy
The "behind an existing Caddy" section assumed the DNS plugin was being set up
from scratch. The realistic case is a box whose Caddy already does Cloudflare
DNS-01 for another domain, so it now covers that: set acme_dns once globally and
every site inherits it, or scope a token per zone with a snippet.

Leads with the failure that will actually happen -- a Cloudflare token is scoped to
named zones, so the existing one grants DNS:Edit on the domain it was made for and
nothing else, and the new site fails its challenge on a permissions error whose
text does not say so.

Also spells out that *.test.rosterchief.app does NOT match test.rosterchief.app: a
wildcard covers exactly one label, so leaving the bare host off the site line gives
the club subdomains a certificate and the control panel none.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:22:37 +02:00
d30b163122 Add maintenance mode: lock the platform down from the control panel
Closes every club subdomain with a 503 in that club's own colours, stands the
scheduled jobs down, and keeps open exactly what is needed to end it again.

The exemptions ARE the feature:

- /accounts/ stays open on the base domain. Close it too and you cannot sign in to
  turn maintenance off -- a lock-down with no key, fixable only from a shell.
- /healthz answers on every host. Close it and the load balancer decides the node
  is dead, stops routing to it, and takes the control panel down with everything
  else.
- migrate and collectstatic are NOT blocked. Maintenance is usually declared in
  order to run them; a blanket guard on BaseCommand would mean turning the mode off
  to do the work you turned it on for. Only the domain jobs (archive_overdue_clubs,
  extend_event_series, import_members_csv) refuse, and they exit non-zero so cron
  mails you -- a scheduled job that silently skips itself is how a month of billing
  goes missing.

The state is cached with a 10-second TTL, not for ever. Write-through makes the
flip instant for the shared Redis of a real deployment, and the TTL is the belt to
that braces: on a per-process cache -- a dev box with no Redis, or a misconfigured
deploy -- a lock-down that reached only one gunicorn worker would be worse than
useless. Live-verified: a club subdomain, its login page and the base domain all
503 while the control panel and the sign-in screens stay up.

Also adds the two deployment pieces asked for: compose.behind-proxy.yaml for a
dev/test box that already runs Caddy on :80 (app on the loopback, host Caddy proxies
to it -- and the host's Caddy still needs the DNS plugin, because the wildcard is
still a wildcard), and deploy/backup.sh + restore-check.sh with a cron schedule. The
backup writes to a .part file and only lands it once gzip -t says it is readable: a
truncated dump that looks like a backup is the failure you find on the day you need
it. The weekly restore rehearsal is the only line in that cron that proves the rest
work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 10:11:15 +02:00
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