fix(deploy): give Postgres a CPU floor that Docker actually honours

`deploy.resources.reservations.cpus` was doing nothing. Outside Swarm, `docker compose up`
silently drops it — verified by inspecting a running container, where CpuShares, CpuQuota
and CpusetCpus were all unset while `limits.cpus` and `reservations.memory` came through as
NanoCpus and MemoryReservation. So the comment calling it "the piece that actually protects
the database" described a guarantee the box never had.

It matters on the CX22 the runbook targets: the ceilings sum to 1.2 + 0.6 + 0.5 = 2.3 on
2 vCPU, so the other services can oversubscribe the machine, and with every container on the
default weight Postgres competed on equal footing with two image resizes and an ffmpeg
poster. Replaced with `cpu_shares`, which does survive the translation — db 2048, caddy
1024, app 512, frontend 256 — so the weighting only binds when the CPU is actually
saturated, which is the moment the database must not lose.

The Caddyfile gains a 10s header-read timeout: there was no read timeout anywhere, so a
client could hold a connection, a tokio task and a `.tmp` file open indefinitely by sending
one byte a minute, and the upload sweeper is keyed on mtime precisely so a live upload never
ages out. Body reads stay unbounded — a 500 MB video over cellular legitimately takes
minutes, and a body timeout would fail exactly the uploads this product exists to collect.

.env.example documents that estimated_guest_count is a live input to the quota divisor
rather than the inert setting both it and the runbook previously implied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-08-11 22:44:48 +02:00
parent 32dfe6874a
commit 0c0d5d5981
6 changed files with 344 additions and 66 deletions

View File

@@ -1,3 +1,24 @@
{
servers {
timeouts {
# Slowloris defence, at the layer that can actually apply it.
#
# There was no read or write timeout anywhere, so a client could open a POST, send one
# byte a minute, and hold a connection, a tokio task and a `.tmp` file indefinitely —
# and the upload sweeper is keyed on mtime precisely so a live upload never ages out,
# so ten such connections consumed disk the upload gate could not see.
#
# read_header is tight: a legitimate client sends its headers in one go.
read_header 10s
# read_body is NOT set, and idle is generous: a guest pushing a 500 MB video over
# cellular legitimately takes many minutes, and a body timeout would fail exactly the
# uploads this product exists to collect. The header timeout is what stops the cheap
# attack; a slow *body* still has to actually send bytes.
idle 5m
}
}
}
{$DOMAIN} {
# Compress everything EXCEPT the SSE stream — gzip buffering delays
# "real-time" likes/comments until the ~30s keep-alive tick.