Self-Hosting

Backup and Restore

Backup and Restore

What actually holds state

WhatWhereBack it up?
Postgres databasepostgres_data volumeYes. This is every page, version, comment, user, and permission.
Uploadsapp_storage volume, mounted at /app/storageYes. Images and attachments live on disk, not in the database.
.envYour install directoryYes. Losing JWT_SECRET logs everyone out. Losing DB_PASSWORD locks you out of your own database.
License key.env or the instance_license tableYes. Keep the original signed string somewhere separate.
Redisredis_data volumeNo. It is a job queue. It rebuilds itself.

A database dump alone is not a backup. Restore it without the storage volume and every image in your wiki turns into a broken link.

All commands on this page are run from your install directory, the one holding docker-compose.yml and .env.

Database

One command, and it needs nothing installed on the host beyond Docker — pg_dump runs inside the Postgres container.

mkdir -p backups
docker compose exec -T postgres \
  pg_dump -U docunexus --no-owner --no-acl docunexus \
  | gzip > backups/docunexus_$(date +%Y%m%d_%H%M%S).sql.gz

--no-owner --no-acl is what lets the dump restore cleanly into a fresh database with a different role setup.

Drive it from cron with the working directory set, since Compose resolves the project from where it runs:

17 3 * * * cd /srv/docu.nexus && docker compose exec -T postgres pg_dump -U docunexus --no-owner --no-acl docunexus | gzip > backups/docunexus_$(date +\%Y\%m\%d).sql.gz

(% has to be escaped in a crontab. That is not a typo.)

Uploads

Volumes are not covered by pg_dump. Tar the storage volume separately. Confirm the real volume name first: Compose prefixes it with the project name, which the installer pins to docunexus in your .env.

docker volume ls | grep app_storage
# docunexus_app_storage   <- the name to use below

docker run --rm \
  -v docunexus_app_storage:/data:ro \
  -v "$PWD/backups":/out \
  alpine tar czf /out/storage_$(date +%Y%m%d).tar.gz -C /data .

Stamp the backup

A dump and a tarball do not say what version they came from. The manifest does: one JSON file recording the image version, every applied migration, row counts per table, and the storage totals at the moment you took it. The restore side uses it to refuse a mismatch instead of guessing.

So the three files are taken together, with the app stopped:

mkdir -p backups
docker compose stop app

docker compose run --rm -T app relocation manifest > backups/docunexus-manifest.json

docker compose exec -T postgres \
  pg_dump -U docunexus --no-owner --no-acl docunexus \
  | gzip > backups/docunexus-db.sql.gz

docker run --rm \
  -v docunexus_app_storage:/data:ro \
  -v "$PWD/backups":/out \
  alpine tar czf /out/docunexus-storage.tar.gz -C /data .

docker compose start app

Keep the three files together; they are one backup.

The nightly cron dump above is deliberately not stamped. It runs against a live instance, which is the right trade for a routine dump — nobody wants a nightly outage — and it is exactly what a manifest cannot describe. Stamp the backups you take on purpose and might restore somewhere else: before an upgrade you may need to roll back, for a quarterly restore drill, and for a move to a new server, which is a stamped backup end to end.

A dump with no manifest still restores. You just check it by hand afterwards instead of having the gate check it for you.

Restore

Stop the app first. Restoring underneath a running application means it is reading and writing a database that is being dropped out from under it.

docker compose stop app

docker compose exec -T postgres \
  psql -U docunexus -d postgres -c "DROP DATABASE IF EXISTS docunexus;"
docker compose exec -T postgres \
  psql -U docunexus -d postgres -c "CREATE DATABASE docunexus OWNER docunexus;"

gunzip -c backups/docunexus_20260803_120000.sql.gz \
  | docker compose exec -T postgres psql -U docunexus -d docunexus --quiet

Put the uploads back next, while the app is still stopped:

docker run --rm \
  -v docunexus_app_storage:/data \
  -v "$PWD/backups":/in \
  alpine sh -c "rm -rf /data/* && tar xzf /in/storage_20260803.tar.gz -C /data"

If you took a manifest with this backup, verify before starting the app. It refuses a dump from a newer version than the image you are running, an empty database, and a storage archive that does not belong to this dump:

docker compose run --rm -T app relocation verify < backups/docunexus-manifest.json

Then start the app:

docker compose start app

The app runs migrations when it starts, so a dump from an older version is brought up to the running schema on the way back up. Restoring a dump from a newer version than the image you are running is not supported and is not something migrations can undo: relocation verify refuses it by name, and the fix is to match the image to the dump first (see Upgrading).