Skip to content
These docs describe Tradr v0.14.0. Running an older release? Check the release notes for what changed.

Back up and restore

Tradr keeps all its state in one PostgreSQL database, so a backup is a database dump plus one file. This page covers both, the restore, and how to check that a backup restores before you need it to.

Two things, and they live in different places.

What Where Lost without it
Your data the pgdata Docker volume Accounts, positions, fills, ledger entries, notes
Your secrets the .env file The ability to read part of a restored database — see below

The database holds everything the application stores, uploaded images included: object storage is a hosted capability and stays unconfigured here. So a plain pg_dump is a complete backup of your data. There is no second data store, and no proprietary format.

.env is not in the database, and one value in it is unrecoverable.

Tradr encrypts each user’s provider API keys at rest with ENCRYPTION_KEY. A restored database still holds those encrypted keys. Without the original ENCRYPTION_KEY, nothing can decrypt them. The only repair is for every user to paste their key again.

Copy .env to the same place you keep your backups. Treat it as a secret. It contains POSTGRES_PASSWORD, SESSION_SECRET, and ENCRYPTION_KEY.

Docker prefixes the volume with the Compose project name. The project name defaults to the directory you run docker compose in, so a clone in tradr/ gives tradr_pgdata.

Confirm it rather than assuming it:

Terminal window
docker volume ls | grep pgdata

Expected result: a line ending in _pgdata. Use that name wherever this page writes tradr_pgdata.

pg_dump reads a consistent snapshot while the stack keeps serving. This is the backup to automate.

Terminal window
docker compose exec -T postgres \
sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > backup-$(date +%F).sql

Expected result: exit code 0, and a file of tens of kilobytes or more. A fresh instance with one account dumps to about 60 KB.

Check the file before you trust it:

Terminal window
ls -l backup-*.sql
tail -1 backup-$(date +%F).sql

Expected result: a non-zero size, and a last line reading -- PostgreSQL database dump complete.

Take a dump on a schedule, and keep more than one. A backup taken every night is worth little if last night’s ran against an already-corrupt database.

Terminal window
# /etc/cron.d/tradr-backup — nightly at 03:15, keep 14 days
15 3 * * * root cd /srv/tradr && docker compose exec -T postgres \
sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' \
> /srv/backups/tradr-$(date +\%F).sql \
&& find /srv/backups -name 'tradr-*.sql' -mtime +14 -delete

Copy the backups somewhere the server cannot reach. A backup on the same disk as the database survives a mistake, not a disk.

Method 2 — volume snapshot, stack stopped

Section titled “Method 2 — volume snapshot, stack stopped”

A snapshot copies the Postgres data directory byte for byte. Stop the stack first. Copying a live data directory produces an archive that Postgres has to recover from, and sometimes cannot.

Terminal window
docker compose down
docker run --rm -v tradr_pgdata:/data -v "$PWD":/backup alpine \
tar czf /backup/pgdata-$(date +%F).tar.gz -C /data .
docker compose up -d

Expected result: an archive of a few megabytes, and the stack back up within a minute.

A snapshot restores only into the same Postgres major version. Tradr ships postgres:16. A logical dump carries no such restriction, so prefer method 1 unless you want a fast whole-instance clone.

Restore into an empty database. A dump does not drop what is already there, so restoring over live data leaves a mixture of both.

Terminal window
docker compose down -v

Expected result: Docker reports Volume tradr_pgdata Removed.

Terminal window
docker compose up -d postgres

Starting postgres alone keeps the api from booting and running migrations into the database you are about to overwrite.

Expected result: docker compose ps lists postgres as healthy, and nothing else.

Terminal window
docker compose exec -T postgres \
sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < backup-2026-08-04.sql

Expected result: exit code 0. psql prints one line per statement and ends on a run of ALTER TABLE.

Keep -v ON_ERROR_STOP=1. Without it, psql prints its errors, continues to the end, and exits 0 — a partial restore that reports success.

Terminal window
docker compose up -d

The api boots against the restored schema. Migrations run, find nothing pending, and the api starts serving.

Terminal window
docker compose exec api tradr migrate --status

Expected result: exit code 0, and a final line reading Schema is up to date.

Then log in as an account that existed before the backup. That is the only check that covers the whole path.

Replace the data directory instead of loading SQL. The stack must be down.

Terminal window
docker compose down
docker volume rm tradr_pgdata
docker volume create tradr_pgdata
docker run --rm -v tradr_pgdata:/data -v "$PWD":/backup alpine \
tar xzf /backup/pgdata-2026-08-04.tar.gz -C /data
docker compose up -d

Expected result: the api reaches healthy, and an account from before the snapshot logs in.

Create the empty volume explicitly. Docker would create it for you on the run. Naming the step keeps a typo from extracting into a brand-new volume that the stack never mounts.

A backup nobody has restored is a hypothesis. The repository ships a drill that tests one for you:

Terminal window
SOURCE_DATABASE_URL=postgres://user:pass@your-host:5432/tradr \
STAGING_ADMIN_URL=postgres://user:pass@other-host:5432/postgres \
scripts/backup-restore-drill.sh

The drill dumps the source and restores it into a throwaway database on a separate server. It then checks three things: no pending migrations, no foreign-key orphans, and a row count per table that matches the source. It drops the throwaway database afterwards.

Expected result: exit code 0. A non-zero exit names what failed.

The drill never writes to the source. It refuses to run when the staging target resolves to the same host, port, and database as the source. Point STAGING_ADMIN_URL at a maintenance database (…/postgres) on a server you do not mind losing.

Run the drill after any change to how you back up, and once in a while regardless. It needs pg_dump, pg_restore, psql, and Node on the machine you run it from.

  1. Take a logical dump on the old server.
  2. Copy the dump and .env to the new server.
  3. Clone the repository on the new server.
  4. Put the old .env in place. Keep ENCRYPTION_KEY byte for byte.
  5. Run docker compose up -d postgres.
  6. Load the dump, as in step 3 above.
  7. Run docker compose up -d.

Expected result: every account logs in with its existing password, and stored provider API keys still work.

Change WEB_PORT, TRUSTED_PROXIES, or WEB_BASE_URL afterwards if the new server needs different values. Leave the three secrets alone.

  • The backup file is 0 bytes — the $POSTGRES_USER expansion ran in your shell. Use the sh -c '…' form above.
  • pg_dump reports role "root" does not exist — the same cause.
  • psql reports relation … already exists during a restore — the target database is not empty. Run docker compose down -v, then start again from step 2.
  • The api crash-loops after a restore and names the encryption key — the .env on this server carries a different ENCRYPTION_KEY than the data was written with. Restore the original .env.
  • The api crash-loops with password authentication failed for user "tradr" — you regenerated .env against an existing volume. Postgres applies POSTGRES_PASSWORD once, when it initialises the data directory, and ignores it on every later start. Put the original password back, or destroy the volume and restore from a dump.
  • docker compose exec reports no such service: postgres — you are not in the directory that holds docker-compose.yml.