Deploying Velero on GKE with a GCS bucket — the gotchas nobody documents
When Workload Identity is misconfigured, Velero installs fine, the Schedule fires, the Backup CR is created — and then silently transitions to FailedValidation because the GCS auth token can’t be fetched. We were in that state for 46 days before noticing. This walkthrough surfaces those failures early, covers the three IAM identities that must align, and includes three restore tests in increasing order of rigour — including the GitOps suspension trick you’ll need for a real in-place restore.
Names are anonymized (my-cluster, my-app, my-bucket); the same shape works whether my-app is a config-file-on-PVC tool, a database, a queue worker — anything with a PVC you want backed up.
What we’re building
+--------------------------+ +----------------------+ +------------------------+
| my-app PVCs | <--- | Velero + node-agent | ---> | GCS bucket |
| (any storage class with | | (Kopia fs-backup) | | my-bucket |
| RWO PVCs) | | | | |
+--------------------------+ +----------------------+ +------------------------+
^
| impersonates GSA via WI
+-------------------------+
| KSA velero/velero-server|
+-------------------------+
^
| iam.workloadIdentityUser
+-------------------------+
| GSA velero-prd@… |
+-------------------------+
^
| roles/storage.objectAdmin
v
gs://my-bucket
Three identities tie everything together:
- Google Service Account (GSA) —
velero-prd@<project>.iam.gserviceaccount.com. The identity Velero impersonates when talking to GCS. - Kubernetes Service Account (KSA) —
velero/velero-server. The pod identity. Annotated withiam.gke.io/gcp-service-account: velero-prd@…. - Workload Identity binding — gives the KSA permission to impersonate the GSA.
If any one of these is wrong, Velero appears to install fine, the Schedule fires, the Backup CR is created — and then silently transitions to FailedValidation because the GCS auth token can’t be fetched. The user sees a healthy-looking kubectl get all and never realises backups aren’t happening. We had this state in production for 46 days before noticing.
This post walks through the install in a way that surfaces those failures early.
Prerequisites
- A GKE cluster with Workload Identity enabled at the cluster level AND on every node pool (
workloadPool: <project>.svc.id.googon the cluster;--workload-metadata=GKE_METADATAon each node pool). Velero docs assume this but don’t reiterate it. gcloudauthenticated to the GCP project that owns the cluster, with project-level IAM admin (you’ll create a GSA, a bucket, and IAM bindings).kubectlconfigured for the target cluster.helminstalled locally.- A namespace
veleroyou’re willing to dedicate (the install creates it via Argo orkubectl create ns velero).
Step 1 — Create the GSA
PROJECT=my-project-id
gcloud iam service-accounts create velero-prd \
--display-name="Velero prd service account" \
--project="$PROJECT"
Verify:
gcloud iam service-accounts describe \
velero-prd@$PROJECT.iam.gserviceaccount.com --project=$PROJECT
If this returns NOT_FOUND later, the install fails silently — Velero impersonates a GSA that doesn’t exist, the metadata server replies "not defined", and every Backup goes FailedValidation. This is the most common cause of the silent-failure state.
Step 2 — Create the GCS bucket
gcloud storage buckets create gs://my-bucket \
--location=EU \
--uniform-bucket-level-access \
--project="$PROJECT"
Use EU / US / ASIA multi-region for high-availability backups, or a single-region location to minimise egress costs if the cluster is in the same region. Storage class defaults to STANDARD; switch to NEARLINE or COLDLINE only if you’re sure backups are read rarely (restores will be slower and pricier).
Verify the bucket actually exists:
gcloud storage buckets describe gs://my-bucket \
--format='value(name,location,storageClass)'
Velero validates the bucket at runtime, not at install time — a missing bucket produces FailedValidation Backups with no other warning.
Step 3 — Grant the GSA permission to write to the bucket
gcloud storage buckets add-iam-policy-binding gs://my-bucket \
--member="serviceAccount:velero-prd@$PROJECT.iam.gserviceaccount.com" \
--role=roles/storage.objectAdmin
storage.objectAdmin is the minimum role Velero needs — it can read, write, and delete objects in the bucket. If you’re paranoid, you can tighten to roles/storage.objectUser (read/write but no delete), but then you lose the ability for Velero to GC expired Backups, and your bucket will grow forever.
Step 4 — Bind the KSA to the GSA via Workload Identity
This is the step that makes the in-pod metadata token fetch work.
gcloud iam service-accounts add-iam-policy-binding \
velero-prd@$PROJECT.iam.gserviceaccount.com \
--role=roles/iam.workloadIdentityUser \
--member="serviceAccount:$PROJECT.svc.id.goog[velero/velero-server]" \
--project="$PROJECT"
Note the member format: serviceAccount:<workloadIdentityPool>[<ns>/<ksa>]. The pool is <project>.svc.id.goog. The KSA name velero-server is what Velero’s Helm chart creates by default. If you customise serviceAccount.server.name in the chart values, adjust here too.
Verify:
gcloud iam service-accounts get-iam-policy \
velero-prd@$PROJECT.iam.gserviceaccount.com --project=$PROJECT \
| grep -A1 workloadIdentityUser
You want to see serviceAccount:<project>.svc.id.goog[velero/velero-server] in the output.
Step 5 — Install Velero via Helm
Bare-bones values file:
# values-velero.yaml
initContainers:
- name: velero-plugin-for-gcp
image: velero/velero-plugin-for-gcp:v1.14.0
volumeMounts:
- mountPath: /target
name: plugins
configuration:
backupStorageLocation:
- name: default
provider: gcp
bucket: my-bucket
config:
serviceAccount: velero-prd@my-project-id.iam.gserviceaccount.com
volumeSnapshotLocation:
- name: default
provider: gcp
# No K8s secret — Workload Identity provides auth via the GSA
credentials:
useSecret: false
serviceAccount:
server:
annotations:
iam.gke.io/gcp-service-account: velero-prd@my-project-id.iam.gserviceaccount.com
# Deploy the node-agent DaemonSet (Kopia) so we can do file-system backup of PVCs.
# Required for any storage class without CSI snapshot support.
deployNodeAgent: true
schedules:
my-app:
schedule: "0 * * * *"
template:
includedNamespaces:
- my-app
defaultVolumesToFsBackup: true
ttl: "720h" # 30 days
That single hourly schedule with a 30-day TTL gives you 720 hourly snapshots in the bucket at steady state. If you want GFS-style tiered retention — fewer, longer-lived snapshots at coarser cadences — keep reading. If a flat 30-day window is fine, skip to “Install”.
Optional: GFS-tiered retention via multiple schedules
The simplest way to get hourly+daily+weekly+monthly retention without writing a custom controller is to define four schedules, each with its own cron + TTL. Velero doesn’t natively promote one Backup across tiers, so each tier produces its own Backup CR. Kopia content-addresses the data, so the bucket cost stays roughly 1× the actual content even when multiple schedules fire at the same minute.
Stagger the crons so no two schedules ever fire concurrently — otherwise tier-boundary moments (Sunday 1st-of-month at 00:00) trigger 4 parallel backups, hammer your node-agent, and (later, if you add pre-backup quiesce hooks) become 4 sequential stop/start cycles on the workload. A 15-minute offset between tiers is enough:
schedules:
my-app-hourly:
schedule: "0 * * * *" # every hour at :00
template:
includedNamespaces: [my-app]
defaultVolumesToFsBackup: true
ttl: 24h
my-app-daily:
schedule: "15 2 * * *" # 02:15 UTC — 15 min after the hourly
template:
includedNamespaces: [my-app]
defaultVolumesToFsBackup: true
ttl: 168h # 7 days
my-app-weekly:
schedule: "30 3 * * 0" # Sun 03:30 UTC
template:
includedNamespaces: [my-app]
defaultVolumesToFsBackup: true
ttl: 720h # 30 days
my-app-monthly:
schedule: "45 4 1 * *" # 1st 04:45 UTC
template:
includedNamespaces: [my-app]
defaultVolumesToFsBackup: true
ttl: 8760h # 365 days
Steady-state retention: ~24 hourly + 7 daily + 4 weekly + 12 monthly ≈ 47 Backup CRs per target.
The schedule-name suffix convention (-hourly / -daily / -weekly / -monthly) matters when you wire up alerting (see the “Alerting” section near the end) — a single rule file can target each tier by regex on the schedule name, so you get different staleness thresholds for free.
What about true GFS promotion (the same Backup tagged as both “today’s hourly” and “today’s daily”)? Velero doesn’t natively support that. You can build a custom controller that extends spec.ttl on the earliest Backup of each calendar period — we designed one and have working code for it — but adding a self-coded shell script to the backup path is a maintainability trade-off you should make consciously. The four-schedule approach above is the native pattern; the custom-promoter alternative is a separate post-worth discussion.
Install:
helm repo add vmware-tanzu https://vmware-tanzu.github.io/helm-charts
helm install velero vmware-tanzu/velero \
--version 12.0.1 \
--namespace velero --create-namespace \
-f values-velero.yaml
Step 6 — Verify the install
The usual checks — are the pods running? are the CRDs there? are the schedules created? — can all pass while every Backup silently fails. The check that actually validates the auth chain is the BackupStorageLocation phase. Run:
kubectl -n velero get backupstoragelocations.velero.io
You want PHASE: Available and LAST VALIDATED within the last ~60 seconds. If it says Unavailable, read the message:
kubectl -n velero get bsl default -o jsonpath='{.status.message}{"\n"}'
Common messages:
cannot fetch token: metadata: GCE metadata "instance/service-accounts/default/token..." not defined→ the WI binding is missing or wrong. Re-check Step 4.NotFound: Unknown service account→ the GSA doesn’t exist (your--memberin Step 4 referenced a typo’d name). Re-check Step 1.Bucket my-...-velero-backup-... not found: 404→ the bucket doesn’t exist or the GSA lacks read access. Re-check Steps 2 and 3.Forbidden→ the GSA exists but the bucket IAM doesn’t grant it write. Re-check Step 3.
When PHASE: Available, your install is correct and Backups will land successfully.
Step 7 — Smoke-test by triggering a one-off Backup
cat <<EOF | kubectl apply -f -
apiVersion: velero.io/v1
kind: Backup
metadata:
name: my-app-smoke
namespace: velero
spec:
includedNamespaces:
- my-app
defaultVolumesToFsBackup: true
ttl: 24h
EOF
Watch:
kubectl -n velero get backup.velero.io my-app-smoke \
-o custom-columns=PHASE:.status.phase,ITEMS:.status.progress.itemsBackedUp,ERRORS:.status.errors
You want PHASE: Completed, ERRORS: <none>. If PHASE: FailedValidation, go back to Step 6 — the BSL is what’s broken.
A successful Backup writes:
gs://my-bucket/
└── backups/my-app-smoke/
├── my-app-smoke.tar.gz
├── my-app-smoke-podvolumebackups.json.gz
└── …
└── kopia/
└── <kopia repo blobs>
You can verify in the GCS console or with gcloud storage ls gs://my-bucket/.
Three tests, in increasing order of rigour
A Backup that exists isn’t proof you can actually restore from it. There are three ways to validate restorability, in increasing order of operational rigour and decreasing order of frequency:
- Shadow-namespace test — restore the Backup into a sibling namespace, hash-compare a known file, cleanup. Non-destructive. Recommended monthly. ~5 min.
- Destructive in-place test — suspend GitOps, delete the live PVC + Deployment, restore in place, verify, resume. Destructive. Recommended after install + after cluster upgrades. ~10 min, of which the target is offline for ~3.
- Manual recovery dry-run — simulate “the PVC content is wrong” and recover via shadow restore +
kubectl cpinto the live pod. The everyday-recovery pattern. Non-destructive. ~3 min.
Each test is a runbook in its own right; we’ll walk through all three.
Test 1 — Shadow-namespace restore
This is the routine “is my backup pipeline healthy?” check.
BACKUP=my-app-smoke # or pick the latest scheduled Backup
cat <<EOF | kubectl apply -f -
apiVersion: velero.io/v1
kind: Restore
metadata:
name: my-app-shadow-drill-$(date +%s)
namespace: velero
spec:
backupName: $BACKUP
includedNamespaces:
- my-app
namespaceMapping:
my-app: my-app-restore-drill
EOF
Wait for it:
RESTORE=$(kubectl -n velero get restore.velero.io --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1:].metadata.name}')
for i in $(seq 1 18); do
PHASE=$(kubectl -n velero get restore.velero.io "$RESTORE" -o jsonpath='{.status.phase}')
echo "t=$((i*10))s phase=$PHASE"
[ "$PHASE" = Completed ] && break
sleep 10
done
In the shadow namespace (my-app-restore-drill) you’ll have:
- A new PVC with the same contents the original had at Backup time
- A new Pod that mounts it (likely
0/1 Readybecause it lacks prod credentials — that’s fine; we only care about the PVC data) - The full Deployment and Service spec, restored verbatim
Hash a file you know:
POD=$(kubectl -n my-app-restore-drill get pod -o jsonpath='{.items[0].metadata.name}')
kubectl -n my-app-restore-drill exec "$POD" -- sha256sum /data/my-state-file
Compare with the same hash in the live namespace. Byte-identical = your backup pipeline is healthy end-to-end.
Cleanup:
kubectl delete ns my-app-restore-drill --wait=false
kubectl -n velero delete restore.velero.io "$RESTORE"
The Backup itself is untouched; you can shadow-restore it again any time.
Test 2 — Destructive in-place test
The shadow-namespace test proves data integrity but not the operational playbook. To validate “if production loses its PVC, can we restore in place?”, you need to:
- Suspend GitOps reconciliation on the target’s Application (otherwise it’ll fight you)
- Delete the live Deployment and PVC
- Trigger Velero restore
- Verify the restored pod has the data
- Resume GitOps
Steps 2-5 are mechanical. Step 1 is the hard part.
Why step 1 is the hard part
If your Velero target’s Argo Application is generated by an ApplicationSet, patching spec.syncPolicy.automated: null on the Application alone does not stick. The ApplicationSet controller reverts it within seconds — it owns the Application and its job is to make the live spec match its template. Argo’s selfHeal then races Velero: it recreates the Deployment + (empty) PVC before Velero can inject a PodVolumeRestore init container into a pod it controls, and the restored data is lost.
We hit this on a production target. The drill “succeeded” — velero get restore showed Completed, the new PVC bound, the pod was Running 1/1 — but /data/<state-file> was empty. The Velero restore had created a new PVC, but the Deployment-spawned pod (Argo-controlled, not Velero-controlled) mounted the PVC without ever running the PodVolumeRestore init container that does the actual data copy from Kopia.
The fix — verified over three attempts
The empirical record:
| Attempt | Suspend method | What was reverted | Result |
|---|---|---|---|
| 1 | Patch Application.spec.syncPolicy.automated: null |
ApplicationSet controller put automated back in <5s |
PVC empty after restore. Recovered via Test 3. |
| 2 | Patch ApplicationSet.spec.template.spec.syncPolicy.automated: null |
Upstream “app-of-apps” Argo Application put it back in <60s | PVC empty after restore. Recovered via Test 3. |
| 3 | AppProject Sync Window (kind: deny, 30 min, namespaces: [my-app]) |
Not reverted within the 3-min drill window | Restored PVC byte-identical to pre-drill (sha256 matched) |
Lesson: on a fully GitOps-managed cluster where the AppProject is also GitOps-managed, any short-window mechanism that holds for ~3 minutes is enough for a typical restore (small PVCs complete in ~80s). The AppProject Sync Window meets that bar without requiring a git PR.
How to do it
Sync Window on the AppProject (the verified-working approach):
PROJECT=$(kubectl -n argocd get application my-app -o jsonpath='{.spec.project}')
kubectl -n argocd patch appproject "$PROJECT" --type=merge -p='{
"spec": {
"syncWindows": [{
"kind": "deny",
"schedule": "* * * * *",
"duration": "30m",
"namespaces": ["my-app"],
"manualSync": true,
"timeZone": "UTC"
}]
}
}'
Probe that the window is effective before destruction:
kubectl -n my-app delete deployment my-app --wait=false
sleep 20
kubectl -n my-app get deployment my-app # should be NotFound; if it's back, abort
Remove the window at the end:
kubectl -n argocd patch appproject "$PROJECT" --type=json \
-p='[{"op":"remove","path":"/spec/syncWindows/0"}]'
⚠ Even the AppProject patch may eventually revert if the AppProject itself is GitOps-managed by an upstream “app-of-apps” Argo Application. The window typically lasts 5-10 minutes — enough for the drill — but watch for revert. The fully-bulletproof variant is to edit the AppProject’s git source values and merge, which takes longer.
Approaches that don’t hold:
- Patching
Application.spec.syncPolicy.automated: nulldirectly — the ApplicationSet owns the Application’s spec and reverts this in seconds. - Patching the ApplicationSet template — the upstream “app-of-apps” Argo Application reverts this in under a minute.
The only at-runtime suspend that actually held on the cluster I tested was the AppProject Sync Window. The reason: each level of the GitOps tree reverts the level below. Sync Windows live on the AppProject and override individual Application sync policies; the AppProject is reconciled less aggressively than Application syncPolicy fields, which gives you enough time to do a quick drill.
The actual destructive-test commands
After the Sync Window is in place and the probe-delete confirms Argo is no longer auto-syncing:
BACKUP=$(kubectl -n velero get backup.velero.io --sort-by=.metadata.creationTimestamp -o json \
| jq -r '[.items[] | select(.metadata.name | startswith("velero-my-app-")) | select(.status.phase=="Completed") | .metadata.name] | last')
# Save the pre-drill hash for comparison
LIVE_POD=$(kubectl -n my-app get pod -l app.kubernetes.io/name=my-app --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
kubectl -n my-app exec "$LIVE_POD" -- sha256sum /data/my-state-file
# Destroy
kubectl -n my-app delete deployment my-app --wait=true --timeout=60s
kubectl -n my-app delete pvc my-app --wait=false
# Restore
cat <<EOF | kubectl apply -f -
apiVersion: velero.io/v1
kind: Restore
metadata:
name: my-app-destructive-$(date +%s)
namespace: velero
spec:
backupName: $BACKUP
includedNamespaces:
- my-app
EOF
# Wait for Completed (typically 60-120s for small PVCs)
RESTORE=$(kubectl -n velero get restore.velero.io --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1:].metadata.name}')
until kubectl -n velero get restore.velero.io "$RESTORE" -o jsonpath='{.status.phase}' | grep -qE 'Completed|Failed'; do sleep 5; done
# Verify
kubectl -n velero get podvolumerestore.velero.io # must show Completed PVRs with bytes>0
NEW_POD=$(kubectl -n my-app get pod -l app.kubernetes.io/name=my-app -o jsonpath='{.items[0].metadata.name}')
kubectl -n my-app get pod "$NEW_POD" -o jsonpath='{range .spec.initContainers[*]}{.name} {end}' # must include "restore-wait"
kubectl -n my-app exec "$NEW_POD" -- sha256sum /data/my-state-file # must match pre-drill hash
# Remove the Sync Window when drill is complete:
kubectl -n argocd patch appproject "$PROJECT" --type=json -p='[{"op":"remove","path":"/spec/syncWindows/0"}]'
If podvolumerestore count is zero or the pod has no restore-wait init container, Argo won the race — the new PVC will be empty. Recover via Test 3 (manual fix) below. Don’t leave production with an empty PVC.
Test 3 — Manual fix in case of effective data loss
This is the everyday recovery pattern. Whenever production data goes bad — operator mistake, drift between expected and actual state, or even the destructive test from above hitting a race — this is how you recover. It doesn’t fight Argo, doesn’t require any GitOps suspension, and works on any cluster.
# 1. Pick the Backup to restore from (most recent Completed predating the corruption)
kubectl -n velero get backup.velero.io --sort-by=.metadata.creationTimestamp \
-o custom-columns=NAME:.metadata.name,PHASE:.status.phase,CREATED:.metadata.creationTimestamp \
| grep my-app | grep Completed | tail -10
BACKUP=velero-my-app-20260603200057 # adjust to your choice
# 2. Restore into a shadow namespace (production untouched)
cat <<EOF | kubectl apply -f -
apiVersion: velero.io/v1
kind: Restore
metadata:
name: my-app-recovery-$(date +%s)
namespace: velero
spec:
backupName: $BACKUP
includedNamespaces: [my-app]
namespaceMapping:
my-app: my-app-recovery-shadow
EOF
# 3. Wait for restore Completed (Kopia data pulls in)
RESTORE=$(kubectl -n velero get restore.velero.io --sort-by=.metadata.creationTimestamp -o jsonpath='{.items[-1:].metadata.name}')
until kubectl -n velero get restore.velero.io "$RESTORE" -o jsonpath='{.status.phase}' | grep -qE 'Completed|Failed'; do sleep 5; done
# 4. Copy the file from the shadow pod into the live pod
SHADOW_POD=$(kubectl -n my-app-recovery-shadow get pod -o jsonpath='{.items[0].metadata.name}')
LIVE_POD=$(kubectl -n my-app get pod -l app.kubernetes.io/name=my-app --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
kubectl -n my-app-recovery-shadow exec "$SHADOW_POD" -- cat /data/my-state-file > /tmp/recovered
sha256sum /tmp/recovered # sanity check the local copy
kubectl -n my-app cp /tmp/recovered "$LIVE_POD":/data/my-state-file
kubectl -n my-app exec "$LIVE_POD" -- sha256sum /data/my-state-file # must match what's in shadow
# 5. Restart the live pod if your app reads the file only at startup
kubectl -n my-app delete pod "$LIVE_POD" # Deployment respawns; same PVC, restored file
kubectl -n my-app rollout status deployment/my-app
# 6. Cleanup
rm -f /tmp/recovered
kubectl delete ns my-app-recovery-shadow --wait=false
kubectl -n velero delete restore.velero.io "$RESTORE"
The Backup itself is untouched — you can use it again for further investigation if needed.
For copying a whole directory rather than a single file, replace step 4 with a tar stream:
kubectl -n my-app-recovery-shadow exec "$SHADOW_POD" -- tar -cf - -C / data \
| kubectl -n my-app exec -i "$LIVE_POD" -- tar -xf - -C /
kubectl cp uses tar under the hood and requires tar in the container; if your image is distroless and lacks tar, use the explicit exec ... cat | exec -i ... cat > target variant.
Alerting: catch the 46-day silent failure mode in 1 hour instead
The Workload-Identity silent-failure mode at the start of this post is genuinely dangerous: Velero pods Running, Schedules Enabled, kubectl get all looks healthy, every Backup is FailedValidation. You can sit in that state for weeks. The fix is monitoring on metrics Velero already exposes for free.
Velero’s controller pod serves /metrics on port 8085 with these alert-grade series (no PushGateway needed, no Alloy CI integration, just scrape):
| Metric | What it tells you |
|---|---|
velero_backup_last_successful_timestamp{schedule=...} |
the heartbeat — last successful backup time per schedule |
velero_backup_validation_failure_total{schedule=...} |
the silent-failure detector — increments any time a Backup gets FailedValidation, which is what happens when the BSL is Unavailable |
velero_backup_failure_total{schedule=...} |
hard failures (BSL was OK at validation time, but the actual backup work errored) |
velero_backup_partial_failure_total{schedule=...} |
partial failures |
velero_backup_attempt_total{schedule=...} |
did the schedule actually fire? |
To wire this up:
- Enable the ServiceMonitor in your Velero Helm values:
metrics: serviceMonitor: enabled: true autodetect: false # render unconditionally, not only when CRD is visible at template time additionalLabels: release: prometheus-stack # matches kube-prometheus-stack's serviceMonitorSelector - Add Prometheus alerting rules. Five generic alerts cover the whole surface — one for failures, four for staleness (tiered by schedule-name suffix so each cadence gets the right threshold):
groups:
- name: velero
rules:
- alert: VeleroBackupFailing
expr: |
(increase(velero_backup_validation_failure_total{schedule!=""}[1h]) > 0)
or
(increase(velero_backup_failure_total{schedule!=""}[1h]) > 0)
labels: { severity: warning, team: infra }
annotations:
summary: "Velero schedule {{ $labels.schedule }} is producing failed Backups"
description: "Validation failures usually mean BackupStorageLocation is Unavailable; hard failures usually mean node-agent / Kopia trouble."
- alert: VeleroHourlyBackupStale
expr: |
time() - max by (schedule) (
velero_backup_last_successful_timestamp{schedule!~".*-(daily|weekly|monthly)$",schedule!=""} > 0
) > 9000
labels: { severity: warning, team: infra }
annotations:
summary: "Velero hourly schedule {{ $labels.schedule }} has not succeeded in 2.5h+"
description: "Last successful backup was {{ $value | humanizeDuration }} ago."
- alert: VeleroDailyBackupStale
expr: |
time() - max by (schedule) (velero_backup_last_successful_timestamp{schedule=~".*-daily$"} > 0) > 90000
labels: { severity: warning, team: infra }
annotations:
summary: "Velero daily schedule {{ $labels.schedule }} has not succeeded in 25h+"
description: "Last successful backup was {{ $value | humanizeDuration }} ago."
- alert: VeleroWeeklyBackupStale
expr: |
time() - max by (schedule) (velero_backup_last_successful_timestamp{schedule=~".*-weekly$"} > 0) > 691200
labels: { severity: warning, team: infra }
annotations:
summary: "Velero weekly schedule {{ $labels.schedule }} has not succeeded in 8d+"
description: "Last successful backup was {{ $value | humanizeDuration }} ago."
- alert: VeleroMonthlyBackupStale
expr: |
time() - max by (schedule) (velero_backup_last_successful_timestamp{schedule=~".*-monthly$"} > 0) > 2764800
labels: { severity: warning, team: infra }
annotations:
summary: "Velero monthly schedule {{ $labels.schedule }} has not succeeded in 32d+"
description: "Last successful backup was {{ $value | humanizeDuration }} ago."
The schedule-name suffix convention (-hourly / -daily / -weekly / -monthly) from the GFS section earlier is what lets a single rule file give each tier the right staleness threshold:
| Schedule suffix (or none) | Threshold | Maps to |
|---|---|---|
(no suffix, e.g. velero-my-app) |
2.5h | hourly bucket via negated regex |
-hourly |
2.5h | hourly |
-daily |
25h | daily |
-weekly |
8d | weekly |
-monthly |
32d | monthly |
Validate before merging:
# promtool via docker (no local Prometheus needed):
docker run --rm -v "$PWD/rules:/rules" --entrypoint promtool \
prom/prometheus check rules /rules/velero-rules.yaml
# Expected: "SUCCESS: 5 rules found"
Synthetic test post-deploy: pause one of your Schedules (kubectl patch schedule.velero.io my-app-hourly --type=merge -p '{"spec":{"paused":true}}') and wait for the staleness threshold to elapse. The corresponding alert should fire. Un-pause to clear. This proves both that the metric flows end-to-end (Velero → Prometheus → Thanos → Ruler → Alertmanager → wherever your team-label routes) and that the rule expression matches.
With this in place, the 46-day silent-failure scenario at the top of this post becomes a 1-hour detection — VeleroBackupFailing fires the first time the BSL fails to validate.
What stays the same regardless of GitOps tooling
Three things you should always do in this order during any data-loss incident:
- Confirm the Backup exists and is good.
kubectl -n velero get backup.velero.ioshould show aCompletedBackup predating the loss. If not, you have a backup-pipeline problem, not a recovery problem. - Restore to a shadow namespace first. Verify hash. This is your sanity check that the Backup actually contains the data you remember.
- Then
kubectl cpinto the live pod. No Argo wrestling. No GitOps suspension. Whatever your cluster’s GitOps tree looks like, this works.
The five failure modes
| Probability | Failure | Symptom | Where to check |
|---|---|---|---|
| Very high | GSA referenced in values file doesn’t exist | Every Backup is FailedValidation. Velero pods look healthy. |
gcloud iam service-accounts describe <gsa> |
| Very high | GCS bucket referenced in values file doesn’t exist | Every Backup is FailedValidation. Velero pods look healthy. |
gcloud storage buckets describe gs://<bucket> |
| High | WI binding (roles/iam.workloadIdentityUser) missing between GSA and KSA |
BSL Unavailable with "GCE metadata ... not defined" |
gcloud iam service-accounts get-iam-policy <gsa> \| grep workloadIdentityUser |
| Medium | GKE node pool lacks --workload-metadata=GKE_METADATA |
Same as above — metadata token endpoint returns 404 | gcloud container node-pools describe <pool> --cluster <cluster> --region <r> |
| Subtle but high if you destructive-drill on Argo apps | Argo’s selfHeal races Velero and recreates the (empty) PVC before Velero can inject restore-wait into a Velero-controlled pod |
Restore “Completed”, no PVRs fired, PVC is empty | Use AppProject Sync Window per Test 2; if you missed it and the drill failed, recover via Test 3 |
Additional notes
- Velero will install happily with a misconfigured BSL. The pods will be
Runningand the Schedules will fire. The only signal isBackupStorageLocation.status.phase. Make this part of your install acceptance test. helm installdoesn’t fail if the GSA / bucket doesn’t exist — those are runtime checks the Velero controller does, not install-time validation Helm does.kubectl get backup -Areturns “No resources found” on some clusters becausebackupis a short name that resolves to a different CRD (Rancher’sbackups.resources.cattle.io, for instance). Use the fully-qualifiedbackups.velero.io.- The Helm chart’s default
serviceAccount.server.nameisvelero-server. If you change it, your Workload Identity binding (Step 4) needs to reference the new name — a mismatch produces the same silentFailedValidationstate.
Most of the install friction is the Workload Identity setup: three or four GCP commands that must align exactly. Get those right, verify with the BSL phase, and run a shadow-restore test monthly.