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:
parent
eceb2b99ce
commit
6e37abda5d
25 changed files with 942 additions and 3 deletions
174
docs/how-to/operations/shower-app.md
Normal file
174
docs/how-to/operations/shower-app.md
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue