- Deployment/Service for the app, StatefulSet/Service for Postgres 17 - Secrets (DB password, session secret, Google client, allowed e-mails) via ExternalSecret from OpenBao - Ingress with a Let's Encrypt certificate, NetworkPolicy for Postgres - Nightly pg_dump CronJob - Optional OpenBao OIDC provider setup script Co-Authored-By: Claude Sonnet 5 <[email protected]>
expense_tracker-gitops
Helm chart that deploys the Budget / expense tracker
(SmokyZone/budget) on the home cluster,
following the same pattern as valheim-gitops (ArgoCD app-of-apps, secrets from OpenBao via
External Secrets Operator).
What's included
- Deployment + Service for the app (non-root, read-only root filesystem, probes)
- StatefulSet + Service for Postgres 17 with a persistent volume
- NetworkPolicy so only the app and the backup job can reach Postgres
- Ingress
budget.smokyzone.dewith a Let's Encrypt certificate (cert-manager, DNS-01) - ExternalSecrets that pull the DB password, session secret, Google client and the list of allowed e-mail addresses from OpenBao
- CronJob with a nightly
pg_dump(14 days retention) into its own volume
Reachable at https://budget.smokyzone.de:30444 from the home network (30444 is the ingress-nginx NodePort, same as for the other apps).
First-time setup
1. DNS (Cloudflare, you)
Add two A records, DNS only (grey cloud, not proxied), pointing at the node's LAN IP:
| Name | Type | Content | Proxy |
|---|---|---|---|
budget |
A | 192.168.2.218 |
DNS only |
argocd |
A | 192.168.2.218 |
DNS only |
argocd.smokyzone.de needs no ingress: ArgoCD already serves TLS on NodePort 30443, so it is
https://argocd.smokyzone.de:30443 (self-signed certificate, expect a browser warning).
Private IPs in public DNS only work as long as your router doesn't apply DNS-rebind protection.
If names don't resolve on the LAN, allow smokyzone.de in the router's rebind exceptions (Fritz!Box:
Home Network → Network → Network Settings → DNS Rebind Protection → Host name exceptions) or add
local DNS overrides instead.
The certificate does not depend on these records (DNS-01 challenge via the existing Cloudflare issuer).
2. Google OAuth client (you)
Google Cloud Console → APIs & Services → Credentials → Create credentials → OAuth client ID, type Web application:
- Authorized redirect URI:
https://budget.smokyzone.de:30444/auth/callback/google - Consent screen: External, publishing status Testing is fine - add your Google account(s) as test users (up to 100; no Google verification needed).
Keep the client ID and secret for the next step.
3. Secrets in OpenBao (you)
KV v2 mount secret/ (same as valheim):
bao kv put secret/expense-tracker/db password="$(openssl rand -base64 32 | tr -d '/+=')"
bao kv put secret/expense-tracker/app \
session_secret="$(openssl rand -base64 48 | tr -d '\n')" \
allowed_emails="[email protected]" \
google_client_id="<client id>.apps.googleusercontent.com" \
google_client_secret="<client secret>"
allowed_emails is a comma-separated allow-list: only these Google accounts can sign in, even
though anyone can authenticate with Google. Removing an address locks that user out immediately,
including existing sessions (after the next ESO refresh, at most 1 h, or restart the pod).
4. Deploy
Register the app in apps-in-apps (already done in this change) and push - ArgoCD creates the
expense-tracker Application. Check:
kubectl -n argocd get app expense-tracker
kubectl -n expense-tracker get pods,externalsecret,certificate,ingress
Until step 3 is done the ExternalSecrets report an error and the pods wait in
CreateContainerConfigError - that is expected; they start by themselves once the secrets exist.
5. Move the existing data in
Your old JSON files go into Postgres for your account (matched by e-mail, so your first Google login lands directly on the imported data):
kubectl -n expense-tracker port-forward svc/expense-tracker-postgres 5432:5432 &
cd ~/dev/cluster/budget # the app repository, with data/*.json in ./data
export PGHOST=127.0.0.1 PGUSER=budget PGDATABASE=budget
export PGPASSWORD="$(kubectl -n expense-tracker get secret expense-tracker-db -o jsonpath='{.data.POSTGRES_PASSWORD}' | base64 -d)"
npm run import:json -- --email [email protected] --dir data
It prints the imported row counts and refuses to overwrite existing data without --replace.
Afterwards stop the old local container (docker compose down in the budget directory) so you
don't keep editing the JSON copy.
Everyday operations
Ship a new version - in the budget repo: commit, run scripts/release.sh, then set image.tag
in values.yaml to the printed tag and push this repo. ArgoCD rolls it out (database migrations
run automatically on start).
Add another user - append the address to allowed_emails in OpenBao. They get their own,
empty account on first login; nobody sees anyone else's data.
Backups - the CronJob writes budget-<timestamp>.dump (pg_dump -Fc) to the
expense-tracker-backups volume every night at 03:15. Run one now with
kubectl -n expense-tracker create job --from=cronjob/expense-tracker-backup manual-$(date +%s).
These dumps live on the same disk as the database: they protect against mistakes, not against
losing the machine. Copy them elsewhere from time to time, e.g. via a throw-away pod:
kubectl -n expense-tracker run bk --restart=Never --image=alpine --overrides='{"spec":{"containers":[{"name":"bk","image":"alpine","command":["sleep","600"],"volumeMounts":[{"name":"b","mountPath":"/backup"}]}],"volumes":[{"name":"b","persistentVolumeClaim":{"claimName":"expense-tracker-backups"}}]}}'
kubectl -n expense-tracker cp bk:/backup ./budget-backups
kubectl -n expense-tracker delete pod bk
Restore - copy a dump into the Postgres pod and restore it over the running database:
kubectl -n expense-tracker cp ./budget-backups/budget-<ts>.dump expense-tracker-postgres-0:/tmp/restore.dump
kubectl -n expense-tracker exec expense-tracker-postgres-0 -- \
pg_restore -U budget -d budget --clean --if-exists --no-owner /tmp/restore.dump
Rotating the DB password - POSTGRES_PASSWORD is only read when the data directory is first
created, so changing it in OpenBao alone breaks the app. Change it inside Postgres first
(ALTER USER budget PASSWORD '...'), then update OpenBao.
OpenBao as a second SSO provider (optional)
OpenBao can act as an OIDC provider, so you can sign in with an OpenBao identity as well as with Google (both map to the same account when the e-mail matches).
export BAO_ADDR=https://192.168.2.218:30200 BAO_SKIP_VERIFY=true BAO_TOKEN=<admin token>scripts/openbao-oidc.sh- creates the key, scope, client and provider, and stores the client ID/secret and OpenBao's CA certificate insecret/expense-tracker/app. (The script follows the OpenBao docs but has not been run against your instance yet; check each step's output.)- Give the entity you log in with an
emailmetadata value (the script prints the commands). - Set
auth.openbao.enabled: trueinvalues.yaml, commit, push.
The issuer is https://192.168.2.218:30200/v1/identity/oidc/provider/expense-tracker. It must be
reachable from the browser (it will warn about OpenBao's self-signed certificate once) and from
the pod, which is why it uses the node address rather than the in-cluster service name.
Security notes
- LAN-only is enforced by DNS, not by the cluster. ingress-nginx runs with
externalTrafficPolicy: Cluster, so the app sees every request as coming from the node (192.168.2.218) - an IP allow-list on the ingress would be meaningless. The hostname points to a private IP, but anyone who reaches the ingress with thatHostheader (e.g. through the router's port-forward) hits the login page. The protection is Google sign-in plusallowed_emails. Note that Let's Encrypt certificates are public (Certificate Transparency), so the hostname is discoverable. For real network isolation setexternalTrafficPolicy: Localon ingress-nginx and add awhitelist-source-rangeannotation. - The container image is pullable without credentials (Gitea packages follow the visibility of the
SmokyZoneaccount). It contains only application code, never secrets or data. - Sessions live 30 days (rolling), in Postgres; cookies are
HttpOnly,SameSite=Lax,Secure. - Deleting the namespace deletes the database volume (StorageClass
local-pathreclaim policy isDelete). Keep backups outside the cluster.
Structure
expense_tracker-gitops/
├── Chart.yaml
├── values.yaml # image tag, host, sizes, feature toggles
├── scripts/openbao-oidc.sh # optional: OpenBao as OIDC provider
└── templates/
├── namespace.yaml
├── externalsecret.yaml # DB + app secrets from OpenBao
├── postgres.yaml # StatefulSet + Service
├── deployment.yaml # app Deployment + Service
├── ingress.yaml
├── networkpolicy.yaml
└── backup.yaml # nightly pg_dump CronJob + PVC