PostgreSQL StatefulSet

Deploying PostgreSQL as a StatefulSet

The image reads the password from a file (POSTGRES_PASSWORD_FILE), so Database Connection's Secret mounts as for the API, and PostgreSQL 18 1,289 's claim mounts at /var/lib/postgresql (Persisting PostgreSQL):

k8s/postgres.yaml: a headless Service and a one-replica StatefulSetYAML
apiVersion: v1
kind: Service
metadata: { name: postgres, labels: { app: booknest, tier: db } }
spec:
  clusterIP: None
  selector: { app: booknest, tier: db }
  ports: [{ name: postgres, port: 5432 }]
---
apiVersion: apps/v1
kind: StatefulSet
metadata: { name: postgres, labels: { app: booknest, tier: db } }
spec:
  serviceName: postgres
  replicas: 1
  selector: { matchLabels: { app: booknest, tier: db } }
  template:
    metadata: { labels: { app: booknest, tier: db } }
    spec:
      containers:
      - name: postgres
        image: localhost:33500/postgres:18
        env:
        - { name: POSTGRES_DB, value: booknest }
        - name: POSTGRES_USER
          valueFrom: { secretKeyRef: { name: db-credentials, key: username } }
        - { name: POSTGRES_PASSWORD_FILE, value: /run/secrets/db/password }
        ports: [{ name: postgres, containerPort: 5432 }]
        readinessProbe:
          exec: { command: [sh, -c, 'pg_isready -U "$POSTGRES_USER" -d booknest'] }
        volumeMounts:
        - { name: data, mountPath: /var/lib/postgresql }
        - { name: db-credentials, mountPath: /run/secrets/db, readOnly: true }
      volumes: [{ name: db-credentials, secret: { secretName: db-credentials } }]
  volumeClaimTemplates:
  - metadata: { name: data }
    spec:
      storageClassName: booknest-data
      accessModes: [ReadWriteOnce]
      resources: { requests: { storage: 1Gi } }

An empty database is where Deploying the API's warning bites: three API replicas starting together would all run CREATE TABLE and seed at once. A Job (Batch and Node-Level Workloads) runs BookNest's own db.init() exactly once instead:

k8s/db-init-job.yaml: create the schema and seed data onceYAML
apiVersion: batch/v1
kind: Job
metadata: { name: db-init, labels: { app: booknest } }
spec:
  backoffLimit: 4
  template:
    spec:
      restartPolicy: OnFailure
      containers:
      - name: init
        image: localhost:33500/booknest-api:1.4
        command: [node, -e, "const db = require('./db'); db.init().then(() => db.close())"]
        envFrom: [{ configMapRef: { name: api-config } }]
        env:
        - name: PGUSER
          valueFrom: { secretKeyRef: { name: db-credentials, key: username } }
        - { name: PGPASSWORD_FILE, value: /run/secrets/db/password }
        volumeMounts: [{ name: db-credentials, mountPath: /run/secrets/db, readOnly: true }]
      volumes: [{ name: db-credentials, secret: { secretName: db-credentials } }]

Stop the API, generate a password, start PostgreSQL, point api-config at postgres, run the Job, restart the API, then delete postgres-0 to see the claim outlive its Pod:

Switching BookNest to the StatefulSetShell
kubectl scale deployment api --replicas=0 >/dev/null
kubectl create secret generic db-credentials --from-literal=username=booknest \
  --from-literal=password="$(openssl rand -hex 16)" --dry-run=client -o yaml | kubectl replace -f -
kubectl apply -f k8s/postgres.yaml && kubectl rollout status sts/postgres | tail -1
sed -i 's/PGHOST: db,/PGHOST: postgres,/' k8s/api-config.yaml
kubectl apply -f k8s/api-config.yaml
kubectl apply -f k8s/db-init-job.yaml && kubectl wait --for=condition=Complete job/db-init
kubectl scale deployment api --replicas=3 >/dev/null && kubectl rollout status deploy/api | tail -1
kubectl get pvc -o custom-columns=NAME:.metadata.name,STATUS:.status.phase,\
CLASS:.spec.storageClassName,SIZE:.status.capacity.storage
kubectl delete pod postgres-0 && kubectl wait --for=condition=Ready pod/postgres-0 >/dev/null
kubectl exec postgres-0 -- psql -U booknest -d booknest -tAc 'SELECT count(*) FROM books'
kubectl delete pod,service db >/dev/null
git add k8s && git commit -qm "Run PostgreSQL as a StatefulSet and create the schema with a Job"
Output
secret/db-credentials replaced
service/postgres created
statefulset.apps/postgres created
partitioned roll out complete: 1 new pods have been updated...
configmap/api-config configured
job.batch/db-init created
job.batch/db-init condition met
deployment "api" successfully rolled out
NAME              STATUS   CLASS           SIZE
data-postgres-0   Bound    booknest-data   1Gi
pod "postgres-0" deleted from booknest namespace
6

The Pod came back as postgres-0 on the same claim, with all six books, and the throwaway db is gone.