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, pg_trgm, and btree_gist 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-compatible object storage instead (MinIO, SeaweedFS, etc.), 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. |
INTEGRATION_ENCRYPTION_KEY | No — and the dump is useless without it | Lives in a Kubernetes Secret / .env, not in PostgreSQL. See the warning immediately below. |
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.
Back up INTEGRATION_ENCRYPTION_KEY with the dump
Section titled “Back up INTEGRATION_ENCRYPTION_KEY with the dump”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.
Ordering for an internally-consistent restore
Section titled “Ordering for an internally-consistent restore”The database and the object store are two backups of one system, and the
order you capture and restore them in decides whether the restored instance is
internally consistent — the TaskAttachment row and the object it points at must
both exist, or neither.
- Backup order — object store first, then the database. Snapshot (or let the
lifecycle-versioned bucket settle) the object store before you take the
pg_dump. An attachment upload writes the object, then the row; capturing the bucket first guarantees every row the dump contains has its object already in the bucket snapshot. The reverse order can dump a row whose object was written after the bucket snapshot — a dangling reference (a row pointing at a missing object). - Restore order — database first, then reconcile the bucket. Restore the database, then restore/attach the bucket at the same-or-newer point in time. Because backup captured the bucket first, a matching-time bucket contains a superset of the objects the DB references: every row resolves, and any extra objects (uploads that never committed a row) are harmless orphans the attachment-GC pass reclaims. Restoring an older bucket than the database is the one unsafe combination — it reintroduces dangling references.
- Quiesce for a clean point. For a strictly consistent pair, take both while writes are paused (a short maintenance window, or scale the API to zero replicas). Without a quiesce, the object-store-first / DB-first ordering above keeps the result safe (orphans, never dangles) rather than perfectly matched.
Manual backup
Section titled “Manual backup”Both scripts take their connection from DATABASE_URL (and optional
REDIS_URL / TRUEPPM_MEDIA_ROOT) — it is the same script and the same flags
everywhere, but which container you run it from, and what DATABASE_URL
points at, differs by stack: the dev Compose stack, the production Compose
stack, and a Helm-deployed pod each need their own invocation, below. Run
scripts/backup.sh --help for the full flag list.
Docker Compose (development)
Section titled “Docker Compose (development)”This targets docker-compose.yml — the bind-mounted dev stack started with
make up. It publishes db on loopback :5432 with the hardcoded
trueppm/trueppm credentials, so backup.sh can run straight from the host:
# 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.
To upload the artifact to a bucket in the same run, add --s3-bucket (and
--s3-endpoint for MinIO, SeaweedFS, or another self-hosted S3-compatible store):
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...DATABASE_URL="postgres://trueppm:trueppm@localhost:5432/trueppm" \ ./scripts/backup.sh --output-dir ./backups \ --s3-bucket trueppm-backups \ --s3-endpoint http://minio.example.internal:9000 \ --s3-prefix prod/dailyThe upload uses the AWS CLI (aws) when it is installed and the MinIO client
(mc) otherwise — despite the name, mc is a generic S3 client and works
against any compatible endpoint, including SeaweedFS; force one with
TRUEPPM_S3_CLIENT. If a bucket is configured and neither client is installed,
the run fails before the dump starts rather than quietly leaving the
artifact on local disk.
Docker Compose (production)
Section titled “Docker Compose (production)”None of the commands in the section above work against docker-compose.prod.yml.
The production db service publishes no host port at all ("ports": null in
docker compose config — deliberate, since #3189) and authenticates with the
DB_PASSWORD you generated into .env, not trueppm. There is also no psql
client anywhere in the stack: packages/api/Dockerfile’s runtime stage installs
only libpq5 (the shared library the compiled psycopg driver links against),
not postgresql-client, so pg_dump is absent from the api/celery images
and scripts/backup.sh is not COPY’d into any image that ships. The only
image in the stack that carries pg_dump is db itself (the stock
postgres:16-alpine), and it has no route to the host filesystem or to the
scripts/ directory on its own.
The fix is to run backup.sh inside a throwaway container built from the
db service, with scripts/ and an output directory bind-mounted from the
host and DATABASE_URL pointing at the db service by its Compose network
name — not localhost, since this container never gets the loopback port
mapping. docker compose run starts a one-off container from a service’s
image/config, attached to the same project network as the running stack, so
db:5432 resolves exactly as it does for the api container:
# From the repo root, with the production stack already up# (docker compose -f docker-compose.prod.yml up -d). Reads DB_PASSWORD from .env.set -a; source .env; set +a
docker compose -f docker-compose.prod.yml run --rm --no-deps \ -v "$(pwd)/scripts:/scripts:ro" \ -v "$(pwd)/backups:/backups" \ -e DATABASE_URL="postgres://trueppm:${DB_PASSWORD}@db:5432/trueppm" \ db bash /scripts/backup.sh --output-dir /backups--no-deps stops this from restarting db’s own dependencies (it has none,
but it is harmless-by-habit on every service). The db image’s entrypoint only
runs its Postgres bootstrap logic when the container’s command starts with
postgres or a flag; handing it bash /scripts/backup.sh execs that directly
and never touches PGDATA. postgres:16-alpine also carries bash, tar,
gzip, and find, everything backup.sh needs beyond pg_dump itself — this
whole invocation was run hands-on against the dev stack’s db service before
being written down here, not inferred from reading the image.
This keeps every feature the dev-stack invocation has — the tarball, the
MANIFEST, --keep-daily pruning, and --s3-bucket upload — because it is
the same script, just run from a container that can reach db and has
somewhere to write. Add -e TRUEPPM_MEDIA_ROOT=/media -v media:/media:ro (the
named volume the compose file declares, resolved by Compose to
<project>_media — trueppm_media under this file’s pinned name: trueppm)
to fold the attachment directory into the same tarball, or back it up
separately as shown below.
If you only need the database and are willing to forfeit media archiving, the
manifest, retention pruning, and the S3 upload leg, exec into the already-running
db container directly:
set -a; source .env; set +a
docker compose -f docker-compose.prod.yml exec -T \ -e DATABASE_URL="postgres://trueppm:${DB_PASSWORD}@localhost: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(localhost is correct here — exec runs inside the already-running db
container, not a fresh one, so it reaches Postgres over the loopback interface
the same way pg_isready’s own healthcheck does.)
Backing up the media volume
Section titled “Backing up the media volume”Task attachments and the workspace logo live in the media named volume
(mounted at /var/lib/trueppm/media in the api/celery/celery-beat
containers), not on the host filesystem — backup.sh --media-dir cannot see
it directly from the host the way it can a dev-stack bind mount. Archive the
volume with a disposable helper container instead:
docker run --rm \ -v trueppm_media:/media:ro \ -v "$(pwd)/backups:/backup" \ alpine tar -czf "/backup/trueppm-media-$(date -u +%Y%m%dT%H%M%SZ).tar.gz" -C /media .Skip this entirely if attachments are on S3-compatible object storage (MinIO,
SeaweedFS, etc.) (TRUEPPM_ALLOW_LOCAL_ATTACHMENT_STORAGE unset) — the bucket
is the backup, per the object storage note above.
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, btree_gist) exist afterward — a schema missing them is
silently broken, so the restore fails loudly instead.
# Compose (development, docker-compose.yml): 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.
Docker Compose (production)
Section titled “Docker Compose (production)”Same mismatch as the backup side: docker-compose.prod.yml’s db publishes no
host port and there is no psql/pg_restore client in the api image. Run
restore.sh the same way as backup.sh above — as a one-off container built
from the db service, with scripts/ and the artifact’s directory
bind-mounted and DATABASE_URL pointing at db by its Compose network name:
set -a; source .env; set +a
docker compose -f docker-compose.prod.yml run --rm --no-deps \ -v "$(pwd)/scripts:/scripts:ro" \ -v "$(pwd)/backups:/backups" \ -e DATABASE_URL="postgres://trueppm:${DB_PASSWORD}@db:5432/trueppm" \ db bash /scripts/restore.sh \ --artifact /backups/trueppm-backup-<UTC>.tar.gz --yesRestart the stack afterward so every container picks up the restored schema and discards any cached state:
docker compose -f docker-compose.prod.yml restart api celery celery-beatIf the artifact was created with TRUEPPM_MEDIA_ROOT/--media-dir set (the
“fold into the same tarball” option above), pass the same media volume
mount through to restore.sh instead of the artifact’s database-only
invocation — it extracts media.tar.gz from that same artifact once the
database restore and extension checks pass:
set -a; source .env; set +a
docker compose -f docker-compose.prod.yml run --rm --no-deps \ -v "$(pwd)/scripts:/scripts:ro" \ -v "$(pwd)/backups:/backups" \ -v media:/media \ -e DATABASE_URL="postgres://trueppm:${DB_PASSWORD}@db:5432/trueppm" \ db bash /scripts/restore.sh \ --artifact /backups/trueppm-backup-<UTC>.tar.gz --media-dir /media --yesThis overwrites files already in the volume (it extracts on top of
whatever is there — it does not first empty the directory) — stop the
api/celery/celery-beat containers first so nothing is writing to it
mid-restore. If the backup instead used the “back it up separately” option —
a standalone trueppm-media-<UTC>.tar.gz that restore.sh has never seen —
restore it with the same disposable helper container pattern used to create
it (this one DOES clear the volume first, since it is a plain tar extract
rather than a restore.sh run):
docker run --rm \ -v trueppm_media:/media \ -v "$(pwd)/backups:/backup:ro" \ alpine sh -c 'find /media -mindepth 1 -delete; tar -xzf /backup/trueppm-media-<UTC>.tar.gz -C /media'Restoring straight from the bucket
Section titled “Restoring straight from the bucket”When the artifact was uploaded off-cluster, --from-s3 downloads it and restores
in one step — no manual aws s3 cp first:
export AWS_ACCESS_KEY_ID=... AWS_SECRET_ACCESS_KEY=...DATABASE_URL="postgres://trueppm:trueppm@localhost:5432/trueppm" \ ./scripts/restore.sh --from-s3 latest \ --s3-bucket trueppm-backups \ --s3-endpoint http://minio.example.internal:9000 \ --s3-prefix prod/daily --yeslatest selects the newest trueppm-backup-*.tar.gz under the prefix; pass an
explicit object key instead to restore a specific one. The downloaded copy lands
in a scratch directory and is deleted when the script exits — use --artifact
with a local path if you want to keep it.
Why the ltree / pg_trgm / btree_gist extension ordering matters
Section titled “Why the ltree / pg_trgm / btree_gist extension ordering matters”TruePPM’s schema depends on three PostgreSQL extensions, and a naive dump/restore that reorders or drops their creation produces a database that restores “successfully” but is silently broken:
ltreebacks theprojects_task.wbs_pathcolumn and its GiST index. WBS subtree and ancestor queries (“everything under this summary task”) areltreeoperators — without the extension the column type does not exist and the restore ofprojects_taskfails outright; with the extension present but the GiST index dropped, the queries still run but fall back to sequential scans.pg_trgmbacks the trigram GIN indexes that power fuzzy task and project search. Missing it means search either errors (index create fails) or silently degrades to unindexedILIKE.btree_gistbacks theunique_task_wbs_path_per_project_liveexclusion constraint onprojects_task— the rule that stops two live tasks in one project from occupying the samewbs_path. That constraint is a GiST index over(project, wbs_path), and GiST has no native support for scalar equality on auuidcolumn;btree_gistis what supplies it. Without the extension the constraint cannot be built and the restore ofprojects_taskfails outright.
The extensions must be created before any table, column, or index that
references them. pg_dump --format=custom records CREATE EXTENSION in the
archive’s table-of-contents ahead of the dependent CREATE TABLE / CREATE INDEX entries, and pg_restore replays the TOC in dependency order — so the
custom-format dump preserves the ordering automatically. This is why the
runbook uses --format=custom and not a plain pg_dump > file.sql piped
into psql: a plain-SQL dump edited or filtered by hand (for example, stripping
CREATE EXTENSION lines because “the target already has them”, or restoring a
single table) can reorder or drop the extension statements and break exactly the
objects above.
If you restore into a database where a platform policy blocks unprivileged
CREATE EXTENSION (some managed Postgres offerings), create the extensions as a
superuser first, then run the restore:
CREATE EXTENSION IF NOT EXISTS ltree;CREATE EXTENSION IF NOT EXISTS pg_trgm;-- Must exist before projects_task is created: its exclusion constraint is a-- GiST index over (project, wbs_path) and cannot be built without it.CREATE EXTENSION IF NOT EXISTS btree_gist;All three are trusted extensions on PostgreSQL 13 and later, so an ordinary
database owner can create them unless your platform restricts it explicitly —
check before assuming you need this step.
restore.sh guards against a silent miss: after the restore it asserts all
three extensions are present (SELECT 1 FROM pg_extension …) and fails the
whole restore if any is absent, so a broken schema surfaces loudly instead of
at the first WBS query weeks later.
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 # read by NO template — a place to record the weekly # retention your bucket's lifecycle policy enforces 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.
It runs an inlined command rather than scripts/backup.sh — the lean
application image carries no pg_dump, so the Job runs from a PostgreSQL client
image with no application code in it. The two producers write the same
MANIFEST field set (created_utc, run_context, pg_dump_version,
db_included, media_included, redis_included, s3_destination) and
restore.sh consumes either artifact. run_context is what tells you which one
you are holding. The CronJob cannot include a Redis snapshot — it has no
redis-cli and no route to the RDB file — and its manifest says so rather than
omitting the field.
Alert on it
Section titled “Alert on it”A scheduled backup nobody is alerting on is the same silent failure one step
later. With alerts.enabled=true, enabling backups also renders:
| Alert | Fires when |
|---|---|
TruePPMBackupJobFailed | a backup Job exhausted its backoffLimit |
TruePPMBackupStale | no successful backup within alerts.thresholds.backup.staleAfterSeconds (default 48h — two missed daily runs) |
TruePPMBackupNeverSucceeded | backups are enabled and no success has ever been recorded. Catches a suspended CronJob, Jobs that never schedule, and kube-state-metrics not being scraped — in which case TruePPMBackupStale cannot fire either |
TruePPMVolumeFillingUp covers every claim in the namespace, including the
backup PVC: a full one turns every subsequent run into a failure.
Raise staleAfterSeconds if you lengthen backup.schedule. The default is
2x a daily schedule; a weekly schedule under a 48h window alerts every week by
construction.
Shipping the artifact off-cluster
Section titled “Shipping the artifact off-cluster”Add backup.s3.* and each artifact is uploaded to an S3-compatible bucket after
it is written locally:
backup: enabled: true s3: enabled: true bucket: trueppm-backups endpoint: "" # empty for real AWS S3; set it for MinIO/SeaweedFS region: us-east-1 prefix: prod/daily # optional key prefix existingSecret: trueppm-backup-s3 # keys: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEYThe object key is <prefix>/trueppm-backup-<UTC timestamp>.tar.gz.
If the upload fails, the job fails. The artifact is never left sitting on the PVC while the CronJob reports success — a green backup job means the copy really did leave the cluster.
For MinIO, SeaweedFS, or another self-hosted store, set endpoint to its URL:
backup: s3: enabled: true bucket: trueppm-backups endpoint: http://minio.storage.svc.cluster.local:9000 region: us-east-1 existingSecret: trueppm-backup-s3Setting endpoint switches the upload to path-style addressing
(endpoint/bucket/key rather than bucket.endpoint/key), which is what a
self-hosted store reached by IP or by a single hostname can actually serve.
Off-cluster plaintext warning
Section titled “Off-cluster plaintext warning”endpoint takes whatever URL you give it, scheme included — TruePPM never
hardcodes http:// or https://. In-cluster plaintext (the example above,
http://minio.storage.svc.cluster.local:9000) is expected and fine: the
traffic never leaves the pod network. But the artifact is a full database
dump — every project, task, dependency, comment, and user email in the
deployment — and if endpoint is repointed at an off-cluster or cross-VPC
bucket with the scheme still http://, that dump crosses the network
unencrypted.
To include local-disk media in the scheduled artifact, set backup.mediaDir to
persistence.media.mountPath (/var/lib/trueppm/media by default) and mount the
same claim read-only via backup.extraVolumes / backup.extraVolumeMounts:
backup: mediaDir: /var/lib/trueppm/media extraVolumes: - name: media persistentVolumeClaim: claimName: trueppm-media # or your persistence.media.existingClaim readOnly: true extraVolumeMounts: - name: media mountPath: /var/lib/trueppm/media readOnly: trueA ReadWriteOnce media claim cannot be read here while the api pod holds it
unless the backup Job happens to land on the same node — which is one of the
reasons persistence.media.accessMode defaults to ReadWriteMany. See
attachment storage.
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: ltree,extension present: pg_trgm, andextension present: btree_gist.- Row counts on the restored database match the source for the core tables
(
SELECT count(*) FROM projects_project;andprojects_task,sprints_sprint). - If the artifact includes media, an attachment restored with
--media-diropens and matches its original checksum — the archive listingmedia.tar.gzonly proves it was packed, not that it comes back intact. - 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 and a small media fixture, backs both up, drops the database and deletes the local copies, restores from the artifact (database and media — including through the S3 leg and the chart’s own CronJob command), and asserts the row counts and the media file’s sha256 checksum 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.
- Durability & Redundancy — what is authoritative, what is reconstructible, and the redundancy ladder this backup sits on.
- Beat Liveness — detecting the dead scheduler that would otherwise stall every drain.
- Record retention — what the purge jobs remove before a backup is even taken.