From 28de86b4e5d505b3ca4d78355e831e513799119a Mon Sep 17 00:00:00 2001 From: Michael Barton Date: Tue, 9 Jun 2026 20:57:14 -0700 Subject: [PATCH] Add automated encrypted daily backup for paperless-ngx to Backblaze B2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses paperless document_exporter for version-independent exports, restic for encrypted/deduplicated snapshots, and launchd for daily 03:00 scheduling. Secrets stored in macOS Keychain at runtime — no plaintext credentials on disk. Co-Authored-By: Claude Sonnet 4.6 --- ansible/dotfiles.yml | 6 +++ ansible/tasks/paperless_backup.yml | 40 +++++++++++++++++++ paperless-ngx/backup.sh | 36 +++++++++++++++++ paperless-ngx/docker-compose.yml | 1 + ...uk.me.michaelbarton.paperless-backup.plist | 26 ++++++++++++ 5 files changed, 109 insertions(+) create mode 100644 ansible/tasks/paperless_backup.yml create mode 100755 paperless-ngx/backup.sh create mode 100644 paperless-ngx/uk.me.michaelbarton.paperless-backup.plist diff --git a/ansible/dotfiles.yml b/ansible/dotfiles.yml index 1d4adc5..d9c6f0c 100644 --- a/ansible/dotfiles.yml +++ b/ansible/dotfiles.yml @@ -71,6 +71,12 @@ - paperless - never + - name: Include paperless backup setup tasks + include_tasks: tasks/paperless_backup.yml + tags: + - paperless-backup + - never + - name: Include dotfile linking tasks include_tasks: tasks/link_files.yml tags: diff --git a/ansible/tasks/paperless_backup.yml b/ansible/tasks/paperless_backup.yml new file mode 100644 index 0000000..46cc0ad --- /dev/null +++ b/ansible/tasks/paperless_backup.yml @@ -0,0 +1,40 @@ +--- +- name: Install restic via Homebrew + community.general.homebrew: + name: restic + state: present + +- name: Create paperless backup staging directory + ansible.builtin.file: + path: "{{ ansible_user_dir }}/.paperless-backup-staging" + state: directory + mode: "0700" + +- name: Copy paperless backup launchd plist + ansible.builtin.copy: + src: "{{ ansible_user_dir }}/.dotfiles/paperless-ngx/uk.me.michaelbarton.paperless-backup.plist" + dest: "{{ ansible_user_dir }}/Library/LaunchAgents/uk.me.michaelbarton.paperless-backup.plist" + mode: "0644" + +- name: Load paperless backup launch agent + ansible.builtin.command: + cmd: launchctl load "{{ ansible_user_dir }}/Library/LaunchAgents/uk.me.michaelbarton.paperless-backup.plist" + args: + creates: "{{ ansible_user_dir }}/Library/LaunchAgents/uk.me.michaelbarton.paperless-backup.plist" + +# One-time manual setup required (run these once before first backup): +# +# security add-generic-password -a $USER -s paperless-backup-b2-id -w '' +# security add-generic-password -a $USER -s paperless-backup-b2-key -w '' +# security add-generic-password -a $USER -s paperless-backup-restic-pw -w '' +# +# IMPORTANT: Also save the restic password in 1Password — lose it and backups are unrecoverable. +# +# Then initialise the restic repo once: +# set -x B2_ACCOUNT_ID (security find-generic-password -a $USER -s paperless-backup-b2-id -w) +# set -x B2_ACCOUNT_KEY (security find-generic-password -a $USER -s paperless-backup-b2-key -w) +# set -x RESTIC_PASSWORD (security find-generic-password -a $USER -s paperless-backup-restic-pw -w) +# restic -r b2:mb-paperless-backup:paperless init +# +# Also restart the paperless stack to pick up the new volume mount: +# docker compose -f ~/.dotfiles/paperless-ngx/docker-compose.yml up -d diff --git a/paperless-ngx/backup.sh b/paperless-ngx/backup.sh new file mode 100755 index 0000000..ba96907 --- /dev/null +++ b/paperless-ngx/backup.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + +LOG_FILE="$HOME/Library/Logs/paperless-backup.log" +STAGING_DIR="$HOME/.paperless-backup-staging" +COMPOSE_FILE="$HOME/.dotfiles/paperless-ngx/docker-compose.yml" + +log() { + echo "[$(date '+%Y-%m-%dT%H:%M:%S')] $*" | tee -a "$LOG_FILE" +} + +log "Starting paperless backup" + +export B2_ACCOUNT_ID +B2_ACCOUNT_ID=$(security find-generic-password -a "$USER" -s paperless-backup-b2-id -w) +export B2_ACCOUNT_KEY +B2_ACCOUNT_KEY=$(security find-generic-password -a "$USER" -s paperless-backup-b2-key -w) +export RESTIC_PASSWORD +RESTIC_PASSWORD=$(security find-generic-password -a "$USER" -s paperless-backup-restic-pw -w) + +log "Clearing staging directory" +mkdir -p "$STAGING_DIR" +rm -rf "${STAGING_DIR:?}"/* + +log "Running paperless document_exporter" +/Applications/Docker.app/Contents/Resources/bin/docker compose -f "$COMPOSE_FILE" exec -T webserver document_exporter -na -nt -f /usr/src/paperless/backup + +log "Running restic backup" +restic -r b2:mb-paperless-backup:paperless backup "$STAGING_DIR" + +log "Pruning old snapshots" +restic -r b2:mb-paperless-backup:paperless forget --keep-daily 7 --keep-weekly 2 --keep-monthly 2 --prune + +log "Paperless backup complete" diff --git a/paperless-ngx/docker-compose.yml b/paperless-ngx/docker-compose.yml index 361f130..bcc9041 100644 --- a/paperless-ngx/docker-compose.yml +++ b/paperless-ngx/docker-compose.yml @@ -51,6 +51,7 @@ services: - /Users/michaelbarton/.paperless:/usr/src/paperless/media:rw - /Users/michaelbarton/Downloads:/usr/src/paperless/export:rw - /Users/michaelbarton/Documents/consume:/usr/src/paperless/consume:rw + - /Users/michaelbarton/.paperless-backup-staging:/usr/src/paperless/backup:rw env_file: docker-compose.env environment: PAPERLESS_REDIS: redis://broker:6379 diff --git a/paperless-ngx/uk.me.michaelbarton.paperless-backup.plist b/paperless-ngx/uk.me.michaelbarton.paperless-backup.plist new file mode 100644 index 0000000..1dcfd70 --- /dev/null +++ b/paperless-ngx/uk.me.michaelbarton.paperless-backup.plist @@ -0,0 +1,26 @@ + + + + + Label + uk.me.michaelbarton.paperless-backup + ProgramArguments + + /bin/bash + /Users/michaelbarton/.dotfiles/paperless-ngx/backup.sh + + RunAtLoad + + StartCalendarInterval + + Hour + 3 + Minute + 0 + + StandardOutPath + /Users/michaelbarton/Library/Logs/paperless-backup.log + StandardErrorPath + /Users/michaelbarton/Library/Logs/paperless-backup.log + +