C1: deploy adelaide-baby-shower-app to ringtail k3s

Adds the Adelaide / Heidi / Addie baby shower app — a Django guest
splash, raffle picker, and prize-assignment console — on ringtail k3s.
Public landing at shower.eblu.me (via fly proxy), tailnet admin at
shower.ops.eblu.me. App source: forge.eblu.me/eblume/adelaide-baby-shower-app,
wheel-published to the Forgejo Packages PyPI index.

Manifests under argocd/manifests/shower/: NFS-backed PVC for /app/media,
local-path PVC for SQLite, ExternalSecret pulling DJANGO_SECRET_KEY from
1Password (item "Shower (blumeops)"), Tailscale ProxyGroup ingress.

Defense-in-depth for the public surface:
  - /admin/ blocked at the fly edge except /admin/login/ and /admin/logout/
  - shower_auth rate limit on the login path
  - new fail2ban filter+jail with a per-service shower-deny.conf
    (nginx-deny action generalized to accept nginx_deny_file)
  - django-axes (5 / 1h) keyed on (username, ip_address)

Plus: Caddy route on indri, Pulumi gandi CNAME, Grafana APM dashboard
mirroring docs-apm.json, runbook at how-to/operations/shower-app.md,
and a service-versions entry. X-Clacks-Overhead set on the new server
block — GNU Terry Pratchett.

Build: containers/shower/default.nix uses dockerTools to ship a
nixpkgs Python plus a startup wrapper that installs the wheel into
/app/data/.venv on first boot and execs gunicorn. Lets the wheel come
from forge PyPI without pinning hashes for every transitive dep.

Prerequisites tracked in the runbook (not yet executed):
  - NFS share sifaka:/volume1/shower (manual Synology step)
  - 1Password item "Shower (blumeops)" with secret-key field
  - container build via `mise run container-build-and-release shower`
  - Pulumi dns-up after merge
  - fly certs add shower.eblu.me

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-05-11 08:14:12 -07:00
commit 6e37abda5d
25 changed files with 942 additions and 3 deletions

View file

