Backups & Restore
TravStats writes its own backups — but not until you switch the schedule on. A fresh instance has automatic backups disabled. The first thing to do on this page is to enable them and then check that a file actually appeared.
Everything below is described from the 2.6.1 code, not from memory. Where the admin panel and this page disagree, the panel wins; tell us.
What a backup is
Section titled “What a backup is”One backup is a single .tar.gz archive containing three things:
| Inside the archive | What it is |
|---|---|
database.sql | A plain-SQL pg_dump of the whole flights database — every flight, cruise, stay, place, trip, achievement, token hash, setting and audit-log row of every user |
uploads.tar.gz | Every upload directory under /app/backend/uploads/ — receipts, parsed emails, parser training samples, trip photos, place photos, profile pictures, lodging photos — as a nested archive. The list is one registry in the code, and a test fails when a directory is added without joining it. |
metadata.json | Timestamp and row counts (users, flights, airports, achievements) at the moment of the dump |
The archive is not a bare .sql.gz. Piping it into psql will
not restore anything — see Restore for the two ways that
work.
What is not in the archive:
/app/data/secrets/— the JWT secret and the encryption key. They live on the data volume, not in Postgres. Without the encryption key, encrypted settings (API keys, SMTP and WebDAV passwords) cannot be decrypted on a new host and have to be re-entered.- Pulled Ollama models — those live in the Ollama container’s own volume and are re-pullable.
For a true bare-metal restore you therefore need the archive and a
copy of /app/data/secrets/.
Schedule and retention
Section titled “Schedule and retention”Admin → Backups → Automatic backup:
| Field | Default | What it does |
|---|---|---|
| Enable automatic backup | off | Master switch. Nothing runs until this is on. |
| Interval | weekly | daily (02:00 UTC every day), weekly (02:00 UTC on Sundays) or monthly (02:00 UTC on the 1st). There is no free cron field. |
| Retention (days) | 30 | Backups older than this many days are deleted by the cleanup that runs after each scheduled backup. Retention is by age, not by count. |
The container runs on UTC, so 02:00 is 02:00 UTC. The scheduler is
node-cron inside the backend process; if the container restarts
mid-backup, the half-written archive is left with status failed and
the next run writes a fresh one.
After enabling, confirm it worked: the Backups list shows a row
with status completed after the first run, and the archive is on
disk (next section). An instance that has been running for weeks with
the switch off has no backups — the panel does not warn about that.
Where the files are
Section titled “Where the files are”docker exec travstats-app ls -lh /app/data/backups# backup-2026-09-06T02-00-00-123Z.tar.gz# backup-2026-08-30T02-00-00-041Z.tar.gzFilename pattern: backup-<ISO timestamp with : and . replaced by ->.tar.gz.
The directory is /app/data/backups/ inside the container (env
BACKUP_PATH overrides it), which is on the travstats-app-data
volume — it survives docker compose down and image updates, and is
lost only with docker volume rm. If your host filesystem is itself
backed up (restic, rsnapshot, ZFS snapshots), the archives travel with
it. If not, use the WebDAV copy below.
Backup on demand
Section titled “Backup on demand”From the admin panel
Section titled “From the admin panel”Admin → Backups → Create backup now runs the same code path as the scheduler and adds a row to the list. Do this before every upgrade.
From the command line, bypassing TravStats
Section titled “From the command line, bypassing TravStats”docker exec travstats-db pg_dump -U flights flights | gzip > flights-pre-upgrade.sql.gzThis is a plain database dump — no uploads, no metadata — and is the right tool when the app itself is unhealthy. Restore it with the manual path below, not with the panel.
Restore
Section titled “Restore”From the admin panel
Section titled “From the admin panel”Admin → Backups → Restore on a completed backup opens a dialog with:
- Scope — full (database and files), database only, or files only.
- Create a backup before restoring — on by default; a safety copy of the current state is written first.
- Target database URL — optional. Leave empty to restore into the running instance’s database; fill it to restore into another Postgres (for a rehearsal on a scratch database, for example).
- A confirmation phrase you have to type.
The restore extracts the archive, replays database.sql with psql
and unpacks uploads.tar.gz back into the upload directories. The
endpoint is rate-limited, and only an admin can call it.
By hand, from the archive
Section titled “By hand, from the archive”When the panel is not reachable:
# 1. Unpack the archive somewhere on the hostmkdir restore && tar -xzf backup-2026-09-06T02-00-00-123Z.tar.gz -C restore
# 2. Stop the app so nothing writes during the restoredocker compose -f docker-compose.prod.yml stop app
# 3. Reset the schema (destructive) and replay the dumpdocker exec -i travstats-db psql -U flights -d flights -c \ "DROP SCHEMA public CASCADE; CREATE SCHEMA public; CREATE EXTENSION IF NOT EXISTS postgis;"docker exec -i travstats-db psql -U flights flights < restore/database.sql
# 4. Put the uploads back (they are a nested tar.gz)docker cp restore/uploads.tar.gz travstats-app:/tmp/uploads.tar.gzdocker exec travstats-app sh -c 'tar -xzf /tmp/uploads.tar.gz -C /app/backend && rm /tmp/uploads.tar.gz'
# 5. Restore /app/data/secrets/ from your own copy if this is a new host
# 6. Start the app — pending migrations run on bootdocker compose -f docker-compose.prod.yml up -d appFor a plain pg_dump taken by hand (the command-line path above),
step 3 is the whole restore: gunzip -c flights-pre-upgrade.sql.gz | docker exec -i travstats-db psql -U flights flights.
Restoring an older dump into a newer TravStats usually works — migrations are additive and re-run on boot. The opposite does not: older code does not know newer columns. Do not downgrade by restoring; downgrade by switching the image tag.
Rehearse this once on a scratch instance. A restore drill takes about ten minutes for a single-user instance.
WebDAV off-host copy
Section titled “WebDAV off-host copy”Admin → Settings → WebDAV (Nextcloud, HiDrive, ownCloud, any RFC 4918 server):
| Field | Meaning |
|---|---|
| Enabled | Master switch for the sync |
| URL | The WebDAV base URL, e.g. https://cloud.example.com/remote.php/dav/files/you/ |
| Username / password | An app-specific password is preferred where the server offers one |
| Backup path | The folder on the share the archives go to |
Once configured, each completed backup can be pushed to the share
(Sync on the backup row, POST /api/v1/backup/:id/sync), the share
can be listed and tested from the panel, and an archive can be pulled
back from the share into the local backup directory for a restore.
The credentials are stored encrypted with the instance’s encryption
key — which is one more reason to keep /app/data/secrets/ safe.
Off-site copy via the API
Section titled “Off-site copy via the API”If WebDAV does not fit (S3, B2, a borg server), pull the newest
completed archive over the API. The backup router is admin-only, so
the token needs the admin scope and an admin user; see
Personal Access Tokens.
#!/bin/bashTOKEN="$(cat ~/.travstats-admin-token)"BASE="https://travstats.example.com/api/v1"
# The list is {"backups":[…]} — pick the newest completed oneID=$(curl -fsS -H "Authorization: Bearer $TOKEN" "$BASE/backup" \ | jq -r '[.backups[] | select(.status=="completed")] | sort_by(.completedAt) | last | .id')
curl -fsS -H "Authorization: Bearer $TOKEN" \ "$BASE/backup/$ID/download" \ --output "/var/backups/travstats/$ID.tar.gz"Combine with rclone, restic, or whatever already runs off-site.
Disk usage
Section titled “Disk usage”Archives are gzip-compressed, so the database part is small; the upload directories are what grow. Rough figures:
- Empty database, no photos: well under 1 MB per archive
- A few hundred flights, no photos: a few hundred KB
- The same plus a few hundred trip and hotel photos: tens to hundreds of MB — the photos dominate
With the defaults (weekly, 30 days) there are at most five archives on disk at a time. Daily with 30 days keeps thirty. Retention is by age, so a longer interval does not keep more copies.
Disaster-recovery checklist
Section titled “Disaster-recovery checklist”- Find a recent archive — local in
/app/data/backups/, on the WebDAV share, or pulled earlier via the API. - Find your copy of
/app/data/secrets/. Without it, every encrypted setting has to be re-entered after the restore. - Stand up a fresh TravStats on the recovery host with the same version.
- Restore — from the panel if the fresh instance is reachable, otherwise by hand as above.
- Verify — flight count, a trip with photos, sign-in as your admin. The audit log records what the instance looked like on the day of the dump.
- Re-enter API keys, SMTP and WebDAV credentials if the secrets were lost.