Post

Bulletproof Paperless-ngx Backups with Restic, pg_dump, and Backrest

I’ve been running Paperless-ngx on one of my Zimaboards for a while now and I finally sat down to build a proper backup strategy around it. This post documents the full setup - from dumping the PostgreSQL database to encrypted restic snapshots, a true 3-2-1 backup across multiple machines, plain document exports accessible from my iPhone, and Backrest as a read-only web UI to browse everything.

The Stack

My Paperless-ngx instance runs as a Docker Compose stack on a Zimaboard running user. The relevant containers are:

  • paperless-webserver-1 - the main Paperless-ngx app
  • paperless-db-1 - PostgreSQL 17
  • paperless-broker-1 - Redis
  • paperless-gotenberg-1 - Gotenberg for PDF generation
  • An Apache Tika container for document parsing

All AppData lives under /DATA/AppData/paperless/, and backups go to /DATA/AppData/backups/paperless/.

The 3-2-1 Strategy

The gold standard for backups is the 3-2-1 rule: 3 copies of your data, on 2 different media/locations, with 1 copy offsite (or on a separate machine). Here’s how that maps to my setup:

CopyWhereHow
1 - PrimaryLocal HD 1TB on Zimaboard #1Restic encrypted repo
2 - SecondarySecond Zimaboard on the networkRsync of restic repo
3 - OffsiteMac → iCloudRsync of plain export folder

Each copy serves a different failure scenario:

  • Zimaboard #1 dies → restore from the second Zimaboard’s copy of the restic repo
  • Both local machines are wiped/ransomwared → restore from iCloud (plain PDFs) or Mac (full restic repo)
  • You just need an invoice on your phone right now → iCloud plain export, no restic CLI needed

Why Multi-Layer Within Each Copy?

Beyond the 3-2-1 distribution, each backup also has three independent recovery paths:

  1. pg_dump - a plain SQL dump, human-readable, database-level restore
  2. Restic encrypted repo - full snapshot history with deduplication and pruning
  3. Plain document export - readable PDFs directly accessible from my iPhone via iCloud

The pg_dump covers database corruption. Restic covers accidental deletion. The plain export covers “I just need that file right now without any tooling.”

The Backup Script

All of this is orchestrated by a single shell script at /DATA/AppData/paperless/paperless-backup.sh, which runs inside a dedicated backup container that has access to all the right volumes.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#!/bin/bash
set -e

mkdir -p "$DUMP_DIR"

TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
DUMP_FILE="${DUMP_DIR}/paperless-${TIMESTAMP}.sql"

# Step 1: Export plain documents
echo "Exporting plain documents..."
docker exec paperless-webserver-1 document_exporter /usr/src/paperless/export
echo "Export done."

# Step 2: Dump PostgreSQL
echo "Starting pg_dump to ${DUMP_FILE}..."
pg_dump -h "$PGHOST" -U "$PGUSER" "$PGDATABASE" > "$DUMP_FILE"
echo "pg_dump done."

# Step 3: Initialize restic repo if needed
if ! restic snapshots >/dev/null 2>&1; then
  echo "Initializing restic repo..."
  restic init
fi

# Step 4: Restic backup
echo "Running restic backup..."
restic backup \
  /usr/src/paperless/data \
  /var/lib/postgresql/data \
  /usr/src/paperless/media \
  /usr/src/paperless/export \
  /usr/src/paperless/consume

# Step 5: Prune old snapshots
echo "Pruning old snapshots..."
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune

echo "Backup completed."

The script runs in order: export → dump → backup → prune. set -e stops execution immediately on any failure so you’re never silently continuing with a broken state.

Docker Compose for the Backup Container

The backup container is a lightweight Alpine-based image with both restic and pg_client installed:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
services:
  paperless-backup:
    image: your-backup-image:latest
    container_name: paperless-backup
    restart: unless-stopped
    environment:
      - RESTIC_REPOSITORY=/repos/paperless/repo
      - RESTIC_PASSWORD=your-restic-password
      - PGHOST=paperless-db-1
      - PGUSER=paperless
      - PGDATABASE=paperless
      - DUMP_DIR=/dumps
    volumes:
      - /DATA/AppData/paperless/paperless-backup.sh:/usr/local/bin/paperless-backup.sh:ro
      - /DATA/AppData/paperless/data:/usr/src/paperless/data:ro
      - /DATA/AppData/paperless/media:/usr/src/paperless/media:ro
      - /DATA/AppData/paperless/export:/usr/src/paperless/export
      - /DATA/AppData/paperless/consume:/usr/src/paperless/consume:ro
      - /DATA/AppData/backups/paperless/repo:/repos/paperless/repo
      - /DATA/AppData/backups/paperless/dumps:/dumps
    network_mode: "host"

The script is mounted read-only from the host, so editing it is just sudo nano /DATA/AppData/paperless/paperless-backup.sh - no container rebuild needed.

Scheduling via Crontab

