2026-07-16
In-building
2026-09 update: the "target" language below describes this ADR's own goal for the Kubernetes track, not the current deployment reality. The actual always-on production deployment remains Docker Compose + GHCR + Watchtower; the home k3s cluster this ADR targets is not yet bootstrapped. This track stays a GitOps rehearsal/learning effort until that migration is deliberately executed.
Production deployment is pull-based Watchtower polling the prod image tag on a docker-compose stack.
The kubernetes/ manifests were a leftover from a local minikube workflow: imagePullPolicy: Never,
image tarballs committed under kubernetes/tars/, no probes, no namespace, broken Service selectors,
ephemeral Postgres storage, plaintext secret files (git-ignored) and no way to run Django migrations
inside the cluster. Deploying that state through any GitOps tool would faithfully reproduce every bug.
The goal is a declarative deployment where git is the single source of truth, the target being a single-node k3s cluster on the home server, with local minikube used for rehearsal.
- ArgoCD syncs
kubernetes/overlays/homefrommain. CI never talks to the cluster; it pushes the image to GHCR and commits a kustomize image-tag bump (sha-<commit>) to the home overlay, serialized by a GitHub Actions concurrency group. ArgoCD picks up the git diff. - Kustomize layout:
kubernetes/baseplus three overlays:minikube-local— plain git-ignored Secrets,kubectl apply -konly (never an ArgoCD source, because ArgoCD checks out the repo and the ignored files are absent there);minikube-argocd— SealedSecrets encrypted for the local minikube controller, used by the manual-syncahc-minikube-testApplication to rehearse the full GitOps loop;home— SealedSecrets encrypted for the home cluster, CI-managed image tag, backups, smoke test.
- Sync phasing: ArgoCD orders resources by phase first and only then by wave, so a PreSync migration hook would run before the databases exist on a fresh cluster. Migrations and CouchDB database creation are therefore Sync-phase hooks at wave -1, after ConfigMaps/Secrets/PVCs (wave -3) and the data stores (wave -2), before the app workloads (wave 0) and Ingress (wave 1). The smoke test is PostSync. Outside ArgoCD the jobs guard themselves with wait initContainers.
- Secrets: Bitnami Sealed Secrets. Only true secrets are encrypted (
ahc-app-secrets,postgres-credentials,couchdb-credentials,flower-secrets); everything else lives in theahc-app-configConfigMap for readable diffs. Credentials are never duplicated across secrets — the app mapsDB_*variables frompostgres-credentialsviasecretKeyRef. A SealedSecret is bound to one controller's private key: minikube and home have separatesealed/directories. - Probes: split endpoints.
/livezhas no dependencies (liveness + startup),/readyzrunsSELECT 1against Postgres (readiness). CouchDB is deliberately outside/readyz: its outage degrades attachments, not the whole application. - Data stores: StatefulSets with volumeClaimTemplates. Postgres 18 mounts the volume at
/var/lib/postgresql(the image keeps PGDATA in a versioned subdirectory since 18). Application file storage gets its own PVCs:app-media-pvc(upload dirs mounted via subPath so committed icons stay visible) andapp-private-pvcshared by web and the Celery worker (offline snapshots). - Migrations as a Job,
makemigrationsremoved from every deploy path;collectstaticruns in a web-pod initContainer writing to an emptyDir shared with gunicorn (ManifestStaticFilesStorage). - CI → cluster reachability: GitHub-hosted runners cannot reach the home server, so the deploy
signal is ArgoCD polling git (option A). The
ahc-deploy-smokeArgo WorkflowTemplate is committed for manual runs, and the CI submit step is gated onvars.ARGO_SERVER_URL(self-disabled until a self-hosted runner or tunnel exists). The workflow first waits until the Deployment references the expectedimage-tag— otherwise a CI-triggered run could bless a stale rollout that ArgoCD has not synced yet. - Data-loss guards: the
ahcNamespace carriesPrune=confirm,Delete=confirmand the data PVCs (app-media-pvc,app-private-pvc,backup-data) carryPrune=false,Delete=false, so automated prune cannot silently remove them. The app image runs as uid 1000, and hostPath-backed provisioners ignorefsGroup, so web/celery pods run a rootprepare-storageinitContainer (mkdir + guarded chown) before the app touches the volumes.
Rejected alternatives: Helm (kustomize already in place, no templating need), ArgoCD Image Updater (extra controller; CI commit is simpler and auditable), External Secrets Operator (requires an external secret store), SOPS/KSOPS (requires an ArgoCD plugin; kubeseal is plain kubectl-side).
- Git history becomes the deployment history; rollback is
git revertof a bump commit. - The Watchtower compose stack keeps running during the transition. Two live Celery beat schedulers
would duplicate Discord notifications, so the home overlay pins
celery-beatto 0 replicas until cutover. - The first sync starts with empty databases — migrating data from the compose stack is a separate manual dump/restore operation.
- Backups: nightly
pg_dump, a CouchDB diagnostic export (_all_docs; a real backup is one-way replication or a PVC snapshot — follow-up), and tar archives of the file PVCs. All land on a PVC on the same disk, which protects against user error, not disk failure; shipping copies off-host (restic/rclone to a NAS) is a post-testing follow-up. - Application PVCs are ReadWriteOnce, which is fine on a single node; a multi-node cluster would need RWX or object storage.
- Losing the sealed-secrets controller key (cluster rebuild) invalidates all SealedSecrets; the git-ignored plain files (kept in a password manager) are the re-sealing source.
- ArgoCD,
- GitOps,
- kustomize,
- sealed-secrets,
- sync-wave,
- k3s.
- ADR-08 — database responsibilities (PostgreSQL / CouchDB / Redis).
- ADR-12 — offline snapshots stored under PRIVATE_STORAGE_ROOT.
- kubernetes/README.md — operational runbook.
- ArgoCD sync phases and waves.
- Sealed Secrets.