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>
This commit is contained in:
2026-07-14 10:25:40 +02:00
parent 98e5f22873
commit 534d01d6fe

View File

@@ -334,6 +334,178 @@ docker compose cp web:/app/media ./media-backup
Restore is `gunzip -c dump.sql.gz | docker compose exec -T db psql -U rosterchief rosterchief`. 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. Test it once, now, rather than the first time you need it.
## Sizing the server
For **15 clubs, ~1000 members, ~10 events per club per week**.
The short answer: **2 vCPU, 4 GB RAM, 40 GB SSD** — a €46/month VPS (Hetzner CX22 or
equivalent). The interesting part is *why*, because the data is not what sizes this box.
### The data is negligible
Row counts for that workload, from the actual schema (attendance dominates: every event
invites a squad, so one event is ~20 rows):
| table | rows/year | MB/year |
|---|---:|---:|
| `events.Attendance` | 52,000 | 16 |
| `events.Event` | 2,600 | 2 |
| `formbuilder` answers | 10,000 | 3 |
| `shop` orders + lines | 3,000 | 1 |
| members, memberships, rosters | ~3,000 | 1 |
| **total, with WAL and bloat** | | **~40 MB/year** |
That is **0.2 GB after five years**. Uploads are club logos — a handful of files. Invoices are
rendered on demand and never stored. Nothing here grows into a problem.
So do not size for the data. Size for the **processes**.
### What actually consumes the box
Measured, running this app under gunicorn with `DEBUG=False`:
| | memory |
|---|---|
| gunicorn master + 3 workers | **~270 MB** (~54 MB per worker) |
| PostgreSQL (default `shared_buffers`) | ~200400 MB |
| Redis (cache only) | < 50 MB |
| Caddy | ~30 MB |
| OS + Docker daemon | ~400 MB |
| **steady state** | **~1.01.2 GB** |
2 GB would run it. 4 GB is the recommendation for three reasons, all of which are the kind of
thing that bites at the worst moment:
1. **`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. Either take the 4 GB, or build
the image elsewhere and pull it.
2. **Rendering an invoice loads WeasyPrint.** It is imported lazily (which is why the workers
measure 54 MB and not 150), so pango and its fonts land in whichever worker renders a PDF —
expect that worker to grow by ~50100 MB the first time someone downloads an invoice.
3. **Headroom is Postgres's page cache.** With 200 MB of data and 4 GB of RAM, the entire
database lives in cache and the disk is never touched for reads.
### Disk
| | |
|---|---|
| Docker images (app ~1 GB with pango, postgres, redis, caddy) | ~1.5 GB |
| Build cache | 24 GB |
| Database, 5 years | < 0.5 GB |
| Backups: 14 daily compressed dumps | < 0.5 GB |
| Logs | ~1 GB |
| **40 GB is roomy; 20 GB works** | |
### CPU and concurrency
2 vCPU. Three workers × four threads is twelve concurrent requests, against a peak of "the
whole club checks the Saturday line-up at 09:00" — perhaps a few hundred requests over a few
minutes. This workload is not CPU-bound; the one CPU-heavy operation is PDF rendering, which
happens a handful of times a month.
### When to grow
Not at "more members" — at these:
- **Uploads become real content** (photo galleries, documents). Media, not rows, is what makes
storage grow, and it is also the trigger for moving to S3.
- **Attendance passes a few million rows** (~20 clubs at this rate, i.e. several years out).
Add an index before adding a server.
- **You want zero-downtime deploys.** That is a second app node, not a bigger one.
## For fun: three nodes on AWS
Wildly over-engineered for 1000 members, but here is what it looks like — and what it costs.
### The layout
```
Route 53 (rosterchief.app + *.rosterchief.app)
|
ACM certificate (wildcard, free)
|
Application Load Balancer (TLS terminates here)
|
+----+----+----+
| | |
ECS task task task 3 × Fargate, one per AZ, same image
| | |
+----+----+----+
|
+----+---------------+----------------+
| | |
RDS PostgreSQL ElastiCache Redis S3 (media)
(Multi-AZ) (cache.t4g.micro) + CloudFront (optional)
```
**The one genuinely nice thing AWS gives you here: ACM issues the wildcard certificate for
free, with DNS validation in Route 53.** The whole DNS-01 dance disappears — no Caddy plugin,
no API token, no renewal. The ALB terminates TLS and forwards to the tasks. That is the single
biggest simplification versus the VPS.
### What changes in the app
Nothing in the code. Only environment:
| | |
|---|---|
| `DJANGO_DATABASE_URL` | the RDS endpoint |
| `DJANGO_REDIS_URL` | the ElastiCache endpoint |
| `AWS_STORAGE_BUCKET_NAME` | the media bucket — **required** now, three nodes cannot share a disk |
| `SECURE_PROXY_SSL_HEADER` | already set; the ALB sends `X-Forwarded-Proto` |
| health check | point the target group at **`/healthz`** — that is what it is for |
Sessions are database-backed, so **no sticky sessions**: any task can serve any request.
**Scheduled jobs get better here.** EventBridge Scheduler firing a one-off ECS task solves the
"run it on exactly one node" problem properly — no cron on three boxes racing each other:
```
EventBridge (cron: 0 6 * * ? *) -> ECS RunTask -> archive_overdue_clubs --commit
```
Backups become RDS automated snapshots + PITR, and `deploy/backup.sh` retires — though the
*restore rehearsal* does not. Snapshots you have never restored are still a hypothesis.
### Monthly cost (eu-central-1, on-demand, indicative)
| | | $/month |
|---|---|---:|
| ALB | fixed + a little LCU | ~22 |
| ECS Fargate | 3 × (0.5 vCPU, 1 GB) | ~54 |
| RDS PostgreSQL | `db.t4g.micro`, 20 GB gp3, single-AZ | ~17 |
| ElastiCache | `cache.t4g.micro` | ~12 |
| S3 + CloudFront | a few GB, low traffic | ~2 |
| Route 53 | hosted zone + queries | ~1 |
| ECR, CloudWatch logs | small | ~3 |
| | **single-AZ total** | **~110** |
| RDS Multi-AZ | doubles the database | +17 |
| | **highly-available total** | **~130** |
**Watch the NAT Gateway.** If the tasks sit in private subnets and reach the internet through
a NAT Gateway, add **~$32/month per AZ plus data charges** — for three AZs that is more than
the compute. Either put the tasks in public subnets with tight security groups, or use VPC
endpoints for ECR/S3/CloudWatch. It is the single most common surprise on an AWS bill of this
shape.
Prices are indicative and move; check the calculator before committing.
### The honest comparison
| | | |
|---|---|---|
| **Hetzner CX22** | 2 vCPU, 4 GB, 40 GB | **~€5/month** |
| **AWS, three nodes** | as above | **~$110130/month** |
Roughly **25×**, for a workload whose database is 200 MB after five years. What the money buys
is real — managed Postgres with PITR, three AZs, no box to patch, free wildcard certificates —
but it is bought for *resilience*, not for capacity. At 1000 members you are paying for the
insurance, not the compute.
A reasonable middle: one VPS now, and move Postgres to a managed service (RDS, or a €15/month
managed Postgres) the day the data starts to matter more than the uptime. That is the change
that is painful to do late, and everything else in this document is already designed for it.
## Going multi-server ## Going multi-server
Nothing in the code changes. What changes is where the services live: Nothing in the code changes. What changes is where the services live: