Skip to content

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.