Backups
scripts/backup.py dumps the two volumes that hold real state:
postgres-data: history (point_history, its compressed chunks and continuous aggregates), auth, sites, faults, Cx records and every other table, as a plainpg_dumpthroughdocker compose exec postgres;oxigraph-data: the entity graph, through the same bulk graph-store GET the SHACL validator uses.
Each run writes a timestamped, gzip'd pair under backups/ (gitignored).
grafana-data is regenerable dashboard config, out of scope.
scripts/restore.py restores a pair into a fresh stack, and
scripts/restore_drill.py proves that it does (see Restore drill).
Code
The dump streams through gzip, so it never sits in memory, and each file is
written as <name>.partial and renamed only when complete: a failed backup
leaves no file that looks like a good one. pg_dump prints a warning about
"circular foreign-key constraints" on TimescaleDB's catalog tables. It's
expected and harmless here: it concerns data-only dumps, and this is a full
dump.
What a backup doesn't hold:
- The ontology named graph. Turtle can't carry named graphs, so only the
default graph (entity data) is backed up. The ontology graph
(
urn:baler:ontology) is a copy of a published ontology file: if you loaded one withRemoteStore.load_ontology, load it again after a restore. - Database roles and passwords. A single-database
pg_dumphas none. The restore target creates its role from.envas usual.
Measured on a 2.0 M-sample database (100 points at 1/min for 14 days, a 236 MB hypertable with one of its three chunks compressed): the backup took 12 s and is 11 MB gzip'd, the restore 6 to 8 s, and the whole drill 23 to 27 s.
Restore
Restore is disaster recovery into a fresh, empty Postgres and Oxigraph,
not a merge onto live data. restore.py refuses a database that already has
tables, and Oxigraph's bulk load adds to whatever graph is there.
-
Stop the stack, and move the old volumes out of the way (or restore on another host with the same checkout):
Code -
Start only postgres and oxigraph, on new volumes. Starting the whole stack would let every service create its tables first, and the restore would then refuse the database.
CodeUse the same
.envas the install the backup came from. In particularBALER_PG_USER(an install from before the rename may usetimberdoodle): the dump's tables belong to that role, and the restore fails withrole "..." does not existif the new database has another. -
Restore both files:
Code -
Start everything:
docker compose up -d.
TimescaleDB
A dump of a TimescaleDB database restores only between
timescaledb_pre_restore() and timescaledb_post_restore(), and only into the
same TimescaleDB extension version (Baler#241). Without the two calls it stops
at could not find hypertable with id 1. restore.py does it for you:
- It reads the version the dump was taken from: the dump has it, in
TimescaleDB's own
_timescaledb_catalog.metadatatable. Backups made before this change work too. - If the target's version differs, it installs the dump's version from those
the postgres image ships (
timescale/timescaledbimages ship many 2.x versions side by side). It refuses with a clear message if the image doesn't have it. Use the image the backup was taken with. - It loads the dump in one
psqlsession:timescaledb_pre_restore(), the dump, thentimescaledb_post_restore(), streamed withON_ERROR_STOP. Any error stops it beforepost_restore, and it exits non-zero and tells you to restore again into a fresh volume. - It checks the database is out of restore mode. If it installed an older
version for the load, it then runs
ALTER EXTENSION timescaledb UPDATEin a fresh session, which is TimescaleDB's upgrade procedure.--no-extension-updateleaves it on the dump's version.
Compressed chunks and continuous aggregates come back as they were: same
chunks, same compression state, same policies (scheduled again by
post_restore). The drill checks every one of these.
Doing it by hand, in case you need to:
Code
The calls are schema-qualified because the dump sets search_path to empty.
Restore drill
An untested backup isn't a backup. scripts/restore_drill.py (Baler#196)
restores a backup into a scratch stack and checks it:
Code
The scratch stack is postgres and oxigraph from docker-compose.yml with
scripts/restore-drill.compose.yml on top, under their own compose project
and volume prefix (baler-restore-drill by default, --project to change it).
So it uses the same images as the live stack and runs beside it without
touching it: its ports are loopback-only ones Docker picks, it uses its own
volumes, and it takes the dump's owner role automatically. TimescaleDB's
background jobs are off in the scratch stack, so no policy moves rows during
the checks. The drill restores with the real restore.py, then checks:
| Check | Passes when |
|---|---|
| every table has the dump's rows | count(*) of every table equals the rows in its COPY block in the dump, including each compressed chunk table and TimescaleDB's catalog (through the extension's own dump filter) |
| out of TimescaleDB restore mode | timescaledb.restoring is off |
| TimescaleDB version | reports the dump's and the restored version |
| compression and refresh policies scheduled | point_history's compression policy and both aggregates' refresh policies exist and are scheduled |
| point_history samples | samples in point_history = rows in its chunks + samples packed in its compressed chunks, both counted from the dump; also reports points and compressed chunks |
| newest sample | reports the newest sample and how long before the backup it was |
point_history_5m/_1h = raw fold | after refreshing a 6-hour window (--window-hours) before the newest sample, and one at the end of the newest compressed chunk, every bucket's sum, count, min and max equals a fold of the raw rows |
| write into a compressed chunk | an idempotent re-insert into the newest compressed chunk, through the ingest path's upsert, leaves the same rows |
| Cx report renders | the newest finished commissioning run's report renders from the restored database and graph |
| graph triples | the restored graph has as many triples as the backup file |
It prints each check, restore drill PASSED or FAILED, and exits non-zero on
any failure. --report file.json also writes the report as JSON. The scratch
stack is removed afterwards, along with its volumes. Only volumes Docker labels
as the scratch project's are removed, and the drill refuses to use the live
project's name. --keep leaves the stack up to look around (it prints the DSN),
and --down --project <name> removes it later.
docker-compose.yml requires MQTT_PASSWORD and the gateway secrets for
services the drill never starts. When they aren't set, the drill sets
placeholders so that docker compose up works on a host with no .env in the
checkout.
Automated: in CI
tests/test_restore_drill.py runs the whole thing on every CI run (in the
integration job, about 40 s). It seeds a scratch source stack the way Baler's
own code builds it: the point_history hypertable with its compression policy
and aggregates, history in a forced-compressed range (with a late write into
it) and an uncompressed range, numbers, booleans, text, units, labels and
quality flags, the auth, site, faults and Cx tables, a Cx run, and the graph.
It then runs the real backup.py and the real restore_drill.py, and compares
the restored stack with the source:
- every value in
point_history, compressed and uncompressed ranges; - the chunks and their compression state;
- both continuous aggregates after a full refresh;
- every other table, row for row;
- the policies, the extension version, the graph and the Cx report HTML.
It needs Docker but not the main stack. Its two scratch projects are restore
and restore-drill:
Code
On a schedule, on your host
The CI test proves the scripts. The point of a drill is to prove your
backups, so run it on the host that has them. scripts/systemd/ has a weekly
timer for it, next to the backup's:
Code
It restores the newest backup in backups/, writes
backups/restore-drill-latest.json and fails the unit when a check fails.
Automating backups
Nothing runs backup.py on its own. backup.py shells out to the
host's docker compose exec postgres pg_dump. It assumes it's
running on the Docker host, not inside a container on the compose
network. That ruled out a compose sidecar (it would need the Docker
socket/CLI mounted into a new image just to keep that call working, more
attack surface than a backup job is worth) in favor of a host-level
scheduled job that reuses backup.py completely unchanged.
systemd (recommended on a Linux host)
Code
baler-backup.timer runs daily by default (OnCalendar=daily,
edit that line directly to change the schedule, see man systemd.time)
with Persistent=true, so a run missed while the host was off fires on
next boot instead of silently skipping. baler-backup.service sets
Environment=PATH=... explicitly: systemd services don't inherit a
login shell's PATH, so without it docker isn't found and the job fails
every run with no output unless you go looking in journalctl.
cron (non-systemd hosts)
Code
A checkout managed by a CI runner (nash-srv)
Don't keep backups in a checkout that GitHub Actions deploys from, like
nash-srv's runner workspace. actions/checkout cleans the workspace on every
deploy, ignored files included, so backups/ would be deleted. And a
backups/ directory created by a root systemd job is root-owned, which makes
the next checkout fail with EACCES (#29). Write them somewhere else, and point
the drill there:
Code
On nash-srv the compose project's env file isn't .env in the checkout
(deploy/nash-srv/setup-runner.sh passes --env-file). Point the scripts at
it. Without the env file, compose can't even resolve the base file there: it
stops on the required MQTT_PASSWORD before it reaches exec. Run them with
the live project's name and env file:
Code
To restore onto nash-srv itself, start postgres and oxigraph with the
deploy's own -f/--env-file arguments so that BALER_PG_USER and the volume
names match. Then run restore.py with the same COMPOSE_PROJECT_NAME,
COMPOSE_ENV_FILES and COMPOSE_FILE.
What nash-srv runs today: a pinned, root-owned copy of backup.py makes the
nightly backup at 03:30 UTC into /var/backups/baler. The weekly restore drill
stays off until the host has finished its post-reboot soak.
Either way
Backups accumulate indefinitely: there's no retention or pruning yet. Watch disk usage, or clean the directory out yourself if it becomes a problem. A drill on the newest backup is what tells you that the ones you keep restore.

