Skip to content

Monitoring

A failed pass says so. The night that never ran — the scheduler stopped, the worker was never deployed, the machine was off — says nothing at all. The morning check is the answer to that silence.

The morning check

python manage.py backup_check
# backup_check: ok, newest whole backup 2026-03-10 03:00 UTC

It asks the store for the newest whole backup under:

  • the config/ folder, when CONFIG is set, and
  • every database's folder — db/, db-<alias>/ for each alias the pass would dump today,
  • or, with neither configured, every media folder.

Objects marked .without-media. do not count: a deploy at noon cannot stand in for a night that never ran. Media does not count either while there are databases or a configuration: tonight's media does not stand in for a database that was not written.

finding message exit
every folder has a whole backup within STALE_HOURS (26) none 0
a database with no backup at all, beside databases that are backed up none — logged at INFO, Databases not backed up yet (below) 0
some folder's newest is older, or there is none MISSING, naming the oldest non-zero (BackupMissingError)
the store could not be read UNCHECKED — as loud as missing non-zero (BackupUncheckedError)
backups are off on this machine none 0, "nothing to do"

The error names every stale folder:

CommandError: backup_check: No whole backup within 26 h under acme/prod/: db-analytics/ (none), config/ (2026-03-08 03:00 UTC)

STALE_HOURS is 26 so that a pass at 03:00 one night and a slow one the next still pass a check at 09:00. Raise it if your nightly pass runs less often than daily.

A database added today

With ALIASES a callable, the list of databases is computed at every check — and a tenant created after the nightly pass has no backup at all at the morning check. That is not a missed night, so:

  • a database folder with no backup at all, beside databases that are backed up, is "not backed up yet": logged at INFO (Databases not backed up yet, the kinds in ob_kinds), not MISSING. Its first nightly pass backs it up;
  • a database whose backups exist but are stale is MISSING, as before;
  • the only database, with no backup at all, is still MISSING — there is nothing beside it to show that the passes run;
  • a database whose dump fails every night is not silent either: each night's FAILED message names it.

Schedule it as in Scheduling; from Python it is offsite_backup.check_last_night(), which returns the oldest of the newest whole backups (or None where backups are off) and raises the same errors.

The last-success file

'LAST_SUCCESS_FILE': '/var/run/acme/backup-last-success',

After every successful pass the file is rewritten with the pass's time in ISO format:

2026-03-10T03:00:12.402113+00:00

Something already watching the machine can read its age — a node-exporter textfile collector, a Docker healthcheck, a monitoring agent's file-age check. It is written by every successful pass, deploy passes and passes by hand included, so it answers "did anything succeed lately", not "did last night run"; the morning check — or backup_status, per kind — answers that. A file that cannot be written is logged (Last-success marker not written) and does not fail the pass. Mount the path where your monitor can see it — the worker writes it at night, the deploy's one-off container before a migration.

The age of every kind

The morning check says MISSING once a day, in the chat. A monitor wants the same answer whenever it asks, silently, and as a number:

python manage.py backup_status                # or: offsite-backup status
# acme/prod/ (stale after 26 h)
#   config/: 2026-03-10 03:00 UTC, 6.2 h ago
#   db/: 2026-03-10 03:00 UTC, 6.2 h ago
#   media/: 2026-03-10 03:00 UTC, 6.2 h ago

For every kind — the config, every database the pass would dump today, and every media source — it reads the newest whole backup from the store, as the morning check does (objects of a pass without media do not count), and its age. Nothing is notified.

finding exit
every kind has a whole backup within STALE_HOURS (or --stale-hours H) 0
a kind's newest is older, or there is none (STALE on its line) 1
the store could not be read 2
backups are off on this machine 0, "nothing to do"

That makes it a healthcheck as it is — a cron job's exit status, a Docker HEALTHCHECK, a monitoring agent's command check, a push to a dead man's switch on success. --json is for a script; from Python, offsite_backup.freshness() returns the same figures (kinds, each with kind, newest, seconds, stale) and raises BackupUncheckedError when the store cannot be read.

Prometheus

python manage.py backup_status --prometheus /var/lib/node_exporter/textfile/offsite_backup.prom

writes the text exposition format for node-exporter's textfile collector (or any scraper that reads a file) — atomically, so a scrape never reads half a file — and still exits as above. Run it from cron every few minutes, or after the pass:

offsite_backup_up{prefix="acme/prod"} 1
offsite_backup_checked_timestamp_seconds{prefix="acme/prod"} 1773133200
offsite_backup_last_success_timestamp_seconds{prefix="acme/prod",kind="db"} 1773111600
offsite_backup_stale{prefix="acme/prod",kind="db"} 0
metric
offsite_backup_up 1 when the store was read; 0 — and nothing else is written — when it was not
offsite_backup_checked_timestamp_seconds when the store was asked
offsite_backup_last_success_timestamp_seconds{kind} when the newest whole backup of the kind was written; absent for a kind with none
offsite_backup_stale{kind} 1 when the kind has no whole backup within the threshold

An alert, for example:

- alert: OffsiteBackupStale
  expr: offsite_backup_stale == 1 or offsite_backup_up == 0
  for: 30m
- alert: OffsiteBackupNotChecked
  expr: time() - offsite_backup_checked_timestamp_seconds > 3 * 3600

The second catches the textfile itself going stale — the job that writes it stopped.

The log

Everything is logged to the offsite_backup logger, with fields in the record's extra (prefixed ob_, so a JSON formatter can keep them apart from your own):

level message fields
INFO Backup written ob_prefix, ob_objects, ob_bytes
INFO Database backed up (a one-off backup) ob_alias, ob_key
INFO Nightly backup skipped: ... (off, done, or being done) —
INFO Databases not backed up yet (the morning check) ob_kinds
ERROR (with traceback) Database not backed up ob_alias
ERROR (with traceback) Store not written; left out of this pass — one of several stores refused an object ob_store
WARNING Backup still locked; left for a later night ob_key, ob_until
WARNING Backup depth not read ob_folder
WARNING Last-success marker not written ob_path, ob_error
WARNING Media file not listed, Media file not readable; left out ob_path, ob_error
WARNING Configuration path not found; left out, Media directory not found — a mount missing where the pass ran (W005 under check --deploy) ob_path
INFO Restore drill passed ob_prefix, ob_stamp
ERROR (with traceback) Database not restored by the drill, Archive not opened by the drill ob_key, ob_step
ERROR (with traceback) Drill database not emptied ob_alias
WARNING Live database not counted (the drill) ob_alias, ob_error (the class only)
WARNING Media volume not found — a VolumeSource Docker volume that does not exist (W005 under check --deploy) ob_volume
WARNING Media file is a hard link; left out — in a Docker volume ob_path
ERROR Notifier not built, Notifier raised ob_notifier, ob_error
ERROR Backup message not formatted ob_error
ERROR Telegram refused the backup message ob_status, ob_description
ERROR Telegram unreachable, Backup message not sent through the bot ob_error (the class only)
ERROR Webhook refused or unreachable ob_status or ob_error (the class only), ob_attempts
ERROR Webhook body not built ob_error
INFO / ERROR Backup written / Backup failed ... from LogNotifier (INFO for a written backup and a passed drill) ob_prefix, ob_text

No message, token, password or private key is ever logged.

Heartbeats of scheduled tasks

A project that tracks when each of its scheduled tasks last ran — a heartbeat table, a "last run" dashboard — sees the package's tasks as never run until each has run once: the nightly pass after its first 03:00, the check after its first 09:00, the drill after its first month. Register them under their task names, the same for Celery and taskiq:

task default schedule (UTC)
offsite_backup.nightly 03:00
offsite_backup.check 09:00
offsite_backup.drill only when scheduled — e.g. the 1st of the month, 05:00 (Scheduling)

What the store holds

python manage.py backup_list prints, per kind, the nights kept, the oldest night, the objects and bytes, the newest stamp, how long the newest object is locked and how many objects came from passes without media. --json for scripts. See Restore.