Backup and restore tooling for a five-node bare-metal Kubernetes cluster running production workloads. Backups go off-site to Cloudflare R2 first, with a secondary copy on network-attached storage.
The design assumption is that the control-plane node is the most likely thing to fail — so the off-site copy is mandatory and the local copy is a convenience, not the safety net. Storage is deliberately attached to a worker node rather than the control plane, so a failing disk cannot take the API server down with it.
| Component | Contents | Recoverable from |
|---|---|---|
| etcd snapshot | Every API object in the cluster | etcdutl snapshot restore |
| PKI | Cluster CA, service-account keys, component certs | Direct copy into /etc/kubernetes/pki |
| kubeconfigs | admin.conf, kubelet.conf, controller-manager.conf, scheduler.conf, super-admin.conf |
Direct copy |
| Static pod manifests | Control-plane component definitions | Direct copy |
| Resource YAML export | Per-namespace and cluster-scoped objects, including CRDs | kubectl apply for selective restore |
SHA256SUMS |
Integrity manifest for every file above | sha256sum -c |
| Vault storage | Tarball of the Vault file backend + checksum (separate schedule) | Untar into the Vault PV; unseals via KMS |
| PostgreSQL dumps | Per-application pg_dump output + checksum (separate schedule) |
psql restore |
The PKI matters as much as the snapshot. Without the original ca.key and
sa.key, a restored control plane issues a new certificate authority, every
worker's kubelet credentials become invalid, and each node needs kubeadm reset
and a full rejoin. With them, workers reconnect on their own.
- Longhorn volume data. etcd holds the Longhorn CRDs describing each volume; the replica data lives on worker disks. Restoring etcd onto rebuilt workers gives you volume definitions pointing at data that no longer exists. Application data is protected separately by the database and Vault jobs.
- Vault depends on the KMS key. The nightly tarball is ciphertext under Vault's barrier key. AWS KMS auto-unseal decrypts it only while the KMS key and the IAM credentials that use it still exist. Lose those and the backup is unreadable.
- Container images. Pulled fresh on restore; anything built locally and never pushed to a registry will not return.
control plane worker node
├── backup-k8s.sh (root cron) ├── USB 3.0 ── 2TB external drive
├── /etc/kubernetes/pki (local) └── /srv/backup (NFS export)
└── /mnt/backup ─────── NFS ───────────────────┘
└────── rclone ──────► Cloudflare R2
The backup script runs on the control plane because it needs local access to
/etc/kubernetes/pki and the etcd static pod. Only the destination is remote.
It used to be, on a bus-powered USB 2.0 hub shared with a keyboard and mouse. A 2.5" spinning drive can draw more current at spin-up than that arrangement supplies, and the drive began failing to reach operating speed:
sd 2:0:0:0: [sda] Spinning up disk...
..............................not responding...
sd 2:0:0:0: [sda] tag#19 timing out command, waited 180s
I/O error, dev sda, sector 3 op 0x0:(READ)
The consequences were not confined to the backup. Processes blocked on that
device entered uninterruptible sleep, which included modprobe. With module
loading wedged, iptable_nat never autoloaded, kube-proxy created empty NAT
chains, and every ClusterIP became unreachable — metrics-server, the MetalLB
speaker and the monitoring agent all crashlooped with dial tcp 10.96.0.1:443: i/o timeout. A failing peripheral took out cluster networking.
Moved to a direct USB 3.0 port on a worker, the same drive enumerates in about a second and has been stable since.
Takeaway: storage that the control plane depends on should not share a power budget with peripherals, and ideally should not be attached to the control plane at all.
sudo apt install -y nfs-kernel-server
sudo mkdir -p /srv/backup/etc/fstab — nofail so a missing drive never blocks boot:
UUID=<DRIVE_UUID> /srv/backup ext4 defaults,nofail 0 2
/etc/exports:
/srv/backup <LAN_SUBNET>/24(rw,sync,no_subtree_check,no_root_squash)
no_root_squash is required because the backup runs as root and writes
root-owned files. It means any root user on the subnet can write as root to this
export — acceptable on an isolated network with the subnet restriction, but it is
a real trade-off worth stating rather than glossing over.
sudo exportfs -ra
sudo systemctl enable --now nfs-kernel-server<NFS_SERVER_IP>:/srv/backup /mnt/backup nfs _netdev,nofail,soft,timeo=50,retrans=2 0 0
soft is not optional. With the default hard, if the NFS server goes away
mid-write, processes block in uninterruptible sleep indefinitely — the same D-state
failure that caused the outage described above, just relocated. soft makes I/O
return an error instead.
Verify the entry before relying on it at boot:
sudo umount /mnt/backup && sudo mount -a && df -h /mnt/backup- Kubernetes provisioned with
kubeadm, etcd 3.6 (verified on v1.36.5) rclone≥ 1.61 — earlier versions (including the Ubuntu 24.04 package, 1.60.1) fail every R2 object upload with501 NotImplemented, succeeding only on internal retry and filling the log with errors on every successful run:curl https://rclone.org/install.sh | sudo bash sudo apt-mark hold rclone # stop apt reinstating the old package
etcdutlon the host, matching the cluster's etcd version, for snapshot verification and restore. etcd 3.6 removedetcdctl snapshot status/restore, and the etcd binaries live inside the static pod, so a rebuilt host will not have them until installed:ETCD_VER=3.6.8 curl -sL https://github.com/etcd-io/etcd/releases/download/v${ETCD_VER}/etcd-v${ETCD_VER}-linux-amd64.tar.gz | tar xz -C /tmp sudo install /tmp/etcd-v${ETCD_VER}-linux-amd64/etcdutl /usr/local/bin/
- An R2 bucket and an S3-compatible API token scoped to it
git clone https://github.com/catdevops1/k8s-backup-pub.git
cd k8s-backup-pub
sudo ./install.sh
sudo $EDITOR /etc/default/backup-k8s
sudo backup-k8s.shinstall.sh checks dependencies, installs both scripts to /usr/local/bin, and
creates /etc/default/backup-k8s from examples/config.example if it does not
exist. An existing config file is never overwritten.
Both scripts read /etc/default/backup-k8s (mode 600). Real bucket names and
paths live there, never in the scripts, so the same file that runs in production
is the one in this repository:
R2_REMOTE="r2:<BUCKET>/cluster"
LOCAL_MOUNT="/mnt/backup"
LOCAL_ROOT="/mnt/backup/k8s-backups"
LOCAL_RETENTION_DAYS=30backup-k8s.sh refuses to run until R2_REMOTE is set. See
examples/config.example for every option.
R2 credentials live in /root/.config/rclone/rclone.conf (mode 600), because
the backup runs from the root crontab:
[r2]
type = s3
provider = Cloudflare
access_key_id = <ACCESS_KEY_ID>
secret_access_key = <SECRET_ACCESS_KEY>
endpoint = https://<ACCOUNT_ID>.r2.cloudflarestorage.com
region = autoThese are deliberately not stored in Vault. In the scenario where you need the backups, Vault may be part of what is down — a credential you can only retrieve from a healthy cluster is useless for restoring a broken one.
sudo rclone lsd r2:<BUCKET> # verifyr2:<BUCKET>/
├── cluster/YYYYMMDD-HHMMSS/ etcd + PKI + manifests + resource export
├── vault/ Vault file-backend tarballs + .sha256
└── db/<app>/ PostgreSQL dumps + .sha256
Retention is enforced by R2 lifecycle rules rather than by the script, so nothing has to reach out and delete remote objects:
| Prefix | Retention |
|---|---|
cluster/ |
30 days |
db/ |
60 days |
vault/ |
set per your recovery window |
| local NFS | 30 days |
Database dumps are kept longer off-site than the cluster snapshots, since they are small and are the only copy of application data. Cluster retention is capped at 30 days to stay within the R2 free tier at roughly 168 MB per daily snapshot.
Lifecycle rules must specify the prefix. A rule with no prefix applies to the
whole bucket, and where rules overlap the shortest retention wins — so an
unprefixed 30-day rule silently overrides a 60-day rule on db/.
All jobs run from the root crontab, staggered so they never compete for the same storage:
# Cluster backup (this repo)
0 1 * * * /usr/local/bin/backup-k8s.sh >> /var/log/k8s-backup.log 2>&1
# Vault file backend (lives on Longhorn, not in etcd)
30 1 * * * /usr/local/bin/backup-vault.sh >> /var/log/vault-backup.log 2>&1
# Per-application database dumps
0 2 * * * /usr/local/bin/backup-db-<app1>.sh >> /var/log/db-backup-<app1>.log 2>&1
10 2 * * * /usr/local/bin/backup-db-<app2>.sh >> /var/log/db-backup-<app2>.log 2>&1flock in the cluster script prevents overlapping runs.
The cluster, Vault and database scripts fail loudly and early:
set -euo pipefailthroughout- Mount guard.
/mnt/backupis an ordinary directory when nothing is mounted there. Without an explicitmountpoint -qcheck, a dump writes to the local root filesystem and reports success — this is how a backup failure goes unnoticed for weeks. The cluster script downgrades this to a warning, since R2 already has the data. - Verification before trust. The etcd snapshot is checked with
etcdutl snapshot statusbefore upload; a snapshot that will not parse is never shipped.ca.crt,ca.keyandsa.keyare asserted present. .partstaging. Database dumps are written to a temporary name and only renamed oncepg_dumpemits its completion marker, so a truncated dump never occupies the final filename.- R2 failure is fatal. A run that produced no off-site copy is not a successful run. Uploaded file count is compared against local file count.
- Timeouts. Every external call is wrapped in
timeout, so failing hardware cannot hang the job indefinitely. - Cleanup on failure. The working directory is
mktempmode 700 with atrap, since it holds the cluster CA private key in plaintext while the job runs.
A healthy run, roughly 55 seconds:
[00:00:01] === Backup 20260830-000001 started ===
[00:00:01] Taking etcd snapshot...
[00:00:02] Verifying snapshot...
+----------+----------+------------+------------+
| HASH | REVISION | TOTAL KEYS | TOTAL SIZE |
+----------+----------+------------+------------+
| e36ed15f | 90242011 | 4327 | 84 MB |
+----------+----------+------------+------------+
[00:00:02] Copying PKI and kubeconfigs...
[00:00:02] Copying static pod manifests...
[00:00:02] Exporting cluster resources...
[00:00:16] Uploading to r2:<BUCKET>/cluster/20260830-000001 ...
[00:00:26] R2 upload verified: 53 files
[00:00:26] Copying to /mnt/backup/k8s-backups/20260830-000001 ...
[00:00:55] Local copy complete (1.7T free)
[00:00:55] === Backup 20260830-000001 completed successfully ===
A folder in the bucket proves nothing. Before every cluster upgrade, each layer is checked by hand:
# etcd: checksum, opens cleanly, key count close to live etcd
grep etcd-snapshot.db SHA256SUMS | sha256sum -c -
etcdutl snapshot status etcd-snapshot.db -w table
sudo ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key \
get / --prefix --keys-only | grep -c .
# Vault: checksum and KV data present
sha256sum -c vault-data-<date>.tgz.sha256
tar tzf vault-data-<date>.tgz | grep -c '^data/logical/'
# PostgreSQL: dump ran to completion
rclone cat r2:<BUCKET>/db/<app>/<dump>.sql | tail -3Newer pg_dump releases end the file with \unrestrict <token> instead of the
old "dump complete" comment — seeing it means the dump finished. Restore with a
psql at least as new as the pg_dump that wrote it.
Place the PKI before running kubeadm init, not after.
kubeadm init generates a fresh CA and then signs every kubeconfig against it.
Restoring the old PKI afterwards invalidates all of them and nothing
authenticates. When ca.crt and ca.key already exist, kubeadm detects and
reuses them instead of generating new ones.
New host, same hostname and IP as the original: worker kubelet.conf files
and the API server certificate SANs both reference it.
# 1. Matching Kubernetes version — a downgrade will not start against a
# newer etcd snapshot. Check backup-info.txt for the version.
sudo apt install -y kubelet=1.36.5-* kubeadm=1.36.5-* kubectl=1.36.5-*
sudo apt-mark hold kubelet kubeadm kubectl
# plus etcdutl — see Requirements
# 2. Retrieve and verify
sudo rclone copy r2:<BUCKET>/cluster/<TIMESTAMP> /restore
cd /restore && sha256sum -c SHA256SUMS
# 3. PKI and kubeconfigs FIRST
sudo mkdir -p /etc/kubernetes/pki
sudo cp -r /restore/pki-backup/* /etc/kubernetes/pki/
sudo cp /restore/*.conf /etc/kubernetes/
# 4. etcd data
sudo etcdutl snapshot restore /restore/etcd-snapshot.db \
--data-dir=/var/lib/etcd \
--name=<MASTER_HOSTNAME> \
--initial-cluster=<MASTER_HOSTNAME>=https://<MASTER_IP>:2380 \
--initial-advertise-peer-urls=https://<MASTER_IP>:2380
sudo chown -R root:root /var/lib/etcd
# 5. Initialise against the restored state
sudo kubeadm init \
--apiserver-advertise-address=<MASTER_IP> \
--control-plane-endpoint=<MASTER_IP> \
--pod-network-cidr=10.244.0.0/16 \
--ignore-preflight-errors=DirAvailable--var-lib-etcdSteps 2–4 are what restore-k8s.sh --prepare-only <TIMESTAMP> automates.
Workers reconnect on their own; sudo systemctl restart kubelet on each if not.
etcd lives on the control plane, so no cluster state is at risk. Reboot and wait. If the node needs to rejoin:
sudo kubeadm reset -f
sudo rm -rf /etc/cni/net.d /var/lib/cni
sudo kubeadm join <MASTER_IP>:6443 --token <TOKEN> \
--discovery-token-ca-cert-hash sha256:<HASH>If the node hosting the NFS export is the one that failed, /mnt/backup on the
control plane returns I/O errors rather than hanging — that is what soft buys —
and the cluster backup continues to R2 with a warning.
sudo rclone copy r2:<BUCKET>/cluster/<TIMESTAMP>/resources /restore/resources
kubectl apply -f /restore/resources/ns-<namespace>.yamlFor GitOps-managed workloads it is usually cleaner to let ArgoCD resync from git than to reapply an export.
Pull the dump, check its .sha256, then restore with:
psql -v ON_ERROR_STOP=1 ... < <app>_YYYYMMDD_HHMMSS.sqlON_ERROR_STOP=1 is essential. Without it psql continues past failures and exits
0, leaving a half-restored database that reports success.
Rebuild the control plane per Scenario 1, then kubeadm reset and rejoin every
worker. Application manifests return from git via ArgoCD, database contents from
the db/ dumps, and Vault from the vault/ tarball — provided the KMS key used
for auto-unseal still exists. Longhorn volumes are recreated empty.
kube-proxy cannot program NAT rules without iptable_nat. Linux normally
autoloads it on demand, but if module loading is blocked — a wedged block device
holding modprobe in uninterruptible sleep will do it — kube-proxy creates empty
chains, every ClusterIP becomes unreachable, and anything talking to 10.96.0.1
fails with i/o timeout. The symptom looks like a networking fault; the cause is
storage.
# /etc/modules-load.d/k8s-modules.conf
iptable_nat
iptable_filter
br_netfilter
overlayWith the matching sysctls in /etc/sysctl.d/k8s.conf:
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
Diagnostic: an existing but empty KUBE-SERVICES chain with (0 references)
means kube-proxy started and could not program rules, not that it failed to run.
sudo iptables -t nat -L KUBE-SERVICES -n | wc -l # dozens when healthy
curl -k --max-time 5 https://10.96.0.1:443/healthz # "ok" when healthy# Manual run
sudo /usr/local/bin/backup-k8s.sh
# Off-site inventory and size
rclone lsd r2:<BUCKET>/cluster
rclone size r2:<BUCKET>/cluster
# Verify a snapshot without restoring
rclone copy r2:<BUCKET>/cluster/<TIMESTAMP>/etcd-snapshot.db /tmp/
etcdutl snapshot status /tmp/etcd-snapshot.db --write-out=table
# Local copies
ls -lht /mnt/backup/k8s-backups/ | head
tail -f /var/log/k8s-backup.log- Backups contain every Kubernetes Secret and the cluster CA private key in plaintext. Treat the bucket, the NFS export and the physical drive as equivalent in sensitivity to root access on the control plane.
- The R2 API token is scoped to a single bucket with object read/write only.
- Neither the NFS export nor the R2 objects are encrypted at rest.
rclone cryptin front of the R2 remote is the obvious hardening step; retrofitting it means re-uploading everything. no_root_squashon the NFS export grants root-equivalent write access to any root user on the subnet.- The temporary working directory is mode
700and removed on exit including on failure.
- No alerting on backup failure; silent failure is invisible until someone reads the log
- Resource export suppresses per-namespace errors, so a partial export can look complete
- Vault and database backup scripts are not yet in this repository
- Vault raft snapshots once the file → raft storage migration is done
- Restore procedure has not been exercised end to end against fresh hardware
- Backups are unencrypted at rest
backup-k8s.sh Backup: snapshot, verify, upload, secondary copy
restore-k8s.sh Restore: PKI placement, etcd restore, cluster init
install.sh Dependency check, install to /usr/local/bin, config
examples/config.example Template for /etc/default/backup-k8s
examples/crontab.example Root crontab entries
README.md This file
Last verified: 12 October 2026 against Kubernetes v1.36.5 and etcd 3.6.8.
Backup contents were checked by checksum, etcdutl snapshot status and a live
key-count comparison before the v1.35 → v1.36 upgrade.