Without Django¶
The passes do not need a Django project. A server that runs a database and a service — a
self-hosted application, an identity provider, a registry — gets the same backups from the
offsite-backup command: the same stores and Object Lock read back, the same age
encryption, the same sources, whole-night rotation, morning check, drill and messages. The
setting is a TOML file whose top level is what OFFSITE_BACKUP holds in a Django project.
It is also what replaces a server's own copy of a backup script: the script keeps deciding
what leaves (it knows the service), and hands the files to offsite-backup, which
decides how — and stops drifting from every other copy.
Install¶
The command needs Python 3.10+ and no Django. Install it as a tool, with the extras the setting names:
pipx install 'django-offsite-backup[cli,s3,telegram]'
# or: uv tool install 'django-offsite-backup[cli,s3,telegram]'
offsite-backup --version
[cli] adds tomli on Python 3.10 only (3.11+ read TOML with the standard library). The
server needs the same binaries as any pass: zstd, age, and pg_dump / psql /
pg_restore of at least the database server's major for a PostgreSQL source
(Installation). python -m offsite_backup is the same command.
The setting¶
# /etc/offsite-backup/registry.toml — root, 0600
PREFIX = "acme/registry"
ENV = "prod"
NAME = "the registry"
[STORE]
BACKEND = "offsite_backup.stores.s3.S3Store"
ENDPOINT_URL = "https://fsn1.your-objectstorage.com"
REGION = "fsn1"
BUCKET = "acme-backups"
ACCESS_KEY = "${BACKUP_S3_ACCESS_KEY}"
SECRET_KEY = "${BACKUP_S3_SECRET_KEY}"
LOCK_DAYS = 13
[ENCRYPTION]
BACKEND = "offsite_backup.encryption.age_cli.AgeCli"
RECIPIENTS = "age1..."
[DATABASES]
BACKEND = "offsite_backup.databases.postgres.PostgresSource"
FORMAT = "custom"
ALIASES = ["default"]
[DATABASES.CONNECTIONS.default]
HOST = "127.0.0.1"
PORT = "5432"
USER = "registry"
PASSWORD = "${REGISTRY_DB_PASSWORD}"
NAME = "registry"
[CONFIG]
BACKEND = "offsite_backup.config.paths.PathsSource"
PATHS = ["/srv/registry/docker-compose.yml", "/srv/registry/config"]
[RETENTION]
KEEP_NIGHTS = 14
[[NOTIFY]]
BACKEND = "offsite_backup.notifiers.telegram.TelegramNotifier"
TOKEN = "${BACKUP_TELEGRAM_TOKEN}"
CHAT_ID = "${BACKUP_TELEGRAM_CHAT_ID}"
TOPIC_ID = "${BACKUP_TELEGRAM_TOPIC_ID}"
Every key and option is the one Settings describes, with its default and its check. What differs off Django:
- Variables. A string may name environment variables:
${NAME}, or${NAME:-default};$$is a literal$. An unset variable is blank — and blank is unset, so a missing secret is still named byE001instead of read as a value. Only strings expand; write numbers (LOCK_DAYS,KEEP_NIGHTS) as TOML numbers. --env-file FILE(repeatable) readsKEY=valuelines —#comments, an optionalexport, one pair of quotes dropped — for the variables. Later files win over earlier ones and over the process environment. Keep the secrets there, root-only, and the TOML file free of them: neither ever reaches argv.- Databases are named in
CONNECTIONS, an alias each, with the keys Django'sDATABASEShas (HOST,PORT,USER,PASSWORD,NAME,OPTIONS).ALIASESsays which are dumped —['default']unless set (Databases).MySQLSourceandSQLiteSourcetakeCONNECTIONSthe same way (for SQLite,NAMEis the file). - Every backend runs here but one. The stores (one or several),
both sealers (
ageandPyrage), every database source, and the media sourcesDirectorySource,S3SourceandVolumeSource(a Docker volume, through thedockerCLI) need no Django.StorageSourcereads a Django storage and is refused by name:E002, with[django]as the extra to install where Django is absent, or "needs a Django project" where it is installed and there is no project. - Paths are best written absolute; a relative one is the working directory's, as under
Django.
RENDER_ENVis the exception: it is read against the setting file's directory, where Django would readBASE_DIR. A mediaDirectorySourceneeds itsROOT— there is noMEDIA_ROOTto default to. - No guard. The once-per-night guard is a Django cache or a database lock; cron fires
once, so it is not needed. Wrap the line in
flock -nif a slow pass could meet the next. - The checks are
offsite-backup checksrather thanmanage.py check— the same codes, without the ones about Django's cache (W003) and test runner (W004).
--config FILE names the setting, or OFFSITE_BACKUP_CONFIG in the environment.
Commands¶
| command | what | exit |
|---|---|---|
run [--without-media] [--reason R] [--only KIND] [--path P] |
a pass now; --path adds a file or directory to the configuration archive (repeatable) — or, with no [CONFIG], makes one of them alone |
0, 1 on failure |
check |
the morning check — MISSING / UNCHECKED said through the notifiers | 0, 1 |
status [--json] [--prometheus FILE] [--stale-hours H] |
the newest whole backup's age per kind, said to nobody (Monitoring) | 0 fresh, 1 stale, 2 store unreadable |
list [--kind K] [--store NAME] [--json] |
what the store holds per kind | 0, 1 |
restore --stamp S [--identity F] [--into DIR] [--kind K] [--database ALIAS] [--path P] [--store NAME] |
download, decrypt, verify — one database (--database narrows --kind), or some media files (--path, repeatable, checked against the manifests); --apply --database ALIAS [--from-database A] [--clean] loads a CONNECTIONS alias (Restore) |
0, 1 |
drill [--identity F] [--database ALIAS] [--without-media] |
the restore drill (Restore) | 0, 1 |
checks [--deploy] |
the system checks, printed; --deploy adds the bucket's lock, pg_dump against the server, the paths and the drill's target |
0, 1 on an error, 3 when backups are off |
failed [--reason R] TEXT |
say that something before the pass failed — a dump that died in the caller's script — as the same FAILED message | 0 |
Every command but checks, restore and drill says "not configured on this machine,
nothing to do" and exits 0 where backups are off; a usage error (no setting, a file that is
not there, TOML that does not parse) is 2. A failure is printed on stderr in one line; the
offsite_backup logger writes to stderr as well (Monitoring).
Scheduling¶
CRON_TZ=UTC
0 3 * * * root flock -n /run/offsite-backup.lock offsite-backup --config /etc/offsite-backup/registry.toml --env-file /etc/offsite-backup/registry.env run --reason nightly
0 9 * * * root offsite-backup --config /etc/offsite-backup/registry.toml --env-file /etc/offsite-backup/registry.env check
Or set OFFSITE_BACKUP_CONFIG in the crontab and drop --config. A systemd timer works the
same way; the exit status is what OnFailure= sees.
A service that makes its own backup¶
Some services have their own backup tool, which writes an archive the database dump alone does not replace. Keep the script that knows the service; let it hand the result over:
#!/bin/sh
set -eu
reason=${1:-nightly}
backup() { offsite-backup --config /etc/offsite-backup/app.toml --env-file /etc/offsite-backup/app.env "$@"; }
# off is not a failure; configured by halves is
state=0; backup checks >/dev/null || state=$?
[ "$state" -eq 3 ] && exit 0
[ "$state" -eq 0 ] || exit 1
work=$(mktemp -d /var/backups/app/.work-XXXXXX)
trap 'rm -rf "$work"' EXIT
app-backup-tool --out "$work/app.tar" \
|| { backup failed --reason "$reason" "the application backup failed; the journal has why"; exit 1; }
backup run --reason "$reason" --path "$work/app.tar" --path /srv/app/app.conf
checksanswers 3 for "off", 1 for "configured by halves", 0 for "on" — the three states, for a script.failedreports a failure the script met before the pass, in the same message format, so the chat reads the same whichever stage broke.run --reason upgrade, before an upgrade the script performs, says on failure that the upgrade stops and the running service (NAME) keeps serving.--pathfiles go into the configuration archive,<PREFIX>/config/<stamp>.tar.zst.age, last in the pass — so the morning check,statusand rotation read it like any other. They are required: one that is missing, or a file under it that cannot be read, fails the pass rather than being left out, and each one that went up is said after the summary (offsite-backup: up PATH) — so a script may delete them on those lines. With--onlyleavingconfigout,--pathis refused (exit 2).LAST_SUCCESS_FILEwrites the time of every successful pass, for a monitor that reads a file's age.
Coming from a hand-written script¶
A script that wrote <PREFIX>/<stamp>.tar.zst.age directly under its prefix leaves those
objects where they are: the package writes under <PREFIX>/<kind>/ and rotates only inside
each kind's folder, so it never reads or deletes the old layout. Delete the old objects by
hand once their locks have run out — or start the package under a new prefix. The message
format is the package's; the reasons nightly, deploy, manual and upgrade read as
before.
From Python¶
The same entry point, for a deployment tool written in Python:
from offsite_backup.cli import main
exit_code = main(['--config', '/etc/offsite-backup/app.toml', 'run', '--reason', 'upgrade'])
Or the functions themselves, after offsite_backup.conf.configure(values) with the setting
as a dict: offsite_backup.run(), check_last_night(), freshness(), run_drill().
configure(None) goes back to Django's settings; main() does that itself when it returns.