deploy: pull prebuilt images instead of building on the event server

The production compose still carried `build:` keys and no `image:` keys, so a
`git clone` onto the CX22 followed by `docker compose up -d` would have started a
fat-LTO release build of 427 crates on a 2-vCPU/4 GB box — the outcome the whole
build-on-the-Mac decision exists to avoid, reached silently because `pull` skips a
service it is told to build rather than failing.

Both services now pull `registry.mc02.dev/eventsnap/*:${EVENTSNAP_VERSION}` with the
`:?` form, so a missing tag fails the command instead of resolving to an empty one.
`docker-compose.build.yml` restores the `build:` keys for the workstation that
produces the images, from the same context paths.

Also here:

- `DOMAIN` gets the same `:?` guard. Blank did not fail — it produced `https://` for
  the frontend's ORIGIN and collapsed the Caddyfile's site block into a malformed
  global block, so the stack came up with no TLS and no site.
- `stop_grace_period: 20s` on the app. Docker's default stop timeout is 10s, exactly
  the app's own drain budget, so a redeploy could SIGKILL the process at the moment it
  was finishing — truncating the in-flight upload the graceful shutdown protects.
- `COMMENTS_ENABLED` is pinned "false" alongside MEDIA_PATH. It is a product decision
  for this event, and `.env.example` ships the generic `true`; pinning it means an
  operator who copies the example and edits only the secrets cannot ship comments on.
- The frontend runtime stage now copies the lockfile and uses `npm ci`. Without it the
  three `^`-ranged deps re-resolved at build time, so an image rebuilt days later could
  differ from the one that was tested. Image also drops 120 MB -> 65 MB.
- `docker-compose.dev.yml` told the operator that production had the same `$`-eating
  bug and to escape the hash as `$$` in `.env`. That is wrong and it breaks a working
  deployment: Compose uses single-quoted env_file values literally, and doubling
  produces a 74-character string `looks_bcrypt` rejects. Verified with `printenv`.

The runbook's rollback pointed at `v0.12.0`, which has 6 migrations against HEAD's 22
and was never built or pushed — running the emergency card's rollback line would have
crash-looped the app with `VersionMissing` during the event. §9 now has you tag one
build twice so the rollback target is bit-identical, and says plainly what that can and
cannot fix. Every `$DOMAIN` command gained the `set -a; . ./.env` it needs, the
down-migration psql commands are wrapped in `sh -c` so the container expands the
credentials rather than sending `-U ""`, and the advice to lower `max_video_size_mb`
is withdrawn: the client guard it was premised on does exist, but is pinned to a
compile-time constant, so lowering the DB value only moves failures later.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Fabian Hamm (Privat)
2026-08-03 18:34:07 +02:00
parent 7d0334bf22
commit 43d37269b6
8 changed files with 876 additions and 25 deletions

693
DEPLOYMENT_RUNBOOK.md Normal file
View File

