Files
expense_tracker-gitops/README.md
T
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

185 lines
8.8 KiB
Markdown

# 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="[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:
```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 [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:
```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-<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
```