Backup and restore

An Iskue installation has two pieces of persistent state: the Postgres database and the attachment files on disk. Everything else — containers, images, the frontend — is rebuilt from the repository. This page covers the single-host Docker Compose deployment (the docker-compose.yml at the repository root); cluster deployments are addressed at the end.

What to back up

DataWhat it containsWhere it lives
DatabaseTickets, projects, users, comments, wiki pages and configuration in the issuehub Postgres databaseNamed volume pgdata, served by container issuehub-postgres
AttachmentsUploaded file content, stored under STORAGE_DIR (/data/attachments inside the backend container)Named volume attachments, mounted into container issuehub-backend
Secrets and settingsJWT_SECRET, DB_PASSWORD, OAuth client secrets and any other overridesThe .env file next to docker-compose.yml

Attachment metadata lives in the database; the file on disk holds only the bytes, and the database row is the source of truth. A database dump and an attachments archive therefore only make sense as a pair taken at the same moment — restoring one without the other leaves orphaned rows or invisible files.

Keep a secure copy of .env with every backup. It is not recoverable from the containers, and without JWT_SECRET and DB_PASSWORD a rebuilt host cannot reproduce the previous deployment (losing JWT_SECRET also signs every user out).

Never run docker compose down -v. The -v flag deletes the pgdata and attachments volumes — the entire database and every uploaded file.

Backing up the single-host deployment

Stop the application first

Stop the backend (and frontend) so no writes land between the database dump and the attachments archive. Postgres stays up — it is what you are dumping from.

Run from the repository root. Postgres keeps running.
docker compose --profile full stop backend frontend

Dump the database

Run pg_dump inside the issuehub-postgres container. It connects over the container's local socket, so no password is required. The custom format (--format=custom) is compressed and lets pg_restore drop and recreate objects on restore.

Creates e.g. issuehub-2026-08-03.dump in the current directory.
docker exec issuehub-postgres pg_dump -U issuehub --format=custom issuehub \
  > issuehub-$(date +%F).dump

Do not add -t/-it to docker exec here — a TTY corrupts binary output on stdout. A raw copy of the pgdata volume is also a valid cold backup, but only with Postgres stopped and only for restoring onto the same Postgres major version (the stack pins postgres:16-alpine); the logical dump is portable and preferred.

Archive the attachments

The attachments named volume is mounted at /data/attachments in the backend container. Using --volumes-from issuehub-backend avoids needing the volume's project-prefixed name, and works while the backend container is stopped (it must exist, which it does after any --profile full start).

Creates e.g. attachments-2026-08-03.tar.gz in the current directory.
docker run --rm --volumes-from issuehub-backend -v "$(pwd)":/backup alpine:3 \
  tar czf /backup/attachments-$(date +%F).tar.gz -C /data attachments

Restart the application

docker compose --profile full start backend frontend

Move the dump, the attachments archive and a copy of .env off the host together. The three files are one point-in-time snapshot; store and restore them as a unit.

Restoring

Restore order: database first, then attachments, then start the application. The steps below assume a fresh host with the repository checked out and the backed-up .env in place; on an existing host, stop the backend and frontend first and skip straight to step 2.

Step 1 — start only Postgres and wait for its healthcheck. First start creates the empty issuehub database and role.
docker compose up -d --wait postgres
Step 2 — substitute your dump's filename. --clean --if-exists makes the restore idempotent on both fresh and existing databases.
docker exec -i issuehub-postgres pg_restore -U issuehub -d issuehub \
  --clean --if-exists < issuehub-2026-08-03.dump

Step 3 — bring the full stack up once so the backend container and the attachments volume exist (the backend's Flyway validation will pass against the restored schema), then stop the app again and unpack the archive into the volume:

Substitute your archive's filename.
docker compose --profile full up -d --build
docker compose --profile full stop backend frontend
docker run --rm --volumes-from issuehub-backend -v "$(pwd)":/backup alpine:3 \
  sh -c 'rm -rf /data/attachments/* && tar xzf /backup/attachments-2026-08-03.tar.gz -C /data'
docker compose --profile full start backend frontend

Verify

A non-zero count confirms the restored schema, including migration history.
docker exec issuehub-postgres psql -U issuehub -d issuehub \
  -c "SELECT count(*) FROM flyway_schema_history;"

Then open http://localhost:8088, sign in, and download an attachment from a ticket you know carried one — this exercises the database and the storage volume together.

Scheduled backups

The dump command is safe to run unattended while the application is up; for strict dump/archive consistency, prefer a maintenance window that stops the backend as above. A minimal nightly database dump from cron:

crontab entry — % must be escaped as \% inside crontab. Ensure /var/backups/issuehub exists and rotate old dumps.
0 2 * * * docker exec issuehub-postgres pg_dump -U issuehub --format=custom issuehub > /var/backups/issuehub/issuehub-$(date +\%F).dump

Cluster deployments

The dev/e2e cluster (deploy/docker-compose.yml) defines no database volumes at all — its Citus nodes are deliberately ephemeral and any container recreate discards the data. Do not put real data on it; there is nothing meaningful to back up.

The production cluster (deploy/docker-compose.prod.yml) stores database state in three named volumes — coordinator-data, worker1-data, worker2-data — behind the containers issuehub-cl-coordinator, issuehub-cl-worker1 and issuehub-cl-worker2, plus a shared attachments volume mounted into issuehub-cl-backend1 and issuehub-cl-backend2.

Do not transplant the single-host procedure onto the cluster. Citus distributes table rows across the worker nodes: a pg_dump taken on the coordinator does stream distributed-table rows through it, but it is not a guaranteed-consistent cluster-wide snapshot under concurrent writes and does not preserve the Citus distribution layout, and independent per-node dumps are not mutually consistent, so they cannot be combined into a valid snapshot. Consistent online backup of a Citus cluster is a Citus operational topic — follow the Citus documentation for your version (citusdata/citus:14.0.0-pg17).

The only safe compose-level fallback is a cold backup: stop the entire stack, then copy all three database volumes together as one set (plus the attachments volume, which can be archived with the same --volumes-from technique against issuehub-cl-backend1).

Stop every cluster container before taking cold copies of coordinator-data, worker1-data and worker2-data.
docker compose -f deploy/docker-compose.prod.yml stop