Backup and restore#
All persistent data lives in the pgdata Docker named volume managed by the Compose stack. Everything else — Caddy's config cache, container images, the source tree — is rebuildable. If pgdata is intact, the stack is recoverable; if it's gone, you have whatever your last backup holds.
This page covers three complementary approaches:
- Logical dump (
pg_dump/pg_restore) — preferred for portability, cross-version migrations, and off-site archival. - Volume snapshot (rsync / tar of the raw volume directory) — faster full restores, useful for host-level disaster recovery.
- Restore procedure — step-by-step recovery from a logical dump into a fresh Compose stack.
Scheduled backup guidance and encryption recommendations are in the final section.
Logical backups with pg_dump#
Logical backups are SQL-level exports produced by pg_dump. They are portable across minor Postgres versions, easy to inspect, and can restore individual tables or schemas.
Prerequisites#
- The Compose stack is running:
docker compose up -d postgres - A
backups/directory exists at the repo root (it is.gitignore'd, so create it once on each host):
- The Postgres credentials in your
.envfile are loaded into the shell, or you pass them explicitly on the command line (see below).
Taking a dump#
The make backup target automates the most common case. It produces a timestamped plain-SQL file in backups/:
Under the hood this runs:
docker compose exec -T postgres \
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
> backups/$(date +%Y-%m-%d_%H-%M-%S).sql
To use a custom-format dump instead (faster restores for large databases, supports parallel restore):
docker compose exec -T postgres \
pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
--format=custom \
> backups/$(date +%Y-%m-%d_%H-%M-%S).pgdump
The -T flag disables pseudo-TTY allocation, which is required when redirecting output to a file.
Verifying a dump#
Check that the dump file is non-empty and has the expected structure:
# Plain SQL dump
head -20 backups/2026-01-15_03-00-00.sql
# Custom format — list table-of-contents without restoring
docker compose exec -T postgres \
pg_restore --list < backups/2026-01-15_03-00-00.pgdump | head -40
A non-empty dump that begins with -- PostgreSQL database dump and ends with -- PostgreSQL database dump complete passed at least the writer-side sanity check. That's not the same as confirming it restores cleanly — for that, see Verifying backup integrity.
Volume snapshots#
A volume snapshot captures the raw Postgres data directory. This approach is faster for full restores and works even if the logical schema cannot be parsed (e.g., after a Postgres upgrade gone wrong), but it is not portable across major Postgres versions.
Locating the volume#
Find where Docker stores the pgdata volume on the host. Compose names volumes <project>_<volumeName>, where <project> defaults to the repo directory name unless you set COMPOSE_PROJECT_NAME or pass -p. Run docker volume ls | grep pgdata if the project prefix isn't ticket-board:
Example output (Linux):
[
{
"CreatedAt": "2026-01-01T00:00:00Z",
"Driver": "local",
"Mountpoint": "/var/lib/docker/volumes/ticket-board_pgdata/_data",
"Name": "ticket-board_pgdata",
"Scope": "local"
}
]
The Mountpoint value is the directory you will snapshot.
Snapshot with rsync#
Stop Postgres first to avoid a torn snapshot:
docker compose stop postgres
# Replace the mountpoint path with the value from docker volume inspect.
sudo rsync -aAX \
/var/lib/docker/volumes/ticket-board_pgdata/_data/ \
/mnt/backup/pgdata-snapshot-$(date +%Y-%m-%d)/
docker compose start postgres
Snapshot with tar#
If rsync is not available, use tar:
docker compose stop postgres
sudo tar -czf \
/mnt/backup/pgdata-$(date +%Y-%m-%d).tar.gz \
-C /var/lib/docker/volumes/ticket-board_pgdata \
_data
docker compose start postgres
Restoring from a volume snapshot#
# 1. Stop the entire stack.
docker compose down
# 2. Remove the existing (damaged) volume.
docker volume rm ticket-board_pgdata
# 3. Re-create the empty volume.
docker volume create ticket-board_pgdata
# 4. Restore the snapshot into the volume.
# rsync approach:
sudo rsync -aAX \
/mnt/backup/pgdata-snapshot-2026-01-15/ \
/var/lib/docker/volumes/ticket-board_pgdata/_data/
# tar approach:
sudo tar -xzf /mnt/backup/pgdata-2026-01-15.tar.gz \
-C /var/lib/docker/volumes/ticket-board_pgdata
# 5. Restart the stack.
docker compose up -d
Major-version upgrades break volume snapshots
A pgdata directory written by Postgres 16 cannot be read by Postgres 17 without pg_upgrade. If you're moving to a new major version, take a logical dump from the old version, restore it into the new one, and treat volume snapshots as last-resort recovery only.
Full restore from a logical dump#
Use this procedure after a total data loss event, a migration to a new host, or a destructive schema change.
Start a fresh stack#
Bring up only Postgres (skip the backend and frontend for now to avoid connection noise during restore):
Drop and recreate the database#
Connect to the running container and reset the target database:
docker compose exec postgres \
psql -U "$POSTGRES_USER" -d postgres \
-c "DROP DATABASE IF EXISTS \"$POSTGRES_DB\";"
docker compose exec postgres \
psql -U "$POSTGRES_USER" -d postgres \
-c "CREATE DATABASE \"$POSTGRES_DB\";"
Restore the dump#
Plain SQL dump:
docker compose exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
< backups/2026-01-15_03-00-00.sql
Custom-format dump (parallel, faster for large databases):
# Copy the dump file into the container first.
docker cp backups/2026-01-15_03-00-00.pgdump \
"$(docker compose ps -q postgres)":/tmp/restore.pgdump
# Restore with 4 parallel jobs.
docker compose exec postgres \
pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
--jobs=4 /tmp/restore.pgdump
Run Alembic migrations (if restoring to a newer schema)#
If the dump is from an older schema version, run migrations after the restore:
Bring up the full stack#
Smoke test#
# Check the backend health endpoint.
curl -s http://localhost/readyz | python3 -m json.tool
# Verify row counts look reasonable.
docker compose exec postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
-c "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;"
Walk the release smoke checklist before declaring the restore successful — it covers login, ticket transitions, MCP, webhooks, and audit log access.
Scheduled backups and encryption#
Recommended schedule#
| Frequency | Retention | Notes |
|---|---|---|
| Daily dump | 7 days local | make backup via cron |
| Weekly dump | 4 weeks off-site | Encrypted, see below |
| Monthly dump | 12 months off-site | Cold storage (e.g. S3 Glacier) |
A single-operator homelab generates very little data, so even monthly off-site dumps are typically under 100 MB compressed. Don't skimp on retention.
Example cron entry#
Add to the host's crontab (crontab -e as the user running Docker):
# Daily logical backup at 03:00, prune files older than 7 days.
0 3 * * * cd /path/to/ticket-board && \
make backup && \
find backups/ -name "*.sql" -mtime +7 -delete
If you're using the systemd unit from Homelab setup, the same path applies — cron runs as the user, not the unit.
Encrypting dumps for off-site storage#
Encrypt before transferring off-site. Using age:
# Generate a key pair once and store the private key securely.
age-keygen -o ~/.config/age/backup.key
# Public key is printed to stdout — record it.
# Encrypt a dump.
age -r age1<your-public-key> \
-o backups/2026-01-15_03-00-00.sql.age \
backups/2026-01-15_03-00-00.sql
# Remove plaintext after encrypting.
rm backups/2026-01-15_03-00-00.sql
Decrypting for restore:
Off-site targets: S3 bucket with versioning enabled, Backblaze B2, or a separate host via rsync-over-SSH. Ensure the private key is stored separately from the encrypted backups (e.g. a password manager or secrets vault). A backup encrypted with a key stored next to it is a backup encrypted with no key at all.
Verifying backup integrity#
Periodically perform a test restore into an isolated stack to confirm the dump is viable. The cleanest pattern is a parallel Compose project — same images, different project name, separate volumes — described in Upgrade dry-run on a copy:
# Spin up a parallel project named ticket-board-verify on a different port.
HTTP_PORT=18080 HTTPS_PORT=18443 \
docker compose -p ticket-board-verify up -d postgres
# Restore the dump.
docker compose -p ticket-board-verify exec -T postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
< backups/latest.sql
# Confirm tables exist.
docker compose -p ticket-board-verify exec postgres \
psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -c '\dt'
# Tear it down (deletes the verify-only volumes too).
docker compose -p ticket-board-verify down -v
A backup that has never been tested is a backup that may not work.
Schedule a quarterly restore drill
Put a 30-minute slot on the calendar once a quarter to spin up a fresh stack from the most recent backup, run the release smoke checklist against it, and tear it down. The drill catches schema mismatches, missing config, and encryption key issues while you still have time to fix them.
What's NOT in a backup#
pgdata holds the durable state of the application, but a working stack also depends on:
.env— secrets and configuration. Back this up separately (encrypted), or you can't restore.Caddyfilelives on the host filesystem (committed to git); local edits ride along in your repo backups.- The
caddy_datavolume holds ACME certificates and is regenerated on next start, so it does not need to be backed up. - The container images — they rebuild from source via
docker compose build.
The single load-bearing file outside the database is .env. Treat it as a backup target on the same cadence as pgdata.