We close the module with the lesson that turns Rutas Norte into a platform you can trust. We already have the data outside the container, on a volume that survives the pod, expandable and with snapshots. But the previous lesson ended with a warning that puts it all in perspective: a snapshot lives in the same storage system as the original, so it does not survive the loss of a zone, the deletion of an account or corruption discovered three weeks late. A real backup is something else: it leaves the cluster, goes to an independent system, includes the API objects too, is coordinated with the application to be consistent, has a defined retention and — this is the one non-negotiable — is tested by restoring it. In this lesson you will build the two pieces Rutas Norte needs: the scheduled logical dump of bookings-postgres and the backup of the complete platform with Velero, and you will run the disaster drill by deleting a whole environment and recovering it against the clock.
Contents
- What really has to be saved in a cluster
- A snapshot is not a backup: the 3-2-1 rule
- RPO and RTO applied to Rutas Norte with numbers
- Logical backup:
pg_dumpfrom a scheduled Job - Compression, encryption and restoring the dump
- Velero: what it does and how it is built
- Installation and the first backup of a namespace
preandposthooks: the consistent backup- Scheduled backups with retention
- Restoring into another namespace
- The disaster drill
- What does not restore on its own, and the minimal runbook
- Retention, cost and personal data
- What really has to be saved in a cluster
The first question is not "how do I take backups" but "of what". In a Kubernetes cluster there are three different things, and each one needs a different technique:
| What | Where it lives | Technique | Do we have it already? |
|---|---|---|---|
| The manifests (Deployments, Services, Ingress, NetworkPolicies…) | In Git, as the source of truth | Version control | Yes: k8s/base and k8s/environments/... |
| The API state (real objects, including those created outside Git: Secrets, PVCs, cert-manager certificates, annotations set by controllers) | In etcd | A backup of API objects (Velero) or an etcd backup | No |
| The volume data | In the PVs | Logical dump and/or exported snapshot | No |
Three observations that shape everything else. Git is not enough: the manifests let you recreate the shape of the platform, but they do not contain the Secrets (which we deliberately kept out of Git in 03-02), nor the certificates issued by cert-manager (04-05), and of course not the data. The etcd backup is the cluster administrator's business, not the application team's, and on a managed cluster you do not even have access to etcd: that is why the practical approach for Rutas Norte is to back up API objects per namespace, which is portable between clusters as a bonus. And what is truly irreplaceable is the data: a Deployment is recreated in seconds; a booking sold and paid for is not.
- A snapshot is not a backup: the 3-2-1 rule
We return to the warning of 05-05 and give it operational shape with the classic rule, which is still the best summary. 3-2-1: at least 3 copies of the data, on 2 different media or systems, with 1 of them off the main site. Applied to Rutas Norte:
| Element | Counts as | Complies |
|---|---|---|
The bookings-postgres volume in production |
Copy 1 (the original) | — |
| Daily CSI snapshots and ones taken before changes | Copy 2, same system | 3 partly, 2 no, 1 no |
pg_dump dumps in a cluster PVC |
Copy 3, same cluster | 3 yes, 2 partly, 1 no |
| Velero backups in an object store in another region | 1 yes |
The fourth row is what turns the set into a strategy: without it, everything else disappears along with the infrastructure it protects. Two reinforcements considered mandatory today: immutability — the object store in lock mode (Object Lock / WORM), so that not even stolen credentials can delete or encrypt the backups — and separation of credentials: the account that writes the backups must not be able to delete them, and the destination must be in another account or subscription.
- RPO and RTO applied to Rutas Norte with numbers
Two acronyms that must be kept well apart because they are constantly confused:
| RPO (Recovery Point Objective) | RTO (Recovery Time Objective) | |
|---|---|---|
| Question | How much data can we lose? | How much time can we be down? |
| Determined by | The frequency of the backups | The speed of the restore |
And now the numbers, which is what turns the conversation into something useful. Rutas Norte sells about 60 bookings per hour on a normal working day, and about 400 on a bank-holiday weekend or at the start of the holidays, with peaks of 700.
| Backup frequency (RPO) | Lost on a normal day | Lost on a bank-holiday weekend | Operational cost |
|---|---|---|---|
| 24 h (nightly backup) | up to 1,440 | up to 9,600 | Minimal |
| 6 h | up to 360 | up to 2,400 | Low |
| 1 h | up to 60 | up to 400 | Medium |
| 5 min (continuous WAL archiving) | ~5 | ~33 | High |
Every row implies a business conversation, not a technical one: how much does it cost to lose 9,600 bookings? It is not just the money; it is rebuilding by hand, handling complaints, customers turning up at the station with no ticket and reputational damage in the middle of peak season.
Rutas Norte's decision:
| Environment | Target RPO | Target RTO | How it is achieved |
|---|---|---|---|
rutas-norte-pro |
1 hour (15 min in peak season) | 2 hours | Hourly logical dump + Velero with snapshots + WAL archiving (pending) |
rutas-norte-pre |
24 h | 8 h | Nightly Velero |
rutas-norte-dev |
No target | Best effort | Recreated from Git |
The RTO measured in the exercise of 05-05 — of the order of two minutes locally — was for restoring a volume. The real RTO of a disaster includes detecting, deciding, restoring, verifying and reopening the service. That is why it has to be measured with a drill (section 11), not estimated.
- Logical backup:
pg_dump from a scheduled Job
pg_dump from a scheduled JobA logical backup is a file with the instructions needed to rebuild the database. Its advantages over a block snapshot are decisive:
Logical dump (pg_dump) |
Block snapshot | |
|---|---|---|
| Consistency | Guaranteed: it runs inside a transaction | Crash-consistent unless coordinated |
| Verifiable | Yes: if it restores, the data is coherent | Proves nothing on its own |
| Portable to another version, cluster or provider | Yes | No |
| Allows restoring a single table | Yes | No |
| Size | Small (compresses very well) | That of the volume |
| Speed on large databases | Slow | Instantaneous |
They do not compete: they complement each other. The snapshot is fast for undoing; the dump is the copy that really proves the data is sound.
First, the volume where the dumps are written: k8s/base/postgres-backups-pvc.yaml, a 50 GiB PVC called postgres-backups, ReadWriteOnce, with the usual labels plus app.kubernetes.io/component: backups. It goes on the rutasnorte-fast class (with Retain) because if the backups are lost, the safety net disappears.
And now the CronJob. Note: the CronJob object is studied in depth in 06-03; here we use it as a tool, keeping only what is essential to read it.
# k8s/environments/pro/cronjob-postgres-backup.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: backup-bookings-postgres
namespace: rutas-norte-pro
labels:
app: backup-bookings-postgres
app.kubernetes.io/component: backups
app.kubernetes.io/part-of: rutas-norte
environment: pro
spec:
schedule: "0 * * * *" # every hour on the hour (RPO of 1 h)
timeZone: "Europe/Madrid"
concurrencyPolicy: Forbid # never two dumps at once
successfulJobsHistoryLimit: 3 # and failedJobsHistoryLimit: 5
jobTemplate:
spec:
backoffLimit: 2
activeDeadlineSeconds: 3600 # if it takes more than 1 h, something is wrong
template:
metadata:
labels: { app: backup-bookings-postgres, environment: pro }
spec:
restartPolicy: Never
serviceAccountName: postgres-backups # its own SA (03-06)
securityContext: { runAsNonRoot: true, runAsUser: 999, fsGroup: 999 }
containers:
- name: pg-dump
image: postgres:16.4
env:
- { name: PGHOST, value: bookings-postgres } # the Service (02-05)
- name: PGUSER
valueFrom:
secretKeyRef: { name: bookings-postgres-credentials, key: username }
- name: PGPASSWORD
valueFrom:
secretKeyRef: { name: bookings-postgres-credentials, key: password }
- name: PGDATABASE
valueFrom:
secretKeyRef: { name: bookings-postgres-credentials, key: database }
command:
- /bin/bash
- -c
- |
set -euo pipefail
TARGET="/backups/bookings-$(date +%Y%m%d-%H%M%S).dump"
# -Fc: PostgreSQL's compressed format; allows restoring
# individual tables. -Z6: compression level (0-9).
pg_dump -Fc -Z6 --no-owner --no-privileges -f "${TARGET}"
# Basic verification: the dump can be READ.
pg_restore --list "${TARGET}" > /dev/null
echo "[$(date -Is)] dump OK: $(du -h ${TARGET} | cut -f1)"
# Local retention: 72 h on the PVC. Long-term goes to the
# object store with Velero (section 9).
find /backups -name 'bookings-*.dump' -mmin +4320 -print -delete
volumeMounts:
- { name: backups, mountPath: /backups }
resources:
requests: { cpu: 200m, memory: 256Mi }
limits: { cpu: "1", memory: 1Gi }
volumes:
- name: backups
persistentVolumeClaim: { claimName: postgres-backups }The decisions inside it, one by one:
concurrencyPolicy: Forbid: if a dump takes longer than an hour, the next one does not start. Two simultaneouspg_dumpruns would double the load on the database exactly when it is already slow.set -euo pipefailand the verification withpg_restore --list: without the former, a failedpg_dumpcan leave a truncated file and the Job finish asCompleted, giving you backups that are worthless; the latter checks that the dump is at least readable. Real verification is restoring it (section 11).--no-owner --no-privileges: the dump does not drag owners or permissions along, which allows restoring it on another cluster with another user. And with its own ServiceAccount (03-06) the pod has only what it needs, while the local 72 h retention turns the PVC into the hot copy for restoring fast; the long retention lives outside the cluster.
And you have to remember the NetworkPolicies of 04-06: the CronJob's pod is new in the namespace and deny-all will block it. Its conversation has to be authorised explicitly:
# k8s/environments/pro/np-06-postgres-backups.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: postgres-backups-egress
namespace: rutas-norte-pro
spec:
podSelector:
matchLabels: { app: backup-bookings-postgres }
policyTypes: [Egress]
egress:
- to:
- podSelector: { matchLabels: { app: bookings-postgres } }
ports: [{ protocol: TCP, port: 5432 }]
- to: # DNS, or nothing resolves
- namespaceSelector:
matchLabels: { kubernetes.io/metadata.name: kube-system }
podSelector: { matchLabels: { k8s-app: kube-dns } }
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }Verification:
kubectl apply -f k8s/base/postgres-backups-pvc.yaml
kubectl apply -f k8s/environments/pro/cronjob-postgres-backup.yaml
kubectl apply -f k8s/environments/pro/np-06-postgres-backups.yaml
# Trigger a manual run without waiting for the hour
kubectl create job --from=cronjob/backup-bookings-postgres \
manual-backup-$(date +%s) -n rutas-norte-pro
kubectl logs -n rutas-norte-pro -l app=backup-bookings-postgres --tail=20
# [2026-08-05T12:00:41+02:00] dump OK: 412M
- Compression, encryption and restoring the dump
Encryption
The -Fc format compresses but does not encrypt. And that file contains the name, ID number, phone number and email address of every Rutas Norte customer: it is exactly the sort of artefact that cannot be left in the clear on any medium. The practical solution is to encrypt with a public key whose private key is not in the cluster:
# In the CronJob's container, replacing pg_dump -f with a pipe:
pg_dump -Fc -Z6 --no-owner --no-privileges \
| age -r "$BACKUP_PUBLIC_KEY" -o "${TARGET}.age"The BACKUP_PUBLIC_KEY variable arrives by secretKeyRef from a backup-public-key Secret. And there is the important point: only the public key lives in the cluster, so whoever compromises the cluster can write backups but cannot read them. The private key is kept outside (a corporate secrets manager, a safe), and the procedure for accessing it must be documented, because a backup nobody can decrypt on the day of the disaster is not a backup.
Restoring
You launch a helper pod with the postgres:16.4 image mounting the postgres-backups PVC at /backups (with kubectl run ... --overrides or a one-off Job), and from inside:
# 1. Locate the dump
ls -lh /backups/
# 2. Restore into a NEW database (never over the production one)
export PGHOST=bookings-postgres PGUSER=rutasnorte PGPASSWORD=...
createdb bookings_restored
pg_restore -d bookings_restored --no-owner --jobs=4 \
/backups/bookings-20260805-120003.dump
# 3. Verify BEFORE touching anything real
psql -d bookings_restored -c "SELECT count(*), max(date) FROM bookings;"
# count | max
# --------+------------
# 184392 | 2026-12-28Always restore into a new database and verify before replacing. Restoring straight over the production database with --clean is the fastest way of turning a recoverable incident into an irreversible one.
pg_restore options that save time on the bad day:
--jobs=4 restores in parallel and greatly reduces the RTO on large databases; --table=bookings restores a single table, which is the most common case (someone dropped a table); --schema-only and --data-only separate structure from data; and --list with --use-list let you inspect the contents and restore only a part.
- Velero: what it does and how it is built
The logical dump protects the database's data. It does not protect the Secrets, nor the PVCs, nor the Ingresses, nor the certificates, nor the rest of the namespace's objects. That is what Velero is for, the standard tool for backing up and migrating Kubernetes resources.
Velero does three things. It backs up the API objects of a namespace (or of the whole cluster), filtered by labels or by type, to an object store (S3, Azure Blob, GCS, MinIO). It backs up the volume data in two ways: by asking the CSI driver for snapshots, or by copying file by file with Kopia/Restic to the same object store, which is what really gets the data out of the storage system. And it restores all of that, on the same cluster or another one, with namespace remapping and filters.
flowchart TB
subgraph CLUSTER["Kubernetes cluster"]
API["kube-apiserver"]
SRV["velero Deployment (controller)<br/>+ object and CSI plugins"]
NA["node-agent DaemonSet<br/>(file copy with Kopia)"]
PVCS[("PVCs of rutas-norte-pro")]
end
OBJ[("Object store<br/>s3://rutasnorte-backups<br/>ANOTHER REGION, immutable")]
SNAP[("CSI snapshots<br/>same storage system")]
SRV -->|"reads objects"| API
SRV -->|"manifests + metadata"| OBJ
SRV -->|"VolumeSnapshot"| SNAP
NA -->|"data file by file"| OBJ
PVCS --- NA
The distinction to be clear about from the start:
| Volume method | Where the data ends up | Does it survive losing the region? | Speed |
|---|---|---|---|
CSI snapshots (--snapshot-volumes) |
In the same storage system | No | Very fast |
Files (--default-volumes-to-fs-backup) |
In the object store | Yes | Slow |
For Rutas Norte: CSI snapshots for the daily one (fast, for undoing) and a copy to the object store for the weekly long-retention backup, which is the one that satisfies the "1" of the 3-2-1 rule.
- Installation and the first backup of a namespace
To practise on minikube we will use MinIO as an S3-compatible object store, deployed in the cluster itself. In production it would be a real bucket in another region and another account.
# 1. Velero CLI (download the release and move the binary to /usr/local/bin)
velero version --client-only
# 2. Store credentials (in production, from an account that CANNOT delete)
printf '[default]\naws_access_key_id=minio\naws_secret_access_key=minio123\n' \
> velero-credentials
# 3. Install the server on the cluster
velero install --provider aws \
--plugins velero/velero-plugin-for-aws:v1.11.0,velero/velero-plugin-for-csi:v0.7.0 \
--bucket rutasnorte-backups --secret-file ./velero-credentials \
--use-node-agent --features=EnableCSI \
--backup-location-config region=minio,s3ForcePathStyle="true",s3Url=http://minio.velero.svc:9000 \
--snapshot-location-config region=minio
kubectl get pods -n velero
velero backup-location get # PHASE must say AvailableThe first backup, of the pre-production namespace:
velero backup create pre-full-$(date +%Y%m%d) \
--include-namespaces rutas-norte-pre --default-volumes-to-fs-backup \
--ttl 168h0m0s --labels environment=pre,type=manual
velero backup describe pre-full-20260805 --detailsName: pre-full-20260805 Phase: Completed TTL: 168h0m0s
Resource List:
apps/v1/Deployment: 5 v1/Service: 5 v1/ConfigMap: 7
v1/Secret: 4 v1/ServiceAccount: 6 v1/PersistentVolumeClaim: 2
networking.k8s.io/v1/Ingress: 2 networking.k8s.io/v1/NetworkPolicy: 6
Backup Volumes (Pod Volume Backups - kopia):
bookings-postgres-.../data: Completed (18.4GB)Note what it has captured: the Secrets, which are not in Git; the PVCs; the NetworkPolicies; the ServiceAccounts. And the volume data, 18.4 GB copied file by file to the object store.
The two volume variants are --snapshot-volumes=false (API objects only: blazingly fast, but with no data) and --snapshot-volumes=true (CSI snapshots: fast, but the data stays in the same storage system).
pre and post hooks: the consistent backup
pre and post hooks: the consistent backupHere we solve the consistency problem we left open in 05-05. Velero's hooks run a command inside the container before and after backing up the volume, which allows the database to be left in a coherent state.
They are declared as annotations on the pod, or on the backup itself. The declarative way, in the Deployment's podTemplate:
metadata:
labels: { app: bookings-postgres, environment: pro }
annotations:
# BEFORE backing up the volume: PostgreSQL backup mode
pre.hook.backup.velero.io/container: postgres
pre.hook.backup.velero.io/command: >-
["/bin/bash","-c",
"psql -U rutasnorte -d bookings -c \"SELECT pg_backup_start('velero', true);\" &&
psql -U rutasnorte -d bookings -c 'CHECKPOINT;'"]
pre.hook.backup.velero.io/timeout: 3m
# AFTER: leave backup mode ALWAYS, whether it went well or badly
post.hook.backup.velero.io/container: postgres
post.hook.backup.velero.io/command: >-
["/bin/bash","-c",
"psql -U rutasnorte -d bookings -c 'SELECT pg_backup_stop();'"]
post.hook.backup.velero.io/timeout: 3mWhat each one does. pg_backup_start (the name from PostgreSQL 15 onwards; it used to be pg_start_backup) puts the server into backup mode: it forces a checkpoint and guarantees that the files in the data directory form a restorable set, even while writes keep coming in. And pg_backup_stop closes that mode: it is essential that it always runs, because a database left in backup mode accumulates WAL without releasing it and ends up filling the disk; that is why the post hook runs even if the backup fails.
A simpler and often preferable alternative for medium-sized databases: the pre hook does a pg_dump straight into an emptyDir that Velero also backs up. It sacrifices some time in exchange for a verifiable logical dump.
| Level | How | Consistency | Cost |
|---|---|---|---|
| No hooks | Direct copy of the volume | Crash-consistent | None |
pg_backup_start/stop hooks |
PostgreSQL backup mode | Application-consistent | Low |
Hook with pg_dump |
Logical dump inside the backup | Consistent and verifiable | Medium |
velero backup create pro-consistent-$(date +%Y%m%d) \
--include-namespaces rutas-norte-pro --default-volumes-to-fs-backup
velero backup logs pro-consistent-20260805 | grep -i hook
# level=info msg="Running exec hook" hookPhase=pre pod=.../bookings-postgres-...
# level=info msg="Running exec hook" hookPhase=post pod=.../bookings-postgres-...
- Scheduled backups with retention
A manual backup is good for learning; what protects you is a schedule with automatic retention, which Velero solves with Schedule and the TTL, which deletes the backup and its data on expiry.
# Daily production backup with snapshots: fast, short retention (7 days)
velero schedule create pro-daily --schedule="0 2 * * *" \
--include-namespaces rutas-norte-pro --snapshot-volumes=true \
--ttl 168h0m0s --labels environment=pro,type=daily
# Weekly to the object store: slow, long retention (90 days)
velero schedule create pro-weekly --schedule="0 3 * * 0" \
--include-namespaces rutas-norte-pro --default-volumes-to-fs-backup \
--ttl 2160h0m0s --labels environment=pro,type=weekly
# Pre-production (14 days)
velero schedule create pre-daily --schedule="0 4 * * *" \
--include-namespaces rutas-norte-pre --default-volumes-to-fs-backup \
--ttl 336h0m0s
velero schedule get # all three, in the Enabled stateThe resulting Rutas Norte calendar:
| Backup | Frequency | Method | Retention | Protects against |
|---|---|---|---|---|
pg_dump dump (CronJob) |
Hourly | Logical, to a PVC | 72 h | Recent human error in the data |
Velero pro-daily |
Daily | CSI snapshots | 7 days | Deployment failure, object deletion |
Velero pro-weekly |
Weekly | Files to the store | 90 days | Loss of zone, of account, old corruption |
| Manual snapshot | Before every risky change | CSI | 72 h | Failed migration |
Only the pro-weekly row satisfies the "1" of the 3-2-1 rule. The others are convenience and speed.
Mandatory monitoring: a backup that fails silently is worse than having no backups, because it creates unfounded confidence. It is spotted with velero backup get | grep -v Completed and investigated with velero backup describe <name> --details and velero backup logs <name> | grep -i error. That check must be an automatic alert, not a manual review; it is set up with the tools of 07-04.
- Restoring into another namespace
Restoring over the original namespace in production is the most delicate operation. The correct practice is to restore somewhere else first and verify:
velero restore create verification-$(date +%s) \
--from-backup pro-weekly-20260802030012 \
--namespace-mappings rutas-norte-pro:rutas-norte-verification \
--include-resources deployments,services,configmaps,secrets,persistentvolumeclaims \
--wait
velero restore describe verification-1754392011 --details
kubectl get all,pvc -n rutas-norte-verificationPhase: Completed
Warnings:
rutas-norte-verification: could not restore, Ingress "web-store" already existsRestore options used every day:
| Option | What for |
|---|---|
--namespace-mappings source:target |
Restore into another namespace: verification, or cloning pre from pro |
--include-resources / --exclude-resources / --selector |
Restore only what is needed: certain types, or a single component |
--existing-resource-policy=update |
Overwrite what already exists (by default it does not) |
--restore-volumes=false |
Objects only, with no data |
And the warning that saves you a fright: by default Velero does NOT overwrite objects that already exist. If you restore over a live namespace, you will see a pile of "already exists" warnings and think the restore failed, when what it has done is protect you. For a real recovery, the target namespace must be empty or you have to use --existing-resource-policy=update deliberately.
- The disaster drill
An untested backup is not a backup. This is the compulsory exercise of the module, and at Rutas Norte it is run every quarter on rutas-norte-pre, against the clock and with a written record.
# --- PRIOR STATE: document what has to come back ---
kubectl get all,pvc,secret,ingress,networkpolicy -n rutas-norte-pre --no-headers | wc -l
kubectl exec -n rutas-norte-pre deploy/bookings-postgres -- \
psql -U rutasnorte -d bookings -c "SELECT count(*) FROM bookings;" | tee previous-inventory.txt
curl -s -o /dev/null -w "%{http_code}\n" https://pre.rutasnorte.example/
# --- STARTING BACKUP ---
velero backup create drill-$(date +%Y%m%d) \
--include-namespaces rutas-norte-pre --default-volumes-to-fs-backup --wait
# --- THE DISASTER ---
T0=$(date +%s); kubectl delete namespace rutas-norte-pre
# --- RECOVERY ---
velero restore create recovery-$(date +%s) \
--from-backup drill-20260805 --wait
kubectl wait --for=condition=available --timeout=900s deployment --all -n rutas-norte-pre
# --- VERIFICATION: pods being Running is not enough ---
kubectl get all,pvc -n rutas-norte-pre
kubectl exec -n rutas-norte-pre deploy/bookings-postgres -- \
psql -U rutasnorte -d bookings -c "SELECT count(*) FROM bookings;" # -> 184392
curl -s -o /dev/null -w "%{http_code}\n" https://pre.rutasnorte.example/ # -> 200
echo "MEASURED RTO: $(( ($(date +%s) - T0) / 60 )) minutes" # -> 23 minutesThe drill record must capture, as a minimum: date and owner, backup used, measured RTO, effective RPO (the time elapsed between the backup and the disaster), what did not come back on its own, issues and follow-up actions. In the second exercise you will write it in full.
The "what did not come back on its own" section is the most valuable in the record. It is literally impossible to know without running the drill, and it is what turns a theoretical recovery into a real one.
- What does not restore on its own, and the minimal runbook
| Element | Why it does not come back | What has to be done |
|---|---|---|
| TLS certificates | cert-manager reissues them; if the old Secret is restored it may be expired | Let them be reissued; watch the Let's Encrypt rate limits (04-05) |
| LoadBalancer IP and DNS records | New IPs are assigned when the Service is recreated, and DNS lives outside the cluster | Reserve static IPs and update www and api of rutasnorte.example, allowing for propagation |
| External secrets | Payment gateway and SMTP provider tokens: they may have rotated | Regenerate from the corporate secrets manager |
| Firewalls, security groups and objects in other namespaces | The former belong to the cloud, not the cluster; the latter are not included if the backup was of a single namespace | Infrastructure as code, and back up ingress-nginx, cert-manager and velero too |
| Cluster resources (StorageClasses, ClusterIssuers, ClusterRoles) | They are not included in a namespace backup, and without them the PVs are not provisioned | --include-cluster-resources=true, and create the StorageClasses before restoring |
Rutas Norte minimal recovery runbook
A short document, in the repository, that someone on call at three in the morning can follow without thinking:
RUNBOOK: recovery of rutas-norte-pro
On-call owner: [email protected] | Last tested: 2026-08-05
0. DECLARE THE INCIDENT. Open a channel, note time T0, warn customer
support. Do NOT improvise: if the scope is unclear, go to step 1 anyway.
1. ASSESS the scope
kubectl get nodes; kubectl get all -n rutas-norte-pro; velero backup get
-> Only corrupted data? -> step 3 | Namespace destroyed? -> step 4
-> Cluster lost? -> step 5
2. CONTAIN. Scale to 0 anything that might keep writing bad data:
kubectl scale deploy bookings-api notifications-worker -n rutas-norte-pro --replicas=0
3. LOGICAL RESTORE (RTO ~30 min)
Most recent dump on the postgres-backups PVC -> pg_restore to bookings_restored
VERIFY the count and the max date -> rename the databases -> start
4. NAMESPACE RESTORE (RTO ~45 min)
velero restore create --from-backup <latest Completed>
Check: pods Ready, PVCs Bound, Ingress with an IP, valid certificate
Update DNS if the LoadBalancer IP has changed
5. NEW CLUSTER (RTO ~4 h)
Create cluster -> addons (ingress-nginx, cert-manager, csi, velero)
Create the StorageClasses BEFORE restoring
velero restore create --include-cluster-resources=true
Regenerate external secrets (payments, SMTP) and update DNS
6. VERIFY THE BUSINESS, not just the pods: count and max date of
bookings, an end-to-end test ticket purchase, and
https://www.rutasnorte.example and https://api.rutasnorte.example -> 200
7. CLOSE: note T_end, the real RTO and RPO, and open the post-mortem.
- Retention, cost and personal data
Cost
Backups cost money, and without automatic retention the spend grows unchecked. With bookings-postgres at around 20 GiB, the 72 hourly dumps take up about 30 GiB (they compress to ~410 MB each), the 7 daily snapshots about 25 GiB as they are incremental, and the 13 weekly backups about 260 GiB in the object store. Levers for tuning it: use cold storage classes for the old ones, take advantage of Kopia's deduplication, and do not back up what can be regenerated (redis-cache is in no backup at all, following what was decided in 05-03).
Personal data
And here is what cannot be left as a technical detail. Rutas Norte's backups contain personal customer data: the name, ID number, phone number and email address of every person who has bought a ticket. That means each backup is a processing of personal data with the same duties as the original system, and in some respects more demanding ones, because backups multiply, replicate and get forgotten.
The minimum requirements Rutas Norte applies:
| Requirement | How it is implemented here |
|---|---|
| Encryption at rest and in transit | Dumps encrypted with age (private key outside the cluster); object store with server-side encryption and access over HTTPS; volumes with encrypted: "true" in the StorageClass (05-04); internal traffic segmented by NetworkPolicies (04-06) |
| Access control | A dedicated ServiceAccount and minimal RBAC (08-01); store credentials with write-only permissions |
| A defined deletion period | A TTL on every Velero Schedule; find -mmin +4320 -delete in the CronJob. Without automatic retention, backups are eternal and that is non-compliance |
| Geographical scope | The backup bucket must be in a planned and declared region. Copying to another region to withstand disasters must not take the data outside the permitted legal scope |
| Access logging and the right to erasure | Auditing of who downloads or restores a backup (08-06); and if a customer exercises their right to erasure, you have to decide and document what happens to the backups containing them |
| Non-production environments | A production clone in pre or on a developer's laptop is real data in a less protected environment. Anonymise or pseudonymise |
REGULATORY COMPLIANCE WARNING
Everything above is technical architecture, not legal advice. The retention periods, the geographical location of the backups, the handling of the right to erasure over data already backed up, the legal basis for the processing and the obligations in the event of a security breach must be reviewed and approved by the organisation's compliance and data protection officer before this configuration is put into production. The values in this course (72 hours, 7 days, 90 days) are teaching examples chosen to illustrate the mechanics of retention, not legal recommendations: the correct period depends on the applicable regulations, the sector and the declared purpose of each processing activity, and keeping personal data for longer than necessary is as much non-compliance as failing to protect it.
Common Mistakes and Tips
| Mistake | Symptom | Fix |
|---|---|---|
| Relying on snapshots as the only backup | Total loss if the zone or the account goes down | A backup in an object store, another region, another account |
| Never testing the restore | You find out on the day of the disaster that the backup is worthless | A quarterly drill with a written record |
| Restoring straight over production | A recoverable incident becomes an irreversible one | Restore into a new database or namespace and verify |
Backups that fail silently, or without set -euo pipefail in the dump |
Truncated files, Jobs in Completed and unfounded confidence for months |
Fail loudly, verify the dump and alert on states other than Completed |
| Unencrypted dumps, or copying production to development without anonymising | Personal data in the clear on a PVC, a bucket or a poorly protected environment | Encryption with a public key (private one outside the cluster); anonymise or pseudonymise |
| No retention | Growing cost and regulatory non-compliance | TTLs on the Schedules and clean-up in the CronJob |
| Forgetting cluster resources | The restore fails because the StorageClasses do not exist | --include-cluster-resources=true; create the classes first |
| Forgetting the NetworkPolicies with the CronJob | The dump cannot connect to the database | Authorise its egress explicitly (04-06) |
A pre hook with no post hook |
The database stays in backup mode and fills the disk with WAL | The post must always run |
Believing the RTO is the velero restore time |
The real drill gives three times as much | Measure end to end, DNS and verification included |
Tips:
- Automate the verification, not just the backup. A weekly Job that restores the latest dump into a test namespace and compares the row count turns "we think we have backups" into "we know we have backups".
- Document the real RPO and RTO, not the desired ones, and review them after every drill. They are the figures the business needs in order to decide how much to invest.
- The backup must be restorable without the person who set it up. If the procedure lives in someone's head, it does not exist: write it in the runbook, in the repository. And when you hesitate between spending on backups or on anything else, spend on backups: it is the only component of the platform whose absence goes unnoticed until it is too late.
Exercises
Exercise 1: the scheduled dump
In rutas-norte-dev, set up the complete logical backup chain:
- Create the 5 GiB
postgres-backupsPVC on therutasnorte-fastclass. - Adapt the CronJob of section 4 to
dev, withschedule: "*/10 * * * *"so as not to wait an hour. - Trigger a manual run and check in the logs that the dump has been created and verified.
- Drop the
bookingstable and restore it into a new database from the dump, without touching the original. - Check that the data matches and explain why restoring into a new database matters.
Exercise 2: disaster drill on rutas-norte-pre
Run the complete drill of section 11 on rutas-norte-pre (create it with the five components if you do not have it) and write the record with: prior inventory, backup used, measured RTO, effective RPO, what did not come back on its own, issues and follow-up actions.
Then compare the measured RTO with the 8-hour target set for pre in section 3 and reason about whether the policy is adequate or has to change.
Exercise 3: design the rutas-norte-pro strategy
The Rutas Norte board asks: "if we lose the whole data centre tomorrow, how long until we can sell again and how many bookings do we lose?". Prepare the answer as a short document:
- A. A table of what is backed up, with what method, frequency, retention and what each backup protects against.
- B. The committed RPO and RTO, with the number of bookings lost in the worst case (bank-holiday weekend, 400 bookings/hour).
- C. The three elements that do not restore on their own and who is responsible for each.
- D. A section on personal data covering what the backups contain, how they are protected and what the compliance officer must validate.
Solutions
Exercise 1
# 1, 2 and 3. The PVC and the CronJob of section 4, with namespace rutas-norte-dev,
# 5Gi and schedule "*/10 * * * *". Then, a manual run:
kubectl apply -f k8s/environments/dev/postgres-backups-pvc.yaml
kubectl create job --from=cronjob/backup-bookings-postgres \
test-backup -n rutas-norte-dev
kubectl wait --for=condition=complete job/test-backup -n rutas-norte-dev --timeout=300s
kubectl logs job/test-backup -n rutas-norte-dev
# [2026-08-05T13:10:04+02:00] dump OK: 12K
# 4: the disaster and the restore, from a pod with the backups PVC mounted
kubectl exec -n rutas-norte-dev deploy/bookings-postgres -- \
psql -U rutasnorte -d bookings -c "DROP TABLE bookings;"
# Inside that helper pod:
createdb bookings_restored
pg_restore -d bookings_restored --no-owner /backups/bookings-20260805-131002.dump
psql -d bookings_restored -c "SELECT count(*) FROM bookings;" # -> 5
# 5: promote the restore, now verified
psql -d postgres -c "ALTER DATABASE bookings RENAME TO bookings_damaged;"
psql -d postgres -c "ALTER DATABASE bookings_restored RENAME TO bookings;"Why into a new database. Three reasons. First, if the dump were corrupt or incomplete, restoring over the top with --clean would have destroyed what was left: you would end up with neither the damaged data nor the backup. Second, the damaged database is the evidence for the post-mortem: without it you will not know what happened or since when. And third, it lets you compare before switching over: row count, maximum date, referential integrity. The final swap is a two-second rename, so the time saved by restoring over the top is negligible against the risk.
Exercise 2
velero backup create drill-pre-$(date +%Y%m%d) \
--include-namespaces rutas-norte-pre --default-volumes-to-fs-backup --wait
T0=$(date +%s)
kubectl delete namespace rutas-norte-pre
velero restore create rec-$(date +%s) --from-backup drill-pre-20260805 --wait
kubectl wait --for=condition=available --timeout=900s deployment --all -n rutas-norte-pre
kubectl exec -n rutas-norte-pre deploy/bookings-postgres -- \
psql -U rutasnorte -d bookings -c "SELECT count(*), max(date) FROM bookings;"
echo "RTO: $(( ($(date +%s) - T0) / 60 )) minutes"A sample record:
| Field | Value |
|---|---|
| Date / owner | 2026-08-05 / [email protected] |
| Backup used | drill-pre-20260805, fs-backup, 18.4 GB |
| Measured RTO / effective RPO | 23 min (deletion → first verified 200 OK) / 47 min |
| Objects restored | 5 Deployments, 5 Services, 2 Ingresses, 6 NetworkPolicies, 4 Secrets, 2 PVCs |
| Did not come back on its own | The TLS certificate (reissued in 4 min by cert-manager); the LoadBalancer Service IP changed; the SMTP Secret was restored with an already-rotated token |
| Issues | The PVCs took 6 min to be provisioned and mounted: 40% of the RTO |
| Actions | (1) A static IP reserved for the Ingress; (2) SMTP token rotation documented in the runbook; (3) evaluate --snapshot-volumes to bring down the PVC time |
Assessment of the policy: the target RTO for pre was 8 hours and the measured one is 23 minutes, so it is met with an enormous margin. But the useful conclusion is another one: the same procedure applied to production would not give 23 minutes, because in pro the volume is ten times bigger, DNS has to be updated with its propagation, external secrets regenerated and the business verified end to end. The correct action is to repeat the drill on a production backup restored into a separate namespace, which is what gives the real figure behind the committed 2 hours.
Exercise 3
A. What is backed up:
| What | Method | Frequency | Retention | Protects against |
|---|---|---|---|---|
| Manifests | Git | Every change | Unlimited | Configuration error |
bookings-postgres data |
Encrypted pg_dump -Fc to a PVC |
Hourly (15 min in peak season) | 72 h | Recent human error in the data |
Objects + volumes of rutas-norte-pro |
Velero + CSI snapshots | Daily at 02:00 | 7 days | Object deletion, failed deployment |
| Objects, volumes and cluster resources | Velero + fs-backup to another region, with --include-cluster-resources |
Weekly Sunday 03:00 | 90 days | Loss of zone, of account, old corruption; rebuilding on a new cluster |
| Volume before every risky change | CSI snapshot | On demand | 72 h | Failed schema migration |
B. Commitments:
| Scenario | RPO | Bookings lost (bank-holiday weekend) | RTO |
|---|---|---|---|
| Data corruption spotted quickly | 1 h | up to 400 | ~30 min |
| Namespace destroyed | 24 h (or 1 h with the dump) | up to 400 | ~45 min |
| Loss of the data centre | 7 days in the worst case, 24 h typically | up to 67,200 | ~4 h |
The last row is the answer to the board's question, and it is deliberately uncomfortable: the backup that survives losing the data centre is the weekly one, so in the worst case up to seven days of bookings would be lost. If that is not acceptable — and it should not be —, there are two investments that fix it: moving the external backup to daily (a 24 h RPO) and, above all, archiving PostgreSQL's WAL continuously to an external object store, which would bring the RPO down to minutes. It is the plan's top-priority improvement.
C. What does not restore on its own:
| Element | Responsible | Action |
|---|---|---|
DNS for www.rutasnorte.example and api.rutasnorte.example |
Platform | Update the records and IPs; allow for propagation |
External secrets: the pagos.proveedorexterno.example key and the SMTP token |
Security | Regenerate from the corporate manager and reapply |
| Infrastructure: nodes, network, firewalls, StorageClasses, ingress-nginx, cert-manager | Platform | Infrastructure as code; create the StorageClasses before restoring |
D. Personal data:
The backups contain the name, ID number, phone number and email address of every customer who has bought a ticket, plus the complete booking history. Protections applied: encryption at rest of the dumps with a public key (the private one kept outside the cluster), bucket encryption, encryption in transit, write-only credentials for the backup process, restore access limited by RBAC and logged, automatic retention by TTL and compulsory anonymisation of any clone destined for non-production environments.
What the compliance officer must validate, before any of this goes into production:
- That the retention periods (72 h, 7 days, 90 days) are the legally correct ones for this purpose, no more and no less.
- That the region of the backup store is within the permitted and declared geographical scope.
- The procedure for the right to erasure: what is done with backups that already contain the data of whoever exercises it, and how it is documented.
- The legal basis for the processing and its reflection in the record of processing activities, including the backups as processing.
- The breach notification protocol if a backup is compromised.
- The data processor terms with the cloud provider hosting the backups.
This document is a technical proposal and it does not replace that review.
Conclusion
Rutas Norte is now a platform you can trust. You know what has to be saved — the manifests, which are already in Git; the API state, with the Secrets and PVCs that Git does not contain; and the volume data, the only truly irreplaceable thing — and that each one demands a different technique. You have internalised the 3-2-1 rule and you know that, of all Rutas Norte's backups, only the weekly one to the object store in another region satisfies the "1"; the others are convenience and speed. And you handle RPO and RTO with concrete numbers: at 400 bookings per hour on a bank-holiday weekend, a daily backup means losing up to 9,600 bookings, and that turns a technical discussion into a business decision somebody has to take and sign off.
You have built the two pieces. The logical backup: an hourly CronJob that runs pg_dump -Fc, verifies the dump with pg_restore --list, encrypts it with a public key whose private counterpart does not live in the cluster, applies local retention and — the detail that is always forgotten — has its own NetworkPolicy to get through production's deny-all. And the platform backup with Velero: its architecture of controller, node-agent and plugins; the two routes for volumes, with the decisive difference that only the copy to the object store really gets the data out of the storage system; the pre and post hooks with pg_backup_start/pg_backup_stop that solve the consistency we left open in 05-05, and the warning that the post must always run on pain of filling the disk with WAL; the Schedules with TTLs that make retention automatic; and restoring with --namespace-mappings to verify before touching anything real.
And you have done what separates a backup from a useful backup: the drill. Deleting the whole of rutas-norte-pre and recovering it against the clock, measuring a real RTO and discovering the list of what does not come back on its own — the certificate, the LoadBalancer IP, the DNS, the external secrets, the cluster resources —, which is literally impossible to know without running it. Out of that comes the runbook someone on call can follow at three in the morning. Alongside all of that stands the warning that is not technical: the backups contain personal customer data, they must be encrypted, have a deletion period, not leave the intended legal scope, and the retention policy and the handling of that data must be reviewed and approved by the compliance officer before any of this goes into production.
This is the end of module 5. The debt we had been dragging since module 2 is settled in full: bookings-postgres keeps its data on a volume that survives the pod, dynamically provisioned by a StorageClass with Retain, expandable on the fly, with snapshots before every risky change and with verified backups inside and outside the cluster.
One ceiling remains, one we have hit three times and always postponed with the same phrase. In 05-03 you discovered that a Deployment has a single template, so all its replicas ask for the same PVC and that is why bookings-postgres is condemned to replicas: 1 with strategy: Recreate. A production database needs more: a stable identity, ordered start-up and its own volume per replica. That is what the volumeClaimTemplate of StatefulSets provides, and with it module 6, Advanced Concepts, begins: StatefulSets, DaemonSets, Jobs and CronJobs — where occupancy-reports, the platform's sixth component, finally arrives —, init containers and sidecars, scheduling with affinity and taints, custom resources and operators. We start with StatefulSets.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
