backup/stages/docker-volumes.sh

#!/usr/bin/env bash
# backup/stages/docker-volumes.sh
# tar up the small "state" volumes that cannot be reconstructed from
# config alone (e.g. vaultwarden bitwarden_rs data, radarr config).
# Excludes live media / large caches - those are backed up at the fs level.

set -euo pipefail

HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck source=/dev/null
. "${HERE}/../../scripts/lib/log.sh"

DEST_ROOT="${DEST_ROOT:-/srv/homelab/backup/snapshots/vols}"
LATEST="${LATEST:-/srv/homelab/backup/latest/vols}"
TS="$(date +%Y%m%dT%H%M%S)"
DEST="${DEST_ROOT}/${TS}"

VOLS=(
    homelab_vaultwarden_data
    homelab_paperless_data
    homelab_paperless_media
    homelab_gitea_data
    homelab_traefik_letsencrypt
    homelab_immich_upload
)

EXCLUDES=(
    --exclude=./caches
    --exclude=./thumbnails
    --exclude=./lost+found
)

dump_one() {
    local vol="$1"
    if ! docker volume inspect "${vol}" >/dev/null 2>&1; then
        log_info "skip ${vol} (not present)"
        return 0
    fi
    local out="${DEST}/${vol}.tar.zst"
    log_info "tar ${vol}"
    docker run --rm \
        -v "${vol}:/src:ro" \
        -v "${DEST}:/out" \
        --entrypoint /bin/sh \
        alpine:3.19 -c '
            set -eu
            cd /src
            apk add --no-cache zstd tar >/dev/null
            tar -cf - . | zstd -q -T0 -3 -o "/out/'"${vol}"'.tar.zst"
        ' >/dev/null || { log_err "dump ${vol} failed"; return 1; }
    if [[ ! -s "${out}" ]]; then
        log_err "dump ${vol} produced empty file"
        return 1
    fi
}

main() {
    install -d "${DEST}"
    local failed=0
    for v in "${VOLS[@]}"; do
        dump_one "${v}" || failed=$((failed + 1))
    done
    if (( failed > 0 )); then
        exit 1
    fi
    install -d "$(dirname "${LATEST}")"
    rm -f "${LATEST}"
    ln -s "${DEST}" "${LATEST}"
}

main "$@"