Encrypted, deduplicated backup & disaster recovery for a self-hosted OpenArchiver email archive — as a tiny, standalone Docker Compose sidecar that never touches your OpenArchiver installation.
Why you might want this:
- 🔒 Encrypted & deduplicated — restic encrypts everything client-side before upload; unchanged data is never transferred twice, so after the first run backups are fast and cheap.
- 🧊 Consistent by construction — the stack is briefly stopped so PostgreSQL and Meilisearch are copied cold. No half-written databases in your backup — and the stack is always restarted afterwards, even if the backup fails.
- 🚑 Real disaster recovery — database, search index, every archived email
and attachment, and the upstream
.envwhoseENCRYPTION_KEYunlocks the stored mailbox credentials. One command restores a fresh host. - ☁️ Any storage backend — S3-compatible (AWS, Backblaze B2, MinIO, Hetzner Object Storage), SFTP incl. Hetzner Storage Box, or a local/external disk.
- 🪶 Tiny footprint — one compose file, one
.env, no daemon. Run it by hand or from cron. Built on resticker.
git clone https://github.com/bst27/openarchiver-backup.git && cd openarchiver-backup
cp .env.example .env # then edit: repository, password, S3 creds, paths
docker compose run --rm restic snapshots # first run auto-creates the (empty) repository
docker compose run --rm backup # make a backupThat's the whole loop. Everything below is detail: what is backed up, setup, restore, other backends and troubleshooting.
| Component | Source | Why it matters |
|---|---|---|
| PostgreSQL | volume *_pgdata |
All metadata: users, audit logs, settings, and the ingestion sources incl. their encrypted mailbox credentials. |
| Email content + attachments | host dir STORAGE_LOCAL_ROOT_PATH (e.g. /var/data/open-archiver) |
The actual archived .eml files and attachments — the bulk of the data. |
| Meilisearch index | volume *_meilidata |
The full-text search index. Rebuildable, but reindexing a large archive takes a long time — backing it up makes restore instant. |
Upstream .env |
OA_PROJECT_DIR/.env |
Holds ENCRYPTION_KEY (which decrypts the stored account credentials), JWT_SECRET, DB/Redis/Meili passwords. Without it the restored credentials are unusable. |
Upstream docker-compose.yml |
OA_PROJECT_DIR/docker-compose.yml |
So the stack can be recreated exactly. |
Valkey (the job queue) is intentionally not backed up — it only holds ephemeral background jobs.
⚠️ One secret you must keep safe and separate from the backup: theRESTIC_PASSWORD(without it the repository cannot be decrypted → no restore). The confidentiality of the archived credentials rests entirely on theRESTIC_PASSWORD, so guard it well.
- A short-lived
mazzolino/resticcontainer mounts the stack's volumes + email storage and the upstream.env/docker-compose.yml. - Before the snapshot it runs
docker stop open-archiver postgres meilisearch valkey tika(so PostgreSQL/Meilisearch are copied cold = consistent), and afterwards it always starts them again — even if the backup failed. - restic deduplicates and encrypts everything, then old snapshots are pruned.
Downtime = the duration of the backup. The first run uploads everything and is slow; subsequent runs only upload changed blocks and are quick.
Security note: the container mounts the Docker socket so it can stop/start the stack. That is effectively host-root access — fine for a self-hosted box you control, but be aware of it.
Version note: PostgreSQL is backed up at the file level, so a restore must go into the same PostgreSQL major version (the stack pins
postgres:17).
- The OpenArchiver stack runs via Docker Compose on this host.
- Docker + Docker Compose v2 (
docker compose). - An S3-compatible bucket — or a local/SFTP target, see Backup targets.
- This repo checked out somewhere on the same host.
Find your stack's volume names:
docker volume ls | grep -E 'pgdata|meilidata'
# e.g. openarchiver_pgdata / openarchiver_meilidatacp .env.example .envThen edit .env — it is commented top-to-bottom and is the single source of
truth. In short:
RESTIC_REPOSITORY— your S3 bucket, e.g.s3:https://s3.eu-central-1.amazonaws.com/my-bucket/openarchiverRESTIC_PASSWORD— generate once:openssl rand -base64 32— store it safely.AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY— bucket credentials.OA_PROJECT_DIR— path of your OpenArchiver checkout (its.envlives there).STORAGE_LOCAL_ROOT_PATH— same value as in OpenArchiver's.env.PG_VOLUME/MEILI_VOLUME— the volume names found above.RESTIC_FORGET_ARGS— retention policy (ships with a sane default).
Using a non-S3 backend (local disk, SFTP/Storage Box)? See Backup targets — you set a different
RESTIC_REPOSITORYand enable the matching compose overlay.
Any restic command auto-creates the repository on first use, so just run:
docker compose run --rm restic snapshotsThe first time you'll see Repository successfully initialized. followed by an
empty snapshot list. (Running it again simply lists snapshots.)
docker compose run --rm backupThis stops the stack, snapshots everything, prunes per the retention policy, and restarts the stack.
Inspect:
docker compose run --rm restic snapshots # list snapshots
docker compose run --rm restic check # verify repository integrityAfter it finishes, confirm the stack is healthy:
docker ps # open-archiver, postgres, meilisearch, valkey, tika should be "Up"After each backup, restic runs forget with RESTIC_FORGET_ARGS (from .env),
then prunes. Tune it in .env (shipped default: keep 7 daily, 4 weekly, 6 monthly,
then --prune):
RESTIC_FORGET_ARGS=--keep-daily 14 --keep-weekly 8 --keep-monthly 12 --pruneLeave it empty to skip forget entirely (keeps every snapshot, prunes nothing).
This is run-on-demand by design. To automate, add a cron entry on the host, e.g.:
30 3 * * * cd /path/to/openarchiver-backup && docker compose run --rm backup >> /var/log/oa-backup.log 2>&1A restore overwrites the live PostgreSQL/Meilisearch volumes and the email storage. Always start with the dry-run.
If restoring onto a fresh host: first lay down the stack so the volumes and containers exist, then stop it:
cd "$OA_PROJECT_DIR"
docker compose up -d # creates volumes + containers
docker compose stop
cd - # back to openarchiver-backupDry-run (shows snapshots, changes nothing):
docker compose run --rm restore latestPerform the restore:
docker compose run --rm restore latest --force
# or a specific snapshot id:
docker compose run --rm restore <snapshotID> --forceThis stops the stack, wipes and restores the two volumes + the email storage in
place, restores the upstream .env/docker-compose.yml to ./restore-out/staging/
for manual pickup, and starts the stack again.
Restore the .env if needed (the script never overwrites it automatically):
cp ./restore-out/staging/.env "$OA_PROJECT_DIR/.env"
cd "$OA_PROJECT_DIR" && docker compose up -dVerify: log in to the web UI, run a search, and check that the email count and ingestion sources look right.
restic is backend-agnostic. The base docker-compose.yml defines the backup
sources (always the same); a backend only changes the destination. S3 needs
nothing but env vars; backends that need host access (a local repo dir, an SSH key)
add it via an overlay you copy to docker-compose.override.yml:
| Target | RESTIC_REPOSITORY |
Env | Overlay |
|---|---|---|---|
| S3 / B2 / MinIO / Hetzner Object Storage | s3:https://<endpoint>/<bucket>/openarchiver |
AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY |
— |
| Local / external disk | /srv/restic |
RESTIC_LOCAL_REPO_PATH (host dir) |
docker-compose.local.yml.example |
| SFTP / another server | sftp:user@host:/srv/restic |
SSH_KEY_PATH |
docker-compose.sftp.yml.example |
| Hetzner Storage Box | sftp:hetzner-sb:restic/openarchiver-prod |
SSH_KEY_PATH + ssh/config — see below |
docker-compose.sftp.yml.example |
Enable an overlay by copying it to the auto-merged name, e.g. for a local disk:
cp docker-compose.local.yml.example docker-compose.override.yml
⚠️ A Hetzner Storage Box is not S3 — it speaks SFTP on port 23. (Hetzner Object Storage is the S3-compatible product; use thes3:row for that.)💡 Without the local overlay a path repo like
/srv/resticwould be written inside the throwaway--rmcontainer and lost — the overlay mounts the host dir in at/srv/restic.
A Storage Box is an SFTP target on port 23. restic's sftp backend can't put a
port in the repository URL, so the port + key are configured in a small ssh/config
that gets mounted into the container. Your private key stays owned by your user —
only the throwaway ssh/config needs to be root-owned (ssh runs as root in the
container and rejects a non-root-owned config; for the private key that ownership
check is skipped, so it loads fine read-only).
Supply your SSH key when creating the Storage Box.
Create ssh/config with the following template. Replace
u123456 with your Storage Box ID and make it root-owned:
Host hetzner-sb
HostName u123456.your-storagebox.de
User u123456
Port 23
IdentityFile /keys/sb_key # in-container path of your key (see step 4)
IdentitiesOnly yes
StrictHostKeyChecking accept-new # trusts the host key on first connect
sudo chown root:root ssh/config && sudo chmod 600 ssh/configRESTIC_REPOSITORY=sftp:hetzner-sb:restic/openarchiver-prod
SSH_KEY_PATH=/home/<you>/.ssh/id_ed25519 # your PRIVATE key; stays yours
# AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY stay emptyThe path after the host alias (restic/openarchiver-prod) is where the repo
lives. SFTP on a Storage Box is chrooted, so it lands as a top-level folder
restic/openarchiver-prod in your box. Different paths = independent repos, so
several repos can share one Storage Box (e.g. restic/host-a, restic/host-b).
The ssh config + key mounts live in a per-deployment overlay that docker compose
auto-merges on top of docker-compose.yml (so the tracked compose stays clean and
S3 stays the no-overlay default). Enable it by copying the example:
cp docker-compose.sftp.yml.example docker-compose.override.ymlThis appends two mounts to backup, restic and restore: ./ssh →
/root/.ssh (the root-owned config) and your key (SSH_KEY_PATH) → /keys/sb_key.
The key goes to a separate path, not inside /root/.ssh — mounting a file into the
read-only ./ssh mount would fail. The real override file is gitignored.
docker compose run --rm restic snapshots # auto-creates the repo, then empty list
docker compose run --rm backupVerify the repo exists on the box:
sftp -P 23 u123456@u123456.your-storagebox.de
sftp> ls restic/openarchiver-prod
config data index keys locks snapshots- Stack didn't restart after a failed backup —
POST_COMMANDS_EXITshould always restart it; if not, rundocker start postgres meilisearch valkey tika open-archiver. - "repository is already locked" — a previous run was interrupted:
docker compose run --rm restic unlock. - Wrong volume names — re-check with
docker volume lsand fixPG_VOLUME/MEILI_VOLUMEin.env.