fix(deploy): close the POSTGRES_PASSWORD trap that broke a fresh deploy either way

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>
This commit is contained in:
fabi
2026-07-30 19:28:34 +02:00
parent 08d92b7531
commit 3bcb7c6a76
4 changed files with 265 additions and 32 deletions

View File

@@ -97,17 +97,51 @@ eventsnap/
git clone https://git.mc02.dev/fabi/EventSnap.git eventsnap
cd eventsnap
# 2. Configure environment
# 2. Configure environment — set EVERY secret NOW, before step 3.
cp .env.example .env
nano .env # set DOMAIN, JWT_SECRET, ADMIN_PASSWORD_HASH, EVENT_NAME, etc.
nano .env # DOMAIN, EVENT_NAME, EVENT_SLUG,
# JWT_SECRET, ADMIN_PASSWORD_HASH,
# POSTGRES_PASSWORD *and* the same password inside DATABASE_URL
# (see "Generate required secrets" below)
# 3. Start the stack
docker compose up -d
```
> **Set every secret before step 3 — `POSTGRES_PASSWORD` especially.** Postgres reads it **only
> when it initialises its data directory**, which happens on the very first `docker compose up -d`.
> Changing it in `.env` afterwards does not change the stored password: the app then authenticates
> with the new one against a volume holding the old one, and you get a permanent restart loop with
> `password authentication failed for user "eventsnap"`. The only fixes are restoring the old
> password or `docker compose down -v`, which **deletes the database, the media and the exports**.
> Getting it right once, up front, costs nothing; getting it wrong costs the volume.
Caddy automatically obtains a Let's Encrypt certificate on first start. The app is live at `https://DOMAIN` within ~30 seconds.
> **If the site never comes up:** with `APP_ENV=production` the backend **refuses to boot** while `JWT_SECRET`/`ADMIN_PASSWORD_HASH` still hold the `.env.example` placeholders (this is deliberate — a publicly-known signing key is worse than downtime). Caddy then waits on the unhealthy `app` container and never serves. Check `docker compose logs app` — a "Refusing to start … placeholder …" line means you skipped step 2. Rotate the secrets (see below) and restart.
> **If the site never comes up:** with `APP_ENV=production` the backend **refuses to boot** while
> `JWT_SECRET`, `ADMIN_PASSWORD_HASH` or the password inside `DATABASE_URL` still hold the
> `.env.example` placeholders (this is deliberate — a publicly-known signing key or database
> password is worse than downtime). Caddy then waits on the unhealthy `app` container and never
> serves. Check `docker compose logs app` — a "Refusing to start … placeholder …" line lists
> **every** unset secret at once, so one edit fixes them all.
>
> **If it comes up but keeps restarting with `password authentication failed for user
> "eventsnap"`:** `POSTGRES_PASSWORD` was changed after the database volume was created. Postgres
> applies that variable only at initialisation, so `.env` and the stored password have drifted
> apart permanently. `docker compose logs app` spells this out. Before the event, with nothing
> worth keeping:
>
> ```bash
> docker compose down -v && docker compose up -d # -v DELETES db + media + exports. No undo.
> ```
>
> **Once the event has real data, never do that.** Put the original password back into
> `DATABASE_URL`, or change the stored one instead:
>
> ```bash
> docker compose exec db psql -U "$POSTGRES_USER" -c \
> "ALTER ROLE eventsnap WITH PASSWORD 'the-password-now-in-your-.env';"
> ```
> **Production note:** `docker compose up -d` does **not** expose the database — Postgres is reachable only on the internal Docker network. For local development where you need host access to Postgres, opt into the dev overlay explicitly:
> ```bash
@@ -179,10 +213,19 @@ TLS certificate and all data volumes survive.
# JWT secret (64 random bytes)
openssl rand -hex 64
# Admin password hash (bcrypt, cost 12)
htpasswd -bnBC 12 "" yourpassword | tr -d ':\n'
# Database password (goes in BOTH DATABASE_URL and POSTGRES_PASSWORD)
openssl rand -hex 24
# Admin password hash (bcrypt). Uses an image the stack already pulls, so it needs
# nothing installed on the host — `htpasswd` lives in apache2-utils, which a stock
# VPS does not have. Emits cost 14 rather than 12; that is fine (admin login is
# rate-limited and hashed off the async runtime), and any $2a/$2b/$2y hash verifies.
docker run --rm caddy:2-alpine caddy hash-password --plaintext 'yourpassword'
```
Wrap the resulting hash in **single quotes** in `.env` — see the note there; a bcrypt
hash is full of `$`, and both Compose and dotenvy would otherwise eat those segments.
### Environment Variables
See [.env.example](.env.example) for the full list with descriptions and defaults. Key variables: