Procedure to restore PVCs from Azure Disk Backup snapshots after PVCs were deleted and re-created, leaving new empty disks in place.
The backup vault (equalvote-backup-vault) still holds recovery points for the
original (deleted) disks — those are what we restore.
Prevent ArgoCD from reconciling during the restore. These apps are multi-source,
so use kubectl patch:
for APP in postgresql keycloak loki kube-prometheus-stack; do
kubectl patch application "$APP" -n argocd --type merge \
-p '{"spec":{"syncPolicy":null}}'
doneRe-enable later with
--sync-policy automated --auto-prune=false --source-pos 0.
Stop pods so PVCs can be swapped:
kubectl scale statefulset -n starvote postgresql --replicas=0
kubectl scale statefulset -n keycloak keycloak-postgresql --replicas=0
kubectl scale statefulset -n loki loki --replicas=0Scale back up after restore.
Identify the empty PVCs that need to be replaced:
kubectl get pvc -A -o custom-columns=\
NAMESPACE:.metadata.namespace,\
NAME:.metadata.name,\
SIZE:.spec.resources.requests.storage,\
VOLUME:.spec.volumeNameExpected empty PVCs:
| Namespace | PVC Name | Size | PV (auto-provisioned) |
|---|---|---|---|
starvote |
data-postgresql-0 |
8Gi | pvc-<new-uuid> |
keycloak |
data-postgresql-0 |
8Gi | pvc-<new-uuid> |
loki |
data-loki-0 |
10Gi | pvc-<new-uuid> |
Record the PV names — you need to delete them (they point to the empty disks).
The vault (equalvote-backup-vault) is a Data Protection backup vault — use
az dataprotection commands, not az backup.
List all backup instances (named after the original disk UUIDs):
az dataprotection backup-instance list \
--vault-name equalvote-backup-vault \
--resource-group equalvote \
--query "[].{name:name, status:properties.currentProtectionState}" \
-o tableExpected output — three original disks that were being backed up before deletion:
Name Status
---------------------------------------- --------------------
pvc-3cd6e8ba-fb7f-4031-a69d-e5cd96e8efba ProtectionConfigured
pvc-56229c63-fd75-49d0-8f6b-7ce7d107bc68 ProtectionConfigured
pvc-7d5e2740-7f0a-4115-acbe-b84975edf773 ProtectionConfigured
For each backup instance, find the latest recovery point and the disk size (stored as JSON in the recovery point metadata):
for INSTANCE in $(az dataprotection backup-instance list \
--vault-name equalvote-backup-vault \
--resource-group equalvote \
--query "[].name" -o tsv); do
RP_ID=$(az dataprotection recovery-point list \
--backup-instance-name "$INSTANCE" \
--vault-name equalvote-backup-vault \
--resource-group equalvote \
--query "max_by([], &properties.recoveryPointTime).name" -o tsv)
META=$(az dataprotection recovery-point show \
--backup-instance-name "$INSTANCE" \
--vault-name equalvote-backup-vault \
--resource-group equalvote \
--recovery-point-id "$RP_ID" \
--query "properties.recoveryPointDataStoresDetails[0].metaData" -o tsv)
SIZE=$(echo "$META" | python3 -c "
import sys, json
d = json.loads(sys.stdin.read())[0]
print(f\"{d['sizeBytes'] / 1024**3:.0f}GiB\")
")
echo "$INSTANCE latest=$RP_ID size=$SIZE"
doneThe recovery point metadata reveals the disk size. Use it to identify each workload:
| Size | Workload | Namespace |
|---|---|---|
| 8GiB | PostgreSQL (starvote) | starvote |
| 8GiB | Keycloak bundled PostgreSQL | keycloak |
| 10GiB | Loki | loki |
The two 8GiB disks are both Bitnami PostgreSQL and cannot be distinguished by size alone. After restoring one, mount it to inspect (step 7) — PostgreSQL contains
PG_VERSION,base/,global/; Keycloak contains akeycloakdatabase inbase/.
For each original disk, restore its latest recovery point to a new managed disk. The Data Protection API uses a two-step process: initialize the restore request JSON, then trigger the restore.
INSTANCE="<original-disk-uuid-from-step-4>"
RP_ID="<latest-rp-id-from-step-4>"
RESTORED_NAME="${INSTANCE}-restored"
# 1) Generate the restore request body
RESTORE_REQUEST=$(az dataprotection backup-instance restore initialize-for-data-recovery \
--datasource-type AzureDisk \
--restore-location westus2 \
--source-datastore OperationalStore \
--recovery-point-id "$RP_ID" \
--target-resource-id "/subscriptions/86f3145a-48cc-4255-8757-dd3104d15e57/resourceGroups/MC_equalvote_equalvote_westus2/providers/Microsoft.Compute/disks/$RESTORED_NAME")
# 2) Trigger the restore (--no-wait returns immediately)
az dataprotection backup-instance restore trigger \
--name "$INSTANCE" \
--vault-name equalvote-backup-vault \
--resource-group equalvote \
--restore-request-object "$RESTORE_REQUEST" \
--no-waitThis creates a disk named pvc-<uuid>-restored in
MC_equalvote_equalvote_westus2. Check progress with:
az disk show -g MC_equalvote_equalvote_westus2 -n "$RESTORED_NAME" --query provisioningStateRepeat for each original disk (3 total).
Mount the disk read-only with a busybox pod and inspect its content without writing anything.
Loki (10GiB): contains chunks/, index/, wal/ directories — just ls:
RESTORED_DISK="<loki-restored-disk-name>"
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-inspect
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
csi:
driver: disk.csi.azure.com
volumeHandle: /subscriptions/86f3145a-48cc-4255-8757-dd3104d15e57/resourceGroups/MC_equalvote_equalvote_westus2/providers/Microsoft.Compute/disks/${RESTORED_DISK}
fsType: ext4
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc-inspect
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
volumeName: pv-inspect
storageClassName: ""
---
apiVersion: v1
kind: Pod
metadata:
name: pg-inspect
spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: pvc-inspect
readOnly: true
containers:
- name: inspect
image: busybox
command: ["ls", "/data"]
volumeMounts:
- name: data
mountPath: /data
readOnly: true
restartPolicy: Never
EOF
kubectl logs pg-inspect
kubectl delete pod,pvc,pv pg-inspect pvc-inspect pv-inspectPostgreSQL (8GiB): both disks have the same directory layout, but the
database names are embedded in the pg_database catalog file (global/1260).
Use strings — no server startup, no writes:
RESTORED_DISK="<postgres-restored-disk-name>"
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-inspect
spec:
capacity:
storage: 8Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
csi:
driver: disk.csi.azure.com
volumeHandle: /subscriptions/86f3145a-48cc-4255-8757-dd3104d15e57/resourceGroups/MC_equalvote_equalvote_westus2/providers/Microsoft.Compute/disks/${RESTORED_DISK}
fsType: ext4
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: pvc-inspect
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 8Gi
volumeName: pv-inspect
storageClassName: ""
---
apiVersion: v1
kind: Pod
metadata:
name: pg-inspect
spec:
volumes:
- name: data
persistentVolumeClaim:
claimName: pvc-inspect
readOnly: true
containers:
- name: inspect
image: busybox
command:
- sh
- -c
- strings /data/global/1260 | grep -v '^[0-9]*$' | sort -u
volumeMounts:
- name: data
mountPath: /data
readOnly: true
restartPolicy: Never
EOF
kubectl logs pg-inspect
kubectl delete pod,pvc,pv pg-inspect pvc-inspect pv-inspectIdentify each disk from the output:
strings output contains |
This disk belongs to |
|---|---|
starvote |
PostgreSQL (starvote) |
bitnami_keycloak |
Keycloak PostgreSQL (keycloak) |
Repeat for the other 8GiB disk.
For each workload, delete the current PVC (which cascades to delete the empty Azure disk — the CSI driver's default reclaim policy is Delete):
# Delete PVCs (this also deletes the empty Azure disks)
kubectl delete pvc -n starvote data-postgresql-0
kubectl delete pvc -n keycloak data-postgresql-0
kubectl delete pvc -n loki data-loki-0
# Delete the orphaned PVs that were bound to those PVCs
kubectl delete pv <pv-name-from-step-3> # starvote postgresql
kubectl delete pv <pv-name-from-step-3> # keycloak postgresql
kubectl delete pv <pv-name-from-step-3> # lokiConfirm the empty Azure disks are gone:
az disk list -g MC_equalvote_equalvote_westus2 --query "[].name" -o table
PostgreSQL (starvote):
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-restored-postgresql
spec:
capacity:
storage: 8Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
csi:
driver: disk.csi.azure.com
volumeHandle: /subscriptions/86f3145a-48cc-4255-8757-dd3104d15e57/resourceGroups/MC_equalvote_equalvote_westus2/providers/Microsoft.Compute/disks/<restored-disk-uuid-for-postgresql>
fsType: ext4
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-postgresql-0
namespace: starvote
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 8Gi
volumeName: pv-restored-postgresql
storageClassName: ""Keycloak PostgreSQL:
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-restored-keycloak-postgresql
spec:
capacity:
storage: 8Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
csi:
driver: disk.csi.azure.com
volumeHandle: /subscriptions/86f3145a-48cc-4255-8757-dd3104d15e57/resourceGroups/MC_equalvote_equalvote_westus2/providers/Microsoft.Compute/disks/<restored-disk-uuid-for-keycloak>
fsType: ext4
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-postgresql-0
namespace: keycloak
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 8Gi
volumeName: pv-restored-keycloak-postgresql
storageClassName: ""Loki:
apiVersion: v1
kind: PersistentVolume
metadata:
name: pv-restored-loki
spec:
capacity:
storage: 10Gi
accessModes:
- ReadWriteOnce
persistentVolumeReclaimPolicy: Retain
storageClassName: ""
csi:
driver: disk.csi.azure.com
volumeHandle: /subscriptions/86f3145a-48cc-4255-8757-dd3104d15e57/resourceGroups/MC_equalvote_equalvote_westus2/providers/Microsoft.Compute/disks/<restored-disk-uuid-for-loki>
fsType: ext4
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: data-loki-0
namespace: loki
spec:
accessModes:
- ReadWriteOnce
resources:
requests:
storage: 10Gi
volumeName: pv-restored-loki
storageClassName: ""Apply them:
kubectl apply -f restored-pvcs.yamlkubectl scale statefulset -n starvote postgresql --replicas=1
kubectl scale statefulset -n keycloak keycloak-postgresql --replicas=1
kubectl scale statefulset -n loki loki --replicas=1Verify each pod mounts the restored PVC and starts without errors:
kubectl logs -n starvote postgresql-0 --tail=20
kubectl logs -n keycloak keycloak-postgresql-0 --tail=20
kubectl logs -n loki loki-0 --tail=20for APP in postgresql keycloak loki kube-prometheus-stack; do
kubectl patch application "$APP" -n argocd --type merge \
-p '{"spec":{"syncPolicy":{"automated":{"prune":false,"selfHeal":true}}}}'
done- Crash-consistent snapshots. Azure Disk Backup does not quiesce the filesystem. PostgreSQL may require WAL replay on startup; this usually succeeds but verify logs.
- Region / zone. Restored disks land in the same region (West US 2). If your AKS node pool uses availability zones, specify one or add a topology constraint to the PV.
- Reclaim policy. The PV uses
Retainto prevent accidental deletion. storageClassName: ""on the PVC is required to bind to an existing PV.- StatefulSet PVC naming. Bitnami PostgreSQL creates PVCs named
data-<release>-0. Confirm the exact name withkubectl get pvc -A.