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]>
This commit is contained in:
2026-09-20 18:46:19 +02:00
co-authored by Claude Sonnet 5
commit 2ee46ceb4a
13 changed files with 881 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
*.tgz
.DS_Store
+16
View File
@@ -0,0 +1,16 @@
apiVersion: v2
name: expense-tracker
description: Helm chart to run the Budget / expense tracker (web app + Postgres) on Kubernetes
type: application
version: 0.1.0
appVersion: "b6da854"
home: https://git.smokyzone.de/SmokyZone/expense_tracker-gitops
sources:
- https://git.smokyzone.de/SmokyZone/expense_tracker-gitops
- https://git.smokyzone.de/SmokyZone/budget
keywords:
- budget
- expense-tracker
- postgres
maintainers:
- name: SmokyZone
+184
View File
@@ -0,0 +1,184 @@
# 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
```
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Configure OpenBao as an OpenID Connect provider for the expense tracker and store the
# resulting client credentials + CA certificate in the KV secret the chart reads.
#
# export BAO_ADDR=https://192.168.2.218:30200 BAO_SKIP_VERIFY=true BAO_TOKEN=<admin token>
# scripts/openbao-oidc.sh
#
# Prerequisites: `bao` and `kubectl` on PATH; the KV secret secret/expense-tracker/app
# already exists (see README step 1). Afterwards enable it: `auth.openbao.enabled: true`.
#
# NOTE: written against the OpenBao OIDC provider API docs; not run against a live server
# from the machine that authored it - check the output of each step.
set -euo pipefail
NAME="${NAME:-expense-tracker}"
# scheme://host:port that both browser and pod can reach; the effective issuer becomes
# <ISSUER_ADDR>/v1/identity/oidc/provider/<NAME> and must equal auth.openbao.issuer in values.yaml
ISSUER_ADDR="${ISSUER_ADDR:-https://192.168.2.218:30200}"
REDIRECT_URI="${REDIRECT_URI:-https://budget.smokyzone.de:30444/auth/callback/openbao}"
KV_MOUNT="${KV_MOUNT:-secret}"
KV_PATH="${KV_PATH:-expense-tracker/app}"
echo "1/5 signing key"
bao write "identity/oidc/key/$NAME" allowed_client_ids="*" algorithm=RS256 rotation_period=24h verification_ttl=24h
echo "2/5 scope 'email' (claim comes from the entity's metadata)"
bao write identity/oidc/scope/email template='{"email": {{identity.entity.metadata.email}}}'
echo "3/5 client"
bao write "identity/oidc/client/$NAME" redirect_uris="$REDIRECT_URI" assignments="allow_all" \
key="$NAME" id_token_ttl=30m access_token_ttl=1h
CLIENT_ID="$(bao read -field=client_id "identity/oidc/client/$NAME")"
CLIENT_SECRET="$(bao read -field=client_secret "identity/oidc/client/$NAME")"
echo "4/5 provider"
bao write "identity/oidc/provider/$NAME" issuer="$ISSUER_ADDR" allowed_client_ids="$CLIENT_ID" scopes_supported="email"
echo "5/5 store credentials + CA in $KV_MOUNT/$KV_PATH"
CA_FILE="$(mktemp)"; trap 'rm -f "$CA_FILE"' EXIT
kubectl -n openbao get secret openbao-tls -o jsonpath='{.data.ca\.crt}' | base64 -d > "$CA_FILE"
[ -s "$CA_FILE" ] || { echo "could not read the CA from secret openbao/openbao-tls" >&2; exit 1; }
bao kv patch -mount="$KV_MOUNT" "$KV_PATH" \
openbao_client_id="$CLIENT_ID" openbao_client_secret="$CLIENT_SECRET" openbao_ca_cert=@"$CA_FILE"
echo
echo "Issuer: $ISSUER_ADDR/v1/identity/oidc/provider/$NAME"
curl -sk "$ISSUER_ADDR/v1/identity/oidc/provider/$NAME/.well-known/openid-configuration" | head -c 300 || true
echo
cat <<MSG
Remaining manual steps
* The person signing in needs an OpenBao *entity* whose metadata carries their email, e.g.
bao write identity/entity name=<name> metadata=email=<address>
bao write identity/entity-alias name=<login> canonical_id=<entity id> mount_accessor=<auth mount accessor>
(or add metadata to the existing entity of the userpass/OIDC user you log in with).
* Set auth.openbao.enabled=true in values.yaml, commit and push.
* The address must also be listed in allowed_emails.
MSG
+21
View File
@@ -0,0 +1,21 @@
{{- define "expense-tracker.labels" -}}
app.kubernetes.io/part-of: expense-tracker
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/* Standard PG* connection variables; the libpq/pg client reads these directly. */}}
{{- define "expense-tracker.pgenv" -}}
- name: PGHOST
value: expense-tracker-postgres
- name: PGPORT
value: "5432"
- name: PGDATABASE
value: {{ .Values.postgres.database | quote }}
- name: PGUSER
value: {{ .Values.postgres.user | quote }}
- name: PGPASSWORD
valueFrom:
secretKeyRef:
name: expense-tracker-db
key: POSTGRES_PASSWORD
{{- end }}
+82
View File
@@ -0,0 +1,82 @@
{{- if .Values.backup.enabled }}
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: expense-tracker-backups
namespace: {{ .Values.namespace.name }}
labels:
app.kubernetes.io/name: expense-tracker-backup
{{- include "expense-tracker.labels" . | nindent 4 }}
spec:
accessModes: ["ReadWriteOnce"]
{{- if .Values.backup.persistence.storageClassName }}
storageClassName: {{ .Values.backup.persistence.storageClassName }}
{{- end }}
resources:
requests:
storage: {{ .Values.backup.persistence.size }}
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: expense-tracker-backup
namespace: {{ .Values.namespace.name }}
labels:
app.kubernetes.io/name: expense-tracker-backup
{{- include "expense-tracker.labels" . | nindent 4 }}
spec:
schedule: {{ .Values.backup.schedule | quote }}
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 2
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 2
template:
metadata:
labels:
app.kubernetes.io/name: expense-tracker-backup
spec:
restartPolicy: OnFailure
securityContext:
runAsUser: 70
runAsGroup: 70
fsGroup: 70
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: pg-dump
image: {{ .Values.postgres.image | quote }}
command:
- sh
- -ec
- |
f="/backup/{{ .Values.postgres.database }}-$(date +%Y%m%d-%H%M%S).dump"
# Write to a temp name first so a failed dump never looks like a good backup.
pg_dump --format=custom --no-owner -f "$f.partial"
mv "$f.partial" "$f"
echo "wrote $f ($(du -h "$f" | cut -f1))"
find /backup -name '*.dump' -mtime +{{ .Values.backup.retentionDays }} -print -delete
find /backup -name '*.partial' -mmin +60 -delete
ls -lh /backup
env:
{{- include "expense-tracker.pgenv" . | nindent 16 }}
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 256Mi
volumeMounts:
- name: backups
mountPath: /backup
volumes:
- name: backups
persistentVolumeClaim:
claimName: expense-tracker-backups
{{- end }}
+153
View File
@@ -0,0 +1,153 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: expense-tracker
namespace: {{ .Values.namespace.name }}
labels:
app.kubernetes.io/name: expense-tracker
{{- include "expense-tracker.labels" . | nindent 4 }}
annotations:
# Stakater Reloader: rolling restart when ESO refreshes a secret from OpenBao
# (env vars are only read at container start). Harmless if Reloader is not installed.
secret.reloader.stakater.com/reload: "expense-tracker-app,expense-tracker-db"
spec:
replicas: {{ .Values.app.replicas }}
selector:
matchLabels:
app.kubernetes.io/name: expense-tracker
template:
metadata:
labels:
app.kubernetes.io/name: expense-tracker
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
seccompProfile:
type: RuntimeDefault
containers:
- name: app
image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
ports:
- name: http
containerPort: 5174
env:
- name: PORT
value: "5174"
- name: PUBLIC_URL
value: {{ .Values.app.publicUrl | quote }}
{{- include "expense-tracker.pgenv" . | nindent 12 }}
- name: SESSION_SECRET
valueFrom:
secretKeyRef:
name: expense-tracker-app
key: SESSION_SECRET
- name: ALLOWED_EMAILS
valueFrom:
secretKeyRef:
name: expense-tracker-app
key: ALLOWED_EMAILS
{{- if .Values.auth.google.enabled }}
- name: GOOGLE_CLIENT_ID
valueFrom:
secretKeyRef:
name: expense-tracker-app
key: GOOGLE_CLIENT_ID
- name: GOOGLE_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: expense-tracker-app
key: GOOGLE_CLIENT_SECRET
{{- end }}
{{- if .Values.auth.openbao.enabled }}
- name: OPENBAO_ISSUER
value: {{ .Values.auth.openbao.issuer | quote }}
- name: OPENBAO_CLIENT_ID
valueFrom:
secretKeyRef:
name: expense-tracker-app
key: OPENBAO_CLIENT_ID
- name: OPENBAO_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: expense-tracker-app
key: OPENBAO_CLIENT_SECRET
# OpenBao's certificate is self-signed (cert-manager), so trust its CA.
- name: NODE_EXTRA_CA_CERTS
value: /etc/openbao-ca/openbao-ca.crt
{{- end }}
# The server starts listening only after Postgres answered and migrations ran.
startupProbe:
httpGet:
path: /livez
port: http
periodSeconds: 3
failureThreshold: 40
readinessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
timeoutSeconds: 3
livenessProbe:
httpGet:
path: /livez
port: http
periodSeconds: 20
timeoutSeconds: 3
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
{{- toYaml .Values.app.resources | nindent 12 }}
volumeMounts:
- name: tmp
mountPath: /tmp
{{- if .Values.auth.openbao.enabled }}
- name: openbao-ca
mountPath: /etc/openbao-ca
readOnly: true
{{- end }}
volumes:
- name: tmp
emptyDir: {}
{{- if .Values.auth.openbao.enabled }}
- name: openbao-ca
secret:
secretName: expense-tracker-app
items:
- key: openbao-ca.crt
path: openbao-ca.crt
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
---
apiVersion: v1
kind: Service
metadata:
name: expense-tracker
namespace: {{ .Values.namespace.name }}
labels:
app.kubernetes.io/name: expense-tracker
{{- include "expense-tracker.labels" . | nindent 4 }}
spec:
selector:
app.kubernetes.io/name: expense-tracker
ports:
- name: http
port: 80
targetPort: http
+74
View File
@@ -0,0 +1,74 @@
# Both Secrets are created by External Secrets Operator from OpenBao (KV v2).
# If a referenced key is missing in OpenBao the ExternalSecret reports an error and the
# Secret is not created - so pods wait in CreateContainerConfigError until it exists.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: expense-tracker-db
namespace: {{ .Values.namespace.name }}
labels:
app.kubernetes.io/name: expense-tracker
{{- include "expense-tracker.labels" . | nindent 4 }}
spec:
refreshInterval: {{ .Values.externalSecret.refreshInterval }}
secretStoreRef:
kind: {{ .Values.externalSecret.storeKind }}
name: {{ .Values.externalSecret.storeName }}
target:
name: expense-tracker-db
creationPolicy: Owner
data:
- secretKey: POSTGRES_PASSWORD
remoteRef:
key: {{ .Values.externalSecret.db.remoteKey }}
property: {{ .Values.externalSecret.db.properties.password }}
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: expense-tracker-app
namespace: {{ .Values.namespace.name }}
labels:
app.kubernetes.io/name: expense-tracker
{{- include "expense-tracker.labels" . | nindent 4 }}
spec:
refreshInterval: {{ .Values.externalSecret.refreshInterval }}
secretStoreRef:
kind: {{ .Values.externalSecret.storeKind }}
name: {{ .Values.externalSecret.storeName }}
target:
name: expense-tracker-app
creationPolicy: Owner
data:
- secretKey: SESSION_SECRET
remoteRef:
key: {{ .Values.externalSecret.app.remoteKey }}
property: {{ .Values.externalSecret.app.properties.sessionSecret }}
- secretKey: ALLOWED_EMAILS
remoteRef:
key: {{ .Values.externalSecret.app.remoteKey }}
property: {{ .Values.externalSecret.app.properties.allowedEmails }}
{{- if .Values.auth.google.enabled }}
- secretKey: GOOGLE_CLIENT_ID
remoteRef:
key: {{ .Values.externalSecret.app.remoteKey }}
property: {{ .Values.externalSecret.app.properties.googleClientId }}
- secretKey: GOOGLE_CLIENT_SECRET
remoteRef:
key: {{ .Values.externalSecret.app.remoteKey }}
property: {{ .Values.externalSecret.app.properties.googleClientSecret }}
{{- end }}
{{- if .Values.auth.openbao.enabled }}
- secretKey: OPENBAO_CLIENT_ID
remoteRef:
key: {{ .Values.externalSecret.app.remoteKey }}
property: {{ .Values.externalSecret.app.properties.openbaoClientId }}
- secretKey: OPENBAO_CLIENT_SECRET
remoteRef:
key: {{ .Values.externalSecret.app.remoteKey }}
property: {{ .Values.externalSecret.app.properties.openbaoClientSecret }}
- secretKey: openbao-ca.crt
remoteRef:
key: {{ .Values.externalSecret.app.remoteKey }}
property: {{ .Values.externalSecret.app.properties.openbaoCaCert }}
{{- end }}
+33
View File
@@ -0,0 +1,33 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: expense-tracker
namespace: {{ .Values.namespace.name }}
labels:
app.kubernetes.io/name: expense-tracker
{{- include "expense-tracker.labels" . | nindent 4 }}
annotations:
cert-manager.io/cluster-issuer: {{ .Values.ingress.clusterIssuer }}
nginx.ingress.kubernetes.io/proxy-body-size: {{ .Values.ingress.proxyBodySize | quote }}
{{- with .Values.ingress.annotations }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
ingressClassName: {{ .Values.ingress.className }}
tls:
- hosts:
- {{ .Values.ingress.host }}
secretName: {{ .Values.ingress.tlsSecretName }}
rules:
- host: {{ .Values.ingress.host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: expense-tracker
port:
name: http
{{- end }}
+9
View File
@@ -0,0 +1,9 @@
{{- if .Values.namespace.create }}
apiVersion: v1
kind: Namespace
metadata:
name: {{ .Values.namespace.name }}
labels:
app.kubernetes.io/name: expense-tracker
{{- include "expense-tracker.labels" . | nindent 4 }}
{{- end }}
+28
View File
@@ -0,0 +1,28 @@
{{- if .Values.networkPolicy.enabled }}
# Postgres accepts connections only from the app and the backup job of this release.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: expense-tracker-postgres
namespace: {{ .Values.namespace.name }}
labels:
app.kubernetes.io/name: expense-tracker-postgres
{{- include "expense-tracker.labels" . | nindent 4 }}
spec:
podSelector:
matchLabels:
app.kubernetes.io/name: expense-tracker-postgres
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app.kubernetes.io/name: expense-tracker
- podSelector:
matchLabels:
app.kubernetes.io/name: expense-tracker-backup
ports:
- protocol: TCP
port: 5432
{{- end }}
+118
View File
@@ -0,0 +1,118 @@
apiVersion: v1
kind: Service
metadata:
name: expense-tracker-postgres
namespace: {{ .Values.namespace.name }}
labels:
app.kubernetes.io/name: expense-tracker-postgres
{{- include "expense-tracker.labels" . | nindent 4 }}
spec:
selector:
app.kubernetes.io/name: expense-tracker-postgres
ports:
- name: postgres
port: 5432
targetPort: postgres
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: expense-tracker-postgres
namespace: {{ .Values.namespace.name }}
labels:
app.kubernetes.io/name: expense-tracker-postgres
{{- include "expense-tracker.labels" . | nindent 4 }}
spec:
serviceName: expense-tracker-postgres
replicas: 1
selector:
matchLabels:
app.kubernetes.io/name: expense-tracker-postgres
template:
metadata:
labels:
app.kubernetes.io/name: expense-tracker-postgres
spec:
securityContext:
# uid/gid of the `postgres` user in the alpine image
runAsUser: 70
runAsGroup: 70
fsGroup: 70
runAsNonRoot: true
seccompProfile:
type: RuntimeDefault
containers:
- name: postgres
image: {{ .Values.postgres.image | quote }}
ports:
- name: postgres
containerPort: 5432
env:
- name: POSTGRES_DB
value: {{ .Values.postgres.database | quote }}
- name: POSTGRES_USER
value: {{ .Values.postgres.user | quote }}
# Only used when the data directory is first initialised; changing it later in
# OpenBao does NOT change the database user's password (see README).
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: expense-tracker-db
key: POSTGRES_PASSWORD
# A sub-directory, because the volume root may contain lost+found.
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
readinessProbe:
exec:
command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""]
periodSeconds: 5
timeoutSeconds: 3
livenessProbe:
exec:
command: ["sh", "-c", "pg_isready -U \"$POSTGRES_USER\" -d \"$POSTGRES_DB\""]
initialDelaySeconds: 30
periodSeconds: 20
timeoutSeconds: 3
failureThreshold: 6
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
resources:
{{- toYaml .Values.postgres.resources | nindent 12 }}
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
# Unix socket dir + scratch space (the image writes there as the postgres user)
- name: run
mountPath: /var/run/postgresql
- name: tmp
mountPath: /tmp
volumes:
- name: run
emptyDir: {}
- name: tmp
emptyDir: {}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
{{- if .Values.postgres.persistence.storageClassName }}
storageClassName: {{ .Values.postgres.persistence.storageClassName }}
{{- end }}
resources:
requests:
storage: {{ .Values.postgres.persistence.size }}
+103
View File
@@ -0,0 +1,103 @@
## Default values for the expense-tracker chart
namespace:
# If true, the chart creates the namespace itself.
create: true
name: expense-tracker
image:
# Built and pushed by `scripts/release.sh` in the `budget` repository.
repository: git.smokyzone.de/smokyzone/budget
tag: "b6da854"
pullPolicy: IfNotPresent
app:
replicas: 1
# Externally visible URL, INCLUDING the ingress-nginx NodePort. Used to build the
# OIDC redirect URIs: <publicUrl>/auth/callback/google (and /openbao).
publicUrl: https://budget.smokyzone.de:30444
resources:
requests:
cpu: 50m
memory: 96Mi
limits:
cpu: 500m
memory: 256Mi
# Secrets are NOT stored in git - they are read from OpenBao via External Secrets Operator.
externalSecret:
# ClusterSecretStore (deployed by the openbao chart) to read from.
storeKind: ClusterSecretStore
storeName: openbao
refreshInterval: 1h
# Path of the secrets inside the KV v2 mount configured on the store (`secret/`).
app:
remoteKey: expense-tracker/app
properties:
sessionSecret: session_secret # >= 32 random characters
allowedEmails: allowed_emails # comma separated: only these addresses may sign in
googleClientId: google_client_id
googleClientSecret: google_client_secret
openbaoClientId: openbao_client_id
openbaoClientSecret: openbao_client_secret
openbaoCaCert: openbao_ca_cert # PEM of the CA that signed OpenBao's TLS certificate
db:
remoteKey: expense-tracker/db
properties:
password: password
auth:
google:
enabled: true
# OpenBao as a second OIDC provider (see README, "OpenBao as SSO provider").
openbao:
enabled: false
# Must be reachable from the browser AND from the pod, and must equal the `issuer`
# configured on the OpenBao OIDC provider. OpenBao's UI NodePort serves this address.
issuer: https://192.168.2.218:30200/v1/identity/oidc/provider/expense-tracker
ingress:
enabled: true
className: nginx
host: budget.smokyzone.de
# cert-manager ClusterIssuer that issues the certificate. The DNS-01 issuer works for a
# hostname that only resolves to a private IP.
clusterIssuer: letsencrypt-dns-cloudflare
tlsSecretName: expense-tracker-tls
# ingress-nginx defaults to 1m; the API accepts JSON bodies up to 5 MB.
proxyBodySize: 6m
annotations: {}
postgres:
image: postgres:17-alpine
database: budget
user: budget
persistence:
size: 5Gi
# Leave empty to use the cluster default StorageClass.
storageClassName: ""
resources:
requests:
cpu: 100m
memory: 192Mi
limits:
cpu: "1"
memory: 512Mi
# Nightly pg_dump into its own volume. This protects against mistakes (a bad import, a
# deleted row), NOT against losing the node's disk - copy the dumps off the machine too.
backup:
enabled: true
schedule: "15 3 * * *"
retentionDays: 14
persistence:
size: 5Gi
storageClassName: ""
# Only the app and the backup job may talk to Postgres.
networkPolicy:
enabled: true
nodeSelector: {}
tolerations: []
affinity: {}