Configuration reference

Everything lives in one file, restoreguard.json, in the directory you run from (or pass -c/--config <path>). This page explains each section; the complete annotated sample at the bottom is a copy-paste starting point (it also ships in the repo as restoreguard.sample.json).

Three global rules:

  • Every section is optional. A check only runs when its section is present. Start with one section and grow.

  • Hosts are SSH aliases from the operator machine’s ~/.ssh/config (key-based auth — see Getting started).

  • Comments and trailing commas are allowed in the file, so you can annotate your own config.

The guided wizard (bare restoreguard on first run, or the setup menu entry) writes the common sections for you, with live validation. The sections it doesn’t cover are marked below — add those by hand from the sample.

dockerHosts — mount drift & stale configs

"dockerHosts": [
  { "alias": "mydockerhost", "dockerPath": "docker" },
  { "alias": "myotherhost", "dockerPath": "/usr/bin/docker" }
]

Audits every running container: declared compose bind-mounts/volumes vs. what is actually mounted, and whether the container still matches its current compose file (stale config). Use an absolute dockerPath when docker isn’t in the SSH user’s PATH.

Host needs: Docker daemon access (root or the docker group), compose plugin ≥ 2.17, read access to the compose project directories and env files.

logicalDbBackups — database dump jobs

"logicalDbBackups": [
  {
    "host": "mydockerhost",
    "path": "/var/backups/db-prod",
    "coversHosts": ["mydockerhost"],
    "maxDumpAgeHours": 26,
    "method": "pg_dumpall",
    "requireProdNaming": true
  }
]

A list — one entry per dump job you run. Dump files are expected to be named <container>_<yyyyMMdd>.sql.gz.

host / path

where the dump files land (checked over SSH)

coversHosts

