# ── Domain ──────────────────────────────────────────────────────────────────── # Public domain Caddy will serve and obtain a TLS certificate for. # # The DNS A record must already point at this server BEFORE the first `up -d`: Caddy # requests a certificate on boot, and Let's Encrypt allows only 5 failed validations per # hostname per hour. Never delete the caddy_data volume — it holds the certificate and # the ACME account key. DOMAIN=my-event.example.com # ── Image version ───────────────────────────────────────────────────────────── # Tag pulled for the `app` and `frontend` services (docker-compose.yml). Production runs # prebuilt images from the registry and never compiles — see DEPLOYMENT_RUNBOOK.md. # Always an immutable tag, never `latest`: rollback is `EVENTSNAP_VERSION=` # + `docker compose up -d`, which works offline if that image is still resident locally. EVENTSNAP_VERSION=v0.13.0 # ── 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 # # SET THIS BEFORE THE FIRST `docker compose up -d`. Postgres reads POSTGRES_PASSWORD # only when it initialises its data directory, on that very first boot. Change it # afterwards and the app authenticates with the new password against a volume still # holding the old one — a permanent restart loop ("password authentication failed"). # The only ways out are restoring the old password or `docker compose down -v`, which # deletes the database, the media and the exports. In production the app refuses to # boot while this is still the placeholder below, so it cannot be missed by accident. 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 an image the stack already pulls (htpasswd needs apache2-utils, which # a stock VPS does not have): # docker run --rm caddy:2-alpine caddy hash-password --plaintext 'yourpassword' # 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 media compression workers. Default 2. Boot-time only. # # CORRECTION TO EARLIER GUIDANCE: this used to say "each worker can run an ffmpeg # transcode, so raise the app memory limit to ~2G if you set 4". There is NO video # transcode anywhere in this codebase — services/video.rs runs # `ffmpeg -ss -i -vframes 1 -vf scale=...`, a single poster frame, and video # originals are stored and served byte-for-byte. Poster extraction costs ~150-250 MB # for a moment; it is not the constraint. # # The real memory consumer is the IMAGE path. `image` 0.25's resize builds an Rgba32F # intermediate at 16 BYTES PER PIXEL, sized (source_width x target_height) — which the # 256 MiB decode guard in imaging.rs does NOT cover. Peak per photo, decode + the 2048px # display resize: ~145 MB at 12 MP, ~223 MB at 24 MP, ~354 MB at 48 MP. # # So on a 2 vCPU / 4 GB box (e.g. Hetzner CX22) KEEP THIS AT 2: # * concurrency 2, two 48 MP photos ≈ 800 MB against the 1G app limit — ~25% margin. # * concurrency 4, the same pair ≈ 1.5 GB — OOM. # * and app=2G + db=1G + frontend/caddy 256M each + ~370 MB of OS/Docker exceeds the # ~3910 MiB a "4 GB" VM actually reports. Raising the limit oversubscribes the host. # 4 is only reasonable on the 4 vCPU / 8 GB box README.md documents. # # Throughput at 2 is not the bottleneck anyone thinks it is: ~2.5s per 12 MP photo, so # 100 photos is ~250 CPU-seconds spread over an entire evening. COMPRESSION_WORKER_CONCURRENCY=2 # ── Comments ────────────────────────────────────────────────────────────────── # Master switch for the comment feature. Boot-time only (NOT in the admin UI), so it # needs a `docker compose up -d` to apply. Anything other than false/0/no/off is on. # # When false the backend rejects NEW comments with 403 and the frontend hides the whole # comment UI, including in the offline keepsake viewer. Likes and captions are entirely # separate features and are unaffected. Existing comments stay in the database (hidden), # so flipping it back restores them. # # Note it gates POSTING only: GET /upload/{id}/comments still serves already-existing # comments, and the keepsake's data.json still embeds their text. Irrelevant if the flag # is off from the first boot, since no comment can ever have been written. # NOTE for the current deployment: `docker-compose.yml` PINS this to "false" on the app # service, and `environment` overrides `env_file` — so changing it here has no effect in # production. Remove that line from the compose file first if you want comments back. COMMENTS_ENABLED=true # ── Logging ─────────────────────────────────────────────────────────────────── # SET THIS IN PRODUCTION. Without it the app falls back to # `eventsnap_backend=debug,tower_http=debug` (see main.rs), and with TraceLayer that is a # debug line per HTTP request — including every preview and thumbnail fetch. Combined with # Docker's json-file driver it writes to the same filesystem as the database and the media. # docker-compose.yml caps each service's logs at 30 MB; this keeps the volume sane in the # first place. The e2e stack has always used exactly this value. RUST_LOG=eventsnap_backend=info,tower_http=warn