README step 2 said to set "DOMAIN, JWT_SECRET, ADMIN_PASSWORD_HASH, EVENT_NAME, etc." -- POSTGRES_PASSWORD was not in that list. .env.example said to set it and keep it in sync with DATABASE_URL. The two documents disagreed, and both readings ended badly. Branch A, README verbatim: the stack came up GREEN and healthy on CHANGE_ME_use_a_strong_password -- a database credential published in the public repo. The production secret guard covered JWT_SECRET and ADMIN_PASSWORD_HASH, and nothing anywhere looked at the Postgres password. Branch B, .env.example verbatim: a permanent restart loop, "password authentication failed for user eventsnap". The guard's own design made Branch B near-certain. It stops the APP on the first `docker compose up -d` -- but not the `db` service in that same command, which initialises its data directory and bakes in whatever password was in .env at that moment. POSTGRES_PASSWORD is honoured ONLY at initdb. So the intended recovery -- see the refusal, fix your secrets, boot again -- was exactly the sequence that broke it. Nothing in the error named the cause, and the remedy (`down -v`) is both unguessable and the one command you must never run once real data exists. Three changes, which have to ship together: the guard alone would just move operators out of Branch A and into Branch B. - The guard now rejects a placeholder DATABASE_URL in production (the password rides in that URL, which is what the app actually reads). Branch A can no longer boot. - It reports EVERY unset secret in one message instead of returning on the first. Fixing two secrets used to cost two boot cycles, on a stack where Caddy waits on the unhealthy app throughout, and each avoidable cycle is another chance to reach for -v. - A 28P01 handler in db.rs turns the unguessable failure into a self-explaining one: it names the initdb semantics, gives `down -v` with an explicit "deletes db + media + exports, no undo", and gives the ALTER ROLE alternative for when data already exists. Docs: step 2 now names POSTGRES_PASSWORD and says every secret must be set BEFORE the first up; the troubleshooting block covers the auth-failure loop and both remedies. Also replaces htpasswd (apache2-utils -- not on a stock VPS) with `docker run --rm caddy:2-alpine caddy hash-password`, an image the stack already pulls, in README, .env.example and the guard's own message. Verified the output ($2a$14) against the shipped $2y$12 example. Verified on a real Postgres, not just in tests: Branch A refuses and names DATABASE_URL; all three placeholders report in one boot; a volume initialised with one password and connected to with another prints the diagnostic; an unrelated connect failure (dead port) stays silent. 8 unit tests, including that non-prod ignores all of it so the e2e stack is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
100 lines
6.5 KiB
Plaintext
100 lines
6.5 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
|
|
#
|
|
# 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 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
|