Episode 7: nightly restic backup to S3 with consistent dumps, heartbeat, and a restore script for a fresh box

Claude-Session: https://claude.ai/code/session_01Y1BTehJjJ4Chr6YscVRXmn
This commit is contained in:
Maximilian Grimm 2026-09-02 19:51:35 +02:00
parent 80d5b00b67
commit cf374770c4
7 changed files with 444 additions and 0 deletions

96
backup/restore.sh Normal file
View file

@ -0,0 +1,96 @@
#!/usr/bin/env bash
# ~$ selfhostedops - bring the whole stack back on an empty Debian box (episode 7)
#
# Usage on a fresh Debian 12/13 server, as root:
# mkdir -p /opt/selfhostedops-stack/backup
# <put your .restic-env into /opt/selfhostedops-stack/backup/> (from the vault)
# curl -fsSLO https://<your-git>/selfhostedops-stack/raw/branch/main/backup/restore.sh
# bash restore.sh install # docker, compose plugin, restic
# bash restore.sh restore # docker volumes + restic restore latest --target /
# bash restore.sh up # docker compose up -d for the stack and NetBird
# bash restore.sh all # the three in a row
#
# Each phase prints its own wall-clock time. Nothing here touches DNS: point
# the records at the new box when the services answer (test first with
# curl --resolve wiki.example.com:443:<new ip> https://wiki.example.com/).
set -Eeuo pipefail
STACK_DIR=${STACK_DIR:-/opt/selfhostedops-stack}
NETBIRD_DIR=${NETBIRD_DIR:-/opt/netbird}
ENV_FILE="$STACK_DIR/backup/.restic-env"
SNAPSHOT=${SNAPSHOT:-latest}
log() { printf '%s %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
phase_start() { PHASE_T0=$(date +%s%N); log "=== phase $1 ==="; }
phase_end() {
local ms=$(( ($(date +%s%N) - PHASE_T0) / 1000000 ))
log "=== phase $1 done in $((ms / 1000)).$(printf '%03d' $((ms % 1000)))s ==="
}
load_env() {
[ -f "$ENV_FILE" ] || { echo "missing $ENV_FILE (copy it from your vault first)"; exit 1; }
set -a
# shellcheck disable=SC1090
. "$ENV_FILE"
set +a
}
phase_install() {
phase_start install
export DEBIAN_FRONTEND=noninteractive
apt-get update -q
apt-get install -y -q ca-certificates curl gnupg restic
# (coreutils install, not a function of this script)
/usr/bin/install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
# shellcheck disable=SC1091
. /etc/os-release
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $VERSION_CODENAME stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -q
apt-get install -y -q docker-ce docker-ce-cli containerd.io docker-compose-plugin
systemctl enable --now docker
docker --version; docker compose version; restic version
phase_end install
}
phase_restore() {
load_env
phase_start restore
log "snapshots in the repository:"
restic snapshots --compact
# Named volumes must exist in docker's registry before the files land in
# /var/lib/docker/volumes/<name>/_data, otherwise compose creates fresh
# empty ones on top. The names come straight out of the snapshot.
log "creating docker volumes"
restic ls "$SNAPSHOT" /var/lib/docker/volumes \
| grep '^/var/lib/docker/volumes/[^/]*_' \
| while read -r dir; do docker volume create "$(basename "$dir")"; done
log "restic restore $SNAPSHOT --target /"
restic restore "$SNAPSHOT" --target /
log "restored:"
du -sh "$STACK_DIR" "$NETBIRD_DIR" /var/lib/docker/volumes/*_*/_data
phase_end restore
}
phase_up() {
phase_start up
cd "$STACK_DIR"
docker compose up -d
# NetBird joins the stack's proxy network, so it comes second.
if [ -d "$NETBIRD_DIR" ]; then
cd "$NETBIRD_DIR"
docker compose up -d
fi
docker ps --format 'table {{.Names}}\t{{.Status}}'
phase_end up
}
case "${1:-}" in
install) phase_install ;;
restore) phase_restore ;;
up) phase_up ;;
all) phase_install; phase_restore; phase_up ;;
*) echo "usage: $0 install|restore|up|all"; exit 1 ;;
esac