The backup runs from the host crontab, not from inside a container. This is intentional - if the backup container crashes, the host cron job still fires:

1
sudo crontab -e
1
0 5 * * * docker exec paperless-backup /usr/local/bin/paperless-backup.sh >> /var/log/paperless-backup.log 2>&1

Rsync to Mac and Second Zimaboard

After the restic backup completes, two additional cron jobs push copies off the primary machine. This is what makes it a true 3-2-1.

1
2
3
4
5
6
7
8
9
10
# Push encrypted restic repo + plain export to Mac (→ iCloud)
15 5 * * * mountpoint -q /mnt/mac-backup && \
  rsync -a --inplace /DATA/AppData/backups/ /mnt/mac-backup/paperless-backup/ && \
  rsync -a --inplace /DATA/AppData/paperless/export/ /mnt/mac-backup/paperless-plain-export/ \
  >> /var/log/paperless-rsync-mac.log 2>&1

# Push encrypted restic repo to second Zimaboard
20 5 * * * mountpoint -q /mnt/server2-backup && \
  rsync -a --inplace /DATA/AppData/backups/ /mnt/server2-backup/paperless-backup/ \
  >> /var/log/paperless-rsync-server2.log 2>&1

Both jobs use mountpoint -q as a guard - if the remote isn’t mounted, the rsync is silently skipped rather than erroring out or writing to a local path by mistake. The Mac rsync runs at 5:15 AM, the second Zimaboard at 5:20 AM - staggered to avoid I/O contention.

Plain Export to iPhone

The restic repo is encrypted - you can’t open it on your phone. The plain document export is just PDFs in folders. By rsyncing it to the Mac → iCloud, it becomes accessible from the iOS Files app with no tooling:

1
2
3
4
5
6
7
iCloud Drive/
  paperless-plain-export/
    2024/
      invoices/
      contracts/
    2025/
      ...

Note: The export folder is also included in the restic backup, which technically means it’s stored twice in the encrypted repo. This is fine - restic’s deduplication means unchanged files take up almost no extra space in subsequent snapshots. And having it in restic means you can restore individual PDFs directly from a snapshot without re-running the exporter.

Browsing Backups Without Server Access - Restic Browser

Backrest is great when your servers are up, but what about when you’re away from home and can’t reach your Zimaboards? That’s where Restic Browser comes in. It’s a cross-platform desktop GUI (macOS, Windows, Linux) that lets you open and browse any restic repo directly on your local machine - no server required.

Since the Mac rsync job pulls a full copy of the restic repo nightly, you always have an up-to-date local copy on your Mac. Just open it in Restic Browser with your password and you can browse snapshots, preview files, and restore them without touching a terminal or needing your Zimaboard to be online.

It’s a nice safety net: even if your homelab is completely unreachable, the data is on your Mac and browsable.

Backrest - Web UI for Restic

Backrest is a web UI and orchestrator for restic. I’m using it in read-only mode for now - browsing snapshots and verifying backups are healthy without touching the CLI.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
services:
  backrest:
    image: garethgeorge/backrest:latest
    container_name: backrest
    restart: unless-stopped
    environment:
      - BACKREST_DATA=/data
      - BACKREST_CONFIG=/config/config.json
      - XDG_CACHE_HOME=/cache
      - TZ=America/Montreal
    ports:
      - 9898:9898
    volumes:
      - /DATA/AppData/backrest/data:/data
      - /DATA/AppData/backrest/config:/config
      - /DATA/AppData/backrest/cache:/cache
      - /DATA/AppData:/userdata:ro
      - /DATA/AppData/backups:/repos

Mounting /DATA/AppData/backups:/repos (the parent folder) instead of a specific repo path means I can add repos for other apps later - Immich, Nextcloud, etc. - without touching the compose file. In the UI, the Paperless repo is at /repos/paperless/repo.

Important: I kept my crontab + shell script as the source of truth for triggering backups. Backrest schedules live inside the container - if it goes down, they don’t fire. For critical data, host-level cron is more resilient.

One Backrest Per Host

I have three Zimaboards. The right call is one Backrest per host - each board needs local access to its own data, and if one goes down the others are unaffected. The only exception would be all repos living on a shared NAS. For now, one per board keeps things simple and independent.

Full Picture

LayerWhat it coversLocation
document_exporterPlain readable PDFsiCloud via Mac rsync
pg_dumpFull database dump/backups/paperless/dumps/
Restic snapshotEncrypted full backup/backups/paperless/repo/ (local)
Rsync → MacCopy 2 offsiteMac → iCloud
Rsync → Zimaboard #2Copy 3 on separate machineSecond Zimaboard
Backrest UISnapshot browser (on-network)http://server:9898
Restic BrowserSnapshot browser (offline/remote)Mac desktop app

The whole thing runs unattended every night at 5 AM across three machines. The only thing I’d add next is a Healthchecks.io ping at the end of the script to get alerted if a backup hasn’t run in 25 hours.

Resources

This post is licensed under CC BY 4.0 by the author.