Skip to content

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.

DataIn backup?Why
PostgreSQL (trueppm database)YesThe 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 localTaskAttachment 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.

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.

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.

Terminal window
# 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).dump

The 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.

Take an on-demand backup by running the script inside a client pod that can reach the database, using the chart-owned connection Secret:

Terminal window
# 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).dump

For a scheduled backup, enable the chart’s CronJob instead of running this by hand — see Scheduled backups with Helm.

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.

Terminal window
# Compose: restore onto a freshly-created empty database
DATABASE_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.

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.

values.yaml
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 default

The 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_KEY

To include local-disk media in the scheduled artifact, set backup.mediaDir and mount your media claim read-only via backup.extraVolumes / backup.extraVolumeMounts.

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:
    1. restore.sh exits 0 and prints extension present: ltree and extension present: pg_trgm.
    2. Row counts on the restored database match the source for the core tables (SELECT count(*) FROM projects_project; and projects_task, sprints_sprint).
    3. The API boots against the restored database (/api/v1/health/ returns 200) 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.