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.
What you have to back up
Section titled “What you have to back up”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.
Back up .env too, and keep ENCRYPTION_KEY
Section titled “Back up .env too, and keep ENCRYPTION_KEY”.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.
Find your volume name
Section titled “Find your volume name”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:
docker volume ls | grep pgdataExpected result: a line ending in _pgdata. Use that name wherever this page
writes tradr_pgdata.
Method 1 — logical dump, no downtime
Section titled “Method 1 — logical dump, no downtime”pg_dump reads a consistent snapshot while the stack keeps serving. This is the
backup to automate.
docker compose exec -T postgres \ sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB"' > backup-$(date +%F).sqlExpected 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:
ls -l backup-*.sqltail -1 backup-$(date +%F).sqlExpected result: a non-zero size, and a last line reading
-- PostgreSQL database dump complete.
Automate it
Section titled “Automate it”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.
# /etc/cron.d/tradr-backup — nightly at 03:15, keep 14 days15 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 -deleteCopy 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.
docker compose downdocker run --rm -v tradr_pgdata:/data -v "$PWD":/backup alpine \ tar czf /backup/pgdata-$(date +%F).tar.gz -C /data .docker compose up -dExpected 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 a logical dump
Section titled “Restore a logical dump”Restore into an empty database. A dump does not drop what is already there, so restoring over live data leaves a mixture of both.
1. Destroy the current database
Section titled “1. Destroy the current database”docker compose down -vExpected result: Docker reports Volume tradr_pgdata Removed.
2. Start Postgres on its own
Section titled “2. Start Postgres on its own”docker compose up -d postgresStarting 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.
3. Load the dump
Section titled “3. Load the dump”docker compose exec -T postgres \ sh -c 'psql -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d "$POSTGRES_DB"' < backup-2026-08-04.sqlExpected 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.
4. Start the rest of the stack
Section titled “4. Start the rest of the stack”docker compose up -dThe api boots against the restored schema. Migrations run, find nothing pending, and the api starts serving.
5. Check the restore
Section titled “5. Check the restore”docker compose exec api tradr migrate --statusExpected 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.
Restore a volume snapshot
Section titled “Restore a volume snapshot”Replace the data directory instead of loading SQL. The stack must be down.
docker compose downdocker volume rm tradr_pgdatadocker volume create tradr_pgdatadocker run --rm -v tradr_pgdata:/data -v "$PWD":/backup alpine \ tar xzf /backup/pgdata-2026-08-04.tar.gz -C /datadocker compose up -dExpected 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.
Prove the backup restores
Section titled “Prove the backup restores”A backup nobody has restored is a hypothesis. The repository ships a drill that tests one for you:
SOURCE_DATABASE_URL=postgres://user:pass@your-host:5432/tradr \STAGING_ADMIN_URL=postgres://user:pass@other-host:5432/postgres \scripts/backup-restore-drill.shThe 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.
Move an instance to another server
Section titled “Move an instance to another server”- Take a logical dump on the old server.
- Copy the dump and
.envto the new server. - Clone the repository on the new server.
- Put the old
.envin place. KeepENCRYPTION_KEYbyte for byte. - Run
docker compose up -d postgres. - Load the dump, as in step 3 above.
- 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.
Troubleshooting
Section titled “Troubleshooting”- The backup file is 0 bytes — the
$POSTGRES_USERexpansion ran in your shell. Use thesh -c '…'form above. pg_dumpreportsrole "root" does not exist— the same cause.psqlreportsrelation … already existsduring a restore — the target database is not empty. Rundocker compose down -v, then start again from step 2.- The api crash-loops after a restore and names the encryption key — the
.envon this server carries a differentENCRYPTION_KEYthan the data was written with. Restore the original.env. - The api crash-loops with
password authentication failed for user "tradr"— you regenerated.envagainst an existing volume. Postgres appliesPOSTGRES_PASSWORDonce, 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 execreportsno such service: postgres— you are not in the directory that holdsdocker-compose.yml.
Next steps
Section titled “Next steps”- Upgrade an instance — back up first; this page is why.
- Database & migrations — what runs against the schema, and when.
- Install with Docker Compose — the stack these commands assume.