Stage 7 · Master
Phase 20 — Production Readiness
Backup
Replace the in-cluster pg_dump CronJob with off-cluster, continuous, point-in-time-capable backups before a single-cluster failure becomes a single-tenant-platform-ending failure.
Phase 7 gave every service a nightly pg_dump CronJob writing to a PersistentVolumeClaim in the same cluster the database itself runs in. That was enough to recover from an operator mistake — an accidental DELETE without a WHERE clause, a bad migration. It is not enough to recover from the failure modes that actually take a platform offline: a cluster-wide storage outage, a compromised or deleted cloud account, a region-level incident, or a slow logical-corruption bug that runs for six hours before anyone notices, by which point last night's dump already contains the corruption. This lesson replaces the PVC dump with pgBackRest configured for two independent repositories and continuous WAL archiving, which is what makes point-in-time recovery possible at all.
Name Precisely What a PVC Dump Cannot Survive
A nightly pg_dump written to a PersistentVolumeClaim in the same cluster as the primary shares that cluster's blast radius: the same storage backend, the same region, frequently the same cloud account. It answers 'did last night's backup succeed' but never 'can we recover into infrastructure that does not depend on anything that just failed.' Disaster recovery means proving restoration into genuinely independent infrastructure — a different account, a different region, storage that the primary's failure cannot touch — with a named recovery point objective and recovery time objective, verified on a schedule, not assumed from a green CronJob.
- Cluster-wide storage failure — the PVC and the primary's data directory sit on the same underlying storage system; a storage-layer incident takes both.
- Compromised or deleted cloud account — an attacker or an operator error that deletes the account or its resources deletes the backup PVC along with everything else it protects.
- Region-level incident — a single-region cluster and a single-region backup fail together; nothing exists outside the blast radius.
- Silent logical corruption — a nightly full dump only ever gives back yesterday's full state; it cannot restore to 09:14:52 this morning, one minute before a bad deploy started corrupting rows, because a dump is not a continuous timeline.
Configure Two Independent pgBackRest Repositories
pgBackRest introduces the concept the nightly dump never had: a repository that is a continuous timeline of a full backup plus every WAL segment generated since, not a single snapshot. Configuring two repositories — a fast local one for quick incremental restores, and a slow off-cluster one that is the actual disaster-recovery target — separates 'restore quickly from an operational mistake' from 'restore at all after the local repository is gone.'
[global]
# repo1: local, fast, short-retained — restores from an operator mistake
# in minutes without touching the network.
repo1-path=/var/lib/pgbackrest/repo1
repo1-retention-full=2
repo1-retention-full-type=count
# repo2: off-cluster, cross-region, cross-account object storage with
# Object Lock (WORM) enabled — the actual disaster-recovery target.
# Independent of repo1's storage, region, and cloud account by design.
repo2-type=s3
repo2-s3-bucket=hoa-pg-backups-dr
repo2-s3-endpoint=s3.dr-region.example-cloud.com
repo2-s3-region=dr-region
repo2-s3-key-type=env
repo2-path=/pgbackrest
repo2-retention-full=14
repo2-retention-full-type=time
repo2-bundle=y
repo2-block=y
# Every WAL segment is pushed to both repositories continuously, not
# only at nightly-dump time — this is what makes point-in-time
# recovery possible instead of only full-backup recovery.
archive-async=y
process-max=4
compress-type=zst
compress-level=6
[hoa]
pg1-path=/var/lib/postgresql/data
pg1-port=5432repo2's bucket, endpoint, region, and credentials are deliberately unrelated to the cluster's own cloud account — an incident that compromises or deletes the primary's account cannot reach repo2 by construction, not merely by policy.
apiVersion: v1
kind: ConfigMap
metadata:
name: postgresql-wal-archiving
namespace: hoa
data:
postgresql.conf.d-archiving.conf: |
wal_level = replica
archive_mode = on
archive_command = 'pgbackrest --stanza=hoa archive-push %p'
archive_timeout = 60s
max_wal_senders = 4
restore_command = 'pgbackrest --stanza=hoa archive-get %f "%p"'archive_timeout=60s bounds the maximum data loss between backups even during a quiet period with few writes — without it, WAL segments only ship once they fill, and a slow tenant could leave an hour of committed transactions unarchived.
Prove Point-in-Time Recovery, Not Just Full-Backup Recovery
The nightly PVC dump could only ever restore to the moment the dump ran. Continuous WAL archiving changes the question from 'which nightly snapshot do we restore' to 'which exact timestamp, transaction, or LSN do we restore to' — the difference between losing a full day of billing charges and losing ninety seconds of them.
package postgres
import (
"context"
"fmt"
"time"
)
// ArchiveLagSeconds reports how far behind the WAL archiver is, in
// seconds, using pg_stat_archiver. A healthy target keeps this near
// zero; a growing value means archive_command is failing silently and
// every backup since the last success is not being extended, which
// erodes the recovery point objective without any visible outage.
func (r *ChargeRepository) ArchiveLagSeconds(ctx context.Context) (float64, error) {
const query = `
SELECT extract(epoch FROM (now() - last_archived_time))
FROM pg_stat_archiver
WHERE last_archived_time IS NOT NULL`
var lagSeconds float64
if err := r.pool.QueryRow(ctx, query).Scan(&lagSeconds); err != nil {
return 0, fmt.Errorf("read archive lag: %w", err)
}
return lagSeconds, nil
}
// PublishArchiveLag is polled every 30s by pkg/telemetry and
// exported as hoa_wal_archive_lag_seconds — the SLI the Backup lesson's
// alert threshold below is built on.
func PublishArchiveLag(ctx context.Context, repo *ChargeRepository, publish func(float64)) {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if lag, err := repo.ArchiveLagSeconds(ctx); err == nil {
publish(lag)
}
}
}
}Archive lag is the single number that turns 'we configured backups once' into 'we know right now whether backups are still happening' — a failing archive_command produces no user-visible error, only a silently growing gap between the last successful WAL push and now.
repo1's two-full-backup local retention exists purely for fast, cheap restores from an operational mistake within the last day or two. repo2's fourteen-day, time-based retention on Object Lock storage is the number that actually bounds how far back a corruption can be discovered and still be recoverable — a corruption noticed on day fifteen has already aged out of repo2 and is unrecoverable by backup alone, which is why the Disaster Recovery lesson pairs this retention window with monitoring that catches corruption inside it, not after.
Treat an Unverified Backup as No Backup
A backup that has never been restored is a bet, not a control — pgBackRest can report success on every backup command while a misconfigured retention rule, a silently truncated WAL segment, or a bucket permission change quietly makes the backup unrestorable. pgbackrest check and a scheduled verify command close that gap by exercising the actual restore path on a schedule, independent of whether anyone remembers to test it by hand.
apiVersion: batch/v1
kind: CronJob
metadata:
name: pgbackrest-verify
namespace: hoa
spec:
schedule: "0 */6 * * *"
concurrencyPolicy: Forbid
jobTemplate:
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
containers:
- name: pgbackrest-verify
image: ghcr.io/hoa/pgbackrest:2.51
command:
- /bin/sh
- -c
- |
set -euo pipefail
pgbackrest --stanza=hoa --repo=1 check
pgbackrest --stanza=hoa --repo=2 check
pgbackrest --stanza=hoa --repo=2 info --output=json \
| tee /tmp/repo2-info.json
python3 /scripts/assert_recent_backup.py \
--max-age-hours=26 --info-file=/tmp/repo2-info.json
volumeMounts:
- name: verify-scripts
mountPath: /scripts
volumes:
- name: verify-scripts
configMap:
name: pgbackrest-verify-scriptscheck confirms WAL archiving and repository connectivity are actually working right now; assert_recent_backup.py fails the Job loudly — paging on-call rather than staying green — if repo2's newest full backup is older than 26 hours, which is the first signal a broken off-cluster path gives before anyone needs it during a real incident.
Applied exercise
Size a retention policy against a named recovery point objective
Billing leadership sets a requirement: no more than 15 minutes of committed maintenance-service transactions may ever be unrecoverable, and any backup older than 30 days must be assumed unrestorable for compliance reasons.
- State the archive_timeout value required to keep the maximum WAL-push gap under 15 minutes, and explain why archive_timeout alone is not sufficient without also monitoring hoa_wal_archive_lag_seconds.
- Write the repo2-retention-full and repo2-retention-full-type values that satisfy the 30-day compliance ceiling, and justify why repo1's shorter local retention does not need to match it.
- Write the pgbackrest restore command that recovers repo2 to a specific timestamp 15 minutes before a stated incident time, using --target-action=promote.
- Identify one failure mode that meeting both numbers on paper still would not catch, and name the control from this lesson that catches it.
Deliverable
A pgbackrest.conf retention block, a filled-in restore command for a stated timestamp, and a short written justification connecting each number to the 15-minute and 30-day requirements.
Completion checks
- archive_timeout is set low enough that no committed transaction can wait longer than 15 minutes to be archived, and the answer explains why a monitored SLI is still required.
- repo2's retention keeps at least 30 days of full backups; repo1's shorter retention is explicitly justified as a fast-recovery path, not a compliance path.
- The restore command targets a specific timestamp with --type=time, not merely 'the most recent backup.'
Why does repo2 use a different cloud account, region, and endpoint than the primary cluster, rather than simply a different bucket in the same account?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.