# expense_tracker-gitops Helm chart that deploys the **Budget / expense tracker** ([`SmokyZone/budget`](https://git.smokyzone.de/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 & 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): ```sh 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="you@gmail.com" \ google_client_id=".apps.googleusercontent.com" \ google_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: ```sh 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): ```sh 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 you@gmail.com --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-.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: ```sh 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: ```sh kubectl -n expense-tracker cp ./budget-backups/budget-.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=` 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 ```