fix(ops): the hourly backup never ran, and an unset POSTGRES_USER crash-loops silently
**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>
This commit is contained in:
10
.env.example
10
.env.example
@@ -87,7 +87,15 @@ SESSION_EXPIRY_DAYS=30
|
||||
ADMIN_PASSWORD_HASH='$2y$12$placeholder_replace_me'
|
||||
|
||||
# ── Event ─────────────────────────────────────────────────────────────────────
|
||||
EVENT_NAME=Max & Maria's Wedding
|
||||
# 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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -118,7 +118,7 @@ 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 . .`
|
||||
ships a multi-GB context to an *emulated* builder. Worse: `frontend/Dockerfile:8` 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:`
|
||||
@@ -184,6 +184,13 @@ EVENTSNAP_VERSION=v0.13.0
|
||||
|
||||
# ── Secrets — ALL of them, before the first `up -d` ───────────────────────
|
||||
JWT_SECRET=<openssl rand -hex 64>
|
||||
# These two are NOT optional and have no defaults. docker-compose.yml interpolates them into
|
||||
# `environment:`, which overrides `env_file`, so leaving them out does not fall back — it creates
|
||||
# a Postgres role and database named "" while DATABASE_URL still says `eventsnap`. The result is
|
||||
# a permanent crash loop whose only clean exit is `down -v`. Compose now refuses to start without
|
||||
# them, but write them here anyway: the three values below must agree with each other.
|
||||
POSTGRES_USER=eventsnap
|
||||
POSTGRES_DB=eventsnap
|
||||
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">'
|
||||
@@ -491,28 +498,33 @@ describes this trap at `backend/src/db.rs` (`explain_auth_failure`); this comman
|
||||
### 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
|
||||
# `.env` is consumed by docker compose, not by your shell — read $DOMAIN out of it first.
|
||||
# Reads that ONE variable rather than sourcing the file: `.env` legitimately holds values with
|
||||
# apostrophes (EVENT_NAME), and `. ./.env` aborts on one with "Unterminated quoted string".
|
||||
DOMAIN=$(sed -n 's/^DOMAIN=//p' .env | tr -d "\"'")
|
||||
|
||||
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
|
||||
### 7.3 Verify what the container actually received
|
||||
|
||||
`MEDIA_PATH` is pinned to `/media` on the `app` service in `docker-compose.yml`. Its siblings
|
||||
are not:
|
||||
`MEDIA_PATH`, `EXPORT_PATH` and `APP_PORT` are all **pinned** on the `app` service in
|
||||
`docker-compose.yml`, exactly as §3 says — editing them in `.env` changes nothing. This step is
|
||||
not about whether they are pinned; it is about confirming the container got the values you think
|
||||
it did, including the two that genuinely do come from `.env`:
|
||||
|
||||
```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.**
|
||||
1. **`DATABASE_URL`** (from `.env`) must contain `@db:5432`. A dev `.env` points it at
|
||||
`@localhost`, which inside the container is the app itself.
|
||||
2. **`EXPORT_PATH`** (pinned) must read `/exports`. If it does not, the pin has been edited —
|
||||
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`** (from `.env`) must match `.env` **byte for byte.**
|
||||
|
||||
**Then actually log in to `/admin` with the real password.** This is not optional politeness:
|
||||
|
||||
@@ -536,7 +548,7 @@ the cost of checking is 10 seconds; the cost of being wrong is the whole event.
|
||||
## 8. Post-deploy verification
|
||||
|
||||
```bash
|
||||
set -a; . ./.env; set +a # $DOMAIN comes from .env, not your shell
|
||||
DOMAIN=$(sed -n 's/^DOMAIN=//p' .env | tr -d "\"'") # $DOMAIN comes from .env, not your shell
|
||||
|
||||
docker compose ps # db, app, frontend healthy; caddy has
|
||||
# no healthcheck and shows only "running"
|
||||
@@ -570,11 +582,11 @@ explanation:
|
||||
$ git ls-tree --name-only v0.12.0 backend/migrations/ | wc -l
|
||||
12 # 6 migrations. HEAD has 31.
|
||||
$ git rev-list --count v0.12.0..HEAD
|
||||
196
|
||||
217
|
||||
```
|
||||
|
||||
`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 007–022, returns `VersionMissing`.
|
||||
tree, booting against a database that already carries versions 007–031, 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
|
||||
@@ -664,7 +676,11 @@ docker save registry.mc02.dev/eventsnap/app:v0.13.0 \
|
||||
|
||||
## 10. Backup
|
||||
|
||||
Full commands are in `README.md:315-435` and are correct — `pg_dump --clean --if-exists`, plus
|
||||
Full commands are in README's **`## Backup`** and **`## Restore`** sections — read `## Restore` to
|
||||
its END (through the media *and* exports restore, and the `chown` that follows), not just the
|
||||
database step. Referenced by heading, not by line number: the previous pointer named a line range
|
||||
that had drifted to the middle of an unrelated section and stopped mid-way through restore step 2,
|
||||
which would have restored the database and no media. They 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.
|
||||
@@ -704,25 +720,37 @@ while uploads are live, because a `pg_dump` is transactionally consistent on its
|
||||
Media is the bulk and *is* recoverable from guests' phones in the worst case, so it stays on the
|
||||
event-night schedule below.
|
||||
|
||||
Install this as **the same user you deployed as** (§5) — not root. `docker compose` needs that
|
||||
user's docker group membership and its compose project, and a root crontab has neither.
|
||||
|
||||
```bash
|
||||
# On the server, before the event. Hourly DB-only dump, keeping the last 48.
|
||||
mkdir -p /root/eventsnap-dumps
|
||||
cat >/root/eventsnap-dump.sh <<'SH'
|
||||
mkdir -p ~/eventsnap-dumps
|
||||
cat >~/eventsnap-dump.sh <<'SH'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
cd /root/eventsnap
|
||||
set -a; . ./.env; set +a
|
||||
OUT="/root/eventsnap-dumps/db-$(date -u +%Y%m%dT%H%M%SZ).sql.gz"
|
||||
# Must match your deploy directory from §5. cron starts in $HOME, so this cannot be relative.
|
||||
cd "$HOME/eventsnap"
|
||||
# NOTE: deliberately does NOT source .env. Nothing here reads it — POSTGRES_USER and POSTGRES_DB
|
||||
# are expanded INSIDE the db container by the single-quoted sh -c below, using the values compose
|
||||
# already injected. Sourcing it was actively harmful: `.env` legitimately contains values with
|
||||
# apostrophes (EVENT_NAME="Max & Maria's Wedding"), and POSIX sh aborts on one with
|
||||
# "Unterminated quoted string". Under `set -eu` this script would exit before pg_dump — every
|
||||
# hour, silently, leaving the only automated backup of the irreplaceable table permanently empty.
|
||||
OUT="$HOME/eventsnap-dumps/db-$(date -u +%Y%m%dT%H%M%SZ).sql.gz"
|
||||
docker compose exec -T db sh -c \
|
||||
'pg_dump --clean --if-exists -U "$POSTGRES_USER" "$POSTGRES_DB"' | gzip >"$OUT.tmp"
|
||||
mv "$OUT.tmp" "$OUT" # atomic: never leave a truncated dump looking complete
|
||||
ls -1t /root/eventsnap-dumps/db-*.sql.gz | tail -n +49 | xargs -r rm
|
||||
ls -1t "$HOME"/eventsnap-dumps/db-*.sql.gz | tail -n +49 | xargs -r rm
|
||||
SH
|
||||
chmod +x /root/eventsnap-dump.sh
|
||||
( crontab -l 2>/dev/null; echo '17 * * * * /root/eventsnap-dump.sh >>/var/log/eventsnap-dump.log 2>&1' ) | crontab -
|
||||
chmod +x ~/eventsnap-dump.sh
|
||||
( crontab -l 2>/dev/null; echo "17 * * * * $HOME/eventsnap-dump.sh >>$HOME/eventsnap-dumps/dump.log 2>&1" ) | crontab -
|
||||
|
||||
# Prove it works NOW, not at 23:00:
|
||||
/root/eventsnap-dump.sh && ls -lh /root/eventsnap-dumps/
|
||||
# Prove it works NOW, not at 23:00 — and prove it produced a NON-EMPTY dump, since the failure
|
||||
# this replaces produced a zero-byte file and a clean exit code.
|
||||
~/eventsnap-dump.sh && ls -lh ~/eventsnap-dumps/
|
||||
gzip -t ~/eventsnap-dumps/db-*.sql.gz && echo "dump is a valid gzip"
|
||||
zcat ~/eventsnap-dumps/db-*.sql.gz | grep -c 'CREATE TABLE' # must be > 0, not just "a file exists"
|
||||
```
|
||||
|
||||
These land on the same filesystem, so they do **not** survive a disk loss — that is what §10.1 is
|
||||
@@ -782,10 +810,13 @@ total ~11–13 GB of ~36 GB usable
|
||||
3–5 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).
|
||||
The "ENOSPC" projection in README's **`### Sizing the disk`** discussion 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 while free space is still **1.25× above the level at
|
||||
which uploads stop** (`handlers::host::disk_is_low`). Note that is the only trigger: the separate
|
||||
10 GB absolute floor this used to describe was removed as unreachable, because the derived
|
||||
threshold is always higher.
|
||||
|
||||
---
|
||||
|
||||
@@ -895,8 +926,10 @@ 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
|
||||
# when the site is fine. Reads the one variable instead of sourcing the file, because `.env`
|
||||
# legitimately contains an apostrophe (EVENT_NAME) and `. ./.env` dies on it — which at 11pm
|
||||
# looks exactly like the outage you came here to diagnose.
|
||||
DOMAIN=$(sed -n 's/^DOMAIN=//p' .env | tr -d "\"'")
|
||||
|
||||
# Is it alive? (200 = app AND database are answering; 503 = the app is up, the DB is not)
|
||||
curl -fsS https://$DOMAIN/health
|
||||
|
||||
@@ -18,9 +18,18 @@ services:
|
||||
logging: *default-logging
|
||||
env_file: .env
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
# `:?` for the same reason EVENTSNAP_VERSION and DOMAIN use it, and this is the worst place
|
||||
# to omit it. These are interpolated into `environment:`, which OVERRIDES `env_file` — so an
|
||||
# unset value does not fall back to `.env`, it resolves to the empty string and initdb
|
||||
# creates a role and database literally named "". `DATABASE_URL` still points at `eventsnap`,
|
||||
# so the app hits `FATAL: role "eventsnap" does not exist` forever, `pg_isready -U "" -d ""`
|
||||
# never passes, `app` never turns healthy, and Caddy — gated on `service_healthy` — never
|
||||
# starts, so port 443 is dead for the whole event. The only clean exit is `down -v`, which
|
||||
# destroys the volume. The runbook's §3 secrets list omitted both of these, so an operator
|
||||
# writing `.env` from the runbook rather than from `.env.example` walked straight into it.
|
||||
POSTGRES_USER: ${POSTGRES_USER:?set POSTGRES_USER in .env (see .env.example)}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}
|
||||
POSTGRES_DB: ${POSTGRES_DB:?set POSTGRES_DB in .env (see .env.example)}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
|
||||
Reference in New Issue
Block a user