I recently started using paperless-ngx and so far, I really like it. I’m running it with Docker, and after importing and tagging around 1,000 documents, I thought it was time to set up backups.

The documentation describes two different approaches, and I’m going with document_exporter, which seems to be the intended way to create backups.

My basic idea is to use ofelia as a cron service that triggers the document_exporter in the paperless container. Then a second service containing only restic is called by ofelia to start the backup. Both the paperless and backup services have the export path mounted. The paperless images aren’t touched or changed in any way, which keeps things simple and separated.

One caveat: The export and backup jobs can’t run directly after each other since we don’t know when the export job will finish. I’m setting the backup job to run 1-2 hours after the export, which should be enough time, but you’ll need to adjust this if your export takes longer. You could handle this with job-local and docker compose exec, but I find that approach a bit messy.

Setup

First, add ofelia to your paperless docker-compose.yml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
services:
  // ...

  ofelia:
    image: mcuadros/ofelia:latest
    restart: unless-stopped
    command: daemon --docker
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./ofelia-logs:/logs
    environment:
      - TZ=Europe/Berlin
    labels:
      ofelia.save-folder: "/logs"

Next, add ofelia labels to the webserver service that runs paperless itself:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
  services:
    // ...

  webserver:
    image: ghcr.io/paperless-ngx/paperless-ngx:latest
    restart: unless-stopped
    depends_on:
      - db
      - broker
    ports:
      - "8010:8000"
    volumes:
      - data:/usr/src/paperless/data
      - media:/usr/src/paperless/media
      - ./export:/usr/src/paperless/export
      - ./consume:/usr/src/paperless/consume
    env_file: docker-compose.env
    environment:
      PAPERLESS_REDIS: redis://broker:6379
      PAPERLESS_DBHOST: db
    labels:
      ofelia.enabled: "true"
      ofelia.job-exec.paperless-export.schedule: "0 30 1 * * *"
      ofelia.job-exec.paperless-export.command: "document_exporter -c -d -p --no-progress-bar /usr/src/paperless/export"

Make sure the export volume ./export:/usr/src/paperless/export is added.

With this configuration, ofelia will trigger the export every night at 01:30:00 (the cron format includes seconds) and write the export data to the local ./export folder.

Now add the restic backup service and update the RESTIC_PASSWORD environment variable:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
  services:
    // ...

  backup:
    image: restic/restic:latest
    restart: unless-stopped
    entrypoint: "sleep infinity"
    environment:
      - TZ=Europe/Berlin
      - RESTIC_REPOSITORY=/backup/paperless-backup
      - RESTIC_PASSWORD=XYZ
    volumes:
      - ./export:/export
      - ./backup:/backup
    labels:
      ofelia.enabled: "true"
      ofelia.job-exec.paperless-backup.schedule: "0 30 2 * * *"
      ofelia.job-exec.paperless-backup.command: "restic backup /export"

The restic backup command will run every night at 02:30:00, one hour after the export. You should adjust this timing based on how long your export takes. My export only takes a few minutes, so there’s enough buffer time even as my database grows.

Now restart all the services with docker compose down && docker compose up -d.

Initialize repository

First, we need to initialize the restic repository manually:

1
2
3
4
5
6
$ docker compose exec backup restic init
created restic repository bfa7851f9b at /backup/paperless-backup

Please note that knowledge of your password is required to access
the repository. Losing your password means that your data is
irrecoverably lost.

You should now see the local folder ./backup/paperless-backup.

First manual backup

Let’s manually trigger a paperless export to verify everything works:

1
2
$ docker compose exec webserver document_exporter -c -d -p /usr/src/paperless/export
100%|██████████████████████████████████| 1034/1034 [00:01<00:00, 583.08it/s]

Then, let’s run our first manual backup:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ docker compose exec backup restic backup /export
repository bfa7851f opened (version 2, compression level auto)
created new cache in /root/.cache/restic
no parent snapshot found, will read all files
[0:00]          0 index files loaded

Files:        3059 new,     0 changed,     0 unmodified
Dirs:            1 new,     0 changed,     0 unmodified
Added to the repository: 3.384 GiB (3.205 GiB stored)

processed 3059 files, 3.485 GiB in 0:24
snapshot c7d0acf6 saved

Now we can validate our first backup:

1
2
3
4
5
6
7
$ docker compose exec backup restic snapshots
repository bfa7851f opened (version 2, compression level auto)
ID        Time                 Host          Tags        Paths    Size
---------------------------------------------------------------------------
c7d0acf6  2025-12-21 14:30:51  6c52bff02082              /export  3.485 GiB
---------------------------------------------------------------------------
1 snapshots

Looks good!

When ofelia finally triggers the jobs on schedule, we’ll see log files in the ./ofelia-logs folder:

$ ls ofelia-logs/
total 15
-rw-r--r-- 1 root root 737 Dec 21 14:10 20251221_151000_paperless-export.json
-rw-r--r-- 1 root root   0 Dec 21 14:10 20251221_151000_paperless-export.stderr.log
-rw-r--r-- 1 root root   0 Dec 21 14:10 20251221_151000_paperless-export.stdout.log
-rw-r--r-- 1 root root 684 Dec 21 14:12 20251221_151200_paperless-backup.json
-rw-r--r-- 1 root root   0 Dec 21 14:12 20251221_151200_paperless-backup.stderr.log
-rw-r--r-- 1 root root 279 Dec 21 14:12 20251221_151200_paperless-backup.stdout.log