Kubernetes Disaster Recovery with Velero and MinIO: Complete Cluster Backup Guide

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

Approaches to Kubernetes Disaster Recovery: GitOps vs Storage Snapshots vs Backup Operators

Stateless apps are easy to recover. Stateful workloads are not. When an accidental kubectl delete namespace, a botched migration, or an etcd corruption hits, simply reapplying your manifests leaves you with empty volumes and broken dependencies.

Production teams typically rely on three distinct recovery patterns:

  • GitOps & Infrastructure as Code (IaC): Tools like ArgoCD or Flux synchronize manifests directly from Git. They rebuild stateless deployments, Services, and CRDs within seconds. However, GitOps is completely blind to actual runtime state. It won’t bring back your Postgres tables or Kafka topics.
  • Cloud Storage Volume Snapshots: Taking scheduled block snapshots (such as AWS EBS, GCP PDs, or Ceph RBD) protects raw bytes. Yet these snapshots run outside Kubernetes awareness. Without pausing write operations or tying disks back to active pod definitions, restoring them across namespaces usually turns into an error-prone scripting headache.
  • Kubernetes-Native Backup Operators (Velero): Velero runs directly inside your cluster, querying the API server and orchestrating backups to S3-compatible storage. It bundles both planes: Kubernetes API resources and persistent volume data (via CSI volume snapshots or file-level node agents like Kopia).

Evaluating Backup Approaches: Pros and Cons

Selecting the right recovery architecture boils down to your Recovery Point Objective (RPO) and operational complexity.

GitOps Repositories

  • Pros: Version-controlled audit trails, pull-request approvals, automated drift correction.
  • Cons: Zero state protection; dynamic secrets generated by operators and actual disk contents require a secondary tool.

Raw Storage Provider Snapshots

  • Pros: Fast block-level replication (often sub-minute RPOs on 500GB+ volumes), zero CPU impact on worker nodes.
  • Cons: Cloud vendor lock-in, hard to coordinate across distributed nodes, tricky cross-cluster migrations.

Velero with S3 Object Storage

  • Pros: Complete cluster portability across bare-metal and public clouds, granular label/namespace filtering, and built-in Kopia node-agent support.
  • Cons: Filesystem-level backups (Kopia) consume node CPU/memory during reads, and transfer speeds depend directly on your object storage network bandwidth.

Recommended Architecture: Velero Paired with MinIO

For hybrid clouds, bare-metal clusters, or air-gapped data centers, pairing Velero with a dedicated MinIO cluster provides the ideal mix of speed and autonomy.

Velero coordinates API queries and backup hooks. Meanwhile, MinIO offers lightweight, S3-compatible object storage delivering up to multiple gigabytes per second across local NVMe drives—entirely free of cloud egress charges.

Velero operates as a controller deployment in your cluster. During a backup run, it packages selected API manifests into a compressed tarball and pushes it to MinIO. For PersistentVolumes, the Velero node-agent DaemonSet uses Kopia to stream file data into the same bucket. When an unexpected control plane glitch or corrupt release strikes at 2 AM, running a single restore command recovers your entire application stack in minutes.

Step-by-Step Implementation Guide

The following walkthrough sets up MinIO, deploys Velero with file-level backups enabled, and tests an end-to-end backup and recovery cycle.

1. Deploy MinIO Object Storage

If you don’t already have an external MinIO deployment, stand up this standalone instance for testing:

# Create a namespace for MinIO
kubectl create namespace minio

# Deploy standalone MinIO with persistent storage
kubectl apply -n minio -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
  name: minio
spec:
  replicas: 1
  selector:
    matchLabels:
      app: minio
  template:
    metadata:
      labels:
        app: minio
    spec:
      containers:
      - name: minio
        image: minio/minio:RELEASE.2024-05-10T01-41-38Z
        args:
        - server
        - /data
        - --console-address
        - :9001
        env:
        - name: MINIO_ROOT_USER
          value: "admin-velero"
        - name: MINIO_ROOT_PASSWORD
          value: "StrongSecretKey123"
        ports:
        - containerPort: 9000
        - containerPort: 9001
        volumeMounts:
        - name: storage
          mountPath: /data
      volumes:
      - name: storage
        emptyDir: {}
---
apiVersion: v1
kind: Service
metadata:
  name: minio
spec:
  ports:
    - port: 9000
      targetPort: 9000
      name: api
  selector:
    app: minio
EOF

Next, log into the MinIO Console at port 9001 or use the MinIO client (mc) to create a bucket named velero-backups.

2. Install the Velero CLI and Configure Credentials

Grab the Velero CLI (v1.13+) for your OS and store your MinIO credentials locally in an INI-formatted file:

# Create credentials-velero file
cat <<EOF > credentials-velero
[default]
aws_access_key_id = admin-velero
aws_secret_access_key = StrongSecretKey123
EOF

3. Install Velero Server with Node-Agent Enabled

Deploy Velero into your cluster. Point it at the in-cluster MinIO service endpoint and pass --use-node-agent to support file-level volume backups without cloud snapshot plugins:

velero install \
  --provider aws \
  --plugins velero/velero-plugin-for-aws:v1.9.0 \
  --bucket velero-backups \
  --secret-file ./credentials-velero \
  --use-node-agent \
  --backup-location-config region=minio,s3ForcePathStyle="true",s3Url=http://minio.minio.svc.cluster.local:9000

Confirm the deployment and the node-agent DaemonSet are healthy:

kubectl get pods -n velero

All controller and node-agent pods should transition to Running status within 30 to 60 seconds.

4. Deploy a Sample Stateful Workload

Create a test namespace containing an Nginx web server mounted to a 1Gi PersistentVolumeClaim:

kubectl create namespace production-app

kubectl apply -n production-app -f - <<EOF
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: web-data
spec:
  accessModes: [ "ReadWriteOnce" ]
  resources:
    requests:
      storage: 1Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-server
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web
  template:
    metadata:
      labels:
        app: web
    spec:
      containers:
      - name: nginx
        image: nginx:alpine
        volumeMounts:
        - mountPath: /usr/share/nginx/html
          name: storage
      volumes:
      - name: storage
        persistentVolumeClaim:
          claimName: web-data
EOF

Write sample data to the volume to verify retention after recovery:

POD_NAME=$(kubectl get pod -n production-app -l app=web -o jsonpath="{.items[0].metadata.name}")
kubectl exec -n production-app $POD_NAME -- sh -c 'echo "Cluster Disaster Recovery Succeeded!" > /usr/share/nginx/html/index.html'

5. Execute a Full Namespace Backup

Instruct Velero to back up the production-app namespace along with its PVC data:

velero backup create production-backup \
  --include-namespaces production-app \
  --default-volumes-to-fs-backup \
  --wait

Inspect the backup status once the command returns:

velero backup describe production-backup --details

You should see phase Completed with 0 errors and a recorded Kopia volume backup under the snapshot list.

6. Test Disaster Recovery

Simulate accidental data loss by deleting the production namespace:

kubectl delete namespace production-app
kubectl get namespace production-app

Once Kubernetes purges the namespace, restore the application and volume from MinIO:

velero restore create --from-backup production-backup --wait
velero restore get

Check that the pod is running and inspect the restored file contents:

RESTORED_POD=$(kubectl get pod -n production-app -l app=web -o jsonpath="{.items[0].metadata.name}")
kubectl exec -n production-app $RESTORED_POD -- cat /usr/share/nginx/html/index.html

The shell will print Cluster Disaster Recovery Succeeded!. Your manifests, PVCs, and file data are completely restored.

Share: