Backup & Restore
TruePPM keeps all durable state in PostgreSQL. A logical pg_dump of the
trueppm database, plus a copy of the media directory when attachments are on
local disk, is a complete, restorable backup. This page is the runbook: how to
take one, how to restore it onto a fresh stack, what is and isn’t captured, and
how to prove the procedure works with a periodic restore drill.
What is in the backup
Section titled “What is in the backup”| Data | In backup? | Why |
|---|---|---|
PostgreSQL (trueppm database) | Yes | The authoritative store — every project, task, sprint, dependency, baseline, comment, and setting. The pg_dump --format=custom artifact preserves the ltree and pg_trgm extensions and the wbs_path GiST index. |
| Media / attachments (local disk) | Yes, when local | TaskAttachment files when TRUEPPM_ALLOW_LOCAL_ATTACHMENT_STORAGE is on. When you use S3/MinIO object storage instead, the bucket is backed up by the object store — not by this artifact (see below). |
| Redis / Valkey (cache + broker) | No (by design) | Valkey holds only ephemeral, reconstructible state: the Django cache, the Celery broker queue, and the Channels real-time layer. None of it is a source of truth. Restoring a stale Redis snapshot onto a running instance would resurrect dead queue entries and serve stale cache — worse than an empty cache, which simply refills on first read. In-flight Celery tasks are re-triggered by the next write; WebSocket clients reconnect. So the backup omits it deliberately. |
backup.sh can take an opt-in Redis SAVE snapshot (--redis) for operators
who want one, but it is off by default and is never used by restore.sh — the
restore path is PostgreSQL-authoritative.
Object storage note
Section titled “Object storage note”If attachments live in an S3-compatible bucket (the recommended production
configuration), that bucket is outside the TruePPM backup artifact by design.
Back it up with your object store’s own tooling (versioning + lifecycle rules, or
aws s3 sync / mc mirror on your schedule). The database dump still captures the
attachment metadata (filename, size, owning task); pair it with your bucket’s
backup so a restore reunites the two.
Manual backup
Section titled “Manual backup”Both scripts take their connection from DATABASE_URL (and optional
REDIS_URL / TRUEPPM_MEDIA_ROOT), so the same command works on the Compose dev
stack and inside a Helm-deployed pod. Run scripts/backup.sh --help for the full
flag list.
Docker Compose
Section titled “Docker Compose”# From the repo root, against the running dev stack (db published on :5432):DATABASE_URL="postgres://trueppm:trueppm@localhost:5432/trueppm" \ ./scripts/backup.sh --output-dir ./backups
# Or from inside the api container (no host psql client needed):docker compose exec -T \ -e DATABASE_URL="postgres://trueppm:trueppm@db:5432/trueppm" \ db sh -c 'exec pg_dump --format=custom --no-owner --no-privileges \ -d "$DATABASE_URL"' > backups/trueppm-$(date -u +%Y%m%dT%H%M%SZ).dumpThe script writes a single timestamped trueppm-backup-<UTC>.tar.gz containing
db.dump, media.tar.gz (when a media dir is given), and a MANIFEST.
Kubernetes / Helm
Section titled “Kubernetes / Helm”Take an on-demand backup by running the script inside a client pod that can reach the database, using the chart-owned connection Secret:
# One-off backup pod using the same image the CronJob uses:kubectl run trueppm-backup --rm -it --restart=Never \ --image=postgres:16-alpine \ --env="DATABASE_URL=$(kubectl get secret <release>-trueppm-connection \ -o jsonpath='{.data.DATABASE_URL}' | base64 -d)" \ -- sh -c 'pg_dump --format=custom --no-owner --no-privileges \ -d "$DATABASE_URL"' > trueppm-$(date -u +%Y%m%dT%H%M%SZ).dumpFor a scheduled backup, enable the chart’s CronJob instead of running this by hand — see Scheduled backups with Helm.
Restore onto a fresh stack
Section titled “Restore onto a fresh stack”restore.sh reloads the artifact onto a clean target, is idempotent
(pg_restore --clean --if-exists, safe to re-run), and verifies the required
extensions (ltree, pg_trgm) exist afterward — a schema missing them is
silently broken, so the restore fails loudly instead.
# Compose: restore onto a freshly-created empty databaseDATABASE_URL="postgres://trueppm:trueppm@localhost:5432/trueppm" \ ./scripts/restore.sh --artifact backups/trueppm-backup-<UTC>.tar.gz --yes
# Kubernetes: restore into the target database from a client pod, then restart# the API so it picks up the restored schema.restore.sh does not restore the Redis snapshot even when the artifact
contains one — the cache and broker rebuild themselves. After a database restore,
restart the API and worker pods so any cached state is discarded.
Scheduled backups with Helm
Section titled “Scheduled backups with Helm”The chart ships an opt-in backup CronJob, off by default. Enabling it silently would create a PersistentVolumeClaim you never asked for, so you turn it on deliberately once you have chosen a destination.
backup: enabled: true schedule: "0 2 * * *" # 02:00 daily, cluster timezone outputDir: /backups keepDaily: 7 # in-job prune to the 7 newest artifacts keepWeekly: 4 # advisory — enforce with your off-cluster lifecycle policy persistence: enabled: true # chart-managed PVC at outputDir size: 10Gi storageClass: "" # cluster defaultThe CronJob runs a pg_dump --format=custom against the database (connection from
the same chart-owned Secret the API uses — no second copy of the password) and
writes a timestamped artifact to the PVC, pruning to keepDaily. To ship the
artifact off-cluster instead, point it at an S3-compatible bucket:
backup: enabled: true s3: enabled: true bucket: trueppm-backups endpoint: https://s3.us-east-1.amazonaws.com region: us-east-1 existingSecret: trueppm-backup-s3 # keys: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEYTo include local-disk media in the scheduled artifact, set backup.mediaDir and
mount your media claim read-only via backup.extraVolumes /
backup.extraVolumeMounts.
Restore drills
Section titled “Restore drills”A backup you have never restored is a hypothesis, not a backup. Prove it:
- Cadence — run a full restore drill on a throwaway target monthly, and again before any risky upgrade or migration.
- What “green” looks like:
restore.shexits0and printsextension present: ltreeandextension present: pg_trgm.- Row counts on the restored database match the source for the core tables
(
SELECT count(*) FROM projects_project;andprojects_task,sprints_sprint). - The API boots against the restored database (
/api/v1/health/returns200) and you can open a project and see its schedule.
- CI evidence — every change to the backup/restore scripts or the CronJob
template runs an automated restore drill in CI (
backup:restore-drill): it seeds a database, backs it up, drops it, restores from the artifact, and asserts the row counts match. A nightly scheduled run exercises the same path so the procedure can’t rot between changes. Green there is your standing evidence that the runbook on this page actually works.
Related
Section titled “Related”- Deployment — the stateful services and the managed-datastore path.
- Beat Liveness & Durability — keeping async work durable, the companion to not losing data.
- Record retention — what the purge jobs remove before a backup is even taken.