@@ -0,0 +1,693 @@
# EventSnap — Production Deployment Runbook
Target: **Hetzner CX22** (2 vCPU, 4 GB RAM, 40 GB disk), single host, docker compose.
Event profile: ~100 guests, ~100 photos + a few videos, one evening, **operator attending and unavailable to troubleshoot**.
Everything here is written for that last constraint. Where a choice trades throughput for
"cannot need attention on the night", it takes the stable option.
> **Note on this repo's own docs.** `README.md:62` and `PROJECT.md:359` specify a **CX33
> (4 vCPU / 8 GB / 80 GB)**. You are deploying to half of that on every axis. Two pieces of
> tuning advice in `.env.example` are calibrated for the CX33 and are actively wrong for a
> CX22 — they are called out in §3. Trust this file over `.env.example` for sizing.
---
## 0. Timeline — the single most important control
### Step zero: commit everything, before you build anything
**The production deployment does not exist in git yet.** At the time of writing, `docker-compose.yml`
and `.env.example` are modified but uncommitted, and `DEPLOYMENT_RUNBOOK.md`,
`docker-compose.build.yml`, both `.dockerignore` files and migrations `021`/`022` are untracked.
None of them is gitignored — they are simply not committed.
That is not a tidiness problem, it is the deployment failing in two ways at once:
- §7 tells you to `git clone` onto the server. `git show HEAD:docker-compose.yml` still has
`build:` keys and **no `image:` keys**, so on that clone `docker compose pull` skips both services
and `docker compose up -d` starts **a fat-LTO release build of 427 crates on the CX22** — the
exact scenario §1 rules out as an expected OOM. The committed file also has no log rotation.
- `sqlx::migrate!()` embeds `./migrations` **at compile time**. A build from the working tree bakes
in 021 and 022 and applies them on first boot; any later rebuild from a clean clone produces an
image that lacks them and crash-loops with `VersionMissing` against its own database.
```bash
git add docker-compose.yml docker-compose.dev.yml docker-compose.build.yml .env.example \
backend/.dockerignore frontend/.dockerignore frontend/Dockerfile \
backend/migrations/021_*.sql backend/migrations/022_*.sql \
DEPLOYMENT_RUNBOOK.md
git commit -m "chore: production compose, ignore files and runbook"
git push
# Prove it landed — this must print two `image:` lines and nothing about `build:`
git show HEAD:docker-compose.yml | grep -E 'image:|build:'
git show HEAD --stat | grep -c migrations/02 # must be 4
```
| When | What |
|---|---|
| **T7 days** | Commit and push everything above. Registry + DNS pre-flight (§5). Build and push images (§6). |
| **T5 days** | First deploy to the server (§7). Verify admin login. Leave it running. |
| **T3 days** | **Freeze migrations.** No further code deploys unless something is broken. |
| **T2 days** | Pre-pull current *and* previous image tags (§9). Run the backup rehearsal (§10). |
| **Event day** | Change nothing. Configuration tweaks via the admin dashboard only (§4). |
**Why the freeze matters more than anything else here.** Migrations run automatically at boot
(`backend/src/db.rs`, `create_pool`) and `sqlx::migrate!()` is used *without* `set_ignore_missing`. An older
image booted against a newer schema does not degrade — it **crash-loops** with `VersionMissing`.
The repo documents this itself in `backend/migrations/014_export_epoch.up.sql:3-8`. Once the
migration set is frozen, rollback is a one-line `.env` edit; before it is frozen, rollback means
hand-running down-SQL under pressure.
---
## 1. Deployment strategy — build on the Mac, push, pull
**Decision: build `linux/amd64` images on the M3 Pro, push to `registry.mc02.dev`, pull on the server.**
Rejected alternatives, with the reason each loses:
| Option | Why not |
|---|---|
| **Build on the CX22** | `backend/Cargo.toml` sets `lto = true` + `codegen-units = 1` over **427 crates**. Fat LTO links the whole program in one single-threaded process; peak RSS is estimated at 2.54 GB against ~1.52.5 GB free with the stack running. OOM is the expected outcome, not a tail risk. And *rollback would also be a build* — 3560 min under pressure. |
| **CI (Gitea on Pi 5)** | Ruled out by you, and correct: a Pi 5 is strictly worse than the CX22 for a fat-LTO link. |
| **True cross-compilation** (`--target x86_64-unknown-linux-musl`) | The dependency graph has **four C-compiling crates**`ring` (per-arch assembly), `zstd-sys`, `libdeflate-sys`, `libsqlite3-sys` — so it needs a real musl cross-toolchain. Alpine has no such package for an arm64 host; the working answer is `cargo-zigbuild`, which means a new base image and a new toolchain days before an unrepeatable event. **Slow but certain beats fast but novel.** |
Emulated build is the right call because the cost lands where it is free: on your laptop, days
early, with nothing depending on it. Estimated wall-clock for the backend is **2560 min with
Rosetta enabled** (hours without it) — start it and walk away.
> **Escape hatch if emulation is unbearable:** spin up a temporary Hetzner CPX41 (8 vCPU/16 GB) in
> the same account, build natively, push, destroy it. Costs cents, same commands, no repo change.
---
## 2. Repo changes — ALREADY APPLIED
> **Status: these are done, in the working tree.** They are documented here so you know what
> changed and why, not as work to repeat. Run `git diff` to review before committing.
### 2.1 `.dockerignore` files (were absent)
`backend/.dockerignore`:
```
target/
```
`frontend/.dockerignore`:
```
node_modules/
.svelte-kit/
build/
```
Without these, the first time you run `cargo build` or `npm install` locally, every image build
ships a multi-GB context to an *emulated* builder. Worse: `frontend/Dockerfile:9` does `COPY . .`
**after** `npm ci`, so a macOS `node_modules/` would be merged over the container's Linux one.
### 2.2 Switch compose from `build:` to `image:`
In `docker-compose.yml`, replace the `build:` block on `app` and `frontend`:
```yaml
app:
image: registry.mc02.dev/eventsnap/app:${EVENTSNAP_VERSION:?set EVENTSNAP_VERSION in .env}
```
```yaml
frontend:
image: registry.mc02.dev/eventsnap/frontend:${EVENTSNAP_VERSION:?set EVENTSNAP_VERSION in .env}
```
Two deliberate properties:
- The `:?` form **fails loudly** on an unset variable instead of resolving to an empty tag.
- **Removing `build:` entirely is a safety feature.** With no `build:` key on the server, a wrong
tag is an instant `manifest unknown` — never a surprise 45-minute compile on the production box.
New `docker-compose.build.yml` (opt-in, Mac only — follows the convention stated in
the header of `docker-compose.dev.yml` that overlays are never auto-loaded):
```yaml
# Build overlay. NOT loaded automatically. Used only where images are BUILT — never on the
# production server, which pulls.
# docker compose -f docker-compose.yml -f docker-compose.build.yml build
services:
app:
build: { context: ./backend, dockerfile: Dockerfile }
frontend:
build: { context: ./frontend, dockerfile: Dockerfile }
```
`e2e/docker-compose.test.yml` keeps its own `build:` blocks, so the e2e gate is unaffected.
### 2.3 Add log rotation to every service
`docker-compose.yml` currently sets **no logging config**, so the default `json-file` driver keeps
logs forever, on the same filesystem as Postgres and the media. Add to each of the four services:
```yaml
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
```
(Equivalently, set it once in `/etc/docker/daemon.json` — but that restarts the Docker daemon,
so do it *before* the stack is live, not after.)
---
## 3. `.env` — production values
```bash
# ── Identity ──────────────────────────────────────────────────────────────
DOMAIN=<your domain>
EVENT_NAME=<...>
EVENT_SLUG=<...>
# ── Image version (NEW — drives the image: tags in docker-compose.yml) ────
EVENTSNAP_VERSION=v0.13.0
# ── Secrets — ALL of them, before the first `up -d` ───────────────────────
JWT_SECRET=<openssl rand -hex 64>
POSTGRES_PASSWORD=<openssl rand -hex 24>
DATABASE_URL=postgres://eventsnap:<SAME PASSWORD>@db:5432/eventsnap
ADMIN_PASSWORD_HASH='<docker run --rm caddy:2-alpine caddy hash-password --plaintext "pw">'
# ── Paths — must match the volume mounts ──────────────────────────────────
MEDIA_PATH=/media # pinned by compose anyway, but keep consistent
EXPORT_PATH=/exports # NOT pinned by compose — see the trap in §7.3
# ── Sizing (see the two corrections below) ────────────────────────────────
DATABASE_MAX_CONNECTIONS=30
COMPRESSION_WORKER_CONCURRENCY=2
# ── Comments off, likes + captions on ─────────────────────────────────────
COMMENTS_ENABLED=false
# ── Logging (NEW — production currently defaults to DEBUG) ────────────────
RUST_LOG=eventsnap_backend=info,tower_http=warn
```
### Two corrections to the repo's own advice — do not follow `.env.example` here
**`COMPRESSION_WORKER_CONCURRENCY`: keep `2`. Do NOT raise to 4, and do NOT raise the app memory
limit to 2G.** `.env.example:92-98` justifies both on the premise that "each worker can run an
ffmpeg transcode". **There is no transcode anywhere in this codebase.** `services/video.rs::run_ffmpeg`
runs `ffmpeg -ss <t> -i <src> -vframes 1 -vf scale=…` — a single poster frame. Video originals are
stored and served byte-for-byte.
The real memory consumer is the **image** path: `image` 0.25's `resize` builds an `Rgba32F`
intermediate at **16 bytes/px**, sized `source_width × target_height`, which the 256 MiB decode
guard in `imaging::decode_limits` does not cover. Estimated peak per photo:
| Source | Peak (decode + display resize) |
|---|---|
| 12 MP (typical phone) | ~145 MB |
| 24 MP (iPhone Pro default) | ~223 MB |
| 48 MP ("Max" mode) | ~354 MB |
At concurrency 2, two 48 MP photos ≈ 800 MB against the 1 GiB cap — ~25% margin. At concurrency 4
the same pair is ~1.5 GB → **OOM**. And app=2G + db=1G + 256M + 256M + ~370 MB OS/Docker ≈ 3954 MiB
against ~3910 MiB MemTotal — the box is oversubscribed before a single photo arrives.
**`quota_tolerance`: keep `0.75`. Raising it does not make anything more generous for a real guest.**
See §4.
---
## 4. Admin dashboard configuration
These live in the DB `config` table, not `.env`. Changes take effect on the **next request**
`patch_config` invalidates the cache synchronously (`admin::patch_config`). No restart needed. Set them
after the first deploy, before the event.
| Key | Default | **Set to** | Why |
|---|---|---|---|
| `upload_rate_per_hour` | 100 | **1000** | A guest multi-selecting 100 photos hits exactly 100. Client backs off and resumes, but this makes it a non-issue. |
| `feed_rate_per_min` | 60 | **240** | Headroom for reconnect bursts. |
| `social_rate_per_min` | 120 | **600** | This is the **likes** limiter — the interaction you're keeping. |
| `export_rate_per_day` | 3 | **20** | **One shared bucket across both archives**`enforce_export_rate` keys on `export:{user_id}` regardless of which archive is being fetched. Downloading Gallery.zip + Memories.zip costs 2 of 3; one retry locks a guest out for 24 h. The limit is now charged when the download *ticket* is minted, so being over it produces a visible German error instead of a tap that silently does nothing. |
| `max_video_size_mb` | 500 | **leave at 500** | See below. |
| `quota_tolerance` | 0.75 | **leave at 0.75** | See below. |
| `quota_enabled`, `storage_quota_enabled`, `rate_limits_enabled` | true | **leave on** | This is the only disk-full safety net. |
**Ignore `estimated_guest_count` and `upload_count_quota_enabled`.** Both are seeded, validated and
rendered in the admin UI — and **read by no code at all** (verified by grep across `backend/src`).
Changing them does nothing. `estimated_guest_count` in particular does *not* feed the quota formula.
### Why quotas are already as generous as you want
```
per_user_limit = floor(free_disk × quota_tolerance / max(active_uploaders, 1))
```
`active_uploaders` is `SELECT COUNT(DISTINCT user_id) FROM upload WHERE deleted_at IS NULL`
(`upload::compute_storage_quota`) — **people who actually uploaded**, not guests who joined. The 70 guests who never
upload are not in the denominator; their share flows to the photographers automatically. **The
redistribution you asked for is already the design.**
With ~28 GB free and a realistic 30 people actually uploading, each gets **~700 MB** — against an
expected ~1.25 GB for the *entire event*. Nobody will be blocked.
Raising `quota_tolerance` would only raise the **saturation ceiling** (media converges to
`t/(1+t)` of free space: 43% at 0.75, 50% at 1.0). It does nothing for a real guest at your volume,
and it eats the headroom the keepsake needs — the export preflight reserves `media × 1.10 × 2`
because `Gallery.zip` and `Memories.zip` each store media **uncompressed** (`export.rs` — both archives store media uncompressed).
**Why `max_video_size_mb` stays at 500.** An earlier draft of this runbook said to lower it to 250,
on the grounds that there is no client-side size check. That was wrong on both halves:
- A client guard **does** exist — `frontend/src/routes/upload/+page.svelte` rejects anything over
`HARD_MAX_UPLOAD_BYTES` (576 MiB) before a byte leaves the phone, alongside the HEIC reject.
- That guard is a **compile-time constant**, not the DB value. Lowering `max_video_size_mb` therefore
changes nothing on the client: the picker still accepts the clip, the phone still starts sending
it, and the *server* aborts it partway (`stream_field_to_file` does stop at the cap, so the AP is
not saturated for the full file — but the guest still gets a mid-upload failure where 500 MB would
simply have worked).
So lowering it is a pure capacity decision with no UX upside, and at your volume there is no capacity
problem to solve. Leave it. If you ever do want a smaller ceiling to bite on the phone, the constant
above has to move with it.
---
## 5. Pre-flight — T7 days (do NOT leave this to event week)
### Registry
From **both** the Mac and the server, as **the user who will deploy** (root's `docker login` does
not help a non-root deploy — credentials go to `~/.docker/config.json`):
```bash
getent hosts registry.mc02.dev # DNS resolves from the server
curl -fsSI https://registry.mc02.dev/v2/ # TLS must be PUBLICLY trusted; Docker rejects
# self-signed without extra daemon config
docker login registry.mc02.dev
```
Also confirm: the `eventsnap` repository/namespace exists if your registry requires pre-creation
(Harbor does; plain `registry:2` does not), and the registry host has ~500 MB free per release.
### DNS and TLS — get this wrong and you are locked out for an hour
The DNS **A record must point at the CX22 before the first `docker compose up -d`**, because Caddy
attempts certificate issuance on boot. Let's Encrypt allows only **5 failed validations per hostname
per hour** (refilling 1 per 12 min). A misconfigured DNS record plus a few impatient restarts will
rate-limit you out of getting a certificate at all.
- Deploy days early so issuance happens with no time pressure.
- **Never delete the `caddy_data` volume** — it holds the certificate and the ACME account key.
- Never run `docker compose down -v`. It destroys `postgres_data`, `media_data`, `exports_data`
*and* `caddy_data`.
### Host preparation
```bash
docker compose version # must be v2.x — the deploy.resources limits need it
free -h && swapon --show # Hetzner images ship no swap
df -h /var/lib/docker # want ≥ 25 GB free
```
**Add 2 GB of swap** as an OOM cushion — a compression spike that would otherwise kill the container
instead swaps out cold pages and merely runs slowly:
```bash
fallocate -l 2G /swapfile && chmod 600 /swapfile && mkswap /swapfile && swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
sysctl -w vm.swappiness=10 && echo 'vm.swappiness=10' > /etc/sysctl.d/99-swap.conf
```
> **Gotcha:** Compose sets each container's `Memory` limit but leaves `MemorySwap` unset, and Docker
> then allows swap equal to the memory limit — so adding host swap silently **doubles** every
> container ceiling. If you add swap, also add `memswap_limit: 1152m` to `app` and `db`, and
> `memswap_limit: 320m` to `frontend` and `caddy` (service-level, not under `deploy:`).
---
## 6. Build and push — on the Mac
Enable **Docker Desktop → Settings → General → "Use Rosetta for x86_64/amd64 emulation"**, and set
**Resources → Memory ≥ 8 GB** (the fat-LTO step will OOM inside the VM otherwise, even with 18 GB on
the host).
```bash
docker run --rm --platform linux/amd64 alpine uname -m # must print x86_64
cd /Users/fabianhammprivat/Projects/EventSnap
VERSION=v0.13.0 # latest existing tag is v0.12.0 — see §9 before reusing it
ROLLBACK=v0.13.0-a # the SAME source, tagged twice; §9 explains why
SHA=$(git rev-parse --short HEAD)
docker buildx create --name eventsnap --use 2>/dev/null || docker buildx use eventsnap
docker buildx build --platform linux/amd64 \
-t registry.mc02.dev/eventsnap/app:$VERSION \
-t registry.mc02.dev/eventsnap/app:$ROLLBACK \
-t registry.mc02.dev/eventsnap/app:$SHA \
--push ./backend
docker buildx build --platform linux/amd64 \
-t registry.mc02.dev/eventsnap/frontend:$VERSION \
-t registry.mc02.dev/eventsnap/frontend:$ROLLBACK \
-t registry.mc02.dev/eventsnap/frontend:$SHA \
--push ./frontend
```
### Verify the architecture — never skip this
An arm64 image pulls fine and then dies with `exec format error`. Catch it here, not on the server:
```bash
docker buildx imagetools inspect registry.mc02.dev/eventsnap/app:$VERSION
docker buildx imagetools inspect registry.mc02.dev/eventsnap/frontend:$VERSION
# Both MUST report Platform: linux/amd64
```
Then prove the binary actually executes. `AppConfig::from_env()` runs before any DB connection
(`main.rs` builds `AppConfig` before touching the pool), so this needs no database:
```bash
docker run --rm --platform linux/amd64 \
-e APP_ENV=production -e JWT_SECRET=x -e DATABASE_URL=x -e EVENT_SLUG=x \
registry.mc02.dev/eventsnap/app:$VERSION
```
Expect the "Refusing to start in production … placeholder" message. **That message means the amd64
binary ran.** `exec format error` means the architecture is wrong.
**Tag policy: never `latest` in production.** With `latest` you cannot tell what is running,
rollback becomes a registry re-push (impossible if the registry is down — exactly when you need it),
and `restart: unless-stopped` after a reboot is ambiguous. Two immutable tags per build: semver and
short SHA. Deploy by semver.
---
## 7. First deploy — on the server
```bash
# Directory name matters: volumes are prefixed with it, and README's backup commands
# hardcode the `eventsnap_` prefix.
git clone <repo> eventsnap && cd eventsnap
cp .env.example .env && nano .env # every value from §3
docker login registry.mc02.dev
docker compose pull # must fully succeed before anything starts
```
### 7.1 The secret pre-flight — run this before the first `up -d`, always
```bash
docker compose run --rm --no-deps app
```
`--no-deps` means `db` never starts, so **no Postgres data directory is initialised**.
`AppConfig::from_env()` runs first and reports *every* placeholder at once (`config::validate_secrets`).
When the only remaining complaint is a database *connection* failure, the secrets are good.
**Why this step exists.** `POSTGRES_PASSWORD` is applied **only at initdb**. `docker-compose.yml`
starts `db` in the same command as `app`, so a single `up -d` with a placeholder bakes the wrong
password in permanently — the app then loops on `password authentication failed`, and the only exits
are `ALTER ROLE` or `down -v`, which deletes the database, the media and the exports. The repo
describes this trap at `backend/src/db.rs` (`explain_auth_failure`); this command is what avoids it.
### 7.2 Bring it up
```bash
# `.env` is consumed by docker compose, not by your shell — load it before using $DOMAIN.
set -a; . ./.env; set +a
docker compose up -d
docker compose logs -f app # wait for "database connected and migrations applied"
curl -fsS https://$DOMAIN/health # → ok (503 means the app is up but the DB is not)
```
### 7.3 Verify the three things compose does *not* pin
`MEDIA_PATH` is pinned to `/media` on the `app` service in `docker-compose.yml`. Its siblings
are not:
```bash
docker compose exec app printenv DATABASE_URL EXPORT_PATH ADMIN_PASSWORD_HASH
```
1. **`DATABASE_URL`** must contain `@db:5432`. A dev `.env` points it at `@localhost`, which inside
the container is the app itself.
2. **`EXPORT_PATH`** must be `/exports`. Anywhere else and the keepsake archives are written to the
container's writable layer and **vanish on the next `up -d`** — including on a rollback.
3. **`ADMIN_PASSWORD_HASH`** must match `.env` **byte for byte.**
**Then actually log in to `/admin` with the real password.** This is not optional politeness:
- The production secret guard only rejects *placeholders* (`config::looks_placeholder`). A hash **corrupted**
by shell or Compose escaping is not a placeholder — the app boots green, `/health` says `ok`, and
every admin login 401s.
- The Admin user row is created **by a successful admin login** (`auth::handlers::admin_login`), and
promoting anyone to Host requires an Admin or Host (`auth::middleware`'s role guard). **No admin login
⇒ no host, ever** ⇒ you cannot close the event, release the gallery, ban anyone, reset a PIN, or
change any limit — for the whole event.
Per the Compose spec, single-quoted `env_file` values *are* used literally and the quotes are
stripped, so the single-quote form in `.env.example` is correct. The comment in
`docker-compose.dev.yml` claiming production has the same bug is **stale**. Verify anyway —
the cost of checking is 10 seconds; the cost of being wrong is the whole event.
**Promote a second person to Host** once you are in, so a single lost session is not fatal.
---
## 8. Post-deploy verification
```bash
set -a; . ./.env; set +a # $DOMAIN comes from .env, not your shell
docker compose ps # all four healthy
docker inspect -f '{{.HostConfig.Memory}}' eventsnap-app-1 # must be 1073741824, not 0
curl -fsS https://$DOMAIN/health # ok — now a real DB check, not a constant
docker compose exec app printenv COMMENTS_ENABLED RUST_LOG
docker builder prune -af && docker image prune -f # reclaim build cache
df -h /var/lib/docker
```
Then, from a phone on cellular (not the office wifi):
- [ ] Join as a guest with a PIN
- [ ] Upload a photo → appears in the feed within a few seconds
- [ ] Upload a video → plays back (iOS Safari range requests)
- [ ] Add a caption, add a like — **no comment UI anywhere**
- [ ] Admin login works; host dashboard reachable
- [ ] Release the gallery on a test event and download both archives
---
## 9. Rollback
### There is no older image you can roll back to. Build the rollback target yourself.
Read this before the event, not during it. The obvious move — drop `EVENTSNAP_VERSION` back to the
previous released tag — **takes the app down permanently** and looks like a crash loop with no
explanation:
```
$ git ls-tree --name-only v0.12.0 backend/migrations/ | wc -l
12 # 6 migrations. HEAD has 22.
$ git rev-list --count v0.12.0..HEAD
154
```
`db.rs` runs `sqlx::migrate!()` with no `set_ignore_missing`, so an image built from a 6-migration
tree, booting against a database that already carries versions 007022, returns `VersionMissing`.
`create_pool` errors, `main` exits 1, and `restart: unless-stopped` restarts it forever — with Caddy
still routing traffic to it. (`014_export_epoch.up.sql` documents this failure mode; §0 restates it.)
No `v0.12.0` image was ever built or pushed either, so the pre-pull would fail with
`manifest unknown` before you ever got that far.
**So: at build time, tag the SAME frozen commit twice.** Two identical images, two names. The
rollback then swaps to a binary that is bit-for-bit what you tested and carries the identical
migration set, which makes it a genuine no-op rather than a gamble:
```bash
# In §6, push both tags from the one build:
VERSION=v0.13.0
ROLLBACK=v0.13.0-a # same source, different name — the rollback target
docker buildx build --platform linux/amd64 \
-t registry.mc02.dev/eventsnap/app:$VERSION \
-t registry.mc02.dev/eventsnap/app:$ROLLBACK \
--push ./backend
# ...and the same two tags for ./frontend
```
**Rolling back — ~30 seconds, no network:**
```bash
sed -i 's/^EVENTSNAP_VERSION=.*/EVENTSNAP_VERSION=v0.13.0-a/' .env
docker compose up -d app frontend
```
This only works offline if both are already resident. **Pre-pull all four at T2:**
```bash
docker pull registry.mc02.dev/eventsnap/app:v0.13.0
docker pull registry.mc02.dev/eventsnap/frontend:v0.13.0
docker pull registry.mc02.dev/eventsnap/app:v0.13.0-a
docker pull registry.mc02.dev/eventsnap/frontend:v0.13.0-a
docker image ls | grep eventsnap # confirm all four
```
Once resident, `up -d`, reboots, restarts and rollbacks need **zero** registry contact. That single
step makes a registry outage on event day irrelevant.
Be clear-eyed about what this buys you: an identical image cannot undo a bad *release*, only an
image that got corrupted or a container that wedged — and `docker compose restart app` already
covers both. It exists so that the rollback line in the emergency card is safe to run rather than
catastrophic. **If you genuinely need to undo a code change during the event, you cannot; freeze
early enough that you never have to.**
**Across a migration boundary — avoid by freezing.** If you must: all 22 migrations have paired
`.down.sql` files, but **none of them removes its own `_sqlx_migrations` row**, so that second step
is mandatory and undocumented:
```bash
docker compose stop app
# `sh -c` so the CONTAINER expands the credentials. They live in the container's environment
# and in .env — not in your shell — so an unwrapped `-U "$POSTGRES_USER"` sends `-U ""` and
# psql answers `FATAL: role "" does not exist`.
docker compose exec -T db sh -c \
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -v ON_ERROR_STOP=1' \
< backend/migrations/0NN_x.down.sql
docker compose exec -T db sh -c \
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c "DELETE FROM _sqlx_migrations WHERE version = NN;"'
```
Migration **014** is the only destructive one on the way up, and the only one with a rehearsal
harness — `backend/scripts/rehearse-014.sh`. Run it once against a real dump before the event.
**Registry-down transport fallback** (layers are already compressed — do not add gzip):
```bash
docker save registry.mc02.dev/eventsnap/app:v0.13.0 \
registry.mc02.dev/eventsnap/frontend:v0.13.0 | ssh root@SERVER 'docker load'
```
---
## 10. Backup
Full commands are in `README.md:315-435` and are correct — `pg_dump --clean --if-exists`, plus
`alpine tar` out of `eventsnap_media_data` and `eventsnap_exports_data`, mounted at `/src` (not
`/media`), with `chown -R 100:101` on restore because the app runs non-root and BusyBox tar has no
`--same-owner`. There is deliberately no script.
Two gaps the README does not cover:
1. **Nothing backs up `.env`**, which holds the only copy of `POSTGRES_PASSWORD`. A dump you cannot
authenticate against is not a backup. Copy `.env` off the box, encrypted, once it is final.
2. **Timing.** Do not use nightly cron — every irreplaceable byte is created inside one evening.
Take the dump and the media tarball back-to-back **the night of the event, after locking uploads
from the host dashboard**, so the pair is consistent.
---
## 11. Disk — the numbers for 40 GB
Expected event (100 photos @ ~4 MB, 5 videos @ ~150 MB). Originals are **always kept** and never
lossily recompressed; derivatives are a ≤800 px preview and a ≤2048 px display JPEG.
```
media (originals + derivatives) ~1.25 GB
peak during keepsake build (+ Gallery + Memories) ~3.5 GB
OS + Docker images + Postgres + logs ~4.5 GB
────────────────────────────────────────────────────────────
total ~1113 GB of ~36 GB usable
```
**40 GB fits with roughly 3× headroom**, provided you build elsewhere (a server-side build adds
35 GB of cache that permanently shrinks the guest quota, because the quota is recomputed against
*live* free space on every upload).
The `README.md:295-299` "ENOSPC" projection models guests **saturating the quota** (~12 GB of
media), not 100 photos. That scenario needs ~10× your expected volume — and it degrades gracefully:
the export preflight refuses up front rather than hitting ENOSPC mid-write, and the host dashboard
warns when free space drops below 10 GB or below the keepsake requirement (`handlers::host`'s low-disk thresholds).
---
## 12. Known issues you are shipping with
None of these has a fix in this runbook; they are listed so nothing is a surprise. Severity is
scored purely by "would this interrupt you during the event".
### Fixed in this pass
| Was | Fix |
|---|---|
| **Queued uploads never rehydrated**`loadQueue()` had one call site, so a guest whose PWA was evicted mid-upload and reopened onto `/feed` had pending photos in IndexedDB that nothing ever read. Silent photo loss. | `loadQueue()` now runs on every authenticated boot in `+layout.svelte`. |
| **A corrupted `ADMIN_PASSWORD_HASH` booted green** and only failed at admin login — unrecoverable mid-event. | `config.rs` now validates the bcrypt *shape*, not just placeholder-ness, and refuses to start. |
| **SSE reconnect thundering herd** — flat 500 ms jitter regardless of backoff. | Jitter now scales with the delay; the feed's in-place refresh is spread over 8002800 ms. |
| **No client-side size/HEIC pre-check** — a doomed 600 MB upload saturated the venue AP before being rejected. | Certain-rejects are refused before any bytes leave the phone. |
| **Upload-queue UI unreachable** — the badged FAB opened the picker, not the queue, so a failed upload had no retry button. | The upload sheet now shows a queue entry whenever the badge is non-zero. |
| **A mid-event 401 left a dead screen** with no nav and no URL bar. | `api.ts` now returns the guest to `/join` (skipping the auth routes themselves). |
| **Rate limiter could panic while holding its mutex** (`timestamps[0]` with `max == 0`), poisoning it process-wide. | Uses `first()` with a fail-open guard. |
| **Hashtag deadlock**`edit_upload` and `add_comment` upserted tags in client/text order. | Both now sort + dedup on the normalised key, matching the upload path. |
| **Unbounded Docker logs + debug-level app logging.** | `logging:` caps every service at 30 MB; `RUST_LOG` documented in `.env.example`. |
### Fixed in the readiness pass — behaviour you should know about
These change what you will observe on the night, so they are listed separately from the table above.
| Was | Now |
|---|---|
| **Any ffmpeg-level failure DESTROYED the video.** A poster-frame error — ffmpeg hanging on a truncated `.mov`, an ENOSPC on `thumbnails/`, a DB blip — propagated into the give-up path, which soft-deleted the upload. Reproduced live: on a host with no ffmpeg the clip was gone ~6 s after its `201 Created`. | No failure in the video branch can fail the upload. The clip stays in the feed and plays from its original; only the poster is missing. |
| **A lost response duplicated the photo.** Every retry minted a fresh upload id, so a phone that lost the reply and re-sent — manually, or automatically on reconnect — stored the same photo two or three times and paid quota for each. | The client sends a `client_upload_id`; migration 022 makes it unique. A retry returns `200` with the original row. Verified live: three sends → one row, quota charged once. |
| **`/health` returned a constant `"ok"`.** The container reported healthy while every request 500'd. | Runs `SELECT 1` with a 2 s timeout: `200 ok` / `503`. Verified live: 200 → stop Postgres → 503 → start Postgres → 200, **with no app restart** (the pool revalidates on acquire). Note that Compose does not restart on an unhealthy probe — this is a diagnostic, deliberately not wired to automatic recovery, because a restart would truncate in-flight uploads to "fix" an outage that clears on its own. |
| **Abandoned `.tmp` uploads were never reclaimed** (see the old issue #2 below — it is now fixed, not deferred). | Swept at boot and hourly, keyed on modification time so a live upload can never age into it. |
| **A full disk made itself worse.** ENOSPC was retried three times, then refunded the quota, soft-deleted the row and *kept* the bytes — freeing nothing and inviting an immediate re-upload into the same full disk. | ENOSPC is classified separately: no retry, no refund, no delete. The row stays live and the photo is served from its original until there is room to compress it. |
| **The keepsake download failed invisibly.** The rate limit was enforced inside the iframe navigation, so an over-limit guest tapped and *nothing happened*, forever. | Charged when the ticket is minted — a normal `fetch` — so it surfaces as a German message naming the daily window. Raise `export_rate_per_day` per §4 anyway. |
| **`/host` and `/admin` subscribed to SSE but never opened the connection**, so the keepsake progress bar froze after a release. | Both connect on mount and disconnect on destroy, as `/export` already did. |
| **`?limit=-5` on the feed returned a 500.** | Clamped at both ends. |
| **All recurring hygiene lived in one unsupervised task** — one panic silently stopped session pruning, media reclamation and the temp sweep for the rest of the event. | Supervised and re-spawned, with an error log. |
| **No pool acquire or statement timeout.** A DB blip parked every request for 30 s. | 5 s acquire, `statement_timeout=15s`, `lock_timeout=5s`. |
### Still open — accepted for this event
| # | Issue | Impact |
|---|---|---|
| 1 | **Guest delete/caption-edit after release invalidates both keepsake archives** (`upload.rs`), forcing a rebuild with a 20 s debounce. **No-op before release**, so it cannot bite during the event. | Post-event only. Left alone because blocking guest deletes has real privacy downsides — that is a product call, not a bug fix. |
| 2 | **Video poster frames do not regenerate after a restart** mid-compression (the backfill filters `mime_type LIKE 'image/%'`). | Cosmetic — the video still plays; only its poster is missing. |
| 3 | **SSE keep-alives are sent as SSE comments** (`:ping`), which the browser's EventSource parser discards without dispatching. A client therefore cannot implement a pure silence timer to detect a half-open socket. | Worked around client-side: the feed runs a jittered 60120 s `/feed/delta` backstop and reconnects when a poll returns content the stream never delivered. A cleaner fix is to emit keep-alives as a *named* event; that is a coordinated backend+frontend change, not worth making during a freeze. |
| 4 | **The lightbox stops at the end of the loaded page** — stepping past the last loaded photo does not fetch the next one. | The guest scrolls the feed (which does page) and re-opens. |
---
## 13. Event-day emergency card
Assume you have a phone and two minutes.
```bash
cd ~/eventsnap
# FIRST LINE, ALWAYS. `.env` is read by docker compose, NOT by your shell — without this,
# every `$DOMAIN` below expands to nothing and `curl https:///health` reads like an outage
# when the site is fine.
set -a; . ./.env; set +a
# Is it alive? (200 = app AND database are answering; 503 = the app is up, the DB is not)
curl -fsS https://$DOMAIN/health
# What is broken?
docker compose ps
docker compose logs --tail=100 app
# Nuclear option that is SAFE (keeps all data):
docker compose restart app
# Roll back to the identically-built sibling image — see §9 for what this can and cannot fix.
# Do NOT substitute an older release tag here; it will crash-loop on the migration set.
sed -i 's/^EVENTSNAP_VERSION=.*/EVENTSNAP_VERSION=v0.13.0-a/' .env && docker compose up -d app frontend
# Disk check
df -h /var/lib/docker
```
**NEVER** run `docker compose down -v`. It deletes the database, all media, all exports and the TLS
certificate. There is no undo.
Most limits are changeable from the **admin dashboard without a restart** — reach for that before
touching the shell.