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

  1. What really has to be saved in a cluster
  2. A snapshot is not a backup: the 3-2-1 rule
  3. RPO and RTO applied to Rutas Norte with numbers
  4. Logical backup: pg_dump from a scheduled Job
  5. Compression, encryption and restoring the dump
  6. Velero: what it does and how it is built
  7. Installation and the first backup of a namespace
  8. pre and post hooks: the consistent backup
  9. Scheduled backups with retention
  10. Restoring into another namespace
  11. The disaster drill
  12. What does not restore on its own, and the minimal runbook
  13. Retention, cost and personal data

  1. 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.

  1. 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.

  1. 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.

  1. Logical backup: pg_dump from a scheduled Job

A 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 simultaneous pg_dump runs would double the load on the database exactly when it is already slow.
  • set -euo pipefail and the verification with pg_restore --list: without the former, a failed pg_dump can leave a truncated file and the Job finish as Completed, 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

  1. 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-28

Always 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.

  1. 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.

  1. 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 Available

The 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 --details
Name: 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).

  1. pre and post hooks: the consistent backup

Here 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: 3m

What 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-...

  1. 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 state

The 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.

  1. 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-verification
Phase:  Completed
Warnings:
  rutas-norte-verification:  could not restore, Ingress "web-store" already exists

Restore 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.

  1. 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 minutes

The 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.

  1. 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.

  1. 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:

  1. 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".
  2. 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.
  3. 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:

  1. Create the 5 GiB postgres-backups PVC on the rutasnorte-fast class.
  2. Adapt the CronJob of section 4 to dev, with schedule: "*/10 * * * *" so as not to wait an hour.
  3. Trigger a manual run and check in the logs that the dump has been created and verified.
  4. Drop the bookings table and restore it into a new database from the dump, without touching the original.
  5. 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:

  1. That the retention periods (72 h, 7 days, 90 days) are the legally correct ones for this purpose, no more and no less.
  2. That the region of the backup store is within the permitted and declared geographical scope.
  3. 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.
  4. The legal basis for the processing and its reflection in the record of processing activities, including the backups as processing.
  5. The breach notification protocol if a backup is compromised.
  6. 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

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved