Lesson 68 of 72 · 25 min read · Updated
Backup and Disaster Recovery with Velero
On this page
The previous lesson showed that an etcd snapshot is a full copy of the cluster's API state, and that it is the right tool when the control plane itself is damaged. For most of an application team's bad days it is the wrong tool. You cannot restore one namespace from it, it holds no data from your PersistentVolumes, and it only fits back into the cluster it came from. When someone runs kubectl delete namespace against the wrong context, or a cluster has to be rebuilt, you need something else.
That something is Velero. It backs up Kubernetes objects and the data in their volumes to object storage outside the cluster, and restores them into the same cluster or into a new one. In this lesson you protect a .NET 10 API and its Postgres database with Velero on kind, break it twice, and measure what comes back. The headline result: after the entire cluster was deleted, a new cluster served all four bookings again about three minutes after kind create cluster started.
We will cover the following topics:
- Disaster recovery vocabulary: RPO, RTO, and why replication is not a backup
- What an etcd snapshot misses, and where Velero fits
- Velero's architecture: server, plugins, BackupStorageLocation, VolumeSnapshotLocation and the node-agent
- File-system backup with Kopia versus CSI snapshots
- The Backup, Restore and Schedule resources, filters and TTL
- Backup hooks for a consistent database backup
- Restore ordering, skipped resources, the existing-resources policy and namespace mappings
- Restoring into a new cluster, and rehearsing restores
Technical Requirements
- Docker Desktop (macOS or Windows) or Docker Engine (Linux), with about 2 CPUs and 4 GB of memory free.
- The
kindCLI v0.33.0 and the node imagekindest/node:v1.36.4, the same Kubernetes version as the rest of this series. - The Velero CLI v1.18.4 (
brew install veleroon macOS), pluskubectl,curl,jqand the .NET 10 SDK. - Familiarity with "Kubernetes Persistent Volumes, PVCs, and Storage" and "Backing Up and Restoring etcd".
Most lessons in this series use Docker Desktop's built-in four-node cluster. This one does not. You will delete a namespace, then a whole cluster, and you need a second cluster to restore into. Velero also installs cluster-wide CRDs and a privileged DaemonSet that reads every Pod's volumes on the node. None of that belongs on the cluster that hosts the rest of your work. So you create two disposable clusters with the kind CLI, dr-a and dr-b, each with one control-plane node and one worker, and each with its own kubeconfig file. Your ~/.kube/config and its current context are never touched.
Velero needs S3-compatible object storage. Older guides use MinIO, but MinIO no longer publishes container images, so docker pull minio/minio fails. This lesson uses RustFS 1.0.3, an Apache-2.0 S3-compatible server. Any S3-compatible store works with the same configuration.
Core Concepts
Disaster Recovery Vocabulary
| Term | Question | Set by |
|---|---|---|
| RPO, Recovery Point Objective | How much data can we lose? | Backup frequency. Nightly backups mean up to 24 hours of lost writes. |
| RTO, Recovery Time Objective | How long can we be down? | How fast you rebuild and restore. Measure it, do not guess it. |
A backup is a point-in-time copy kept somewhere else. Replication, such as a Postgres replica or a multi-zone cluster, keeps you running when hardware fails, but it also copies a DROP TABLE within milliseconds. Only a backup lets you go back in time. A common rule is 3-2-1: three copies, two kinds of media, one off-site. For Kubernetes, off-site starts with "not inside the cluster being protected".
What an etcd Snapshot Does Not Cover
| etcd snapshot | Velero | |
|---|---|---|
| Volume data | No, only the PV and PVC objects | Yes, file-system backup or CSI snapshots |
| Granularity | All or nothing | Namespace, resource type, label selector |
| Restore target | Same cluster, by replacing etcd | Same cluster, another namespace, a new cluster |
| Managed clusters (AKS, EKS, GKE) | No access to etcd | Works everywhere |
On self-managed clusters you want both. On managed clusters, Velero or a product built on it is usually all you have.
Velero's Architecture
You never call Velero directly. The velero CLI creates custom resources, and the server reconciles them.
| Component | Job |
|---|---|
Velero server (Deployment in namespace velero) | Reconciles Backups, Restores and Schedules, and reads and writes objects through the API |
| Object store plugin (init container) | Talks to storage. velero-plugin-for-aws covers AWS S3 and every S3-compatible store. |
| BackupStorageLocation (BSL) | Bucket, prefix, endpoint, credentials and access mode for backups |
| VolumeSnapshotLocation (VSL) | Where native cloud disk snapshots go. Not used here. |
| node-agent (DaemonSet) | Copies volume data from the node with Kopia, and back again on restore |
| BackupRepository | An encrypted, deduplicating Kopia repository per namespace inside the bucket |
A backup writes a tarball of JSON manifests plus metadata under backups/<name>/, and volume data into the Kopia repository under kopia/<namespace>/. The repository is encrypted with the password in the Secret velero-repo-credentials.
File-System Backup and CSI Snapshots
| CSI snapshots | File-system backup (node-agent + Kopia) | |
|---|---|---|
| How | The CSI driver takes a VolumeSnapshot. The optional data mover copies it to object storage. | Reads the mounted volume's files on the node and uploads them |
| Needs | A CSI driver with snapshot support | A running Pod that mounts the volume |
| Portable to another cloud | Only with the data mover | Always |
| Volume types | Whatever the driver provisions | Most types, including local and NFS. Not hostPath. |
The last row matters on local clusters. kind's default StorageClass standard (local-path provisioner) creates hostPath volumes. Velero skips them with a warning, and the backup still says Completed. You will hit this in Step 6. File-system backup is opt-in per volume, either through the Pod annotation backup.velero.io/backup-volumes: <volume> or for every volume with --default-volumes-to-fs-backup.
Backups, Restores and Schedules
velero backup create, velero restore create and velero schedule create each create one custom resource of the same name. A Schedule is a cron expression plus a Backup template. While a backup or restore runs, the server creates one PodVolumeBackup or PodVolumeRestore per volume as a work item for the node-agent.
Filters select what goes in. --include-namespaces / --exclude-namespaces take namespaces, --include-resources / --exclude-resources take resource types (for example events,events.events.k8s.io), and --selector takes a label selector that matches the labels on each object. --ttl sets how long a backup is kept, with a default of 720h (30 days). Cluster-scoped objects such as PVs are added automatically when a namespaced object needs them.
Backup Hooks
Copying a running database's files gives a crash-consistent copy, like pulling the power plug. Hooks make it application-consistent by running commands inside a container before or after its volumes are copied:
| Annotation | Meaning |
|---|---|
pre.hook.backup.velero.io/container | Container to run in |
pre.hook.backup.velero.io/command | Command as a JSON array |
pre.hook.backup.velero.io/timeout | Wait limit. The default is 30 seconds. |
pre.hook.backup.velero.io/on-error | Fail (the default) or Continue |
post.hook.backup.velero.io/... works the same way after the copy. The hook in this lesson runs pg_dump into the data volume, which gives a portable logical dump, and then CHECKPOINT, which flushes dirty pages to disk.
How a Restore Works
Velero recreates objects in priority order. The default from velero server --help starts with customresourcedefinitions, namespaces, storageclasses, ..., persistentvolumes, persistentvolumeclaims, ..., serviceaccounts, ..., secrets, configmaps, limitranges, priorityclasses, pods, replicasets.apps, ..., endpoints, services. Unlisted types come alphabetically in between. nodes, events, csinodes, volumeattachments and Velero's own objects are never restored.
An object that already exists is skipped with a warning. With --existing-resource-policy update, Velero patches it instead, which fails for immutable fields. For file-system volumes, Velero recreates the PVC, lets the provisioner create a fresh PV, and injects an init container named restore-wait so the application starts only after the node-agent has written the data.
--namespace-mappings old:new restores under a new name, which is the safe way to test next to production. A ConfigMap labeled velero.io/change-storage-class: RestoreItemAction in the velero namespace maps StorageClass names between clusters.
Restoring Into a New Cluster
The new cluster needs:
- Velero with a BSL pointing at the same bucket and prefix. The backup-sync controller then creates Backup objects for what it finds.
- The same Kopia repository password.
velero installuses the default, so if you changed it, recreatevelero-repo-credentialsfirst. - StorageClasses with the same names, or a mapping.
- Access to the images, and the CRDs and operators the app depends on.
Set the BSL to ReadOnly during recovery, so a half-built cluster cannot create or garbage-collect backups. Schedules are not stored in the bucket, so recreate them from Git when the new cluster takes over. Finally, rehearse: a backup that has never been restored is only a hope.
Hands-On: Kubernetes Commands
These commands come from the step-by-step run. KUBECONFIG points at the cluster in use.
Check Velero and its storage location
PHASE must be Available.
Back up a namespace and check the volume data
Completed only means the run finished. Look at WARNINGS and at Pod Volume Backups.
Read hook output and download a backup
Restore into another namespace, or only some resource types
Back up every volume without annotations
Back up by label, and see what you miss
The resource list in this run contained only the ReplicaSet, the Namespace, the PV, the PVC and the Pod. The Deployment, the Service and the Secret were missing. Only the Pod template carries app: postgres. The ReplicaSet inherits that label, and the PVC and PV come in through the Pod. If you back up by label, label every object.
Schedules and deletion
Make the storage location read-only
velero backup-location set has no access-mode flag, so patch the resource:
Step-by-Step Example
The Scenario
A hotel runs a .NET 10 bookings API with Postgres 17 in the namespace hotel. You will run an object store outside every cluster, deploy the app on cluster dr-a, install Velero, back up and restore after deleting the namespace, rehearse a restore under another name, schedule backups, then delete dr-a completely and restore onto a new cluster dr-b.
Pinned versions: Velero v1.18.4, velero-plugin-for-aws v1.14.4, rustfs/rustfs 1.0.3, amazon/aws-cli 2.38.3, postgres 17.11, kindest/node v1.36.4, Npgsql 10.0.3.
Step 1 — An Object Store Outside the Cluster
Run the object store as a plain container on the kind Docker network, the bridge network kind attaches its nodes to. Pods resolve it by container name, and your machine reaches it through the published port. kind creates this network with its first cluster, so if docker network inspect kind fails, do Step 2 first.
Create the bucket with the AWS CLI in a container on the same network:
Step 2 — Create the First Cluster
Save kind-dr.yaml. The node image is pinned, because kind v0.33.0 would otherwise pick its newer default.
Step 3 — The .NET Bookings API
Replace Program.cs. The API creates its table on startup and retries while Postgres starts. The PostgresException filter is there because two replicas raced on the first deployment, and one failed with duplicate key value violates unique constraint "pg_class_relname_nsp_index". /info reports the Pod, its node and the booking count, so you can tell which cluster is answering.
The Dockerfile uses the chiseled ASP.NET Core runtime image. Add a .dockerignore with bin/ and obj/.
kind nodes run their own containerd, so load the image into them:
Step 4 — Deploy the API and Postgres
Save hotel.yaml. The Postgres Pod template's annotations opt the data volume into file-system backup and define the pre-backup hook. For now the PVC uses kind's default StorageClass standard.
Apply it and write three bookings:
Step 5 — Install Velero
Save the S3 credentials as credentials-velero:
| Setting | Why |
|---|---|
s3Url=http://velero-s3:9000 | The address the server uses. Pods resolve the container name through the kind network's DNS. |
publicUrl=http://localhost:9000 | The address the CLI on your machine uses for logs, describe and download. In a first attempt without it, velero backup describe failed with lookup velero-s3: no such host. |
s3ForcePathStyle="true" | http://host/bucket instead of http://bucket.host, which self-hosted stores need |
--use-volume-snapshots=false | No VolumeSnapshotLocation, because there is no cloud disk API |
--use-node-agent | Deploys the node-agent DaemonSet |
The install created 13 CRDs (kubectl get crd -o name | grep velero.io), including backups, restores, schedules, backupstoragelocations, podvolumebackups and backuprepositories.
Step 6 — The First Backup, and a Silent Gap
Completed in under a second is suspiciously fast for a database:
The hook ran and 44 objects were saved, but not one byte of the database. Restoring this would give you an empty Postgres. Always check the WARNINGS column, and check that every important volume appears under Pod Volume Backups or CSI Snapshots.
Step 7 — Fix the Storage and Back Up Again
The local-path provisioner creates local volumes for a StorageClass annotated with defaultVolumeType: local. Save storageclass.yaml:
A PVC's StorageClass is immutable, and this is a lab, so recreate the namespace on local-fs. Then start the port-forward again and post the same three bookings as in Step 4.
This time 47.8 MB were copied, the data directory plus the dump. The hook output is in the backup log:
In the bucket, manifests and metadata sit under backups/ and the encrypted volume data under kopia/hotel/:
The tarball holds one JSON file per object, grouped by type: 24 events, 3 pods, 2 deployments.apps, 2 replicasets.apps, 2 services, 1 persistentvolumeclaims, 1 persistentvolumes, 1 secrets, and so on, across 13 resource types. The Secret is in that tarball, only base64-encoded. Your bucket is now as sensitive as the cluster, so encrypt it and restrict access to it.
Step 8 — Disaster One: The Namespace Is Gone
Deleting the namespace also deletes the PVC, and the Delete reclaim policy removes the PV with its data:
The restore took eight seconds. Of the 44 backed-up items, the 24 events are excluded by design and the old PV is skipped, which leaves the 19 restored. Kubernetes creates kube-root-ca.crt and the default ServiceAccount in every new namespace before Velero gets to them, so those existing objects are skipped. The PVC was recreated, the provisioner made a new PV, and the node-agent filled it.
The injected restore-wait init container kept Postgres from starting until the data was back. The bookings and the hook's dump file are both there.
Step 9 — A Restore Drill in a Separate Namespace
Add a fourth booking in production, then restore hotel-backup-1 next to it under another name:
The copy holds the backup's point in time, three bookings, on its own PVC and database. That works because the connection string uses the short name Host=postgres, which resolves inside the Pod's own namespace. With postgres.hotel.svc.cluster.local, the drill copy would have written to production's database. Delete the drill when you are done:
Step 10 — Schedule Backups
In this run the schedule waited for its first cron tick and did not back up right away. Trigger one from it:
Kopia deduplicates, so the second backup uploaded only 17 MB of 47.8 MB. The useless first backup can go:
Step 11 — Disaster Two: The Whole Cluster Is Gone
Delete the cluster with everything in it. Only the bucket survives.
Rebuild the way you would in an emergency: cluster first, then the platform pieces the app expects (image, StorageClass), then Velero with the same velero install command as in Step 5.
After the install, make the location read-only, and wait about a minute for the sync:
Both backups are back. velero schedule get printed nothing, because Schedules never go to the bucket. Restore the newest backup:
Start a new port-forward against dr-b before running curl. All four bookings are back, including the one made after hotel-backup-1, and node comes from the Downward API on the new cluster. The timestamps of this run:
| Time | Event |
|---|---|
| 15:22:54 | Last backup |
| 15:23:28 | Cluster dr-a deleted |
| 15:23:42 | Cluster dr-b created |
| 15:24:40 | Velero installed |
| 15:25:35 | Backups synced from the bucket |
| 15:25:50 | Restore completed |
| 15:26:28 | API answering with all four bookings |
The RTO was about three minutes, for a tiny lab. Measure your own. The RPO was 34 seconds with no writes in between, so nothing was lost. With only the nightly schedule, the worst case is 24 hours.
Step 12 — Partial Restores and Promoting the New Cluster
If only a Service is deleted, restore just that resource type. Existing objects are left alone:
failed in the list is only a warning for the existing Service. Restoring the whole backup with --existing-resource-policy update over the running namespace produced warnings such as PersistentVolumeClaim "postgres-data" is invalid: spec: Forbidden: spec is immutable after creation and Pod "bookings-api-5b56d87769-cs9gz" is invalid: spec: Forbidden: pod updates may not change fields other than .... Use update for configuration such as ConfigMaps, Secrets and Deployments. To repair a database, restore into a clean namespace.
Once you trust dr-b, give the location write access back and recreate the schedule. In real life it comes from Git.
Step 13 — Clean Up
Summary
- Define RPO (set by backup frequency) and RTO (measured by rehearsal) first. Replication is not a backup, because it copies mistakes too.
- An etcd snapshot restores one cluster's whole API state without volume data. Velero backs up selected objects and their volumes to object storage outside the cluster, and restores them anywhere.
- Velero is controllers plus CRDs: Backup, Restore, Schedule, BackupStorageLocation, and per-volume work items for the node-agent. The CLI only creates these objects.
- File-system backup with Kopia skips
hostPathvolumes with only a warning, so aCompletedbackup can still lack your data. Check--detailsand the warnings every time. - Pre-backup hooks such as
pg_dumpandCHECKPOINTmake database backups application-consistent. - Restores run in priority order, never restore nodes or events, and skip existing objects unless you use
--existing-resource-policy update, which cannot change immutable fields.--namespace-mappingsgives safe drills next to production, but watch for fully qualified service names. - For a new cluster: same bucket, same repository password, matching StorageClasses and images, the location set to ReadOnly during recovery, and Schedules recreated from Git. Here, a deleted cluster was serving its data again about three minutes after the rebuild started.
- Protect the bucket (it holds your Secrets), control retention with TTLs, and rehearse restores regularly.