Restore¶
A backup nobody has restored is a hope. This page is the procedure, and a drill to run every month so the procedure is known to work before the day it is needed.
What you need¶
- The private key — from your password manager. Never copy it to the production server for a routine restore; restore from your own machine, or from a throwaway one.
- A machine with the project's image (or a checkout with the package installed),
age(or the[pyrage]extra — either opens what the other sealed),zstd, and the database's client:psql/pg_restoreof the server's major version or newer,mysql/mariadbfor MySQL and MariaDB; nothing for SQLite. - Settings that point at the same store and
PREFIX. A read-only S3 key is enough and better than the server's.
The private key is given as a file (--identity FILE) or in the AGE_IDENTITY
environment variable — never as an argument, which ps would show. From the variable it is
written to a 0600 file in a private temporary directory for age, and removed with it.
1. See what there is¶
python manage.py backup_list
acme/prod/
config/: 14 nights since 2026-02-25, 19 objects, 0.4 MB, newest 2026-03-10T14-12Z, locked until 2026-03-23T14:12:31+00:00, 5 without media
db/: 14 nights since 2026-02-25, 19 objects, 410.2 MB, newest 2026-03-10T14-12Z, locked until 2026-03-23T14:12:31+00:00, 5 without media
media/: 14 nights since 2026-02-25, 28 objects, 74322.0 MB, newest 2026-03-10T03-00Z, locked until 2026-03-23T03:02:10+00:00
--kind db narrows it (repeat for several); --json is for scripts. With
several stores it lists the first; --store NAME lists another.
2. Download and decrypt¶
export AGE_IDENTITY="$(cat ~/secure/backup-acme-prod.key)" # or --identity FILE
python manage.py backup_restore --stamp latest --into ./restore
restore/config/2026-03-10T03-00Z.tar
restore/db/2026-03-10T03-00Z.sql (verified)
restore/media/2026-03-10T03-00Z.001.tar
restore/media/2026-03-10T03-00Z.002.tar
--stamp latestis the newest whole backup (the newest night inconfig/, or indb/without a configuration archive); a pass without media is not "whole". Give a stamp such as--stamp 2026-03-10T14-12Zto take any pass, a deploy's included.- Every object of that stamp is downloaded, decrypted and — if it starts with zstd's magic
bytes — decompressed, into
<into>/<kind>/.--intodefaults to./restore-<stamp>. --kind db(repeatable) takes only some kinds — skip a night's media when you need the database.--database ALIAStakes that one database's dump (its kind,dbordb-<alias>) and nothing else; beside--kindit narrows — a--kindlist without that database is refused rather than widened.--store NAMEreads from another of several stores — the local copy while the off-site one is out of reach, or the other way round. The first store is the default, for every command and function on this page (store=in Python).- Every database dump is verified: a plain dump must end with
PostgreSQL database dump complete; a custom-format one must passpg_restore --list. A dump that fails says so and stops. - Backups copied under
<PREFIX>/released/are not part of a stamp; restore those with--applyand the full key (below).
What you get:
| kind | file | open with |
|---|---|---|
db, db-<alias> |
<stamp>.sql (plain) or <stamp>.dump (custom) |
psql, pg_restore |
media, media-<name> |
<stamp>.<NNN>.tar, one per volume, and <stamp>.<NNN>.manifest.json beside each |
tar -xf — each volume on its own; the manifest lists its files |
config |
<stamp>.tar |
tar -xf; rendered.env is the environment |
A custom-format dump under a legacy .sql… name — written by a predecessor that named
its custom dumps .sql.zst.age — just works: the format is read from the magic bytes
(PGDMP), not from the name. It is verified with pg_restore --list and loaded with
pg_restore by --apply. The file --into writes keeps the object's name (<stamp>.sql),
so open it with pg_restore, not psql; head -c 5 on it prints PGDMP for a custom dump.
3. Load a database¶
python manage.py backup_restore --stamp latest --apply --database default
--apply --database ALIASdownloads, decrypts, verifies and loads the dump into thatDATABASESalias — withpsql --single-transactionfor plain SQL,pg_restore --single-transaction --exit-on-errorfor the custom format, both--no-owner.- With
--apply,--stamp latestmeans the newest dump of that database, a deploy's included — the most recent state is what you want back. --stampalso takes a full key:--stamp acme/prod/released/db-tenant_42/2026-03-01T10-00Z.sql.zst.age.-
--from-database ALIASloads another alias's backup — production'sdefaultinto a scratch database:python manage.py backup_restore --stamp latest --apply --database scratch --from-database default -
The target must be empty.
--cleanreplaces a non-empty one: for PostgreSQL it runsDROP SCHEMA public CASCADE; CREATE SCHEMA public;first (objects in other schemas are not dropped); for MySQL and MariaDB it drops every table and view; for SQLite the file is overwritten. MySQL dumps load throughmysql/mariadb, SQLite through the online backup API (Databases).
From Python:
from offsite_backup import restore_database
restore_database('latest', 'scratch', identity='~/secure/backup-acme-prod.key', source_alias='default')
restore_database('2026-03-10T03-00Z', 'default', clean=True) # identity from AGE_IDENTITY
restore_database(source, alias, *, identity=None, clean=False, source_alias=None,
identity_text=None, store=None) answers with the key it loaded.
identity_text is the private key's text, for code that holds it rather than a file:
it is written to a private 0600 temporary file for age and removed afterwards. It is
meant for a dedicated, narrowly used identity the server holds on purpose — the wake key
of a one-off backup — never the backups'
main key, which stays off the server. Passing both identity and identity_text is
refused (MisconfiguredError).
restore_database(key, 'tenant_42', identity_text=settings.WAKE_IDENTITY)
4. Media and configuration¶
Some media files¶
python manage.py backup_restore --stamp latest --path invoices/2026/03 --path logo.png --into ./restore
restore/media/invoices/2026/03/0001.pdf (verified)
restore/media/invoices/2026/03/0002.pdf (verified)
restore/media/logo.png (verified)
--path (repeatable) is a file's name in the archive, or a folder: everything under it
(invoices/2026 takes invoices/2026/..., never invoices/2026-old/...). Every volume has
a manifest; the restore downloads the manifests, then only the
volumes that hold a match, extracts the matches under <into>/<kind>/, and checks each
file against the SHA-256 its manifest recorded — (verified). A file that differs, or a
manifest promising a file its volume lacks, stops the restore with an error. A volume
written before manifests existed (0.3 and earlier) is downloaded and searched whole, and
its files are not verified. --kind media-private narrows it to one source; --path does
not combine with --database or --apply.
from offsite_backup.restore import restore_media
restored = restore_media('latest', ['invoices/2026/03'], into=Path('restore'), identity='key.txt')
Everything¶
mkdir -p media && for v in restore/media/*.tar; do tar -xf "$v" -C media; done
mkdir -p deploy && tar -xf restore/config/*.tar -C deploy
Names in a media volume are relative to the source's ROOT (or are the full object keys,
for an S3 source). Copy them back into the volume, directory or bucket the application
reads. rendered.env is the environment the release ran with — the file to rebuild .env
from on a new machine.
Rebuilding a lost machine¶
- Provision the machine; install Docker.
backup_restore --stamp latest --into ./restoreon your own machine.- Unpack the configuration archive and copy its deployment directory to the new machine;
rebuild
.envfromrendered.env(and rotate any secret you suspect left with the old machine). - Start the database;
backup_restore --stamp latest --apply --database defaultfrom inside the new deployment, withAGE_IDENTITYset for that one command only — or load the dump you already decrypted in step 2 withpsql/pg_restore, and the private key never touches the server. - Unpack the media volumes into the media volume or bucket.
- Start the application; run
backup_offsite --reason manualand check the message.
The monthly drill¶
Restoring is a skill that decays, and a backup that was never restored is a hope. The drill does what a person would, end to end, and says the outcome where the backups are said:
python manage.py backup_drill # or: offsite-backup drill
python manage.py backup_drill --without-media # the volumes are the slow part
- The newest whole pass is found (as
--stamp latest) and every object of it downloaded and decrypted — the identity from--identity FILE,DRILL['IDENTITY_FILE']orAGE_IDENTITY. - Every database dump is verified, then restored into
DRILL['DATABASE'], a throwaway database the project names — emptied first (PostgreSQL: every schema but the system ones dropped; MySQL and MariaDB: every table and view; SQLite: every table, view and trigger of the file), loaded, and every table counted there withcount(*). For SQLite the target is a file, and a target naming the live file by another path is refused like a live alias. - With
DRILL['COMPARE_LIVE'](on by default), the live database is counted too, and the message shows both. Rows are compared, not required to match: production kept writing after the backup. A table the live database has and the restore lacks is named (⚠️ Not in the backup, live now: ...) — a migration since the pass, or a hole — without failing the drill. A live database that cannot be counted is logged and left uncompared. - Every media volume and the configuration archive is decrypted and read to its end as a
tar (unless
DRILL['MEDIA']is false or--without-mediais given) — files and bytes counted. A volume's manifest is not opened: it describes the volume, which is. - The throwaway database is emptied again and the workspace removed, whatever happened.
- The outcome goes through the notifiers: Restore drill passed with a line per database and per kind of archive, or Restore drill FAILED naming every object that did not come back and the step it failed at (Notifications). One database that does not restore does not stop the others; the command exits non-zero at the end, naming each.
OFFSITE_BACKUP = {
...,
'DRILL': {
'DATABASE': 'drill', # an alias in DATABASES (or CONNECTIONS off Django)
'IDENTITY_FILE': '/run/secrets/drill.key', # or AGE_IDENTITY, or --identity
# 'MEDIA': False, # leave the volumes unopened
# 'COMPARE_LIVE': False, # never count the live database
},
}
DATABASES['drill'] = {**DATABASES['default'], 'HOST': 'scratch-db', 'NAME': 'drill'}
Never the live database. The drill refuses a target that is a database it backs up —
the same alias, or another alias with the same host (localhost, 127.0.0.1 and ::1 are
one), port (an empty one is 5432) and name — before it reads the store, and again for every
database the pass wrote. check --deploy reports that, or a target that names no database
at all, as E011, before the month it would run. What the drill does to its target is
destructive by design: point it at a database that holds nothing you want.
From Python, offsite_backup.run_drill(identity=None, identity_text=None, database=None,
media=None) returns the report (stamp, target, databases, archives, seconds) and
raises DrillFailedError naming what failed.
Where it runs: the identity¶
The drill needs the private key, which by design is not on the production server (Security model). Two ways to run it, in order of preference:
- On a machine of its own — a small drill host or a CI job with the identity, a
read-only store key and a scratch PostgreSQL. It needs no copy of the project: the
command line runs the drill from a TOML setting with the same
PREFIX,STOREandENCRYPTION, aDATABASESblock whoseCONNECTIONShold the drill's alias, andCOMPARE_LIVEoff (the live database is out of reach there). - In the project's scheduler —
beat_schedule(drill=...)orregister(broker, drill=...)(Scheduling). Simple, and it compares with the live database — but the worker then holds a key that opens every backup sealed to it. Accept that only knowingly; seal the backups to a second recipient whose key exists only for the drill, so it can be rotated on its own.
A database source of your own¶
The drill asks the database source two more things than a pass does: tables(alias), every
table with its row count, and clear(alias), empty the throwaway database — PostgreSQL,
MySQL/MariaDB and SQLite answer both; same_database() decides what counts as the live one
(host, port and name; for SQLite, the file). A source that
does not implement them fails the drill by its name, and the rest still runs.
By hand¶
The steps the command automates, for a machine that cannot run it:
backup_restore --stamp latest --into ./drill; note the stamp and that every dump says(verified).- Start a clean PostgreSQL of the same major as production
(
docker run -d --name drill -e POSTGRES_PASSWORD=drill -p 55432:5432 postgres:18). - Load the dump into it —
psql/pg_restore, orbackup_restore --stamp <stamp> --apply --database drill --from-database default. -
Count every table in both databases and compare:
SELECT format('SELECT %L AS t, count(*) FROM %I.%I', table_name, table_schema, table_name) FROM information_schema.tables WHERE table_schema = 'public' AND table_type = 'BASE TABLE' ORDER BY table_name \gexec -
Unpack one media volume and open a few files; unpack the configuration archive and read
rendered.env— is every variable the application needs there? - Write down the date, the stamp, the table count and how long it took. Delete the drill
database and
./drill.