The upload gate refuses any photo that would leave too little room to build the keepsake, and the archive needs both halves at once — so it demands `2.2 x media + 10 GB reserve + 1.5 GB headroom`. Every gigabyte of photos therefore costs about 3.2 GB of disk budget, and usable media works out at `(disk - 11.5 GB) / 3.2`. On a 30 GB volume that is only ~5.8 GB of photos. Measured, not theorised: a 945-photo wedding (8.74 GB of real files) stopped after ~780 photos with 24.98 GB free — the gate wanted 25.91 GB on a disk that was 79% empty. Uploads ended for want of an archive nobody had asked for yet. `KEEPSAKE_ENABLED=false` drops the archive term, leaving only `DISK_RESERVE_BYTES` — the one question still live without an archive, which is whether Postgres can still write. That turns the same 30 GB volume into ~20 GB of usable media. Verified against the exact disk state that ended the run: the photo that returned 413 with the keepsake armed is accepted with it off. The headroom term goes with it, deliberately. It exists only to keep this gate strictly ahead of the EXPORT PREFLIGHT, and with no keepsake there is no preflight to stay ahead of. Boot-time immutable, like COMMENTS_ENABLED: flipping it mid-event would move the gate under uploads already accepted against the old one. Releasing the gallery and minting download tickets are refused while it is off, and the host dashboard hides the release control rather than offering a button that answers 403 — a new `keepsake_enabled` field on `GET /host/event` carries that. The gate and the host's low-disk banner now share `upload_gate_required_free`. They were already two expressions of one threshold, and the banner exists to fire BEFORE the gate closes; applying the switch to only one of them would warn the host about a limit that can no longer fire, or stay silent past one that can. Tests pin the lead in both modes. The cost is real and worth stating plainly: there is no downloadable gallery at the end, so guests keep only what they save from the feed. Prefer a bigger disk where you can — ~45 GB holds this library with the keepsake intact. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
242 lines
16 KiB
Plaintext
242 lines
16 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.17.4; v0.17.5 is the release you
|
|
# cut for the event. Build and push it (plus its identical rollback twin v0.17.5-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 an older release tag. v0.17.4 carries 16 migrations
|
|
# against a database that will hold 31, so it returns VersionMissing and crash-loops forever
|
|
# behind a live Caddy — and unlike an unbuilt tag, an image for it may well PULL cleanly, so a
|
|
# successful `docker pull` is not evidence that the tag is safe. §9 covers this in full.
|
|
EVENTSNAP_VERSION=v0.17.5
|
|
|
|
# ── 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
|
|
|
|
# ── Keepsake archive (and the disk ceiling it imposes) ────────────────────────
|
|
# Master switch for the downloadable gallery. Boot-time only, like COMMENTS_ENABLED.
|
|
#
|
|
# This is really a DISK-SIZING knob. The keepsake needs room for both halves at once (a ZIP
|
|
# and an HTML viewer, each gallery-sized), and the upload gate refuses any photo that would
|
|
# leave too little space to build it. The gate demands:
|
|
#
|
|
# free >= 2.2 x media + DISK_RESERVE (10 GB) + UPLOAD_GATE_HEADROOM (1.5 GB)
|
|
#
|
|
# so usable media is roughly `(disk - 11.5 GB) / 3.2`. On a 30 GB volume that is only ~5.8 GB
|
|
# of photos — uploads stop with most of the disk still free. Measured: a 945-photo wedding
|
|
# (8.74 GB) hit the wall after ~780 photos with 25 GB free.
|
|
#
|
|
# KEEPSAKE_ENABLED=true (default) archive available; ~5.8 GB of photos on a 30 GB disk
|
|
# KEEPSAKE_ENABLED=false no archive; ~20 GB of photos on a 30 GB disk
|
|
#
|
|
# Turning it OFF is irreversible for the event in the way that matters: there is no download
|
|
# at the end, so guests keep only what they save from the feed themselves. Releasing the
|
|
# gallery and minting download tickets are refused while it is off. The 10 GB database
|
|
# reserve still applies — that one protects Postgres, not the archive.
|
|
#
|
|
# Prefer a bigger disk if you can: ~45 GB holds a 9.7 GB library WITH the keepsake.
|
|
KEEPSAKE_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
|