#5823 "Check a backup: compare what it would restore with what is in place, without restoring". - steward/backup/check.py: pg_restore writes the backup's rows as COPY text with no database involved. The rows in place go out through COPY in the same form. Tables are compared on their primary keys, over the columns both sides have, and grouped by area. - Sealed values are opened with each side's own key and compared as plaintext digests, so a secret sealed twice still matches. They are never shown. A backup whose key can't open its own secrets fails the check. - What Steward records as it runs (last-seen times, a schedule's last run, backup history, scraped UniFi fields) is left out of the comparison: contents.ACTIVITY, STATE_SETTINGS and HAND_SET_ONLY. - Settings → Backups: - a Check button on each sent backup, which fetches it from the bucket (new s3.get_file); - "Check a file" to upload one, with the size limit raised before the CSRF check reads the form; - a results page. - Each backup Steward sends checks itself once written, and its history row says how that went. - New CLI command `steward check-backup FILE`. CI's image smoke step checks a real backup against its source and expects a match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6.9 KiB
Backups: Steward backing itself up, and coming back
Steward sends a backup of itself to an S3 bucket once a day. Fabled Archives
is the intended target. A backup is one file, and steward restore brings
Steward back from it into an empty database.
What a backup holds
steward-<UTC time>.tar, containing:
| Member | What it is |
|---|---|
steward.dump |
pg_dump --format=custom of Steward's database |
secret.key |
The key Steward's stored passwords and tokens are sealed with |
manifest.json |
Format, Steward version, migration heads, time, and the tables whose rows were left out |
A backup is for system recovery: it holds what you would otherwise have to set up again, and no logs or history.
Kept:
- settings, with the passwords and keys they hold;
- users and API tokens;
- hosts and their agent enrolments, so agents don't need enrolling again;
- monitors, alert rules, maintenance windows and maintenance cadences;
- Ansible inventory and schedules;
- dashboards and share links;
- SNMP devices, Prometheus targets and widget templates, UniFi client names.
Left out (the tables come back empty and fill again as Steward runs):
- the audit log;
- alert history and alert state. Each rule starts inactive and is watched again within a minute;
- metric anomalies and their reviews;
- metrics, their rollups and learned normals;
- monitor check results;
- Docker, Traefik and UniFi history;
- Ansible run records and maintenance job history;
- the email queue, pending invites and password-reset links.
The list is steward/backup/contents.py. A test fails when a new table
appears in neither list, so whoever adds a table decides.
The secret key is in the file. That makes a restore complete on its own. It also means anyone holding a copy can read every password and key Steward keeps. Keep the bucket private.
Setting it up with Fabled Archives
- In Archives, make a backup target for Steward with an S3 key. The default append-only key is enough: Steward only uploads and never deletes. How long backups are kept is set on the Archives side.
- In Steward, open Settings → Backups and fill in:
- Endpoint: the S3 address Archives shows for the target, without the
bucket (for example
http://archives:8081). Requests are path-style:endpoint/bucket/file. - Bucket: the target's bucket name.
- Access key and Secret key: from Archives. The secret is sealed at rest; a blank box on save keeps the stored one.
- Endpoint: the S3 address Archives shows for the target, without the
bucket (for example
- Press Test the bucket. A key that may not list ("AccessDenied") still counts as reached: an append-only key can't list, and uploads work anyway.
- Tick Back up every day, pick the hour (on your clock), and save. Back up now makes one straight away.
A day missed while Steward was down is made up when it comes back. A failed
backup is tried again an hour later, never sooner. The last 30 runs are
listed on the page, and each one is in the audit log (backup.made /
backup.failed).
Alert when backups stop
While backups are on, Steward records two metrics under the source
Steward itself, resource backup:
| Metric | Meaning |
|---|---|
backup_age_secs ("Newest backup's age") |
Seconds since the last backup was sent, updated every 15 minutes |
backup_bytes ("Backup size") |
The size of the last backup sent |
A rule such as backup_age_secs > 93600 (26 hours) fires when a daily backup
has been missed.
Checking a backup
Settings → Backups → Check compares a backup with what is in place, without changing anything. It shows what a restore would bring back, change or remove. Each row under Recent backups has its own Check button, and Check a file takes a file downloaded from the bucket. From a shell:
docker exec -u app <steward container> steward check-backup /tmp/steward-20261015T030000Z.tar
The check reports:
- for each area (Settings, Users and API tokens, Hosts and agents, Monitors, Alert rules, Ansible, Dashboards, Plugins), how many entries are the same, differ, are only in the backup or are only here;
- each setting that differs, with both values. A password or key shows only "differs": sealed values are opened with their own key and compared, never shown;
- whether the backup's secret key is the one in use, and whether it opens the backup's own sealed values. A backup whose key can't open them fails the check, because its secrets would not come back;
- whether the backup was made by this Steward's schema, an older one or a newer one.
What Steward records as it runs is left out of the comparison: when an agent
or monitor was last seen, a schedule's last run, the backup history. These
change by the minute. The list is ACTIVITY and STATE_SETTINGS in
steward/backup/contents.py.
Each backup Steward sends is checked once it is written, and its row under Recent backups says so. A backup is sent even when its check can't run.
Checking a backup on the bucket reads it back from there. An append-only key may not be allowed to; the page then says AccessDenied, and the file can be downloaded and checked with Check a file instead.
Making a backup by hand
docker exec -u app <steward container> steward backup --to /tmp/backups
This writes the file and sends it nowhere. It needs the same database access as Steward itself.
Restoring
steward restore writes into an empty database. Pass --wipe to drop
everything in the database's public schema first.
-
Stop Steward (scale its service to 0) so nothing writes during the restore.
-
Download the backup file from Archives.
-
Run the restore with the image, pointed at the database:
docker run --rm \ -e STEWARD_DATABASE_URL=postgresql+asyncpg://steward:…@db:5432/steward \ -v steward_data:/data -v "$PWD":/restore \ git.fabledsword.com/bvandeusen/steward:latest \ steward restore /restore/steward-20261012T030000Z.tar- The backup's secret key is written to
/data/secret.key. - If
STEWARD_SECRET_KEYis set instead, it must equal the backup's key, or the restore stops and says so. Read the key withtar -xOf <file> secret.key.
- The backup's secret key is written to
-
Start Steward. On start it runs any migrations the backup is behind on.
How the restore works. pg_restore runs in its three sections: schema, then data, then constraints and indexes. Between the data and the constraints, a kept row that points at a left-out row is put right by its foreign key's ON DELETE:
- a SET NULL link is cleared and the row kept;
- a row whose foreign key would cascade or block is removed.
pg_restore 17 writes each section as a script for psql. It runs against
Postgres 16 with one setting line dropped (transaction_timeout, which 16
doesn't know).
CI checks the whole path on every build. The built image backs up its own database, restores into a second one, checks a setting came back and the metrics table is empty, then starts Steward on it.