#!/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 ====="