- 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]>
185 lines
8.8 KiB
Markdown
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
|
|
```
|