Skip to content

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.

One backup is a single .tar.gz archive containing three things:

Inside the archiveWhat it is
database.sqlA 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.gzEvery 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.jsonTimestamp 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/.

Admin → Backups → Automatic backup:

FieldDefaultWhat it does
Enable automatic backupoffMaster switch. Nothing runs until this is on.
Intervalweeklydaily (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)30Backups 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.

Terminal window
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.gz

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

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”
Terminal window
docker exec travstats-db pg_dump -U flights flights | gzip > flights-pre-upgrade.sql.gz

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

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.

When the panel is not reachable:

Terminal window
# 1. Unpack the archive somewhere on the host
mkdir restore && tar -xzf backup-2026-09-06T02-00-00-123Z.tar.gz -C restore
# 2. Stop the app so nothing writes during the restore
docker compose -f docker-compose.prod.yml stop app
# 3. Reset the schema (destructive) and replay the dump
docker 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.gz
docker 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 boot
docker compose -f docker-compose.prod.yml up -d app

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

Admin → Settings → WebDAV (Nextcloud, HiDrive, ownCloud, any RFC 4918 server):

FieldMeaning
EnabledMaster switch for the sync
URLThe WebDAV base URL, e.g. https://cloud.example.com/remote.php/dav/files/you/
Username / passwordAn app-specific password is preferred where the server offers one
Backup pathThe 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.

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/bash
TOKEN="$(cat ~/.travstats-admin-token)"
BASE="https://travstats.example.com/api/v1"
# The list is {"backups":[…]} — pick the newest completed one
ID=$(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.

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.

  1. Find a recent archive — local in /app/data/backups/, on the WebDAV share, or pulled earlier via the API.
  2. Find your copy of /app/data/secrets/. Without it, every encrypted setting has to be re-entered after the restore.
  3. Stand up a fresh TravStats on the recovery host with the same version.
  4. Restore — from the panel if the fresh instance is reachable, otherwise by hand as above.
  5. 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.
  6. Re-enter API keys, SMTP and WebDAV credentials if the secrets were lost.