SmokyZoneandClaude Sonnet 5 2ee46ceb4a Add Helm chart for the expense tracker (app, Postgres, ingress, backups)
- 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]>
2026-09-20 18:46:19 +02:00

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.de with 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 & ServicesCredentialsCreate credentialsOAuth 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).

  1. export BAO_ADDR=https://192.168.2.218:30200 BAO_SKIP_VERIFY=true BAO_TOKEN=<admin token>
  2. scripts/openbao-oidc.sh - creates the key, scope, client and provider, and stores the client ID/secret and OpenBao's CA certificate in secret/expense-tracker/app. (The script follows the OpenBao docs but has not been run against your instance yet; check each step's output.)
  3. Give the entity you log in with an email metadata value (the script prints the commands).
  4. Set auth.openbao.enabled: true in values.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 that Host header (e.g. through the router's port-forward) hits the login page. The protection is Google sign-in plus allowed_emails. Note that Let's Encrypt certificates are public (Certificate Transparency), so the hostname is discoverable. For real network isolation set externalTrafficPolicy: Local on ingress-nginx and add a whitelist-source-range annotation.
  • The container image is pullable without credentials (Gitea packages follow the visibility of the SmokyZone account). 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-path reclaim policy is Delete). 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
S
Description
Helm chart / GitOps deployment of the expense tracker (budget)
Readme
38 KiB
Languages
Shell 83.3%
Go Template 16.7%