restic → Backblaze B2¶
resticker runs three containers: nightly backup, weekly prune and weekly check. Secrets are kept out of Portainer: a root-only env file is mounted and sourced by a wrapper entrypoint.
compose/nas/restic.yml
# Portainer stack: restic (resticker = restic + cron in a container)
# Secrets live ONLY in /volume1/docker/restic/restic.env (root:root 600), mounted and sourced by /scripts/run.sh,
# so they never appear in Portainer's DB or `docker inspect`.
x-common: &common
image: mazzolino/restic:1.8.2 # pin by digest too: mazzolino/restic:1.8.2@sha256:<digest>
hostname: nas
restart: unless-stopped
entrypoint: ["/bin/sh", "/scripts/run.sh"]
logging:
driver: json-file
options: { max-size: "5m", max-file: "3" }
services:
restic-backup:
<<: *common
container_name: restic-backup
environment:
TZ: ${TZ}
BACKUP_CRON: ${RESTIC_BACKUP_CRON} # nightly; real value in private runbook
RESTIC_BACKUP_SOURCES: "/source/docker"
RESTIC_BACKUP_ARGS: "--exclude-file /scripts/excludes.txt --exclude-caches --tag nightly -o b2.connections=4"
PRE_COMMANDS: "/scripts/pre-backup.sh" # SQLite online-backup dumps of the media-app DBs
POST_COMMANDS_SUCCESS: "/scripts/status.sh backup success"
POST_COMMANDS_FAILURE: "/scripts/status.sh backup failure"
POST_COMMANDS_INCOMPLETE: "/scripts/status.sh backup failure"
SKIP_INIT: "true" # run `restic init` once by hand
volumes:
- /volume1/docker:/source/docker:ro
- /volume1/docker/restic/dbdump:/dbdump
- /volume1/docker/restic/status:/status
- /volume1/docker/restic/scripts:/scripts:ro
- /volume1/docker/restic/restic.env:/run/secrets/restic.env:ro
healthcheck:
# healthy while the last successful backup is recent
test: ["CMD-SHELL", "f=/status/.restic-backup-ok; [ -f $$f ] && [ $$(( $$(date +%s) - $$(stat -c %Y $$f) )) -lt 129600 ]"]
interval: 15m
timeout: 10s
start_period: 2m
retries: 1
restic-prune:
<<: *common
container_name: restic-prune
environment:
TZ: ${TZ}
PRUNE_CRON: ${RESTIC_PRUNE_CRON} # weekly
RESTIC_FORGET_ARGS: "--host nas ${RESTIC_KEEP_ARGS}"
RESTIC_PRUNE_ARGS: "-o b2.connections=4"
POST_COMMANDS_SUCCESS: "/scripts/status.sh prune success"
POST_COMMANDS_FAILURE: "/scripts/status.sh prune failure"
SKIP_INIT: "true"
volumes:
- /volume1/docker/restic/status:/status
- /volume1/docker/restic/scripts:/scripts:ro
- /volume1/docker/restic/restic.env:/run/secrets/restic.env:ro
restic-check:
<<: *common
container_name: restic-check
environment:
TZ: ${TZ}
CHECK_CRON: ${RESTIC_CHECK_CRON} # weekly
RESTIC_CHECK_ARGS: "--read-data-subset 5% -o b2.connections=4"
POST_COMMANDS_SUCCESS: "/scripts/status.sh check success"
POST_COMMANDS_FAILURE: "/scripts/status.sh check failure"
SKIP_INIT: "true"
volumes:
- /volume1/docker/restic/status:/status
- /volume1/docker/restic/scripts:/scripts:ro
- /volume1/docker/restic/restic.env:/run/secrets/restic.env:ro
# /volume1/docker/restic/restic.env (chown root:root; chmod 600) — NEVER commit the real file
B2_ACCOUNT_ID=${B2_KEY_ID} # bucket-scoped application key ID
B2_ACCOUNT_KEY=${B2_APP_KEY}
RESTIC_REPOSITORY=b2:${B2_BUCKET}:${RESTIC_REPO_PATH}
RESTIC_PASSWORD=${RESTIC_PASSWORD} # lose this and the backup is unreadable — keep it in a password manager
#!/bin/sh
# /volume1/docker/restic/scripts/run.sh — load secrets from the mounted env file, then hand off to resticker
set -a; . /run/secrets/restic.env; set +a
exec /sbin/tini -- /entrypoint "$@"
# restic --exclude-file for /source/docker (regenerable or bulky data, logs, runtime files)
/source/docker/plex/Library/Application Support/Plex Media Server/Media
/source/docker/plex/Library/Application Support/Plex Media Server/Cache
/source/docker/plex/Library/Application Support/Plex Media Server/Codecs
/source/docker/plex/Library/Application Support/Plex Media Server/Crash Reports
/source/docker/plex/Library/Application Support/Plex Media Server/Logs
/source/docker/plex/Library/Application Support/Plex Media Server/Updates
/source/docker/plex/Library/Application Support/Plex Media Server/Diagnostics
/source/docker/plex/transcode
/source/docker/radarr/MediaCover
/source/docker/sonarr/MediaCover
/source/docker/restic/restic.env
/source/docker/restic/dbdump/*.tmp
logs
*.log
*.log.*
logs.db
logs.db-*
*.pid
transcode
Transcode
@eaDir
#recycle
.DS_Store
# Btrfs snapshot browse dirs (Snapshot Replication) - never back these up
/source/docker/#snapshot
#!/bin/bash
# Runs before every nightly backup. Never fails the backup; problems are recorded in /status/restic-status.json.
set -u
ST=/tmp/prebackup.state; : > $ST
command -v sqlite3 >/dev/null || apk add --no-cache sqlite >/dev/null 2>&1 || echo "WARN apk add failed"
# 1) Consistent copies of live SQLite DBs (online backup API, read-only open of the WAL db)
S=/source/docker; P="$S/plex/Library/Application Support/Plex Media Server/Plug-in Support/Databases"
dump(){ # name src
local n=$1 src=$2
if [ ! -f "$src" ]; then echo "dbdump $n missing"; echo "db_$n=missing" >> $ST; return; fi
local uri="file:${src// /%20}?mode=ro" chk='PRAGMA quick_check;'
# Plex DBs use a custom FTS tokenizer that stock sqlite3 can't load, so only a light sanity check there
case $n in plex.*) chk='SELECT CASE WHEN count(*)>0 THEN "ok" END FROM sqlite_master;';; esac
if sqlite3 "$uri" ".timeout 20000" ".backup '/dbdump/$n.tmp'" && [ "$(sqlite3 "/dbdump/$n.tmp" "$chk" 2>/dev/null | head -1)" = ok ]; then
mv -f "/dbdump/$n.tmp" "/dbdump/$n"; echo "dbdump $n ok $(stat -c %s "/dbdump/$n")"; echo "db_$n=ok" >> $ST
else rm -f "/dbdump/$n.tmp"; echo "dbdump $n FAILED"; echo "db_$n=failed" >> $ST; fi
}
dump sonarr.db "$S/sonarr/sonarr.db"
dump radarr.db "$S/radarr/radarr.db"
dump prowlarr.db "$S/prowlarr/prowlarr.db"
dump bazarr.db "$S/bazarr/config/db/bazarr.db"
dump seerr.db.sqlite3 "$S/seerr/db/db.sqlite3"
dump plex.library.db "$P/com.plexapp.plugins.library.db"
dump plex.library.blobs.db "$P/com.plexapp.plugins.library.blobs.db"
exit 0
status.sh (not shown; about 20 lines) writes restic-<job>.json (status, time, snapshot count, repo size) into restic/status/ and
touches .restic-backup-ok on success, which drives the container healthcheck (docker ps shows unhealthy if the backup is stale). (TODO: add a sanitized copy.)
First-time setup¶
sudo mkdir -p /volume1/docker/restic/{scripts,dbdump,status}
sudo install -m 600 -o root -g root /dev/null /volume1/docker/restic/restic.env # then fill it in
# one-off init (the stack uses SKIP_INIT=true)
sudo docker run --rm --env-file /volume1/docker/restic/restic.env mazzolino/restic:1.8.2 restic init
See Backups & snapshots for the schedule, retention and restores.