Three follow-ups on the shower deployment branch:
1. containers/shower/default.nix now uses buildPythonPackage to install
the adelaide-baby-shower-app wheel + its deps at nix build time. The
wheel comes from the forge PyPI index with a pinned SRI hash. The
entrypoint no longer does pip-at-boot — it just runs migrations,
collectstatic, and execs gunicorn.
2. ansible/roles/borgmatic/defaults/main.yml:
- Adds shower to borgmatic_k8s_sqlite_dumps (context k3s-ringtail)
so /app/data/db.sqlite3 is dumped via kubectl exec on every run.
- Adds /Volumes/shower (sifaka SMB mount on indri) to
borgmatic_source_directories so prize-photo media gets archived.
3. NFS share docs corrected to match the real on-sifaka pattern:
exports allowlist 192.168.1.0/24 + 100.64.0.0/10 with all_squash to
admin (matching frigate/paperless/etc.), not "Squash=No mapping".
The pod's runAsUser doesn't need to match an on-disk uid because
all_squash rewrites every write to admin:users.
Also adds a missing service-versions entry for the tailscale container
introduced in PR #347 — pre-existing gap surfaced by the
container-version-check hook on this commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
8.2 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 has the app + its Python deps baked in at nix build time
(buildPythonPackage against the wheel fetched from forge PyPI). The
entrypoint runs migrations, runs collectstatic, and execs gunicorn —
no pip-at-boot. 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.
Backups
borgmatic (running on indri) captures both halves of the persistent state on its daily 2 a.m. run:
/app/data/db.sqlite3— dumped viakubectl exec'ssqlite3.backup()against the live pod (entry inborgmatic_k8s_sqlite_dumps, contextk3s-ringtail). The dumped file lands inborgmatic_k8s_dump_diron indri and is picked up by the main source-directory sweep./app/media— picked up via/Volumes/shower, the SMB mount ofsifaka:/volume1/showeron indri. The same Synology share is exposed via SMB and NFS simultaneously; ringtail's pod uses the NFS export, while indri reads the SMB side for the borgmatic source.
Both archive to sifaka (borg-backups) and BorgBase offsite, with
retention keep_daily=7 / keep_monthly=12 / keep_yearly=1000.
The SMB mount on indri is set up manually once via Finder (Cmd-K →
smb://sifaka/shower, save credentials, "Always log in" so it
reconnects after reboot). If /Volumes/shower is missing at backup
time borgmatic will fail loudly — source_directories_must_exist: true
applies to all entries.
One-time setup steps
These steps are required the first time the service is deployed and are not encoded in the manifests.
1. NFS + SMB share on sifaka
On the Synology DSM web UI:
- Control Panel → Shared Folder → Create. Name:
shower, Location: Volume 1. Leave the rest at default. - Control Panel → File Services → NFS → NFS Rules (on the
showerrow's Permissions tab). Add a rule mirroring the other shares' pattern: Hostname/IP=192.168.1.0/24and again for100.64.0.0/10, Privilege=Read/Write, Squash=Map all users to admin(=all_squash), and tick Allow connections from non-privileged ports. (See sifaka#NFS Exports — the existingfrigate,paperless, etc. shares use this exact pattern.) - Control Panel → File Services → SMB: leave SMB enabled
globally. No per-share rule required — the share inherits the
default
eblumeaccess. - The directory ownership at
/volume1/showerwill end uproot:root, mode0777(DSM default) — which is fine becauseall_squashrewrites every NFS write toadmin:users, and the0777lets pods read what other pods wrote. Nochownneeded.
After the share exists, mount it on indri for borgmatic:
- In Finder, Cmd-K →
smb://sifaka/shower, sign in aseblume, and tick Remember in Keychain + Always log in so it reconnects on reboot. This produces/Volumes/shower, which the borgmatic source-directory list points at.
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). The wheel is fetched
from forge PyPI at nix build time and baked into the image — no
pip-at-runtime. To bump the version, change version in
containers/shower/default.nix and update wheelHash (or set it to
pkgs.lib.fakeHash and let the next build print the correct one).
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