which `dockerHosts’ database containers this job is responsible for

maxDumpAgeHours

freshness limit; 26 suits a nightly job with margin

method

what the job runs: pg_dumpall, mysqldump, or mongodump. A container whose engine needs a different method is a RED — a pg_dumpall of a MariaDB "succeeds" but is unrestorable

requireProdNaming

true mirrors jobs that dump only containers named prod (skipping staging) and flags unmatched ones for review; false expects a dump for every running DB container

Host needs: read access to the dump directory.

pveNodes — Proxmox image backups

"pveNodes": [
  { "alias": "mypve", "node": "pve", "backupStorages": ["pbs-datastore", "local-dumps"] }
],
"pbsMaxSnapshotAgeHours": 26

Checks that every VM/LXC on the node has a fresh image backup. backupStorages lists PBS datastores and plain vzdump directory storages — the storage type decides the tier, and either counts as coverage.

If several nodes share one PBS datastore, list that datastore on exactly one node — otherwise the same backups are counted twice.

Host needs: root on the PVE node (pvesh).

fileBackups — restic, borg, kopia, snapper, dir, Home Assistant

"fileBackups": [
  { "name": "restic app configs", "kind": "restic", "alias": "mydockerhost",
    "repo": "/mnt/nas/restic-repo", "passwordFile": "/root/.restic-pass", "maxAgeHours": 26,
    "canaryPath": "/opt/apps/docker-compose.yml" },
  { "name": "borg system configs", "kind": "borg", "alias": "myotherhost",
    "repo": "/var/backups/borg", "passwordFile": "/root/.borg-pass", "maxAgeHours": 26,
    "canaryPath": "/etc/fstab" },
  { "name": "kopia snapshots", "kind": "kopia", "alias": "mydockerhost", "maxAgeHours": 26 },
  { "name": "btrfs snapshots (snapper)", "kind": "snapper", "alias": "myotherhost",
    "snapperConfig": "root", "maxAgeHours": 26 },
  { "name": "volume tarballs", "kind": "dir", "alias": "myotherhost",
    "path": "/opt/volume-backups", "maxAgeHours": 26 },
  { "name": "home-assistant full backup", "kind": "haos", "alias": "mypve",
    "vmid": 9000, "maxAgeHours": 192 }
]

One entry per backup source; each configured source must produce artifacts — an empty repository or directory is a RED, not a shrug.

canaryPath (restic/borg) opts into the restore canary: a small file that must restore from the latest snapshot on every audit — pick one that is always in the backup, like a compose or config file. Per kind:

Kind Extra fields Host needs

restic

repo, passwordFile, optional canaryPath

repo + password file readable on the host — the secret never leaves it

borg

repo, passwordFile, optional canaryPath

repo + passphrase file readable on the host

kopia

connect the host to its repo once (kopia repository connect …); kopia keeps the connection and password itself, so listing needs no secret in the config

snapper

snapperConfig

snapper installed, the named config existing (btrfs)

dir

path

a readable directory of archive files (e.g. offen/docker-volume-backup tarballs)

haos

vmid

Home Assistant OS full backups, read via the Proxmox qemu guest agent: agent enabled in the HA VM, root on its PVE host

sqliteBackupDirs — SQLite hot-copy detection

"sqliteBackupDirs": [
  { "name": "appdata copies", "alias": "mydockerhost", "path": "/backups/appdata" }
]

One entry per rsync/plain-copy backup folder of app data. The folder is scanned recursively for -wal/-shm files on every audit — finding one means the SQLite database next to it was copied while open (the silently-corrupt backup).

Host needs: read access to the folder (GNU find).

offsiteJobs — rclone off-site syncs

"offsiteJobs": [
  { "name": "pbs to onedrive", "alias": "mypve",
    "logPath": "/var/log/pbs-onedrive-sync.log",
    "rcloneRemote": "onedrive:", "maxSyncAgeHours": 26 },
  // log-only (no capacity probe — e.g. a backend without `rclone about`):
  { "name": "nightly usb copy", "alias": "myotherhost",
    "logPath": "/var/log/usb-sync.log" }
]

One entry per scheduled sync job. The job’s wrapper script must log === <yyyy-MM-dd HH:mm> …​ sync start === when it starts and === sync finished rc=N === when it ends — a minimal wrapper:

echo "=== $(date '+%F %R') $(hostname) sync start ===" >> "$LOG"
rclone sync /path remote:bucket >> "$LOG" 2>&1
echo "=== sync finished rc=$? ===" >> "$LOG"

rcloneRemote is optional: set it to also watch the remote’s occupancy (rclone about; skip it for backends that don’t support about).

Host needs: read access to the log; with rcloneRemote, the host’s rclone remote must authenticate.

pbsOffsite is the older single-job flavor of this section and keeps working; new configs should prefer offsiteJobs.

zfsReplications — sanoid/syncoid & zfs send

"zfsReplications": [
  { "name": "tank/data @ pve -> nas",
    "sourceAlias": "mypve", "sourceDataset": "tank/data",
    "targetAlias": "mynas", "targetDataset": "backup/pve/data",
    "maxSnapshotAgeHours": 26, "maxReplicaAgeHours": 26 },
  // snapshot-only (no replica): watch that the snapshot job keeps running
  { "name": "tank/scratch snapshots",
    "sourceAlias": "mypve", "sourceDataset": "tank/scratch" }
]

One entry per dataset. target* fields come as a pair or not at all: with a target, the replica’s newest snapshot is checked too (zfs send/syncoid still shipping); without, only source snapshot freshness (sanoid still running).

Host needs: zfs on PATH for the SSH user; read access is enough — the probe is zfs list -H -p -t snapshot (read-only, no zfs send/recv is ever run).

trueNas — ZFS snapshots, cloud sync, pools

"trueNas": {
  "alias": "mytruenas",
  "excludeDatasets": [],
  "maxSnapshotAgeHours": 26,
  "maxSyncAgeHours": 26
}

Pool health and scrub age, per-dataset capacity, snapshot freshness, cloud-sync task state, and top-level datasets that never leave the box. excludeDatasets are dropped before they enter the model at all — use it for datasets that are intentionally not audited.

Host needs: a TrueNAS SCALE admin user (midclt call, read-only queries).

pbsOffsite — off-site copy of the PBS datastore (hand-edited)

"pbsOffsite": {
  "alias": "mypve",
  "logPath": "/var/log/pbs-sync.log",
  "rcloneRemote": "remote:",
  "targetName": "pbs-datastore",
  "maxSyncAgeHours": 26
}

For labs that sync the PBS datastore off-site with an rclone wrapper script. The script’s log must mark runs with === <ts> sync start === / === sync finished rc=N === lines; occupancy of the remote comes from rclone about.

Host needs: read access to the log; the host’s rclone remote must authenticate non-interactively.

pbsMaintenance — PBS GC & verification (hand-edited)

"pbsMaintenance": {
  "execAlias": "mypve",
  "containerId": 110,
  "datastore": "main",
  "host": "pve",
  "maxGcAgeDays": 7,
  "maxVerifyAgeHours": 50,
  "maxSyncJobAgeHours": 26,
  // bare-metal hosts backed up with proxmox-backup-client (backup ids under
  // host/ in the datastore — usually the hostname):
  "hostBackups": ["mydockerhost"],
  "maxHostBackupAgeHours": 26
}

The hygiene the PBS UI won’t nag you about: garbage collection has run recently, verify jobs exist, and the last completed verification passed and is fresh. A failed verification means corrupted chunks — that is a RED. Read by running proxmox-backup-manager inside the PBS container from its PVE host (pct exec), so no PBS API token is needed.

Two more things ride on the same probe:

  • PBS→PBS sync jobs are auto-discovered (sync-job list) — when any exist, the last completed sync must have succeeded and be fresh. Nothing to configure beyond maxSyncJobAgeHours.

  • hostBackups (optional): bare-metal hosts backed up straight to PBS with proxmox-backup-client. Each listed backup id must have a fresh snapshot under host/ in the datastore (found via the datastore’s on-disk path — still read-only).

Host needs: root on the PVE host that runs the PBS container.

smartHosts — physical disk health

"smartHosts": ["mypve"]

SMART overall-health per disk. List the machines that own the physical disks — hypervisors and bare-metal boxes. A virtualized NAS only sees virtual disks, so probe its host instead.

Host needs: root (raw device access), smartmontools installed.

reportingFile — where audit reports are delivered (a shared, standalone file)

Report destinations live in their own file (the wizard writes reporting.json), and the main config just points at it:

"reportingFile": "reporting.json"

The point of the separate file is sharing: the same reporting.json is a self-contained connection layout, so a second tool — for example HCC — can read the exact same file, connect to the same folder/bucket/database, and pull back the reports RestoreGuard wrote there. RestoreGuard writes; another tool reads; one file describes the connection for both. The path is relative to the main config. (An inline reporting section is still accepted as a legacy form, but the wizard writes the separate file — use reportingFile for anything new; set one or the other, not both.)

reporting.json — the destinations file
{
  "folder": { "id": "local-spool", "enabled": true, "path": "/var/lib/restoreguard/reports", "keepLast": 30 },
  "s3": {
    "id": "offsite-minio", "enabled": true,
    "endpoint": "http://192.168.1.10:9000", "bucket": "backups",
    "prefix": "restoreguard/", "region": "us-east-1", "forcePathStyle": true,
    "accessKeyFile": "/etc/restoreguard/s3.access", "secretKeyFile": "/etc/restoreguard/s3.secret"
  },
  "mongo": {
    "id": "mongo-main", "enabled": true,
    "connectionStringFile": "/etc/restoreguard/mongo.uri",
    "database": "restoreguard", "collection": "reports"
  }
}

Each destination has an id (any string, must be unique — the wizard auto-generates a GUID, edit it to taste) and enabled (default true). RestoreGuard writes to every enabled destination. Each report copy is stamped with its own target (the id it was saved to) plus a content hash that is identical across copies — so a reader (HCC) tells where and when a report was saved and dedups by hash (see Automation). A duplicate id is rejected when the file is read.

Every audit delivers its JSON report (the same document --json prints) to all destinations in the file, in parallel. Each is optional; with no reportingFile (and no inline reporting), reports go to the per-user default folder (Documents\RestoreGuard\reports on Windows, ~/.local/share/restoreguard/reports elsewhere — overridable with the RESTOREGUARD_REPORTS_DIR environment variable). A destination that can’t be written fails the run with exit code 1 — a report that silently went nowhere would blind every consumer. The guided setup lives behind the r menu entry, which live-probes each destination (a write + delete round-trip) as you answer; doctor re-verifies them on every preflight.

  • folder — writes rg-report-<utc-timestamp>.json per audit plus an atomically-replaced latest.json, the stable filename scripts should read. Files appear via temp-file + rename, so a watcher never sees a half-written report. path is optional (Enter = the default folder) and resolves relative to the reporting file; keepLast prunes all but the newest N timestamped reports (omit to keep everything).

  • s3 — any S3-compatible store (MinIO, Garage, R2, AWS). Puts the timestamped key and <prefix>latest.json into an existing bucket. forcePathStyle: true (the default) is what self-hosted stores expect; set it false for AWS’s bucket-in-hostname addressing. Credentials go inline (accessKey/secretKey) or — better — in root-only files via the \*File variants (relative paths resolve against the reporting file’s directory, so the file stays portable to another tool).

  • mongo — inserts each report as one document, so history is queryable (db.reports.find({overall:"red"}), by generatedAt, by findings.ruleId, …​). connectionString or connectionStringFile; database/collection default to restoreguard/reports. MongoDB-wire-compatible servers (e.g. FerretDB) work here unchanged.

The full annotated destinations file ships as reporting.sample.json. Not covered by the first-run wizard — configure with the r menu entry or by hand.

suppressionsFile

"suppressionsFile": "suppressions.json"

Path to the suppressions list, relative to the config file.

The full annotated sample

Comments and trailing commas are allowed, so this works as-is — save it as restoreguard.json, delete the sections you don’t have, and replace the placeholder aliases:

{
  // RestoreGuard config — EVERY section is optional; a check only runs when its
  // section is present. Hosts are SSH ALIASES from the operator machine's
  // ~/.ssh/config (key-based, passwordless auth — the audit runs ssh -o BatchMode=yes).
  // Full guide: https://ofbirds.org/docs/goose/ (in-repo: docs/modules/ROOT/pages/).
  // Comments and trailing commas are allowed in this file.

  // Docker hosts to audit for mount drift (declared compose config vs live mounts).
  "dockerHosts": [
    { "alias": "mydockerhost", "dockerPath": "docker" },
    // Use an absolute path when docker isn't in the SSH user's PATH:
    { "alias": "myotherhost", "dockerPath": "/usr/bin/docker" }
  ],

  // Nightly logical DB dump jobs (files named <container>_<yyyyMMdd>.sql.gz).
  // coversHosts: which dockerHosts' DB containers each job is responsible for.
  // method: what the job runs (pg_dumpall | mysqldump | mongodump) — a container
  // whose ENGINE needs a different method is a RED method-mismatch (a pg_dumpall
  // of a MariaDB or DocumentDB "succeeds" but is unrestorable).
  // requireProdNaming: true mirrors jobs that dump only containers named 'prod'
  // (skipping 'staging'); false expects a dump for EVERY running DB container.
  "logicalDbBackups": [
    {
      "host": "mydockerhost",
      "path": "/var/backups/db-prod",
      "coversHosts": ["mydockerhost"],
      "maxDumpAgeHours": 26,
      "method": "pg_dumpall",
      "requireProdNaming": true
    },
    {
      "host": "myotherhost",
      "path": "/var/backups/db-mysql",
      "coversHosts": ["myotherhost"],
      "maxDumpAgeHours": 26,
      "method": "mysqldump",
      "requireProdNaming": false
    }
  ],

  // Proxmox VE nodes. backupStorages lists PBS datastores AND plain vzdump dir
  // storages — the storage type decides the tier, and either counts as image
  // coverage for a guest. List a shared PBS datastore on EXACTLY ONE node.
  "pveNodes": [
    { "alias": "mypve", "node": "pve", "backupStorages": ["pbs-datastore", "local-dumps"] }
  ],
  "pbsMaxSnapshotAgeHours": 26,

  // ZFS snapshots & replication outside TrueNAS (sanoid/syncoid, zfs send).
  // Per dataset: the source must keep getting snapshots; with target* set (a pair),
  // the replica's newest snapshot must be fresh too — a dead replication LOOKS fine
  // because the replica keeps its old snapshots. Probe is `zfs list` (read-only).
  "zfsReplications": [
    {
      "name": "tank/data @ mypve -> mynas",
      "sourceAlias": "mypve",
      "sourceDataset": "tank/data",
      "targetAlias": "mynas",
      "targetDataset": "backup/pve/data",
      "maxSnapshotAgeHours": 26,
      "maxReplicaAgeHours": 26
    },
    {
      // snapshot-only: no replica, just "is sanoid/the snapshot job alive".
      "name": "tank/scratch snapshots",
      "sourceAlias": "mypve",
      "sourceDataset": "tank/scratch"
    }
  ],

  // TrueNAS SCALE box: pool health/scrub, per-dataset capacity, snapshot freshness,
  // cloud-sync task state. excludeDatasets are dropped BEFORE entering the model.
  "trueNas": {
    "alias": "mytruenas",
    "excludeDatasets": [],
    "maxSnapshotAgeHours": 26,
    "maxSyncAgeHours": 26
  },

  // SQLite hot-copy detection: rsync/plain-copy backup folders of app data
  // (vaultwarden, *arr, Home Assistant...). Scanned RECURSIVELY for -wal/-shm
  // files — those exist next to a database only while it is OPEN, so inside a
  // backup they prove the .db was copied mid-write (restores may be corrupt).
  "sqliteBackupDirs": [
    {
      "name": "appdata copies",
      "alias": "mydockerhost",
      "path": "/backups/appdata"
    }
  ],

  // Off-site sync jobs: any scheduled rclone script that logs
  // "=== <yyyy-MM-dd HH:mm> ... sync start ===" / "=== sync finished rc=N ===".
  // rcloneRemote is optional (capacity via `rclone about`; skip for backends
  // without about-support). Every configured job must have run: an empty log
  // is RED offsite/never-ran.
  "offsiteJobs": [
    {
      "name": "pbs to onedrive",
      "alias": "mypve",
      "logPath": "/var/log/pbs-onedrive-sync.log",
      "rcloneRemote": "onedrive:",
      "maxSyncAgeHours": 26
    },
    {
      // log-only: no capacity probe.
      "name": "nightly usb copy",
      "alias": "myotherhost",
      "logPath": "/var/log/usb-sync.log"
    }
  ],

  // LEGACY: the older single-job, PBS-flavored version of offsiteJobs — still
  // works, but prefer offsiteJobs for anything new.
  "pbsOffsite": {
    "alias": "mypve",
    "logPath": "/var/log/pbs-onedrive-sync.log",
    "rcloneRemote": "onedrive:",
    "targetName": "pbs-nvme",
    "maxSyncAgeHours": 26
  },

  // PBS GC + verify-job hygiene, read by running proxmox-backup-manager inside the
  // PBS container from its PVE host (pct exec — needs root on the PVE host).
  "pbsMaintenance": {
    "execAlias": "mypve",
    "containerId": 110,
    "datastore": "main",
    "host": "pve",
    "maxGcAgeDays": 7,
    // Last completed verification must be OK and younger than this (daily
    // schedule + margin). A failed verification is RED - corrupted chunks.
    "maxVerifyAgeHours": 50,
    // PBS->PBS sync jobs are AUTO-DISCOVERED: when any exist, the last completed
    // sync must have succeeded and be younger than this.
    "maxSyncJobAgeHours": 26,
    // Bare-metal hosts backed up with proxmox-backup-client: each backup id
    // (usually the hostname, under host/ in the datastore) must have a fresh
    // snapshot. Optional - omit if no host backs up straight to PBS.
    "hostBackups": ["mydockerhost"],
    "maxHostBackupAgeHours": 26
  },

  // SMART health of PHYSICAL disks — probe the machines that own the disks
  // (hypervisors, bare-metal NAS). A virtualized NAS only sees virtual disks.
  "smartHosts": ["mypve"],

  // File-level backup sources. Kinds: "restic" / "borg" (repo + password/passphrase
  // file on the host — the secret never leaves it), "dir" (a directory of archive
  // files, e.g. offen/docker-volume-backup tarballs), "haos" (Home Assistant OS full
  // backups via the Proxmox qemu guest agent). Each configured source MUST produce
  // artifacts — an empty repo / dir / no full HA backup is a RED finding.
  // canaryPath (restic/borg, optional): the restore canary — a small file that must
  // restore from the LATEST snapshot on every audit (streamed + counted on the host;
  // nothing is written, no content leaves the machine). Pick a file that is always
  // in the backup, like a compose/config file. 0 bytes back = RED.
  "fileBackups": [
    {
      "name": "restic app configs",
      "kind": "restic",
      "alias": "mydockerhost",
      "repo": "/mnt/nas/restic-repo",
      "passwordFile": "/root/.restic-pass",
      "maxAgeHours": 26,
      "canaryPath": "/opt/apps/docker-compose.yml"
    },
    {
      "name": "borg system configs",
      "kind": "borg",
      "alias": "myotherhost",
      "repo": "/var/backups/borg",
      "passwordFile": "/root/.borg-pass",
      "maxAgeHours": 26,
      "canaryPath": "/etc/fstab"
    },
    {
      "name": "myvolume tarballs",
      "kind": "dir",
      "alias": "myotherhost",
      "path": "/opt/volume-backups",
      "maxAgeHours": 26
    },
    {
      "name": "home-assistant full backup",
      "kind": "haos",
      "alias": "mypve",
      "vmid": 9000,
      "maxAgeHours": 192
    },
    {
      "name": "btrfs snapshots (snapper)",
      "kind": "snapper",
      "alias": "myotherhost",
      "snapperConfig": "root",
      "maxAgeHours": 26
    },
    {
      // kopia keeps its own repo connection+password on the host: run
      // `kopia repository connect ...` there once; listing then needs no secret.
      "name": "kopia snapshots",
      "kind": "kopia",
      "alias": "mydockerhost",
      "maxAgeHours": 26
    }
  ],

  // Where each audit DELIVERS its JSON report (the same document --json prints).
  // The destinations live in their OWN self-contained file so a SECOND tool — e.g.
  // HCC — can read the SAME file, connect to the same folder/bucket/DB, and pull the
  // reports RestoreGuard wrote there. This is just a pointer to that file (path
  // relative to this config); its schema is in reporting.sample.json. Omit it and
  // reports go to the per-user default folder (Documents\RestoreGuard\reports on
  // Windows, ~/.local/share/restoreguard/reports elsewhere; RESTOREGUARD_REPORTS_DIR
  // overrides). The wizard (`r` menu) writes reporting.json and this pointer for you.
  "reportingFile": "reporting.json",

  // Known exceptions with reasons + dates — a JSON list of entries like:
  //   { "host": "mydockerhost", "service": "myapp-staging-postgres-1",
  //     "ruleId": "db-backup/uncovered", "reason": "staging data is disposable",
  //     "decidedOn": "2026-06-29", "expires": "2026-12-31",          // both optional:
  //     "retriggerCondition": "revisit if staging holds real data" }
  // Rule ids: the user guide's "What gets checked" page. Suppressed findings stay
  // visible in every report; expired/dead entries become findings themselves.
  "suppressionsFile": "suppressions.json"
}