**The backup that did not exist.** `.env.example` ships `EVENT_NAME=Max & Maria's Wedding` and the runbook tells you to `cp .env.example .env`. Compose's env_file parser reads that fine. POSIX `sh` does not: `. ./.env` aborts with "Unterminated quoted string" (verified, rc=2), and every variable defined after that line is left unset. The §10.2 cron script is `#!/bin/sh` + `set -eu` + `. ./.env`, so it exited before `pg_dump` — every hour, into a log nobody reads. The only automated backup of the one thing the runbook calls irreconstructible produced nothing, and §10.2's own "prove it works NOW" only catches it if `.env` is already final at that moment. The script reads NOTHING from `.env` — `POSTGRES_USER`/`POSTGRES_DB` are expanded inside the db container by the single-quoted `sh -c`. The source line was pure liability and is gone. `EVENT_NAME` is now double-quoted in `.env.example`, which both parsers read identically (verified), and the three interactive sourcing sites now read just `$DOMAIN` instead of sourcing the whole file. The verify step also proves the dump is a non-empty valid gzip containing tables, rather than that a file exists. Also fixes the script's `cd /root/eventsnap`, which contradicts §5's non-root deploy and §13's `~/eventsnap` — under a non-root deploy it failed the same way, silently. **The crash loop with no message.** `docker-compose.yml` interpolated `POSTGRES_USER`/`POSTGRES_DB` with no default and no `:?` guard, into `environment:`, which OVERRIDES `env_file`. Unset does not fall back — it resolves to the empty string, initdb creates a role and database named "", `DATABASE_URL` still says `eventsnap`, and the app hits `FATAL: role "eventsnap" does not exist` forever. `pg_isready -U "" -d ""` never passes, so `app` never turns healthy and Caddy — gated on `service_healthy` — never starts: port 443 dead for the whole event, exit only via `down -v`. Both now carry `:?` guards (verified they fire), and §3's ".env template — ALL of them" list, which omitted both, now includes them. Other runbook corrections: the backup/restore pointer named a line range that had drifted into an unrelated section and stopped mid-restore, before the media restore and the mandatory `chown` — now referenced by heading, which cannot go stale. §7.3 told you to verify that `EXPORT_PATH` is not pinned when §3 correctly says it is. Stale counts: rev-list 196 -> 217, "versions 007–022" -> 007–031, `frontend/Dockerfile:9` -> :8, and the low-disk description now matches the code (the 10 GB absolute floor was removed as unreachable). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
217 lines
14 KiB
Plaintext
217 lines
14 KiB
Plaintext
# ── 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=<previous>`
|
|
# + `docker compose up -d`, which works offline if that image is still resident locally.
|
|
#
|
|
# ⚠ THIS TAG DOES NOT EXIST YET. The newest git tag is v0.12.0; v0.13.0 is the release you
|
|
# cut for the event. Build and push it (plus its identical rollback twin v0.13.0-a) BEFORE
|
|
# the first `docker compose up -d` — see DEPLOYMENT_RUNBOOK.md §6 (build) and §9 (rollback).
|
|
# Copying this file and starting the stack without that step fails with `manifest unknown`.
|
|
#
|
|
# Do NOT "fix" this by dropping back to v0.12.0: no image was ever built for it, and a
|
|
# 6-migration tree booting against a 31-migration database returns VersionMissing and
|
|
# crash-loops forever behind a live Caddy. §9 covers this in full.
|
|
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. The code default is 15 (DEFAULT_MAX_CONNECTIONS in backend/src/db.rs),
|
|
# and docker-compose.yml pins this value in `app.environment` so an edit here cannot reach the
|
|
# container. That pin is deliberate: since the value became boot-FATAL when unparseable — so an
|
|
# operator tuning a knob that never took effect gets told, instead of silently staying on the
|
|
# default — a stray quote or a trailing inline comment in `.env` would crash-loop the app behind
|
|
# a live Caddy. Change the pin in compose, not this line.
|
|
#
|
|
# SIZE IT TO THE CORES, NOT TO THE GUESTS. The earlier advice here was ~30, reasoned from
|
|
# "~100 guests polling the feed at once" back when a feed page cost ~449 ms and connections
|
|
# were spent waiting. Migration 024 replaced the feed view's GROUP BY with scalar subqueries
|
|
# and a page now costs well under a millisecond, so concurrency is no longer where the time
|
|
# goes. On a 2 vCPU box 30 simultaneous queries cannot run — they queue on the CPU instead of
|
|
# on the pool, which is the same wait wearing a different hat, and 30 Postgres backends plus
|
|
# shared_buffers is snug in the 1G that docker-compose.yml allots `db`.
|
|
#
|
|
# 15 on 2 vCPU / 4 GB. Raise toward 30 only alongside more cores AND a bigger `db` memory
|
|
# limit — an OOM in Postgres doesn't degrade one feature, it takes the whole event down.
|
|
DATABASE_MAX_CONNECTIONS=15
|
|
|
|
# Log level: see the "Logging" section near the bottom of this file.
|
|
#
|
|
# Defined THERE and nowhere else, deliberately. This file used to assign RUST_LOG twice —
|
|
# once here and once there — and Compose takes the LAST assignment, so editing this line to
|
|
# `debug` to chase a problem during the event changed nothing at all, silently. A key that
|
|
# appears twice in a .env is a trap regardless of which value is better.
|
|
|
|
# ── 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 ─────────────────────────────────────────────────────────────────────
|
|
# DOUBLE-QUOTED, and it matters. Compose's env_file parser reads `Max & Maria's Wedding`
|
|
# unquoted just fine — but the runbook also tells you to `set -a; . ./.env; set +a` in a plain
|
|
# shell, and POSIX `sh` aborts on the apostrophe with "Unterminated quoted string" (rc=2).
|
|
# Everything defined BELOW this line is then left unset, silently: the hourly pg_dump cron in
|
|
# §10.2 does exactly this, so it would exit before ever writing a backup, every hour, into a log
|
|
# nobody reads. Double quotes are read identically by both parsers (verified) — keep them, and
|
|
# keep them double, since single quotes would make a literal `$` in a name survive but are what
|
|
# `ADMIN_PASSWORD_HASH` above needs for the opposite reason.
|
|
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:
|
|
#
|
|
# divisor = max(active_uploaders, estimated_guest_count, 1)
|
|
# per_user_limit = max(floor(free_disk * quota_tolerance / divisor), 500 MiB)
|
|
#
|
|
# estimated_guest_count is a FLOOR ON THE DIVISOR, not decoration — it is a live knob
|
|
# (upload::quota_limit_bytes). Earlier drafts of this file and the runbook both omitted
|
|
# it and told operators it was inert; it is not.
|
|
#
|
|
# It is recomputed against LIVE free space on every upload, so in principle it self-
|
|
# throttles: guests converge on a fixed point at tolerance/(1+tolerance) of the free space
|
|
# you started with — 43% at 0.75.
|
|
#
|
|
# ON THIS BOX THAT FIXED POINT NEVER BINDS, and it is worth knowing which knob actually
|
|
# stops the disk filling. The arithmetic above used to be quoted as "~30 GB of a fresh
|
|
# 70 GB", which is an 80 GB CX33; this deploys to a CX22 with 40 GB. At ~28 GB free and
|
|
# estimated_guest_count = 100 flooring the divisor, the formula yields ~210 MB per guest —
|
|
# BELOW the 500 MiB floor — so every guest is granted the floor and the per-user quota
|
|
# stops bounding aggregate growth at all.
|
|
#
|
|
# What actually bounds it is the keepsake preflight in upload.rs: uploads are refused once
|
|
# free < media x 1.1 x 2 + 10 GB, which on 40 GB lands at ~8 GB of media (README, "Sizing
|
|
# the disk"). So if a guest reports being blocked, the number to look at is total media,
|
|
# not this one.
|
|
#
|
|
# Raising this still AUTHORISES GUESTS TO FILL MORE OF THE DISK on a larger box, and it
|
|
# still eats the headroom the keepsake needs — 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 <t> -i <src> -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 4 would put two giants at ~1.5 GB against the 1G app limit — 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.
|
|
#
|
|
# The "two 48 MP photos at once" worst case this number used to be sized against is no
|
|
# longer reachable: compression.rs takes an EXCLUSIVE `heavy` permit for any job whose
|
|
# estimated peak exceeds HEAVY_IMAGE_BYTES (150 MiB), so two giants serialise no matter what
|
|
# this is set to. What concurrency 2 now buys is two ORDINARY phone photos in parallel
|
|
# (~145 MB peak each), which is both memory-safe and short enough not to starve the two
|
|
# tokio worker threads a 2 vCPU box gets.
|
|
#
|
|
# Do NOT drop this to 1 hoping to protect the CPU. It halves throughput on the common light
|
|
# path for a heavy path that is already serialised, and a longer compression backlog means
|
|
# more feed tiles served from full-size originals (VirtualFeed falls back to /original while
|
|
# derivatives are pending) — trading a little CPU for a lot of venue-wifi bandwidth.
|
|
#
|
|
# 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
|