jar-lid-archive: a staging deployment on its own box

Jar Lid Archive (Django + Postgres 18 + SeaweedFS) on jar-lid-archive-staging-box
(nodito VM 3300, 1 core / 1 GB / 10 GB), public at jars-staging.contrapeso.xyz
through vipy's Caddy. New role roles/jar_lid_archive; per-host values in
host_vars, so a production host is a new [jar_lid_archive] entry, not a new
playbook.

── Containers, following :master ──────────────────────────────────────────
The same three services as the app repo's compose.yaml, so staging rehearses
what dev and CI run. Every playbook run pulls :master and `up -d --wait`s;
compose recreates web only when the digest moved. --wait gates on web's
/health/, which checks the database and the bucket, so a green run means the
app serves. Upgrading is re-running the playbook.

── web is on the host network ─────────────────────────────────────────────
Docker's published ports are DNATed before UFW sees them, so `8000:8000` would
answer on the LAN whatever UFW says, and binding to the tailnet IP races
tailscaled at boot. On the host network gunicorn's port is an ordinary one: UFW
default-deny covers the LAN, tailscaled's ts-input accepts tailscale0 ahead of
UFW (checked on mempool-box), and the headscale ACL grants tag:prd-vipy exactly
:8000. db and storage publish on 127.0.0.1 only. Verified: LAN times out, vipy
gets 200.

That matters for HTTPS: the app trusts X-Forwarded-Proto
(DJANGO_BEHIND_TLS_PROXY=1), which is only safe while Caddy is the sole client.
Login POST through the public URL passes CSRF; cookies are Secure.

── Secrets ────────────────────────────────────────────────────────────────
Generated straight into host_vars/jar_lid_staging_local/vault.yml, never
printed: Django key, Postgres and S3 secrets, and seed passwords for the
fixture's alice/bob/admin usernames - never the fixture's published passwords,
since the site is public. The group vault gains one line, this host's Gatus push
token; decrypted, it differs from before by exactly that line.

── Backups, and the outage the first one caused ───────────────────────────
One uncompressed tar (pg_dump -Fc + the SeaweedFS data dir), age-encrypted on
the box at 02:20, pulled by small-backups-box; 3 days at both ends. Verified end
to end, including decrypting a pulled artefact on lapy.

The first test run stopped web AND storage and left the app down 26 minutes:
SeaweedFS outlasted its 150s healthcheck budget on this disk, compose marked it
unhealthy, and web's depends_on never started it. Now only web is stopped - it
is the only writer to both stores, so they are still captured at one instant -
and the healthchecks have start_periods sized for the disk.

Also inherited from the app's compose.yaml: storage writes /tmp/s3.json and
chowns it to seaweed, so on a RESTART of the same container root cannot
re-create it (sticky /tmp + protected_regular): "Permission denied". Fixed here
with rm -f first; the app repo needs the same line.

── The disk is the problem ────────────────────────────────────────────────
The VM disk is on proxmox-tank-1, the HDD mirror, which knots-box's random reads
(~6 MB/s) keep near saturation. Inside this VM: ~96% "full" I/O pressure while
idle, ~5 min to start web, sudo occasionally over 12s. Moving it to local-zfs
(NVMe) is the fix; noted in the README, not done.

── Also ───────────────────────────────────────────────────────────────────
- lan_ip read from the Proxmox guest agent; host key checked against the one
  the agent reads off the disk before it went into known_hosts.
- Monitoring: liveness + disk (400), probe_jar-lid-archive-staging (403), public
  /health/ + DNS (402), backup-dump/backup-store.
- tofu: the VM definition.
- Deployed v2026.10.03.1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
counterweight 2026-10-03 23:56:30 +02:00
parent 97c7d580bc
commit c3a7ca0ba7
Signed by: counterweight
GPG key ID: 883EDBAA726BD96C
22 changed files with 929 additions and 215 deletions

View file

