From e5d13d05df7440cfd609d6a10b5b27d49b48651e Mon Sep 17 00:00:00 2001 From: Julian Lackner Date: Sat, 9 May 2026 14:23:03 +0000 Subject: [PATCH] Add docker-backup.sh --- docker-backup.sh | 169 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 docker-backup.sh diff --git a/docker-backup.sh b/docker-backup.sh new file mode 100644 index 0000000..1f687a3 --- /dev/null +++ b/docker-backup.sh @@ -0,0 +1,169 @@ +#!/bin/bash +# +# Docker services backup script +# Backs up /opt/ directories to Internxt via rclone +# +# Special handling for Netbird: the Management service database lives inside +# a Docker named volume mounted at /var/lib/netbird/ in the container, NOT +# in the bind-mounted /opt/netbird/ directory. We extract it via +# `docker compose cp` per the official Netbird backup docs. +# + +set -euo pipefail + +# ---- Configuration ---- +SERVICES=("netbird" "pocket-id" "caddy" "vaultwarden") +SOURCE_DIR="/opt" +RCLONE_REMOTE="internxt" # name of your configured rclone remote +RCLONE_PATH="vps-backups" # path/folder on the remote +LOCAL_TMP="/tmp/docker-backups" # staging directory for archives +RETENTION_DAYS=30 # delete backups older than this (local + remote) +LOG_FILE="/var/log/docker-backup.log" + +# Netbird-specific: which container holds the management DB? +# Newer single-container setup uses "netbird-server"; older multi-container +# setup uses "management". We auto-detect below. +NETBIRD_DIR="${SOURCE_DIR}/netbird" +NETBIRD_DB_PATH_IN_CONTAINER="/var/lib/netbird" + +# ---- Setup ---- +TIMESTAMP=$(date +%Y-%m-%d_%H-%M-%S) +ARCHIVE_NAME="docker-backup_${TIMESTAMP}.tar.gz" +ARCHIVE_PATH="${LOCAL_TMP}/${ARCHIVE_NAME}" +STAGING_DIR="${LOCAL_TMP}/staging_${TIMESTAMP}" + +mkdir -p "$LOCAL_TMP" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" | tee -a "$LOG_FILE" +} + +# Make sure containers come back up and staging is cleaned even if something fails +STOPPED_SERVICES=() +cleanup() { + local exit_code=$? + for service in "${STOPPED_SERVICES[@]}"; do + log "Restarting ${service}..." + (cd "${SOURCE_DIR}/${service}" && docker compose start) || \ + log "WARNING: failed to restart ${service}" + done + rm -rf "$STAGING_DIR" + exit $exit_code +} +trap cleanup EXIT INT TERM + +# ---- Pre-flight checks ---- +log "===== Backup started =====" + +if ! command -v rclone &>/dev/null; then + log "ERROR: rclone not found" + exit 1 +fi + +if ! rclone listremotes | grep -q "^${RCLONE_REMOTE}:$"; then + log "ERROR: rclone remote '${RCLONE_REMOTE}' not configured" + exit 1 +fi + +# ---- Detect Netbird setup style ---- +# Returns the container name for the management service, or empty if not found. +detect_netbird_container() { + if [[ ! -d "$NETBIRD_DIR" ]]; then + echo "" + return + fi + cd "$NETBIRD_DIR" + # Try newer setup first, then older + if docker compose config --services 2>/dev/null | grep -qx "netbird-server"; then + echo "netbird-server" + elif docker compose config --services 2>/dev/null | grep -qx "management"; then + echo "management" + else + echo "" + fi +} + +NETBIRD_DB_CONTAINER=$(detect_netbird_container) +if [[ -n "$NETBIRD_DB_CONTAINER" ]]; then + log "Detected Netbird management container: ${NETBIRD_DB_CONTAINER}" +else + log "WARNING: could not detect Netbird management container; DB will not be backed up" +fi + +# ---- Stop containers ---- +# Netbird must be stopped to get a consistent DB copy. We stop all services +# for SQLite consistency (pocket-id, vaultwarden) too. +for service in "${SERVICES[@]}"; do + service_dir="${SOURCE_DIR}/${service}" + if [[ ! -d "$service_dir" ]]; then + log "WARNING: ${service_dir} does not exist, skipping" + continue + fi + log "Stopping ${service}..." + (cd "$service_dir" && docker compose stop) + STOPPED_SERVICES+=("$service") +done + +# ---- Stage data for archiving ---- +log "Staging files in ${STAGING_DIR}" +mkdir -p "$STAGING_DIR" + +# Copy all bind-mounted /opt/ directories +for service in "${SERVICES[@]}"; do + service_dir="${SOURCE_DIR}/${service}" + [[ -d "$service_dir" ]] || continue + log "Staging ${service_dir}" + cp -a "$service_dir" "$STAGING_DIR/" +done + +# Extract Netbird's management database from inside the container. +# The container is stopped, but `docker compose cp` works on stopped containers. +if [[ -n "$NETBIRD_DB_CONTAINER" ]]; then + log "Extracting Netbird database from container ${NETBIRD_DB_CONTAINER}" + mkdir -p "${STAGING_DIR}/netbird/_netbird-db" + ( + cd "$NETBIRD_DIR" + docker compose cp -a \ + "${NETBIRD_DB_CONTAINER}:${NETBIRD_DB_PATH_IN_CONTAINER}" \ + "${STAGING_DIR}/netbird/_netbird-db/" + ) +fi + +# ---- Restart containers (before upload, to minimize downtime) ---- +for service in "${STOPPED_SERVICES[@]}"; do + log "Starting ${service}..." + (cd "${SOURCE_DIR}/${service}" && docker compose start) +done +STOPPED_SERVICES=() # clear so cleanup trap doesn't double-start + +# ---- Create archive ---- +log "Creating archive: ${ARCHIVE_NAME}" +tar -czf "$ARCHIVE_PATH" -C "$STAGING_DIR" . + +ARCHIVE_SIZE=$(du -h "$ARCHIVE_PATH" | cut -f1) +log "Archive created: ${ARCHIVE_SIZE}" + +# Free staging now that the archive exists +rm -rf "$STAGING_DIR" + +# ---- Upload to Internxt ---- +log "Uploading to ${RCLONE_REMOTE}:${RCLONE_PATH}/" +rclone copy "$ARCHIVE_PATH" "${RCLONE_REMOTE}:${RCLONE_PATH}/" \ + --log-file="$LOG_FILE" \ + --log-level INFO \ + --stats=30s + +log "Upload complete" + +# ---- Cleanup old backups ---- +log "Cleaning up local backups older than ${RETENTION_DAYS} days" +find "$LOCAL_TMP" -name "docker-backup_*.tar.gz" -mtime +${RETENTION_DAYS} -delete + +log "Cleaning up remote backups older than ${RETENTION_DAYS} days" +rclone delete "${RCLONE_REMOTE}:${RCLONE_PATH}/" \ + --min-age "${RETENTION_DAYS}d" \ + --include "docker-backup_*.tar.gz" \ + --log-file="$LOG_FILE" \ + --log-level INFO + +log "===== Backup complete =====" \ No newline at end of file