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>
5.8 KiB
| title | modified | last-reviewed | tags | ||||
|---|---|---|---|---|---|---|---|
| Shower App on Ringtail | 2026-05-10 | 2026-05-10 |
|
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:
- fly nginx
geo $shower_banned— per-service ban list populated by fail2ban (/etc/nginx/shower-deny.conf) - fly nginx
limit_req zone=shower_auth— 3 r/s per Fly-Client-IP - django-axes — 5 fails / 1 hour lockout per
(username, ip_address) - 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:
- Control Panel → Shared Folder → Create. Name:
shower, Volume 1. - Control Panel → File Services → NFS → NFS Rules. Add rule for
shower: Hostname=ringtail, Privilege=Read/Write, Squash=No mapping. chown -R 1000:1000 /volume1/showerover 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
- Bump the wheel version in the app repo (
adelaide-baby-shower-app) and release it to Forgejo PyPI. - Bump
appVersionincontainers/shower/default.nixto match. mise run container-build-and-release shower. Verify the build withmise run runner-logs.- Update the
newTaginargocd/manifests/shower/kustomization.yamlto the new[main]SHA tag. - Commit (C0 after PR merge — see build-container-image#Squash-merge and container tags).
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)
Related
- expose-service-publicly — Fly.io proxy + Tailscale pattern
- deploy-k8s-service — generic ArgoCD service onboarding
- ringtail — the cluster
hosting.md— app's deployment contract