@ -0,0 +1,121 @@
# `jar_lid_archive`
Runs [Jar Lid Archive](https://forgejo.contrapeso.xyz/counterweight/jar-lid-archive)
as a docker compose stack: Django under gunicorn (`web`), Postgres 18 (`db`) and
SeaweedFS as the S3 photo store (`storage`). The same three services as the app
repo's own `compose.yaml`, so staging rehearses what dev and CI run.
Deployed by `services/jar-lid-archive/deploy_jar_lid_archive_playbook.yml` to
every host in `[jar_lid_archive]`. Today that is `jar_lid_staging_local`
(jar-lid-archive-staging-box, nodito VM 3300), public at
`jars-staging.contrapeso.xyz` through vipy's Caddy.
Per-host values are in `host_vars/<host>/main.yml`, and secrets are in `vault.yml`
beside it. A production host would be a second inventory entry with its own
`jar_lid_archive_instance`, domain and vault, not a second playbook.
## Updates: follows `:master`
CI pushes `:master` on every green build of master. Every run of the playbook
runs `docker compose pull` and `up -d --wait`, and compose recreates `web` only
if the digest moved. There is no watchtower, so **a merge reaches staging when
the playbook runs**, not on its own. The run ends by printing `/health/`, which
includes the deployed version.
`up --wait` only returns once every container's healthcheck passes. `web`'s is
`/health/`, which runs `SELECT 1` and touches the bucket, so a green run means
the app actually serves.
## Networking: why `web` is on the host network
Docker's published ports are DNATed in the `nat` table, before UFW's `INPUT`
rules ever see the packet. A `ports: "8000:8000"` would answer on the LAN as well
as the tailnet, whatever UFW says. Binding the publish to the tailnet IP avoids
that, but at boot Docker races tailscaled for the address and loses.
So `web` uses `network_mode: host`, and gunicorn's port is an ordinary host
port:
- **LAN:** UFW default-deny blocks it.
- **Tailnet:** tailscaled's `ts-input` chain accepts `tailscale0` ahead of UFW
(checked on mempool-box), so the headscale ACL is the real control. It grants
`tag:prd-vipy` exactly `tag:jar-lid-archive-staging-box:8000`.
- `db` and `storage` publish on `127.0.0.1` only, which is how `web` reaches
them from the host network.
This is load-bearing for HTTPS. The app is told to trust `X-Forwarded-Proto`
(`DJANGO_BEHIND_TLS_PROXY=1`), which is only safe while Caddy is the only
thing that can reach gunicorn. Caddy overwrites whatever value the client sent.
## Secrets and seed users
The Django secret key, the Postgres password, the S3 secret and the three seed
passwords were generated into `host_vars/jar_lid_staging_local/vault.yml`. They
reach the box as `/opt/jar-lid-archive/.env` (0600, root), which compose
interpolates. `docker-compose.yml` itself has no secrets.
The seed users are alice, bob and admin (superuser), with the fixture's usernames
and **vault** passwords. The fixture's `*-test-pass` passwords are published in
the app repo and this site is public. `seed_users` runs on every deploy and
resets each listed user's password, so rotating one means editing the vault and
re-running the playbook.
```sh
ansible-vault view host_vars/jar_lid_staging_local/vault.yml # from ansible/
```
## Backups
`services/jar-lid-archive/setup_backup_jar_lid_archive.yml` uses
`backup_source`. At 02:20 it stops `web` only, and
`/usr/local/bin/jar-lid-archive-dump.sh` writes one tar with:
- `db.dump`: `pg_dump -Fc`
- `seaweedfs/`: the photo store's data directory, as files
`web` is the only writer to the database and the photo store. With it stopped,
both are quiet and are captured at the same instant. `storage` is deliberately
left running: on this disk it can take minutes to restart, and the first test run
that stopped it left the app down for 26 minutes. Uploads fail while `web` is
down.
The tar is not gzipped, because the bulk is JPEGs. Three days are kept locally,
and small-backups-box keeps three as well. Staging data is disposable.
### Restore
```sh
age -d -i ~/.age/counterweight_age jar-lid-archive-staging_<ts>.tar.age | tar -xf - -C /tmp/restore
# on the box, stack down:
docker compose -f /opt/jar-lid-archive/docker-compose.yml down
rm -rf /opt/jar-lid-archive/seaweedfs && cp -a /tmp/restore/seaweedfs /opt/jar-lid-archive/
docker compose -f /opt/jar-lid-archive/docker-compose.yml up -d db
docker compose -f /opt/jar-lid-archive/docker-compose.yml exec -T db \
pg_restore -U jarlid -d jarlid --clean --if-exists < /tmp/restore/db.dump
docker compose -f /opt/jar-lid-archive/docker-compose.yml up -d --wait
```
## Monitoring
| What | Where |
|---|---|
| Host liveness and disk | infra/400 (automatic for `managed`) |
| `/health/` from the box, every 5 min | `probe_jar-lid-archive-staging` (403) |
| `/health/` through DNS, Caddy, TLS and the tailnet | `jars-staging` public site (402) |
| Dump ran / artefact arrived | `backup-dump_` / `backup-store_jar-lid-archive-staging` |
infra/401 has nothing to watch here, because no systemd units of ours run on
this box.
## Size and disk
The VM's disk is on `proxmox-tank-1`, nodito's mirror of two HDDs. knots-box's
random reads keep that mirror close to saturated, and this box has measured
**~96% "full" I/O pressure** with nothing of its own running. Everything is slow
here, but especially container starts. The healthcheck `start_period`s are sized
for that. Moving the disk to `local-zfs` (NVMe) would fix it.
1 core, 1 GB RAM and 10 GB disk, by choice, for staging. The disk holds Debian,
the images, the photos and three days of local backups, and each backup is
about the size of the whole photo store. infra/400's disk check alarms at 85%.