Skip to main content

PostgreSQL backup & restore

Every database in the homelab is a CloudNativePG cluster built from the cnpg component, so the names are the same in every namespace: the cluster is cnpg, its pods are cnpg-1 and cnpg-2, the database is app, and the application's credentials are in the Secret cnpg-app.

This page has two parts. The first is the quick dump and restore with kubectl exec, for moving data around by hand. The second restores a database from the nightly dump that k8up stores in the backup repositories.

Find the primary​

With two instances, either pod can be the primary, and it changes on every failover or node drain. Always resolve it instead of assuming cnpg-1; a restore into a replica fails.

NS=<namespace>
PRIMARY=$(kubectl get pod -n "$NS" -l cnpg.io/instanceRole=primary \
-o jsonpath='{.items[0].metadata.name}')

The commands below run pg_dump and psql inside that pod as the postgres user over the local socket, so no password is involved.

Ad-hoc dump and restore​

Dump the app database to the workstation:

kubectl exec -n "$NS" "$PRIMARY" -c postgres -- pg_dump -d app --clean > backup.sql

Load it back. Note -i without -t: a TTY cannot be combined with piped input.

kubectl exec -i -n "$NS" "$PRIMARY" -c postgres -- psql -d app < backup.sql

--clean makes the dump drop each object before recreating it, so it can be replayed into a database that already has the schema. Stop the application first (scale its Deployment to zero) so nothing writes while the restore runs.

Restore from a k8up snapshot​

Each backup run starts a pre-backup pod that streams pg_dump -d app --clean straight into Restic. The result is a snapshot containing a single file, /<namespace>-cnpg-pgdump.sql, in both repositories: Hetzner Object Storage and Backblaze B2.

snapshot → restic dump → <namespace>-cnpg-pgdump.sql → pv | kubectl exec → primary

You need restic and pv on the workstation, plus kubectl access to the cluster.

1. Point Restic at the repository​

Everything Restic needs is already in the app's namespace. The bucket name comes from the Schedule, the credentials from the Secrets it references:

BUCKET=$(kubectl get schedule schedule-hetzner -n "$NS" -o jsonpath='{.spec.backend.s3.bucket}')
secret() { kubectl get secret "$1" -n "$NS" -o jsonpath="{.data.$2}" | base64 -d; }

export RESTIC_REPOSITORY="s3:https://hel1.your-objectstorage.com/$BUCKET"
export RESTIC_PASSWORD=$(secret k8up-restic-password password)
export AWS_ACCESS_KEY_ID=$(secret k8up-s3-credentials AWS_ACCESS_KEY_ID)
export AWS_SECRET_ACCESS_KEY=$(secret k8up-s3-credentials AWS_SECRET_ACCESS_KEY)

To restore from Backblaze instead, the bucket and the Restic password are the same; only the endpoint and the key change:

export RESTIC_REPOSITORY="s3:https://s3.eu-central-003.backblazeb2.com/$BUCKET"
export AWS_ACCESS_KEY_ID=$(secret workspace-outputs backup_key_schedule_backblaze_id)
export AWS_SECRET_ACCESS_KEY=$(secret workspace-outputs backup_key_schedule_backblaze_secret)

2. Pick a snapshot​

The cluster already lists what each repository holds:

kubectl get snapshots.k8up.io -n "$NS" \
-o custom-columns=ID:.spec.id,DATE:.spec.date,PATHS:.spec.paths,REPO:.spec.repository

Or ask Restic directly, filtered to the dump:

restic snapshots --path "/$NS-cnpg-pgdump.sql"

Snapshots of the data volumes live in the same repository under other paths; the filter leaves only the database dumps. Note the ID of the one to restore, or use latest.

3. Extract the dump​

restic dump writes the single file to stdout, without recreating a directory tree:

SNAPSHOT=latest
restic dump --path "/$NS-cnpg-pgdump.sql" "$SNAPSHOT" "/$NS-cnpg-pgdump.sql" > "$NS-cnpg-pgdump.sql"

A quick sanity check before touching the database: the file should end with -- PostgreSQL database dump complete.

tail -n 3 "$NS-cnpg-pgdump.sql"

4. Stop the application​

Scale the workload that uses the database to zero so it neither writes during the restore nor holds locks on the objects being dropped:

kubectl scale deployment/<app> -n "$NS" --replicas=0

5. Replay the dump​

pv shows progress, throughput and an ETA while the file streams into the primary:

pv "$NS-cnpg-pgdump.sql" | kubectl exec -i -n "$NS" "$PRIMARY" -c postgres -- psql -d app
512MiB 0:01:03 [8.12MiB/s] [===========> ] 74% ETA 0:00:21

The replica follows through streaming replication; nothing has to be done on it.

6. Verify and start the application​

kubectl exec -it -n "$NS" "$PRIMARY" -c postgres -- psql -d app
\dt
SELECT COUNT(*) FROM your_table;
\q

Then scale the application back up and check its logs for a clean start.

The whole thing as a script​

#!/bin/bash
set -euo pipefail

NS="<namespace>"
APP="<deployment>"
SNAPSHOT="latest"
DUMP="$NS-cnpg-pgdump.sql"

secret() { kubectl get secret "$1" -n "$NS" -o jsonpath="{.data.$2}" | base64 -d; }
BUCKET=$(kubectl get schedule schedule-hetzner -n "$NS" -o jsonpath='{.spec.backend.s3.bucket}')

export RESTIC_REPOSITORY="s3:https://hel1.your-objectstorage.com/$BUCKET"
export RESTIC_PASSWORD=$(secret k8up-restic-password password)
export AWS_ACCESS_KEY_ID=$(secret k8up-s3-credentials AWS_ACCESS_KEY_ID)
export AWS_SECRET_ACCESS_KEY=$(secret k8up-s3-credentials AWS_SECRET_ACCESS_KEY)

restic dump --path "/$DUMP" "$SNAPSHOT" "/$DUMP" > "$DUMP"

PRIMARY=$(kubectl get pod -n "$NS" -l cnpg.io/instanceRole=primary \
-o jsonpath='{.items[0].metadata.name}')
echo "Restoring into $NS/$PRIMARY"

kubectl scale "deployment/$APP" -n "$NS" --replicas=0
pv "$DUMP" | kubectl exec -i -n "$NS" "$PRIMARY" -c postgres -- psql -d app
kubectl scale "deployment/$APP" -n "$NS" --replicas=1

echo "Restore complete."

Limits​

The dump is logical and taken once a day, so a restore loses everything written since the last run; there is no WAL archive and no point-in-time recovery. When a pre-backup pod fails, that night's run has no dump at all while the volume backup still succeeds, so check the snapshot date before relying on latest. See backups-cnpg for how that failure shows up, and Backups end to end for the tiers around it.