CAMPUX Cloud Bootcamp Advanced Build · Disaster Recovery ← All labs
Advanced Build · Advanced
~5–6 hrs · Costs real money (two AKS)
CLI · kubectl · Velero · Helm · torn down
Reliability · Disaster Recovery

Prove you can lose a region.

A backup you have never restored is a rumour. In this build you run a stateful workload on one AKS cluster, back it up with Velero to geo-redundant storage, then lose the cluster on purpose and bring the data back — byte-for-byte — inside a second cluster in another region. Then you make the whole drill run itself every week, because the DR plan nobody rehearses is the one that fails at 3 a.m.

Fig. 1 · AKS DR: RPO and RTO, rehearsed
Resource group · rg-lab-drbackuprestorePrimary AKSthe live clusterGRS backupVelero · geo-redundantSecondary AKSrestore targeta backup nobody has restored is a hypothesis
● Screen walkthrough Not yet recorded · ~14 min
Reel · 00:00 / 14:00

Back up a database, delete its cluster, restore into another region, and watch the checksum match — then hand the drill to GitHub Actions.

Placeholder — the page below stands alone until the reel lands
Why

Two numbers your director will ask for by name

When a region goes dark, two questions decide whether you keep your job: how long until we're back (RTO, recovery time objective) and how much data did we lose (RPO, recovery point objective). Everyone quotes targets; almost nobody has measured their real ones, because measuring means actually destroying something and recovering it. This lab makes you do exactly that — with a stateful database, across regions — so the numbers you put in a runbook are ones you have seen with your own eyes.

The tool is Velero: it snapshots your Kubernetes objects and the disks behind your persistent volumes, and writes both to blob storage. Point that storage at a geo-redundant (GRS) account and Azure replicates your backups to the paired region for free — so a cluster in East US 2 can restore from a backup taken in East US without you copying a byte.

A backup you have never restored is a rumour, not a recovery.

This one is the priciest lab on the site — read first

You run two AKS clusters (D-series nodes) at once, so this bills at a real clip — budget $10–20 for a single sitting and do it in one go. The final step deletes everything; set a budget alert before you start. To halve the cost while you learn the mechanics, drop the node VM size to Standard_B2ms and node count to 2.

Before you begin — prerequisites

You need a paid Azure subscription, the Azure CLI, kubectl, Helm, the Velero CLI, and jq. This is an advanced build: it assumes you are comfortable with AKS, kubectl contexts, and Helm. If AKS is new, run the identity and networking labs first — this page won't teach the basics under you.

Setup

Two clusters, matched on purpose

Create a primary cluster in East US and a DR cluster in the paired region, East US 2. They must match — same VM size, same node count — or a restored persistent volume can land on an incompatible disk tier and fail. Save each as a named kubectl context so you can aim commands at either cluster deliberately.

export MSYS_NO_PATHCONV=1   # Windows/Git Bash: leave resource-id args alone
RG="rg-dr-lab"; PRIMARY="aks-primary"; DR="aks-dr"
az group create -n "$RG" -l eastus

for pair in "$PRIMARY:eastus" "$DR:eastus2"; do
  NAME="${pair%%:*}"; LOC="${pair##*:}"
  az aks create -g "$RG" -n "$NAME" -l "$LOC" \
    --node-count 3 --node-vm-size Standard_D4s_v3 \
    --network-plugin azure --enable-managed-identity \
    --enable-oidc-issuer --zones 1 2 3 --generate-ssh-keys
done

az aks get-credentials -g "$RG" -n "$PRIMARY" --context aks-primary
az aks get-credentials -g "$RG" -n "$DR"      --context aks-dr
kubectl get nodes --context aks-primary
kubectl get nodes --context aks-dr
Checkpoint Both contexts return three Ready nodes across zones 1–3. Provisioning two clusters takes several minutes — a good moment to read ahead. The matched spec is not cosmetic: a mismatched VM size is the classic cause of a PVC that restores but never binds.
Step 1

Geo-redundant storage, and a key that can touch only it

Velero writes to a blob container. Make the storage account GRS so Azure replicates it to East US 2 automatically, and give Velero a service principal scoped to that storage account onlyStorage Blob Data Contributor, not subscription owner. Least privilege is the difference between a leaked backup key and a leaked cloud.

SA="velerodr$RANDOM"; CONTAINER="velero-backups"
SUB_ID=$(az account show --query id -o tsv)

az storage account create -n "$SA" -g "$RG" -l eastus \
  --sku Standard_GRS --min-tls-version TLS1_2 --allow-blob-public-access false
az storage container create -n "$CONTAINER" --account-name "$SA"

STORAGE_ID=$(az storage account show -n "$SA" -g "$RG" --query id -o tsv)
SP=$(az ad sp create-for-rbac -n velero-sp \
  --role "Storage Blob Data Contributor" --scopes "$STORAGE_ID" --sdk-auth)
CLIENT_ID=$(echo "$SP" | jq -r .clientId)
CLIENT_SECRET=$(echo "$SP" | jq -r .clientSecret)
TENANT_ID=$(echo "$SP" | jq -r .tenantId)
echo "storage: $SA  container: $CONTAINER"
Checkpoint The account is Standard_GRS (confirm with az storage account show -n "$SA" -g "$RG" --query sku.name) and the service principal's role scope is the storage id, nothing broader. GRS replicates to the paired region on its own — that is what lets the DR cluster read these backups without a copy job.
Step 2

Velero: one writer, one reader

Install Velero on the primary as the writer, and on the DR cluster pointed at the same GRS-replicated container as a read-only location. This is the correct production shape — only one cluster ever writes backups, so they can't fight over the store.

cat > /tmp/velero-creds.conf <<EOF
AZURE_SUBSCRIPTION_ID=$SUB_ID
AZURE_TENANT_ID=$TENANT_ID
AZURE_CLIENT_ID=$CLIENT_ID
AZURE_CLIENT_SECRET=$CLIENT_SECRET
AZURE_RESOURCE_GROUP=$RG
AZURE_CLOUD_NAME=AzurePublicCloud
EOF

# PRIMARY — read/write backup location + volume snapshots
velero install --provider azure \
  --plugins velero/velero-plugin-for-azure:v1.10.0 \
  --bucket "$CONTAINER" \
  --secret-file /tmp/velero-creds.conf \
  --backup-location-config resourceGroup=$RG,storageAccount=$SA \
  --snapshot-location-config apiTimeout=5m \
  --kubecontext aks-primary

# DR — same bucket, marked read-only (--access-mode ReadOnly)
velero install --provider azure \
  --plugins velero/velero-plugin-for-azure:v1.10.0 \
  --bucket "$CONTAINER" --no-secret \
  --secret-file /tmp/velero-creds.conf \
  --backup-location-config resourceGroup=$RG,storageAccount=$SA \
  --use-volume-snapshots=false \
  --kubecontext aks-dr
velero backup-location set default --access-mode=ReadOnly --kubecontext aks-dr
Checkpoint velero backup-location get --kubecontext aks-primary shows Available; on aks-dr the same location is Available and ReadOnly. Both clusters now see one shared, geo-replicated backup store.
Step 3

Stateful data — and a checksum to judge the restore by

Deploy PostgreSQL with a persistent volume and seed it with relational data — two tables joined by a foreign key — so the restore has to bring back consistent state, not just a flat file. Then compute a checksum of the data. That single hash is how you'll later prove the recovery was byte-perfect rather than merely "the pod came up".

helm repo add bitnami https://charts.bitnami.com/bitnami && helm repo update
kubectl create namespace production --context aks-primary

helm install pg-prod bitnami/postgresql --kube-context aks-primary -n production \
  --set auth.postgresPassword=DrLabP@ss123 \
  --set primary.persistence.enabled=true \
  --set primary.persistence.size=10Gi \
  --set primary.persistence.storageClass=managed-premium
kubectl rollout status statefulset/pg-prod-postgresql -n production --context aks-primary

kubectl exec -i pg-prod-postgresql-0 -n production --context aks-primary -- \
  psql -U postgres -d postgres <<'EOF'
CREATE TABLE customers (id SERIAL PRIMARY KEY, name TEXT, email TEXT UNIQUE, tier TEXT DEFAULT 'standard');
CREATE TABLE orders   (id SERIAL PRIMARY KEY, customer_id INT REFERENCES customers(id), amount NUMERIC(10,2), status TEXT);
INSERT INTO customers (name,email,tier) VALUES
  ('Alice Chen','[email protected]','enterprise'),
  ('Bob Martinez','[email protected]','standard'),
  ('Carol Johnson','[email protected]','enterprise');
INSERT INTO orders (customer_id,amount,status) VALUES
  (1,15000.00,'completed'),(1,8750.50,'processing'),(2,2340.00,'completed'),(3,99999.99,'completed');
SELECT md5(string_agg(id::text||name||email, ',' ORDER BY id)) AS checksum FROM customers;
EOF
Checkpoint Write the checksum value down. That md5 is your RPO evidence: after the cross-region restore you'll run the identical query and the hashes must match. If they don't, the restore lost or reordered data — exactly the silent failure this lab exists to catch.
Step 4

Schedule backups — and confirm the disk came too

Create an hourly schedule (your RPO target) and a daily one with 30-day retention, then take one manual backup now and inspect it. The most dangerous Velero mistake is a backup that captured your Kubernetes YAML but not the volume snapshot behind it — it restores pods with empty disks and you don't find out until the incident.

velero schedule create hourly-production --schedule="0 * * * *" \
  --include-namespaces production --ttl 48h0m0s --kubecontext aks-primary
velero schedule create daily-production --schedule="0 2 * * *" \
  --include-namespaces production --ttl 720h0m0s --kubecontext aks-primary

velero backup create manual-$(date +%H%M) \
  --include-namespaces production --wait --kubecontext aks-primary
velero backup get --kubecontext aks-primary        # PHASE=Completed, ERRORS=0
velero backup describe <backup-name> --details --kubecontext aks-primary \
  | grep -A3 "Persistent Volumes"                   # must list 1 PV
Checkpoint The manual backup is Completed with zero errors, and --details shows one Persistent Volume included — the PostgreSQL disk. A backup that lists no PVs is the silent-failure case; treat it as a broken backup, not a minor warning.
Step 5

Lose the cluster. Bring it back in another region.

Now the drill. Simulate the disaster — delete the production namespace on the primary — then restore from the shared backup into the DR cluster, and re-run the checksum. This is the whole point: not "did a backup exist," but "can I stand the data back up somewhere else and prove it's intact."

# 1 · the disaster
kubectl delete namespace production --context aks-primary

# 2 · restore into the DR cluster from the geo-replicated backup
velero restore create dr-restore --from-backup <backup-name> \
  --kubecontext aks-dr --wait
kubectl rollout status statefulset/pg-prod-postgresql -n production --context aks-dr

# 3 · the verdict — same query, compare to the checksum you saved
kubectl exec -i pg-prod-postgresql-0 -n production --context aks-dr -- \
  psql -U postgres -d postgres -tAc \
  "SELECT md5(string_agg(id::text||name||email, ',' ORDER BY id)) FROM customers;"
Checkpoint The DR-cluster checksum equals the one you saved in Step 3. You have recovered a stateful workload across regions and proven the data is byte-identical — the difference between claiming DR and having it. Note the wall-clock time the restore took: that is your measured RTO, not a guess.
ScenarioWhat you didMeasured RTORPO
Namespace lossRestore in place, same cluster~5 min≤ 1 hr (hourly schedule)
Full cluster / region lossRestore into DR cluster, other region~20–30 min≤ 1 hr; GRS store itself, minutes
Step 6

Make the drill run itself

A DR plan tested once is theatre. Wire the same backup → restore → checksum sequence into a GitHub Actions workflow on a weekly cron, so degradation surfaces in a green-or-red check while it's cheap to fix — not during the outage. Federate the workflow into Azure (no stored secret) and fail the job if the checksum drifts.

# .github/workflows/dr-drill.yml (essentials)
name: Weekly DR Drill
on:
  schedule: [{ cron: '0 3 * * 0' }]   # Sundays 03:00 UTC
  workflow_dispatch:
jobs:
  drill:
    runs-on: ubuntu-latest
    steps:
      - uses: azure/login@v2
        with: { creds: ${{ secrets.AZURE_CREDENTIALS }} }
      - name: Backup, restore to DR, verify checksum
        run: |
          az aks get-credentials -g "$RG" -n aks-primary --context aks-primary --overwrite-existing
          az aks get-credentials -g "$RG" -n aks-dr      --context aks-dr      --overwrite-existing
          B="dr-drill-$(date +%Y%m%d-%H%M)"
          velero backup create "$B" --include-namespaces production --wait --kubecontext aks-primary
          velero restore create --from-backup "$B" --kubecontext aks-dr --wait
          NEW=$(kubectl exec -i pg-prod-postgresql-0 -n production --context aks-dr -- \
            psql -U postgres -tAc "SELECT md5(string_agg(id::text||name||email, ',' ORDER BY id)) FROM customers;")
          [ "$NEW" = "${{ vars.BASELINE_CHECKSUM }}" ] || { echo "::error::checksum drift"; exit 1; }
Checkpoint A manual workflow_dispatch run goes green and the job fails loudly if the checksum drifts. You now own an automated, evidence-producing DR drill — the line that separates "we have backups" from "we have tested recovery," and the one auditors actually want to see.
Down

Tear it down — this one bills fast

Two clusters plus GRS storage add up quickly. One resource-group delete removes both clusters, the storage account, and the disks. Do it the moment you're done, and clean up the service principal too.

az group delete -n rg-dr-lab --yes --no-wait
az ad sp delete --id "$CLIENT_ID"        # remove the Velero service principal
az group exists -n rg-dr-lab             # -> false once the async delete finishes
Checkpoint The group is deleting and the service principal is gone, so nothing keeps billing. Confirm in Cost Management the next day that no AKS or storage charges are still accruing — the habit that keeps a learning lab from becoming a surprise invoice.
End

What you can now honestly claim

You provisioned matched AKS clusters across paired regions, backed up a stateful PostgreSQL workload with Velero to geo-redundant storage, restored it into a different region, and proved data integrity by checksum — then automated the whole drill in GitHub Actions with measured RTO and RPO. That is "designed and validated cross-region Kubernetes disaster recovery with automated drills and documented RTO/RPO" — a staff-level line on a reliability posting, done rather than described. The transferable lesson outlives Velero: recovery is a claim you verify, on a schedule, or it isn't real.

Footnotes
  1. GRS replicates asynchronously to the paired region, so the backup store's own RPO is minutes, not zero — a backup written seconds before a total primary-region outage might not have replicated yet. For most workloads that window is acceptable; know it's there before you promise RPO=0 on the backups themselves.
  2. The Velero install flags shift between chart and plugin versions. Pin the plugin (here v1.10.0) to your Velero version and check the plugin compatibility matrix — a mismatched plugin is the usual cause of a backup that completes but silently skips the volume snapshot.
  3. Restoring PostgreSQL from a disk snapshot recovers the data files as they were on disk, which for a busy database means crash-recovery on startup. It works, but for zero-data-loss on a hot database, pair volume snapshots with WAL archiving — the disk snapshot is the floor, not the ceiling.