# Docker's default json-file driver has NO rotation at all, and every container writes to the # same filesystem as postgres_data, media_data and exports_data. Filling that filesystem does # not degrade one subsystem — Postgres stops being able to write and the whole event goes down # (see README "Sizing the disk"). This caps logs at 30 MB per service, permanently. # # Paired with RUST_LOG in .env: without it the app falls back to `eventsnap_backend=debug, # tower_http=debug` (main.rs), which is a debug line per HTTP request including every preview. x-logging: &default-logging driver: json-file options: max-size: "10m" max-file: "3" services: db: image: postgres:16-alpine restart: unless-stopped logging: *default-logging 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: # Production PULLS a prebuilt image; it never compiles. A release build of this crate is # fat-LTO over 427 dependencies (see backend/Cargo.toml [profile.release]) and peaks well # above the RAM a 4 GB box has spare with the stack running — and a rollback would be a # second build under pressure. Images are built on a workstation and pushed; see # docker-compose.build.yml and DEPLOYMENT_RUNBOOK.md. # # There is deliberately NO `build:` key here: without one, a wrong tag fails instantly with # "manifest unknown" instead of silently starting a 45-minute compile on the event server. # The `:?` form fails loudly on an unset variable rather than resolving to an empty tag. image: registry.mc02.dev/eventsnap/app:${EVENTSNAP_VERSION:?set EVENTSNAP_VERSION in .env} restart: unless-stopped logging: *default-logging 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 # Pinned for the same reason as MEDIA_PATH: `environment` beats `env_file`, so this cannot # be lost by an operator who copies `.env.example` and edits only the secrets — which is # the likely path, and `.env.example` ships the generic default of `true`. # # This is a product decision for this event, not a technical one: guests should be present # at the party, not in a comment thread. Likes and captions stay on and are unaffected. # Boot-time only, so changing it means `docker compose up -d`, not an admin toggle. # To re-enable comments, delete this line and set COMMENTS_ENABLED in .env. COMMENTS_ENABLED: "false" depends_on: db: condition: service_healthy # Longer than the app's own 10s shutdown backstop (main.rs SHUTDOWN_GRACE), because Docker's # default stop timeout is ALSO 10s — so a redeploy raced the graceful drain and could SIGKILL # the process at the exact moment it was finishing, truncating the in-flight upload the # graceful shutdown exists to protect. The app always exits well before 20s. stop_grace_period: 20s 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: # Pulled, not built — see the note on `app` above. image: registry.mc02.dev/eventsnap/frontend:${EVENTSNAP_VERSION:?set EVENTSNAP_VERSION in .env} restart: unless-stopped logging: *default-logging 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. # `:?` for the same reason EVENTSNAP_VERSION uses it. A blank DOMAIN doesn't fail — it # produces `https://` here and collapses the Caddyfile's site block below, so the stack # comes up with no TLS and no site and the only symptom is a browser error. ORIGIN: "https://${DOMAIN:?set DOMAIN in .env}" 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 logging: *default-logging 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:?set DOMAIN in .env} 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: