The two-phase preflight from eb0e405 landed in ONE place and was spliced inside
the other. `run_zip_export` ended up containing both blocks nested, so the
Gallery path pruned "Memories" archives that were not its to reclaim, while
`run_html_export` silently kept the single-phase form.
That left the exact deadlock the two-phase preflight exists to break, still
open on half the product. At a gallery size where a rebuild needs the previous
generation's bytes: the ZIP prunes its own superseded archive and rebuilds, and
the HTML preflight fails against a Memories archive still on disk. The prune
that would free it runs only after a success that can never happen, and any
epoch bump — a guest deleting one photo — retires the current viewer
immediately. Permanently stuck, unreachable from any handler, discovered at the
end of the night with nobody there.
Both halves now call one `ensure_export_space_reclaiming`, keyed on the
caller's OWN prefix, so they cannot drift again.
Three smaller things found in the same pass:
- The boot-failure panel hardcoded light-mode colours, and its heading set none
at all — the UA default black on the `#100f0f` dark background. On the one
screen whose entire job is to be readable, and in the failure mode where the
app's own stylesheet may be what did not load. Moved to classes in the inline
<style> so the `html.dark` variants apply.
- `.env.example` assigned RUST_LOG twice, 120 lines apart. Compose takes the
last one, so an operator raising the level mid-event to chase a problem would
have changed nothing, silently.
- The comment justifying `detail = ?message` in error.rs still claimed
`validate_display_name` allows newlines. It rejects control characters now —
but that is one input against every 4xx message in the app, so the escaping
is what makes the guarantee general. Said so.
151/151 backend, 58/58 vitest, clippy clean, svelte-check 0 errors, both
builds, caddy validate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
180 lines
12 KiB
Plaintext
180 lines
12 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.
|
|
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 10 (backend/src/db.rs) — set it explicitly,
|
|
# because a `.env` written by hand from this file's secrets is otherwise silently on 10.
|
|
#
|
|
# 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 ─────────────────────────────────────────────────────────────────────
|
|
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 <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
|