@ -0,0 +1,174 @@
---
title: Shower App on Ringtail
modified: 2026-05-10
last-reviewed: 2026-05-10
tags:
- how-to
- operations
- kubernetes
- django
---
# Shower App on Ringtail
How the Adelaide / Heidi / Addie baby shower app is deployed. The app is a
Django project ([`adelaide-baby-shower-app`](https://forge.eblu.me/eblume/adelaide-baby-shower-app))
released as a wheel to the Forgejo Packages PyPI index and run on
[[ringtail]]'s k3s cluster. Public landing page at `shower.eblu.me`, staff
console + admin UI at `shower.ops.eblu.me` (tailnet only).
The contract this deploy implements is defined in the app repo's
`docs/how-to/hosting.md` — read that for the env-var contract, security
model, and storage requirements before changing anything here.
## Routing
```
Internet → shower.eblu.me
│ (Fly.io nginx — public)
Caddy on indri (shower.ops.eblu.me)
Tailscale ProxyGroup ingress (shower.tail8d86e.ts.net)
Service shower:8000 → Pod (Django + gunicorn)
```
| Hostname | Reachable from | Notes |
|---|---|---|
| `shower.eblu.me` | Public internet | `/admin/` blocked except `/admin/login/`, `/admin/logout/` |
| `shower.ops.eblu.me` | Tailnet | Full app surface, including the admin |
| `shower.tail8d86e.ts.net` | Tailnet | Bare ProxyGroup endpoint Caddy proxies to |
## Defense layers (public side)
The public path stacks four checks against `/admin/login/` brute force:
1. **fly nginx `geo $shower_banned`** — per-service ban list populated by
fail2ban (`/etc/nginx/shower-deny.conf`)
2. **fly nginx `limit_req zone=shower_auth`** — 3 r/s per Fly-Client-IP
3. **django-axes** — 5 fails / 1 hour lockout per `(username, ip_address)`
4. **edge `/admin/` block** — anything that isn't `/admin/login/` or
`/admin/logout/` returns 403 from nginx, period
The fail2ban filter `shower-admin-login.conf` matches 401/403/429 on
`/admin/login/`. The 429 case catches attackers who keep hammering after
django-axes has already locked them out.
## Persistent storage
| Mount | PVC | Type | Why |
|---|---|---|---|
| `/app/media` | `shower-media` | NFS RWX on sifaka (`/volume1/shower`) | Prize photos survive pod rescheduling |
| `/app/data` | `shower-data` | k3s `local-path` RWO | SQLite DB; NFS file locking can't be trusted for WAL/journal |
The container's entrypoint installs the wheel into `/app/data/.venv` on
first boot, runs migrations, runs `collectstatic`, and `exec`s gunicorn.
A `local_settings.py` shim overrides `DATABASES.NAME`, `MEDIA_ROOT`, and
`STATIC_ROOT` to absolute paths under `/app/`, sidestepping the wheel's
`BASE_DIR = parent.parent` of an in-site-packages settings module.
## One-time setup steps
These steps are required the first time the service is deployed and are
not encoded in the manifests.
### 1. NFS share on sifaka
On the Synology:
1. Control Panel → Shared Folder → Create. Name: `shower`, Volume 1.
2. Control Panel → File Services → NFS → NFS Rules. Add rule for
`shower`: Hostname=`ringtail`, Privilege=Read/Write, Squash=No mapping.
3. `chown -R 1000:1000 /volume1/shower` over SSH so the pod's uid 1000
can write.
### 2. 1Password item
Item name: **`Shower (blumeops)`** in the `blumeops` vault.
Required property:
| Field | Value |
|---|---|
| `secret-key` | Output of `openssl rand -base64 48` |
The `ExternalSecret` `shower-app-secrets` will sync this into the
`shower` namespace as a `Secret` and `envFrom` exposes it as
`DJANGO_SECRET_KEY` to the container.
**Never reuse a key that has ever been in git history.** Per the app's
hosting.md, an early dev key was committed before being replaced with
the `django-insecure-...` placeholder; the production key must be
freshly generated.
### 3. Container image
Built by the `build-container` Forgejo Actions workflow on the
`nix-container-builder` runner (ringtail, amd64). Trigger with:
```fish
mise run container-build-and-release shower
```
After the workflow finishes, update `images[].newTag` in
`argocd/manifests/shower/kustomization.yaml` to the resulting
`vX.Y.Z-<sha>-nix` tag, then commit (C0).
### 4. DNS
`pulumi/gandi/__main__.py` declares the `shower-public` CNAME pointing
at `blumeops-proxy.fly.dev.`. Apply with:
```fish
mise run dns-preview
mise run dns-up
```
### 5. Fly.io certificate
```fish
fly certs add shower.eblu.me -a blumeops-proxy
```
(Add to `mise-tasks/fly-setup` so re-runs of the one-time setup pick
it up.)
### 6. Caddy on indri
`shower` is in `ansible/roles/caddy/defaults/main.yml`. Push with:
```fish
mise run provision-indri -- --tags caddy
```
## Deploying a new version
1. Bump the wheel version in the app repo (`adelaide-baby-shower-app`)
and release it to Forgejo PyPI.
2. Bump `appVersion` in `containers/shower/default.nix` to match.
3. `mise run container-build-and-release shower`. Verify the build
with `mise run runner-logs`.
4. Update the `newTag` in `argocd/manifests/shower/kustomization.yaml`
to the new `[main]` SHA tag.
5. Commit (C0 after PR merge — see [[build-container-image#Squash-merge and container tags]]).
6. `argocd app sync shower`.
## Verifying after a deploy
```fish
kubectl --context=k3s-ringtail -n shower get pods
kubectl --context=k3s-ringtail -n shower logs deploy/shower
curl -sf https://shower.ops.eblu.me/ # tailnet
curl -sf https://shower.eblu.me/ # public
curl -I https://shower.eblu.me/admin/users/ # expect 403 (edge block)
curl -I https://shower.ops.eblu.me/admin/ # expect 200 / 302 (login)
```
## Related
- [[expose-service-publicly]] — Fly.io proxy + Tailscale pattern
- [[deploy-k8s-service]] — generic ArgoCD service onboarding
- [[ringtail]] — the cluster
- [`hosting.md`](https://forge.eblu.me/eblume/adelaide-baby-shower-app/src/branch/main/docs/how-to/hosting.md) — app's deployment contract