Etcd backup and restore
This page explains how etcd backups work for a VSHN Managed Talos cluster, which artifacts are needed for disaster recovery, and how to restore a cluster after partial or complete control plane loss.
|
This page is a work in progress. The procedures have been tested on a PoC cluster, including an etcd restore after control plane loss and a full rebuild after the cluster was decommissioned. |
Backups
etcd snapshots
We use component-talos-backup to back up etcd.
The component is enabled through the global defaults for the Talos distribution and deploys a CronJob in the syn-talos-backup namespace.
By default, the CronJob:
-
takes an etcd snapshot through the Talos API every 6 hours,
-
compresses the snapshot with
zstd, -
encrypts it with age for the configured recipient public keys,
-
uploads it to the S3 bucket
<cluster-id>-etcd-backup.
The CronJob reaches the Talos API through the kubernetesTalosAPIAccess machine feature.
This grants the os:etcd:backup role to the syn-talos-backup namespace.
The component doesn’t create the S3 bucket. The Gandalf install workflow creates it together with the other cluster buckets.
|
The snapshots can only be decrypted with the |
Cluster object backups
The cluster-backup component (K8up) also dumps all cluster objects, including Secrets, every hour.
It stores them in a restic repository in the S3 bucket <cluster-id>-cluster-backup.
The restic password is stored in Vault under clusters/kv/<tenant>/<cluster-id>/cluster-backup.
This backup contains the Cluster API secrets needed to rebuild a self-managed cluster: the cluster CA, Talos secrets bundle, kubeconfig, and talosconfig. These secrets otherwise exist only in etcd. An etcd snapshot on its own isn’t enough for a full rebuild because replacement control plane nodes must use machine configurations generated from the original secrets bundle. The object backup provides an etcd-independent copy of this PKI.
Required recovery artifacts
A full recovery needs all four artifacts below. Losing any one of them makes recovery impossible.
| Artifact | Location |
|---|---|
etcd snapshot + |
S3 |
Object backup (PKI copy) + |
S3 |
Cluster configuration |
Project Syn tenant repo (git) |
Tokens, passwords, and SSH keys |
Vault (protect this meta-dependency accordingly) |
|
Neither backup includes persistent volume data. An etcd restore recovers the PV and PVC objects, but the referenced volumes might no longer exist. Backing up application data stored on persistent volumes is the application owner’s responsibility. |
Restore
|
During a restore, never run |
Both scenarios need access to the Talos API of the control plane nodes. That API is only reachable from inside the cluster network, so set up the SSH jumphost and a SOCKS5 proxy first, as described in Restore access to the node network.
Download and decrypt a snapshot
export CLUSTER_ID=c-example-cluster-1234
# find and download the latest snapshot
mc ls s3/${CLUSTER_ID}-etcd-backup/${CLUSTER_ID}/ | sort | tail -1
mc cp s3/${CLUSTER_ID}-etcd-backup/${CLUSTER_ID}/<latest>.snap.zst.age ./backup.zst.age
# decrypt and decompress (requires an age private key of a configured recipient)
age -d -i <age-keyfile> ./backup.zst.age | zstd -d -o ./snap.db
Restore access to the node network
The jumphost details are stored as outputs in the Gandalf state file of the cluster.
Run this from the directory that contains .gandalf-state.json.
ssh_key=$(jq -r '.outputs.ssh_private_key_path.value' .gandalf-state.json)
jumphost_ip=$(jq -r '.outputs.jumphost_public_ip.value' .gandalf-state.json)
jumphost_host_key=$(jq -r '.outputs.jumphost_ed25519_hostkey.value' .gandalf-state.json)
<guided-setup-repo>/cloudscale/scripts/socks-proxy \
"${ssh_key}" 127.0.0.1 "${jumphost_ip}" "${jumphost_host_key}"
export https_proxy=socks5://127.0.0.1:19000
Scenario 1: Etcd lost, nodes intact
Use this procedure when etcd quorum is lost, for example after data corruption or a failed upgrade, but the control plane VMs still exist.
-
Wipe only the ephemeral partition on all control plane nodes.
talosctl reset -n <cp1>,<cp2>,<cp3> --graceful=false --reboot \ --system-labels-to-wipe EPHEMERALUse
--system-labels-to-wipe EPHEMERAL, not--wipe-mode all. The latter also removes the boot and state partitions, leaves the VMs with no bootable device, and forces a full machine replacement. WipingEPHEMERALremoves/var, including etcd, while keeping the operating system and machine configuration. -
After reboot, the configured nodes wait for etcd to be bootstrapped. Restore the snapshot on one node.
talosctl -e <cp1> -n <cp1> bootstrap --recover-from=./snap.db -
The other control plane nodes join the restored etcd automatically. Verify the cluster.
talosctl -e <cp1> -n <cp1>,<cp2>,<cp3> etcd members # expect 3 members and no learners kubectl get nodes
Scenario 2: Full cluster loss
Use this procedure when all cluster control plane nodes have been lost. Run the regular Gandalf install workflow to recreate the infrastructure, but replace the PKI setup and etcd bootstrap steps as described below.
This section will be replaced by a dedicated Gandalf talos-restore workflow.
|
Prerequisites
-
A local
gandalfinstallation and the guided setup repo -
talosctl,clusterctl,kind,kubectl,age,zstd,restic, andjq -
commodorewith its helper tools (commodore tool install --missing) -
Access to Vault, the Project Syn tenant repo, and the cluster’s S3 buckets
-
The
ageprivate key of a configured backup recipient
Recover the artifacts
-
Download and decrypt the latest etcd snapshot as described above.
-
Extract the Cluster API secrets from the object backup.
export RESTIC_PASSWORD=$(vault kv get -field=password clusters/kv/<tenant>/${CLUSTER_ID}/cluster-backup) restic -r s3:<s3-endpoint>/${CLUSTER_ID}-cluster-backup --no-lock \ dump latest /syn-cluster-backup-object-dumper.tar > dump.tar tar xf dump.tar ./split/syn-cluster-api/Secret.json -O > capi-secrets.jsonl -
Build
preseed.yamlwith the five required secrets and strip their object metadata.jq -s '{apiVersion:"v1", kind:"List", items:[ .[] | (.items? // [.])[]? | select(.kind == "Secret") | select(.metadata.name | IN(env.CLUSTER_ID+"-ca", env.CLUSTER_ID+"-talos", env.CLUSTER_ID+"-talosconfig", env.CLUSTER_ID+"-kubeconfig", "capcs-cloudscale")) | del(.metadata.ownerReferences, .metadata.uid, .metadata.resourceVersion, .metadata.creationTimestamp, .metadata.managedFields, .metadata.generation, .metadata.annotations, .status) ]}' capi-secrets.jsonl > preseed.yaml -
Extract the admin talosconfig and kubeconfig.
jq -r '.items[]|select(.metadata.name==env.CLUSTER_ID+"-talosconfig").data.talosconfig' preseed.yaml | base64 -d > talosconfig-restored jq -r '.items[]|select(.metadata.name==env.CLUSTER_ID+"-kubeconfig").data.value' preseed.yaml | base64 -d > restored.kubeconfig
Run the install workflow with interventions
The table below shows which workflow steps to run through Gandalf, perform manually, or skip. The cluster is restored after the reconciliation, so the last two workflow steps aren’t needed.
| Press n in the Gandalf UI to mark the current step as completed and move on without running it. |
| Workflow step | How | Notes |
|---|---|---|
1 to 9: prerequisites through the S3 objects user and buckets |
Gandalf |
Reuse the surviving infrastructure. |
10: |
Skip |
This step regenerates the existing backup passwords. |
11 to 16: schematic through |
Gandalf |
Enter the exact versions used by the original cluster. |
17: |
Manual |
Perform intervention 1 below, then press n. |
18 to 20: floating IPs, Project Syn configuration, |
Gandalf |
|
21: |
Manual |
Perform intervention 2 below, then press n. |
22: |
Skip |
The restored cluster already contains cert-manager and all Cluster API providers. |
23: |
Manual |
Follow Reconcile the self-managed state. |
24 and 25: |
Skip |
Delete the |
-
Run the Gandalf cloudscale install workflow. The infrastructure steps (private network, subnet, router, S3 objects user, Talos image, jumphost, and
kindcluster) are idempotent and reuse resources that still exist.Use the exact Talos, Kubernetes, and image customization versions from before the loss. Using different versions would turn the restore into an uncontrolled upgrade. Upgrade only after the cluster is healthy.
The API floating IP should also still exist because the cluster configuration and DNS refer to it. If it was released, for example after losing a full cloudscale zone, the workflow allocates a new one and writes it to the cluster’s Project Syn configuration, but you have to point the
apiDNS record at the new address yourself.Skip the
And I set secrets in Vaultstep. It doesn’t check for existing values and regenerates thecluster-backupandglobal-backuppasswords. Vault keeps previous versions, so an overwritten password can be rolled back, but it’s easier to avoid the detour. -
Perform the
And I install cluster API in the Kind clusterstep manually and skip it in Gandalf.Use the provider versions found in the backed-up
Providerobjects. Apply the patched cacppt CRDs and image, and pre-seed the original PKI.export KUBECONFIG=./seed.kubeconfig clusterctl init -n syn-cluster-api \ --core cluster-api:<version> --bootstrap talos:<version> \ --control-plane talos:<version> --infrastructure cloudscale-ch-cloudscale:<version> # patched cacppt CRDs + image (from the guided setup repo), then verify the image kubectl apply --server-side --force-conflicts -f cloudscale/manifests/cacppt-crds.yaml \ -f cloudscale/manifests/cacppt-clusterrole.yaml -f cloudscale/manifests/cacppt-webhooks.yaml kubectl -n syn-cluster-api set image deploy/cacppt-controller-manager manager=<patched-cacppt-image> # let the bootstrap provider adopt the original secrets kubectl -n syn-cluster-api apply -f preseed.yamlDon’t configure a proxy for the Talos CAPI control plane provider (cacppt), and don’t run this step through Gandalf. The regular workflow points cacppt at the SOCKS5 proxy, which lets the provider reach the new nodes and bootstrap an empty etcd as soon as the first machine starts. Without proxy configuration, cacppt can’t reach the nodes and the snapshot stays applicable.
-
Continue the workflow through the floating IP, Project Syn configuration, and cluster creation steps. The infrastructure provider creates the new control plane VMs. Because the original PKI is already present, it generates their machine configurations from the original secrets bundle. The nodes should reach the etcd state
Preparingand wait for bootstrap. -
Replace the
And wait for the Talos cluster to become readystep with the snapshot restore.export TALOSCONFIG=./talosconfig-restored # verify that the old talosconfig authenticates against a new node talosctl -e <new-cp1> -n <new-cp1> service etcd # expect STATE=Preparing # restore the snapshot on one node only talosctl -e <new-cp1> -n <new-cp1> bootstrap --recover-from=./snap.db talosctl -e <new-cp1> -n <new-cp1>,<new-cp2>,<new-cp3> etcd members # expect 3 members and no learnersIf the authentication check fails with a TLS error, stop. It means the new nodes didn’t adopt the original PKI.
-
Freeze self-management as soon as the API server becomes available. The restored snapshot starts the cluster’s own Cluster API controllers and Argo CD. Scaling them down prevents them from reconciling stale state while the
kindcluster still manages the replacement machines.export KUBECONFIG=./restored.kubeconfig kubectl -n syn-argocd-operator scale deploy --all --replicas=0 kubectl -n syn scale deploy,sts --all --replicas=0 kubectl -n syn-cluster-api scale deploy --all --replicas=0If the cluster’s floating IPs were lost, you’ll need to adjust the API IP in restored.kubeconfig. -
Verify the restore.
kubectl get nodes # new control plane nodes and workers should be Ready kubectl get namespaces # existing ages confirm that this is the restored state kubectl get pods -A # running workloads confirm that service account tokens are valid kubectl get csr --field-selector spec.signerName=kubernetes.io/kubelet-serving # auto-approved by the restored kubelet-serving-cert-approver
Reconcile the self-managed state
The restored etcd contains Cluster API objects for machines that no longer exist.
The kind cluster contains the current objects for the replacement machines.
-
Set the kubeconfig paths used throughout the reconciliation.
export RESTORED_KUBECONFIG=./restored.kubeconfig export SEED_KUBECONFIG=./seed.kubeconfig -
On the restored cluster, set
failurePolicytoIgnorefor the cabpt, cacppt, capcs, and capi webhook configurations. Their deployments are still scaled down, so aFailpolicy would block the following operations.for webhook in $(kubectl --kubeconfig "${RESTORED_KUBECONFIG}" \ get validatingwebhookconfiguration,mutatingwebhookconfiguration -o name \ | grep -iE 'cabpt|cacppt|capcs|capi'); do kubectl --kubeconfig "${RESTORED_KUBECONFIG}" get "${webhook}" -o json \ | jq '(.webhooks[].failurePolicy)="Ignore"' \ | kubectl --kubeconfig "${RESTORED_KUBECONFIG}" replace -f - doneOnce the Cluster API providers are managed by a Commodore component, deleting the webhook configurations becomes an alternative to this step, because Argo CD recreates them. -
Delete the stale Cluster API resources in
syn-cluster-apion the restored cluster. Clear their finalizers first because the controllers that normally process them are scaled down. This also prevents them from touching any VMs.for resource in machine cloudscalemachine taloscontrolplane machineset \ machinedeployment cluster cloudscalecluster cloudscalemachinetemplate \ talosconfig talosconfigtemplate clusterresourceset clusterresourcesetbinding; do for object in $(kubectl --kubeconfig "${RESTORED_KUBECONFIG}" \ -n syn-cluster-api get "${resource}" -o name 2>/dev/null); do kubectl --kubeconfig "${RESTORED_KUBECONFIG}" -n syn-cluster-api \ patch "${object}" --type merge -p '{"metadata":{"finalizers":null}}' || true kubectl --kubeconfig "${RESTORED_KUBECONFIG}" -n syn-cluster-api \ delete "${object}" --wait=false || true done done # expect no resources kubectl --kubeconfig "${RESTORED_KUBECONFIG}" -n syn-cluster-api \ get cluster,taloscontrolplane,machine,cloudscalemachineNotFounderrors during the loop are harmless. They mean that cascading deletion already removed the resource. -
Give cacppt on the
kindcluster access to the nodes by configuring the SOCKS5 proxy. At this point etcd is already bootstrapped, and Talos rejects a second bootstrap. The provider can safely observe the cluster and mark the source control plane as initialized, whichclusterctl moverequires.# verify that the SOCKS tunnel is running # restart it through the guided setup if needed pgrep -af 'ssh.*-D.*19000' KIND_NODE_IPS=$(kubectl --kubeconfig "${SEED_KUBECONFIG}" get nodes \ -ojsonpath='{.items[*].status.addresses[?(@.type=="InternalIP")].address}' \ | tr ' ' ',') kubectl --kubeconfig "${SEED_KUBECONFIG}" -n syn-cluster-api \ set env deploy/cacppt-controller-manager \ http_proxy=socks5://<kind-gateway-ip>:19000 \ https_proxy=socks5://<kind-gateway-ip>:19000 \ no_proxy=10.244.0.0/16,10.96.0.0/16,${KIND_NODE_IPS} -
Record the current machines and provider IDs, then move the Cluster API objects from
kindto the restored cluster.kubectl --kubeconfig "${SEED_KUBECONFIG}" -n syn-cluster-api get machine -o wide clusterctl move -n syn-cluster-api --kubeconfig "${SEED_KUBECONFIG}" \ --to-kubeconfig "${RESTORED_KUBECONFIG}" -
Set the webhook
failurePolicyback toFailand scale the Cluster API deployments back up.for webhook in $(kubectl --kubeconfig "${RESTORED_KUBECONFIG}" \ get validatingwebhookconfiguration,mutatingwebhookconfiguration -o name \ | grep -iE 'cabpt|cacppt|capcs|capi'); do kubectl --kubeconfig "${RESTORED_KUBECONFIG}" get "${webhook}" -o json \ | jq '(.webhooks[].failurePolicy)="Fail"' \ | kubectl --kubeconfig "${RESTORED_KUBECONFIG}" replace -f - done kubectl --kubeconfig "${RESTORED_KUBECONFIG}" -n syn-cluster-api \ scale deploy --all --replicas=1 -
Verify the adoption before continuing.
kubectl --kubeconfig "${RESTORED_KUBECONFIG}" -n syn-cluster-api \ get machine -o wideAll machines must become
Ready, and their provider IDs must match the values recorded on thekindcluster. Confirm in cloudscale that only the expected replacement VMs exist, which are three control plane nodes and the configured number of workers. No additional VMs may appear. -
Scale Argo CD and steward back up.
kubectl --kubeconfig "${RESTORED_KUBECONFIG}" -n syn-argocd-operator \ scale deploy --all --replicas=1 kubectl --kubeconfig "${RESTORED_KUBECONFIG}" -n syn \ scale deploy,sts --all --replicas=1 -
Delete the
kindcluster once the machine adoption and the controller restart are verified.kind delete cluster -n capi-seed-${CLUSTER_ID}
Post-restore cleanup
-
Delete PVCs and PVs that reference volumes which no longer exist. The affected workloads will provision new, empty volumes (see the note about PV data above).
-
Point the ingress DNS records to the floating IP allocated during the install workflow. Don’t use a load balancer VIP because it changes whenever the load balancer is recreated.
-
Update any external allow-lists with the new public IP of the recreated egress router.
-
Check for old cloudscale load balancers in the
errorstate and delete those that aren’t claimed by a service. -
Make sure backups resume. A new snapshot should appear in
<cluster-id>-etcd-backupafter the next 6-hour interval, and the K8up schedule should run again. -
Let Argo CD sync and review the remaining drift. The Talos image slug shouldn’t drift at this point. If it does, Cluster API replaces every node, so verify the cluster’s Project Syn configuration instead of letting the sync proceed.
-
Delete all local files that contain secret material, including snapshots, preseed files, talosconfig, kubeconfig, and dumps.