blumeops/docs/how-to/operations/shower-app.md
Erich Blume 6e37abda5d 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>
2026-05-11 08:14:12 -07:00

5.8 KiB

title modified last-reviewed tags
Shower App on Ringtail 2026-05-10 2026-05-10
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) 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 execs 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:

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:

mise run dns-preview
mise run dns-up

5. Fly.io certificate

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:

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

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)