How-To Guide
Restore etcd from an S3 Snapshot (Disaster Recovery)¶
Goal: Restore a K3S control plane from an etcd snapshot stored in S3, either after losing a single control plane node or after losing the whole control-plane quorum (full disaster recovery).
Time: ~20–40 minutes (single-server reset + rejoin of the remaining CPs)
Critical: An etcd snapshot restores namespaces, workloads, and CRs — but Secrets
are encrypted at rest and their decryption key is NOT in the snapshot. You MUST
have the encryption-config.json backup (see
Back Up K3S Encryption Keys) or every Secret in the cluster
is unrecoverable. This was verified on 2026-07-02 (see Verification).
What the etcd snapshot contains — and what it does not¶
Recoverable from the etcd S3 snapshot |
NOT in the snapshot — back up separately |
|---|---|
Namespaces, Deployments, all workload state |
Secret values (AES-CBC encrypted at rest) |
CRs (ArgoCD Applications, CNPG Clusters, …) |
The encryption key → |
Node objects, RBAC, ConfigMaps |
The cluster join token → |
ArgoCD then re-installs operators/CRDs |
|
For a survivable DR you need all four of: the S3 etcd snapshot, the
encryption-config.json, the cluster token, and the git-tracked tofu config.
Where the backups live¶
etcd S3 snapshots — bucket per cluster, region = the endpoint region. For helix-production:
backup-etcd-prod-helixpass-anauonnbg1.your-objectstorage.com. Uploaded by every control plane every 12h (etcd-snapshot-schedule-cron = "0 */12 * * *"), retention 10 (configured inkube-hetzner/module/main.tf,etcd_s3_backup).Snapshot name pattern:
<name>-<cp-hostname>-<unixtime>(one object per CP per run).The S3 credentials + endpoint are in the cluster’s
.env(TF_VAR_hetzner_s3_access_key/_secret_key,TF_VAR_etcdbackup_s3_endpoint).
Prerequisites¶
The
encryption-config.jsonbackup for this cluster (KeePass entry<cluster>-k3s-encryption-config) — see Back Up K3S Encryption Keys.The cluster join token (from any surviving CP, or your bootstrap backup).
SSH access to at least one (target) control plane node.
The cluster’s
.envsourced for S3 credentials.
Step 1: Pick a snapshot¶
List from any surviving CP:
ssh root@<cp-ip> "k3s etcd-snapshot ls"
Or list the S3 bucket directly (no CP needed — useful in a full-region loss). There is
no mc/aws-cli on the nodes and /usr/bin/mc is Midnight Commander — use boto3:
cd kube-hetzner/clusters/<cluster> && source .env
uv run --with boto3 python3 - <<'PY'
import os, boto3
ep = "https://" + os.environ["TF_VAR_etcdbackup_s3_endpoint"]
region = os.environ["TF_VAR_etcdbackup_s3_endpoint"].split(".")[0]
s3 = boto3.client("s3", endpoint_url=ep, region_name=region,
aws_access_key_id=os.environ["TF_VAR_hetzner_s3_access_key"],
aws_secret_access_key=os.environ["TF_VAR_hetzner_s3_secret_key"])
b = os.environ["TF_VAR_etcdbackup_s3_bucket"]
for k in sorted(s3.list_objects_v2(Bucket=b).get("Contents", []), key=lambda x: x["LastModified"]):
print(k["LastModified"].strftime("%Y-%m-%d %H:%M"), f'{k["Size"]/1e6:.1f}MB', k["Key"])
PY
Step 2: Stop k3s on ALL other control plane nodes¶
The reset must happen on exactly one node; the others must be down so they don’t fight the new single-member etcd.
# On EVERY control plane EXCEPT the chosen restore target:
ssh root@<other-cp-ip> "systemctl stop k3s"
Step 3: Reset + restore on ONE control plane node¶
Two options — the file-path method (proven in the drill below) is the robust default.
Option A — download the snapshot, restore from a local file:
# On your workstation: download the snapshot (boto3 as in Step 1, s3.download_file(...))
# then copy it to the target CP:
scp <snapshot> root@<target-cp-ip>:/tmp/
ssh root@<target-cp-ip>
k3s server \
--cluster-reset \
--cluster-reset-restore-path=/tmp/<snapshot> \
--token="<cluster-token>"
Option B — restore directly from S3 (the CP already carries the etcd-s3 config in
/etc/rancher/k3s/config.yaml, so only the path is needed):
ssh root@<target-cp-ip>
k3s server \
--cluster-reset \
--cluster-reset-restore-path=s3://<bucket>/<snapshot> \
--token="<cluster-token>"
--token is mandatory — without it k3s aborts with
server/token does not exist, please pass --token to complete the restoration.
This is a one-shot: k3s restores etcd, resets membership to a single node, backs up the
old TLS dir, and exits with:
Managed etcd cluster membership has been reset, restart without --cluster-reset flag now.
Step 4: Restore the encryption key BEFORE the first normal start¶
If this node’s disk is intact (single-node loss), the key is still in place — skip.
If restoring onto a fresh node (full DR), copy the backed-up encryption-config.json
into place now, otherwise the API server comes up but cannot decrypt any Secret:
# On the target CP, before starting k3s normally:
install -m 600 -o root -g root /path/to/encryption-config.json \
/var/lib/rancher/k3s/server/cred/encryption-config.json
Step 5: Start the restore target normally¶
# On the target CP:
systemctl start k3s # (installed nodes) — starts without --cluster-reset
# verify:
k3s kubectl get ns
k3s secrets-encrypt status # -> Enabled, hashes match
k3s kubectl get secret -A # -> Secrets are LISTED AND DECRYPTABLE (proves Step 4)
Step 6: Rejoin the other control plane nodes¶
On each other CP, wipe its stale etcd member and rejoin the restored node:
ssh root@<other-cp-ip>
systemctl stop k3s
rm -rf /var/lib/rancher/k3s/server/db # remove stale etcd member data
systemctl start k3s # rejoins; --server already points at the cluster
Rejoin one at a time, waiting for each to become Ready and for etcd to report the
new member healthy before doing the next, to preserve quorum. Verify:
k3s kubectl get nodes
curl -sk https://127.0.0.1:2381/metrics | grep etcd_server_id # on each CP
ssh root@<cp> "journalctl -u k3s | grep 'added member'"
Verification (restore drill)¶
Tested: 2026-07-02 · Cluster: helix-production · k3s: v1.34.9+k3s1
Snapshot: a live k3s etcd-snapshot save from control-fsn1, pulled from
backup-etcd-prod-helixpass-anau (nbg1).
Method (non-destructive, does NOT touch live prod): restore-to-scratch in a throwaway single-node k3s in Docker — the definitive proof that the S3 snapshot is valid and restorable end-to-end. Repeatable drill:
SNAP=/tmp/<downloaded-snapshot>; TOKEN=<prod cluster token>
docker volume rm k3s-dr-data 2>/dev/null
# Phase 1 — cluster-reset-restore (one-shot, exits):
docker run --rm --privileged -e K3S_TOKEN="$TOKEN" \
-v k3s-dr-data:/var/lib/rancher/k3s -v "$(dirname $SNAP)":/snap:ro \
rancher/k3s:v1.34.9-k3s1 \
server --cluster-reset --cluster-reset-restore-path=/snap/$(basename $SNAP) --token="$TOKEN"
# Phase 2 — start normally, then inspect:
docker run -d --name k3s-dr-test --privileged \
-v k3s-dr-data:/var/lib/rancher/k3s \
rancher/k3s:v1.34.9-k3s1 server --disable=traefik,servicelb,metrics-server
docker exec k3s-dr-test kubectl get ns
docker exec k3s-dr-test kubectl get nodes
# teardown:
docker rm -f k3s-dr-test; docker volume rm k3s-dr-data
Outcome: ✅ etcd restored (current-rev: 16485638); the scratch API server served the
real helix-production state — all 8 node objects (control-fsn1/hel1/nbg1, witness-nbg1,
fsn1-1/2, hel1-1/2), all 17 namespaces (argocd, cnpg-system, crossplane-system,
longhorn-system, strimzi, ingress-system, traefik, …), and 7 ArgoCD Applications.
Key finding: in the scratch restore, kubectl get secret -A returned nothing and
CNPG Clusters showed 0 — because (a) the throwaway node generated a fresh
encryption-config.json so the prod-encrypted Secrets were undecryptable, and (b) operator
CRDs aren’t installed on a bare node. Both are expected. The (a) case is the whole reason
Step 4 exists: a full-DR restore onto fresh nodes MUST restore the original
encryption-config.json, or all Secrets are lost. Snapshot integrity itself was proven
regardless.
Troubleshooting¶
server/token does not exist— you omitted--tokenin Step 3. It is mandatory.API up but
kubectl get secretempty / decryption errors — theencryption-config.jsondoesn’t match the snapshot’s key (Step 4 was skipped or used the wrong key). Restore the correct key from the KeePass backup.Scheduled S3 snapshots missing while on-demand works — the k3s server cached a stale S3 client (e.g. bucket created after k3s started, so it cached “bucket does not exist”). A control-plane restart drops the cache. See k3s upgrade failures for the restart-via-tofu path.