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>
90 lines
5.8 KiB
Plaintext
90 lines
5.8 KiB
Plaintext
# ── Domain ────────────────────────────────────────────────────────────────────
|
|
# Public domain Caddy will serve and obtain a TLS certificate for.
|
|
DOMAIN=my-event.example.com
|
|
|
|
# ── App server ────────────────────────────────────────────────────────────────
|
|
APP_PORT=3000
|
|
# Set to `production` in real deployments. This activates the secret guard that
|
|
# refuses to boot with placeholder JWT_SECRET / ADMIN_PASSWORD_HASH values.
|
|
# (docker-compose.yml already sets APP_ENV=production for the app service.)
|
|
APP_ENV=production
|
|
|
|
# ── Database ──────────────────────────────────────────────────────────────────
|
|
# Set a strong password and keep it in sync between DATABASE_URL and
|
|
# POSTGRES_PASSWORD. Generate one with: openssl rand -hex 24
|
|
DATABASE_URL=postgres://eventsnap:CHANGE_ME_use_a_strong_password@db:5432/eventsnap
|
|
POSTGRES_USER=eventsnap
|
|
POSTGRES_PASSWORD=CHANGE_ME_use_a_strong_password
|
|
POSTGRES_DB=eventsnap
|
|
# Connection pool size. Default 10. For a busy event (~100 guests polling the feed
|
|
# + SSE + uploads at once) raise to ~30 so requests don't queue on a pool permit.
|
|
# PAIRED WITH THE DB CONTAINER'S MEMORY LIMIT: 30 backends plus Postgres 16's default
|
|
# shared_buffers is already snug in the 1G that docker-compose.yml allots the `db`
|
|
# service. If you raise this, raise `db.deploy.resources.limits.memory` with it — an
|
|
# OOM in Postgres doesn't degrade one feature, it takes the whole event down.
|
|
DATABASE_MAX_CONNECTIONS=30
|
|
|
|
# ── Authentication ────────────────────────────────────────────────────────────
|
|
# Generate with: openssl rand -hex 64
|
|
JWT_SECRET=change_me_to_a_random_64_byte_hex_string
|
|
SESSION_EXPIRY_DAYS=30
|
|
|
|
# Admin dashboard password (bcrypt hash).
|
|
# Generate with: htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
|
|
# IMPORTANT: keep the SINGLE QUOTES. A bcrypt hash is full of `$` (e.g. $2b$12$…$…),
|
|
# and both Docker Compose's env_file interpolation and dotenvy's variable substitution
|
|
# would otherwise eat the `$…` segments (reading them as unset vars) and corrupt the
|
|
# hash — every admin login then 401s. Single quotes make both read it literally.
|
|
ADMIN_PASSWORD_HASH='$2y$12$placeholder_replace_me'
|
|
|
|
# ── Event ─────────────────────────────────────────────────────────────────────
|
|
EVENT_NAME=Max & Maria's Wedding
|
|
EVENT_SLUG=max-maria-2026
|
|
|
|
# ── Storage ───────────────────────────────────────────────────────────────────
|
|
MEDIA_PATH=/media
|
|
# Export archives (Gallery.zip / Memories.zip). MUST be outside MEDIA_PATH —
|
|
# /media is publicly served, so exports here would be downloadable without auth.
|
|
EXPORT_PATH=/exports
|
|
|
|
# ── Runtime settings (upload limits, rate limits, capacity) ───────────────────
|
|
# NOTE: These are NOT environment variables. Upload size caps, rate limits, guest
|
|
# count and quota tolerance are stored in the database `config` table (seeded once
|
|
# at first boot) and changed at runtime from the ADMIN DASHBOARD — the backend does
|
|
# not read them from .env. Setting them here has no effect. Current seeded defaults:
|
|
# upload rate 100 / hour / guest (raised from 10 by migration 015)
|
|
# feed rate 60 / minute
|
|
# export rate 3 / day
|
|
# max image size 20 MB
|
|
# max video size 500 MB
|
|
# estimated guests 100
|
|
# quota tolerance 0.75 (see below — NOT a warning threshold)
|
|
# Adjust these in the admin UI before the event if needed.
|
|
#
|
|
# quota_tolerance is the MULTIPLIER IN THE PER-USER QUOTA FORMULA, not the point at
|
|
# which anything warns you:
|
|
#
|
|
# per_user_limit = floor(free_disk * quota_tolerance / active_uploaders)
|
|
#
|
|
# It is recomputed against LIVE free space on every upload, so it self-throttles: guests
|
|
# converge on a fixed point at tolerance/(1+tolerance) of the free space you started
|
|
# with — 43% at 0.75, i.e. ~30 GB of a fresh 70 GB.
|
|
#
|
|
# Raising it therefore AUTHORISES GUESTS TO FILL MORE OF THE DISK. Setting 0.95 in the
|
|
# belief that it means "warn me later" moves the fixed point to ~49% and eats the
|
|
# headroom the keepsake needs — and the keepsake needs a lot, because Gallery.zip and
|
|
# Memories.zip are each roughly a second copy of every original (both store media
|
|
# uncompressed). Budget for media + 2x media, or move exports to their own volume.
|
|
#
|
|
# 0.75 is the tested default. Lower it if the box is tight; raise it only if you have
|
|
# provisioned export headroom separately.
|
|
|
|
# ── Workers ───────────────────────────────────────────────────────────────────
|
|
# Number of parallel image/video compression workers. Default 2. This is the main
|
|
# throughput bottleneck: with 2 workers a burst of uploads can take ~5s to appear.
|
|
# For a large event (100+ guests) 4 is a good target — but each worker can run an
|
|
# ffmpeg transcode, so if you raise this ALSO raise the app container's memory limit
|
|
# in docker-compose.yml (`app.deploy.resources.limits.memory`) from 1G to ~2G, or a
|
|
# burst of large videos can OOM the box and take Postgres down with it.
|
|
COMPRESSION_WORKER_CONCURRENCY=2
|