Two smaller operational items. POSTGRES 512M -> 1G. DATABASE_MAX_CONNECTIONS is 30 for a ~100-guest event (feed polling + SSE + uploads at once), and 30 backends plus Postgres 16's default shared_buffers leaves very little headroom at 512M. An OOM here doesn't degrade one feature -- every request path touches the database, so it takes the event down. Memory is the cheaper knob than shrinking the pool back and reintroducing the queueing it was raised to fix. .env.example now names the pairing explicitly, the way it already does for COMPRESSION_WORKER_CONCURRENCY. SOCIAL WRITES WERE UNTHROTTLED. toggle_like, add_comment and delete_comment were the only mutating endpoints in the app with no limit at all -- upload, join, recover, export and admin login all carry one. Asymmetric coverage rather than a deliberate decision. Low severity, and honestly so: a like fans an SSE broadcast to every client, but the export regeneration a comment deletion triggers is contained (REGEN_DEBOUNCE 20s, workers born with their epoch, superseded ones inert). So the ceiling is 120/min -- far above anything a real guest produces. This bounds a script, not an enthusiastic double-tapper. ONE bucket across all three actions: separate buckets would let a caller triple the aggregate write rate by alternating between them. Keyed per USER, matching the feed and upload limits -- at a venue every guest is behind one NAT, and an IP key is what made the /join and /feed limits turn guests away in the first place. Migration 020 seeds both keys, and both are wired into the admin allowlist, the config UI and the e2e reseed -- the step two earlier per-area toggles missed, which left switches that existed in code and could never be flipped. Tests: 4 e2e, including that the shared bucket really is shared (the part most likely to be lost in a refactor) and that one guest hitting the ceiling doesn't block another behind the same IP. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
130 lines
4.4 KiB
YAML
130 lines
4.4 KiB
YAML
services:
|
|
db:
|
|
image: postgres:16-alpine
|
|
restart: unless-stopped
|
|
env_file: .env
|
|
environment:
|
|
POSTGRES_USER: ${POSTGRES_USER}
|
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
|
POSTGRES_DB: ${POSTGRES_DB}
|
|
volumes:
|
|
- postgres_data:/var/lib/postgresql/data
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
# 1G, not 512M. DATABASE_MAX_CONNECTIONS defaults to 30 for a ~100-guest event
|
|
# (feed polling + SSE + uploads at once), and 30 backends plus Postgres 16's
|
|
# default shared_buffers leaves very little headroom at 512M. An OOM here does
|
|
# not degrade one feature — it takes the event down, because every request
|
|
# path touches the database. Memory is the cheaper knob than shrinking the
|
|
# pool back and reintroducing the queueing it was raised to fix.
|
|
#
|
|
# Raising DATABASE_MAX_CONNECTIONS further means raising this too.
|
|
memory: 1G
|
|
|
|
app:
|
|
build:
|
|
context: ./backend
|
|
dockerfile: Dockerfile
|
|
restart: unless-stopped
|
|
env_file: .env
|
|
environment:
|
|
# Activates the production secret guard in config.rs — refuses to boot with
|
|
# placeholder JWT_SECRET / ADMIN_PASSWORD_HASH.
|
|
APP_ENV: production
|
|
# The media volume is mounted at /media (below), so the app MUST write there.
|
|
# Pin it here rather than trusting .env: if MEDIA_PATH in .env points elsewhere
|
|
# (e.g. a host path used for running the backend natively) the container can't
|
|
# create it and every upload 500s with EACCES. `environment` overrides `env_file`,
|
|
# so this is authoritative for the container.
|
|
MEDIA_PATH: /media
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
volumes:
|
|
- media_data:/media
|
|
# Export archives live OUTSIDE /media so the public media ServeDir can't
|
|
# serve them — downloads go only through the ticket-gated handler.
|
|
- exports_data:/exports
|
|
expose:
|
|
- "3000"
|
|
healthcheck:
|
|
# Use 127.0.0.1, NOT localhost: the app binds IPv4 (0.0.0.0) but `localhost`
|
|
# resolves to ::1 (IPv6) first inside the container, so a localhost probe gets
|
|
# "connection refused" and the container never turns healthy — which would leave
|
|
# Caddy (gated on `condition: service_healthy` below) blocked forever on boot.
|
|
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:3000/health || exit 1"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 20s
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
# Bounds a runaway ffmpeg transcode (large uploads, 2 workers) so it can't
|
|
# OOM the single box and take down Postgres.
|
|
memory: 1G
|
|
|
|
frontend:
|
|
build:
|
|
context: ./frontend
|
|
dockerfile: Dockerfile
|
|
restart: unless-stopped
|
|
env_file: .env
|
|
environment:
|
|
# adapter-node behind Caddy TLS needs the public origin for CSRF checks on
|
|
# POST form actions — without it they fail only in production.
|
|
ORIGIN: "https://${DOMAIN}"
|
|
depends_on:
|
|
- app
|
|
expose:
|
|
- "3001"
|
|
healthcheck:
|
|
# 127.0.0.1, not localhost — see the app healthcheck note above (IPv4 bind vs
|
|
# ::1 resolution would leave this container permanently unhealthy).
|
|
test: ["CMD-SHELL", "wget -q -O- http://127.0.0.1:3001/ >/dev/null 2>&1 || exit 1"]
|
|
interval: 10s
|
|
timeout: 5s
|
|
retries: 5
|
|
start_period: 15s
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
memory: 256M
|
|
|
|
caddy:
|
|
image: caddy:2-alpine
|
|
restart: unless-stopped
|
|
environment:
|
|
# The Caddyfile's site address is `{$DOMAIN}`, read from THIS container's env.
|
|
# Without it, `{$DOMAIN}` expands to empty, the site block collapses, and Caddy
|
|
# serves nothing / fails to obtain a TLS cert. `env_file` alone wouldn't help —
|
|
# Caddy needs it in `environment`, and this keeps the Caddyfile the single source.
|
|
DOMAIN: ${DOMAIN}
|
|
ports:
|
|
- "80:80"
|
|
- "443:443"
|
|
volumes:
|
|
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
- caddy_data:/data
|
|
depends_on:
|
|
app:
|
|
condition: service_healthy
|
|
frontend:
|
|
condition: service_healthy
|
|
deploy:
|
|
resources:
|
|
limits:
|
|
memory: 256M
|
|
|
|
volumes:
|
|
postgres_data:
|
|
media_data:
|
|
exports_data:
|
|
caddy_data:
|