Restic Backup Orchestrator - Back up services and files to a restic repository.
- Several backup jobs from one YAML file, with defaults shared by all jobs
- Stops and restarts the services of a job during its backup (Docker/Podman Compose, systemd)
- Job order with
before/after, dependencies withrequires, and as many jobs at once asconcurrencyallows; jobs that share services never run at the same time - Database dumps streamed straight into the repository (
stdin_command), no temp files - Typed, validated restic options (excludes, tags, limits, ...) and retention policies
(
keep) with a single prune for the whole repository, and an optionalrestic checkafter it - Hooks before and after each job and on failure, with the job's result and warnings in their environment
- Services that a killed or crashed run left stopped are started by the next run
restor run --dry-runshows what a run would stop and back up;restor maintainruns prune and check on a schedule of their ownrestor restorerestores a snapshot, as a dry run unless--applyis givenrestor restic ...runs restic against the configured repository, for everything else
Download a package or archive from the
releases. restor needs restic 0.17.1 or
newer; the packages install it as a recommended dependency.
restic versions: restor is tested against restic 0.17.1, the oldest release it
supports, and 0.19.1, the newest release it was tested with. Every CI run tests both, and
the newest is raised when restic releases. An older restic is refused before anything is
stopped. A newer one runs with a warning in the log; please report problems with it.
Distributions with an older restic (Debian 12 ships 0.14) need the
upstream binary, which restic self-update
keeps current.
sudo apt install ./restor_<version>_amd64.deb # Debian, Ubuntu
sudo dnf install ./restor-<version>-1.x86_64.rpm # Fedora, RHELCreate the config and the env file, check them and enable the daily timer:
sudo cp /usr/share/doc/restor/examples/config.yaml /etc/restor/config.yaml
sudo install -m 0600 /usr/share/doc/restor/examples/restor.env /etc/restor/restor.env
sudoedit /etc/restor/config.yaml /etc/restor/restor.env
sudo restor validate
sudo systemctl enable --now restor.timertar xzf restor_<version>_linux_amd64.tar.gz
cd restor_<version>_linux_amd64
sudo install -m 0755 restor /usr/local/bin/restor
sudo install -d -m 0700 /etc/restor
sudo cp example/config.yaml /etc/restor/config.yaml
sudo install -m 0600 example/restor.env /etc/restor/restor.env
sudoedit /etc/restor/config.yaml /etc/restor/restor.env
sudo restor validate
sudo cp example/restor.service example/restor.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now restor.timerThe package also installs user units that read ~/.config/restor/config.yaml, the file
every command reads for a user other than root:
mkdir -p ~/.config/restor
cp /usr/share/doc/restor/examples/config.yaml ~/.config/restor/
install -m 0600 /usr/share/doc/restor/examples/restor.env ~/.config/restor/
restor validate
systemctl --user enable --now restor.timer
sudo loginctl enable-linger "$USER" # keep the timer running while logged outrestor run # back up every job, then forget, prune and check
restor run web db # only these jobs (their order among each other is kept)
restor run --dry-run # show what would be stopped and backed up, change nothing
restor run --skip-check # this time without check (also --skip-prune)
restor maintain # prune, unlock and check only, no backups (see below)
restor validate # check the config, print the jobs in start order and
# each retention policy once, with the jobs using it
restor snapshots # list the snapshots of every job
restor snapshots web # of this job only (restic snapshots --tag=web)
restor snapshots --all # every snapshot, also of renamed or removed jobs
restor restore 1a2b3c4d --target /tmp/restore # dry run: list what it would do
restor restore 1a2b3c4d --target /tmp/restore --apply # restore below /tmp/restore
restor restic snapshots # run restic with the configured repository and env file
restor versionEvery command reads /etc/restor/config.yaml when run as root and
~/.config/restor/config.yaml ($XDG_CONFIG_HOME/restor/config.yaml if set) as any other
user, unless --config <file> (-c) says otherwise. There is no fallback from one to the
other: a normal user who wants the system configuration runs restor with sudo.
--debug logs every command line. Logs go to stderr, under systemd to the
journal: journalctl -u restor (or journalctl --user -u restor). --log-format json
writes one JSON object per line instead, for log collectors.
restor run exits 0 when every job was backed up and 1 when the config is invalid, a job
failed (also when its services did not come back) or the run was cancelled. A failed
forget, prune or check is logged and runs the
top-level on_failure hook; a failed post or on_failure hook is only logged. Neither
changes the exit code. restor maintain exits 1 when prune, unlock or check failed.
Before anything is stopped, it checks
that restic is installed and supported (see below). restor snapshots,
restor restore and restor restic exit with restic's exit code.
On SIGINT, SIGTERM or SIGHUP (a closed SSH session) restor stops the running backups,
restarts the services it stopped,
runs the post hook of those jobs, starts no further job and exits within its shutdown
timeout (the largest start_timeout + 4 minutes, + 1 minute for a post hook, logged as
shutdown_timeout). The shipped units allow 900 s (TimeoutStopSec); raise it if a
start_timeout is above 10 minutes. Keep KillMode=process and the CPUWeight=/
IOWeight= settings in custom units: containers that podman-compose starts again keep
their monitor process in restor's cgroup and would inherit a Nice= setting. The units
start after network-online.target and the container engines, so that a run caught up at
boot finds the services running.
Services left stopped: before a job stops its services, restor records them in
/var/lib/restor (root) or ~/.local/state/restor, and removes the record once they run
again. When a run ends in between (killed after its shutdown timeout, out of memory, a
power loss), the next run starts those services first, warns about it in the log and in
RESTOR_WARNINGS, and only then backs up. Until they start, the record stays and every
run tries again.
example/config.yaml shows and explains every option; example/restor.env is a template
for the env file. A small configuration:
version: 2
repository:
url: s3:https://s3.example.com/backups
env_file: restor.env # RESTIC_PASSWORD_FILE, AWS_*, ...: only restic sees it
prune: true
defaults:
keep: {daily: 7, weekly: 4, monthly: 6}
jobs:
db:
type: docker-compose
compose_file: /srv/app/compose.yml
services: [db]
sources: [/srv/app/db]
before: [web]
web:
type: docker-compose
compose_file: /srv/app/compose.yml
services: [web]
sources: [/srv/app/uploads]
exclude: ["*.tmp", "cache/"]
etc:
sources: [/etc]The file is YAML (.yaml/.yml) with version: 2. It is checked completely at
startup and every problem is reported with its field path; an unknown key is an error,
so typos never go unnoticed. Durations are strings such as 30s, 5m or 4h.
$VAR, ${VAR} and ${VAR:-default} in values are replaced from restor's process
environment; $$ is a literal $. A variable that is unset or empty is an error, so
${DATA}/db never becomes /db; write ${VAR:-} when empty is fine, and $$ for a $
that restic should see (*.$$1). Relative env_file, compose_file, sources and
exclude_file paths are resolved against the directory of the config file; ~ is not
expanded (use $HOME). YAML anchors and merge keys work: keep shared blocks under a
top-level key starting with x- and merge them with <<: *name.
Job files: include: [jobs.d/*.yaml] adds the jobs of other files, for hosts with
many jobs or a configuration management that writes one file per service. Each entry is
a path or a glob pattern relative to the config file; a pattern that matches nothing is
fine, a plain path must exist, an empty job file adds no jobs, and the config file
itself is never included. A job file
holds only jobs: (plus x- keys for anchors, which work within that file only); its
relative paths are resolved against its own directory. It is checked as strictly as the
config file, every job name must be unique across the files, and before/after work
across them. restor validate lists the files it read.
A file with only
version and repository is valid: restor snapshots, restor restore and
restor restic work with it, for example on a new host whose real configuration is still
in the backup; restor run then fails with "no jobs configured".
Repository: url (or RESTIC_REPOSITORY/RESTIC_REPOSITORY_FILE in the env file),
host, env_file, backend_options (--option key=value), limit_upload,
limit_download (KiB/s), retry_lock, prune, max_unused, check,
check_read_data_subset. The repository is passed to restic as RESTIC_REPOSITORY, so
it never shows up in command lines or logs. host is the host name of the snapshots
(default: the system's); set it to keep a series going after the host was renamed.
The env file holds the variables of restic only: the repository password and the
backend credentials, and restic's own settings such as RESTIC_CACHE_DIR or
RESTIC_COMPRESSION. One KEY=value per line, like systemd's EnvironmentFile=, but
values are taken literally, also in quotes: a $ or \ in a password stays as it is.
Only a # comment may follow a closing quote (KEY="abc"def is an error). Keep it
private (chmod 600);
restor warns when other users can read it. Hooks and service managers do not see it.
Jobs: the name is case-insensitive, its lowercase form is the restic tag, and it must
not start with - or contain , or whitespace.
| Key | Meaning |
|---|---|
type |
docker-compose, podman-compose or systemd; leave it out for files only |
sources |
paths to back up (required, unless stdin_command) |
stdin_command, stdin_filename |
back up the output of this command instead of sources, saved as stdin_filename (default stdin); see below |
compose_file |
the compose file (compose types, required); for compose.yaml, compose.yml, docker-compose.yml or docker-compose.yaml the override file next to it (compose.override.yml, …) is used too, as docker compose without -f does |
profiles |
compose profiles to enable (--profile): without them Compose does not list, and restor does not stop, the services of a profile |
project_name |
the compose project name (-p), when the project was started with one |
units, scope |
the systemd units (required) and system (default) or user |
services |
services to stop; default: all of the compose file / all units |
before, after |
jobs that run after / before this one |
requires |
jobs that must succeed for this one to run (it also runs after them) |
stop |
stop the services during the backup (default true) |
start_all |
after the backup, also start the services that were not running (default false) |
stop_timeout, start_timeout |
wait for the services to stop / start (default 1m / 2m); compose gets the stop timeout in whole seconds, rounded up, and it replaces each service's stop_grace_period: give a database that needs longer a larger stop_timeout |
backup_timeout |
cancel a backup that takes longer; the services start again, the job fails |
keep |
retention: last, hourly, daily, weekly, monthly, yearly, within…, and tags next to another rule (not one of the job's own tags) |
exclude, iexclude, exclude_file, exclude_if_present, exclude_caches, exclude_larger_than, one_file_system |
what restic leaves out (the restic options of the same name) |
skip_if_unchanged |
create no snapshot when nothing changed |
tags |
extra snapshot tags (not the name of a job) |
pre, post, on_failure |
hooks, see below |
Every key except type, sources, stdin_command, stdin_filename, compose_file,
profiles, project_name, units, scope, services, before, after and
requires can also be set under
defaults. A job's own value replaces the default; lists are replaced, not merged.
Database dumps: a job with stdin_command backs up what the command writes to
stdout, as one file named stdin_filename, with restic's --stdin-from-command: no dump
on disk, no pre hook to write it and no post hook to remove it. The command is a list,
run without a shell (use a script for pipes). When it exits with an error, restic saves
no snapshot and the job fails.
jobs:
pg:
type: docker-compose
compose_file: /srv/app/compose.yml
stop: false # the database must run to be dumped
stdin_command: [docker, compose, -f, /srv/app/compose.yml, exec, -T, db,
pg_dumpall, -U, postgres]
stdin_filename: pg.sql
before: [files] # dump first, then back up the files next to itWith a compose or systemd type, the job stops its services during the backup as
usual, so the command runs while they are down: set stop: false when it needs them (as
above), or list in services only the ones to stop (stop app, dump db). restor warns
at startup about a dump job that stops all of its services.
Such a job reads no files: exclude, exclude_file, one_file_system and the other
file selection keys are errors in the job itself and ignored when they come from
defaults. Restore the dump with restor restic dump <snapshot-ID> /pg.sql. restic runs
the command, so it gets restic's environment, including the env file: unlike a hook it
can read the repository password. Use it only for commands you trust.
Order and concurrency: concurrency (default 1) is how many jobs run at once; 0
runs all at once. A job starts once the jobs it runs after have finished, whether they
succeeded or not: before/after order jobs, they do not make one depend on the success
of another. Among the jobs that may start, the one first by name starts first, so with
concurrency: 1 and no before/after the jobs run in name order. Jobs that share a
compose file or a systemd unit never run at the same time. restor validate prints the
resulting order.
Dependencies: requires: [db] makes a job wait for db like after, and skips it
when db failed: no file backup without the database dump next to it. The skipped job
counts as failed: the run exits 1, its on_failure hook runs (its pre and post do
not) with RESTOR_ERROR saying which job it required, and it is listed in
RESTOR_FAILED_JOBS. A skipped job in turn skips the jobs that require it. An
incomplete snapshot (some unreadable files) does not skip. restor run files without
db runs files without checking db.
Services: only the services that are running when a job's backup starts are stopped,
and only those are started again afterwards, also when the backup failed. When none of
them runs, restor warns and backs up anyway: check compose_file or units if the
services should have been running (a project started with -p needs project_name,
services of a profile need profiles). Services that are not running again within
start_timeout fail the job: its on_failure hook runs and the run exits 1. Its snapshot
still counts, so forget and prune run, and the other jobs are not affected (those that
require it are skipped). A unit that is failed, or a container that exited, ends the
wait at once. A container that is restarting does not count as running. With
start_all: true the backup also starts the job's other services (services, or all of
the compose file / all units), so a service that was down comes back up; a failed start
of those is only a warning and does not fail the job. start_all works with stop: false
too. Compose start does not create containers that were removed (down), and one-shot
services (init containers, Type=oneshot units) run again, so list services when the
project has some. After a cancel only the stopped services are started. A systemd unit
counts as started once systemctl is-active reports active, a Type=oneshot unit also
once it has run (inactive). Services count as started only when two checks half a
second apart see them running, so one that crashes at once (a container that exits, a
Type=simple unit that fails) fails the job. A Type=simple unit is active as soon as
its process runs, so a crash a few seconds later is not seen; use Type=notify, or check
the unit in a post hook, when its start has to be confirmed. A lock per compose file and per unit keeps two restor runs from working on the
same services; the second one fails.
Dependencies between units: restor stops and waits for the listed units only, and
it does not read systemd dependencies. A Podman quadlet pod runs every container in a unit
of its own: list the pod's unit and all of its containers' units, or the backup may start
while a container is still shutting down. A unit with Wants=, Requires= or BindsTo=
on another job's unit starts that unit again when it is started, which can cancel the
other job's stop while both run at once (systemctl reports "Job for … canceled"). Give
such a job after the other one, or set concurrency: 1.
Hooks are commands split on spaces and run without a shell; use a script with its
absolute path for anything more (a relative path resolves against the working directory,
/ under systemd, and restor warns about it). A script that starts a program in the
background should redirect its output (daemon >/dev/null 2>&1 &): restor waits 10 s
for output that is still open and then warns.
| Hook | Runs | Environment |
|---|---|---|
pre |
before the services are stopped; if it fails, the job fails and nothing else runs | RESTOR_JOB, RESTOR_HOOK=pre |
post |
after the services are started again, whenever pre succeeded |
also RESTOR_STATUS (success, incomplete, failed), RESTOR_SNAPSHOT_ID, RESTOR_ERROR, RESTOR_WARNINGS |
job on_failure |
after post, when the job failed |
as post |
top-level post |
once after every run, after forget, prune and check, and after restor maintain |
the run summary below |
top-level on_failure |
after the top-level post, when a job, forget, prune or check failed |
the run summary below |
RESTOR_WARNINGS (; -separated, set only when there are any) lists what went wrong
without failing the job: services that did not stop in time or were not running (the
backup ran on live data), an incomplete snapshot, services an earlier run left stopped,
skipped forgets and prunes.
The top-level hooks get a summary of the whole run: RESTOR_HOOK, RESTOR_COMMAND
(run or maintain), RESTOR_STATUS
(failed when anything failed, incomplete when a snapshot is incomplete, else
success), the comma-separated job names in RESTOR_JOBS, RESTOR_SUCCEEDED_JOBS,
RESTOR_INCOMPLETE_JOBS and RESTOR_FAILED_JOBS, the snapshots in RESTOR_SNAPSHOT_IDS
(db=4f2a…,web=9c1e…) and, when anything failed, every failure in RESTOR_ERROR
(; -separated) and the warnings of every job in RESTOR_WARNINGS (job: warning). A
top-level post is the place for one notification per run. Values longer than 32 KiB are
cut, and NUL bytes in a program's output are dropped, so that the hook always starts.
A failing post or on_failure hook is logged and changes nothing. After a cancel
(SIGTERM) a job's post hook still runs, for at most one minute, so that it can undo its
pre hook (e.g. a maintenance mode); the other hooks are skipped.
example/hooks/ (in the release archives, and in /usr/share/doc/restor/examples/hooks/
of the packages) has scripts to copy and adapt; each one explains its use at the top:
| Script | Hooks | Does |
|---|---|---|
ntfy.sh |
top-level post, or job pre/post |
push notification through ntfy: one per run with every job's result and the errors, or one per job (started, backed up, unchanged, incomplete, failed); priority by result |
notifyapp.sh |
top-level post, or job pre/post |
the same messages to the Notify iPhone app, one thread per host |
mail.sh |
top-level post, or job post |
a mail through the local sendmail with the results and every RESTOR_* variable |
healthchecks.sh |
job pre + post, or top-level post |
reports each job (or the whole run) to Healthchecks: start, success, or failure with the error |
prometheus.sh |
top-level post |
writes metrics for node_exporter's textfile collector: the run's and each job's result, and when each job last backed up (completely), for alerts on failed or missing backups |
The notification scripts report whatever hook they run as, so the hooks pick the events,
and NOTIFY_LEVEL in hooks.conf drops the less important ones (warning: only runs
with incomplete or failed backups). For one message per run, set only the top-level
post:
post: /etc/restor/hooks/ntfy.sh # top level, next to jobs; not under defaultsA job's post already reports a failed job, and the top-level post a failed run; an
on_failure with the same script would repeat them.
sudo install -d -m 0700 /etc/restor/hooks
sudo install -m 0755 example/hooks/*.sh /etc/restor/hooks/
sudo install -m 0600 example/hooks/hooks.conf /etc/restor/hooks.conf # tokens, targetsTokens go into hooks.conf, not into the hook command: restor logs a hook's arguments
at debug level, and hooks do not see the env file.
Retention: after all backups, every job with keep and a complete snapshot runs
restic forget --group-by=host --host=<host> --tag=<job>, so each job's snapshots on this
host are one group, also when its sources or tags change, and the snapshots that
other hosts save into the same repository are never touched. With repository.prune, one prune and
unlock follow, but only when every job of the run has a complete snapshot: a job that
failed, or whose snapshot is incomplete (restic could not read some files), keeps its
history and prune waits for a better run. A run that skip_if_unchanged skipped counts as
complete.
Check: with repository.check: true, every run ends its maintenance with one
restic check, also after failed jobs, so damage to the repository is found before a
restore needs it. check_read_data_subset also reads back part of the data (5%,
1/10, 2G); without it, only the structure is checked. A failed check counts like a
failed prune: it is logged, the run summary says failed and the top-level on_failure
hook runs, but the exit code stays 0 when every job backed up.
Maintenance on its own schedule: restor maintain runs prune and unlock, then
check (with max_unused and check_read_data_subset), without backups or forget,
whatever repository.prune and repository.check say; --skip-prune or --skip-check
leave one out. For a weekly check that reads part of the data next to daily backups, set
check: false (and check_read_data_subset) and add a second timer:
# /etc/systemd/system/restor-maintain.service: a copy of restor.service with
ExecStart=/usr/bin/restor maintain --config /etc/restor/config.yaml
# /etc/systemd/system/restor-maintain.timer
[Timer]
OnCalendar=Sun *-*-* 04:00:00
Persistent=trueSchedule it apart from the backups: prune needs an exclusive repository lock and fails
while a backup runs (unless retry_lock waits for it).
restor restore <snapshot-ID> restores one snapshot with restic restore. Find the ID
with restor snapshots (or --all), or in the log line of the backup that made it; the
8-character short ID works. latest is refused: in a shared repository it is the newest
snapshot of any job and host.
restor restore 1a2b3c4d --target /tmp/restore # dry run, lists every file
restor restore 1a2b3c4d --target /tmp/restore --apply # /tmp/restore/<original path>
restor restore 1a2b3c4d --in-place --include /srv/app/db --apply- Dry run by default: without
--applyrestic only lists what it would restore; nothing is written. - Where: exactly one of
--target <dir>and--in-placeis required.--targetrestores the snapshot's absolute paths belowdir:--target /srv/app/dbwrites/srv/app/db/srv/app/db/…, not into the original place.--in-placerestores to the original paths; it is the same as--target /. - Which files:
--include,--iinclude,--exclude,--iexclude(patterns, as in restic);--overwritealways(restic's default),if-changed,if-newerornever;--verifyreads the restored files back. - Files only: restore does not stop or start services, take job locks or run hooks.
Before an in-place restore, stop the services that use the files and
restor.timer(systemctl stop restor.timer), so that no backup runs meanwhile. - Only the repository part of the configuration is needed, so it works on a new host with
a file that has
versionandrepositoryonly. Ownership is restored only when run as root.
- Forget selects this host: forget used to select a job's snapshots by tag only, so in
a repository that several hosts share, one host's
keepalso thinned out another host's snapshots of a job with the same name. It now passes--host. After a host was renamed, setrepository.hostto the old name, or its old snapshots are never forgotten again. - Unset variables are errors:
${VAR}without a default and an unset or emptyVARused to become empty; write${VAR:-}where that was meant. - Services that do not come back fail the job: not running within
start_timeoutused to be a warning only. The snapshot still counts for forget and prune. - Env file: text after a closing quote is an error instead of being dropped.
check_read_data_subsetno longer needscheck: true: it also applies torestor maintain.
restor now reads only configuration version 2 and needs a command. The old keys are
reported with their replacement, so restor validate guides the rewrite:
| Version 1 | Version 2 |
|---|---|
restor --config <file> |
restor run --config <file> (the shipped units are updated) |
| JSON or TOML file | YAML |
projects |
jobs |
mode: sequential / parallel |
concurrency: 1 / 0 |
global.restic_repo |
repository.url |
env_file, environments |
repository.env_file only; values are no longer expanded and only restic gets them |
global.auto_prune |
repository.prune; forget runs for every job with keep, also without prune |
global.prune_options |
repository.backend_options, limit_upload, limit_download, retry_lock, max_unused, or RESTIC_* in the env file |
global.pre_backup_cmd, post_backup_cmd |
defaults.pre, defaults.post |
service_manager |
type; noop is no type at all |
systemd_units, systemd_scope |
units, scope |
stop_services |
stop |
stop_timeout: 30 |
stop_timeout: 30s (now optional, default 1m; start_timeout 2m) |
retention_policy: --keep-daily 7 |
keep: {daily: 7} |
backup_options |
exclude, iexclude, exclude_file, exclude_if_present, exclude_caches, exclude_larger_than, one_file_system, skip_if_unchanged, tags |
forget_options |
keep (e.g. keep: {daily: 7, tags: [important]}) |
pre_backup_cmd, post_backup_cmd |
pre, post |
Changed behaviour:
- Retention groups by host: forget used restic's default grouping (
host,paths). It now groups each job's snapshots by host only, so the first forget after the upgrade applieskeepto all of a job's snapshots together, including those of oldersourcesthat were never forgotten before. - Compose variables: compose files no longer see the variables of the env file. Put
the variables a compose file needs into the
.envfile next to it, as Compose expects. - Jobs that share services wait for each other instead of failing as locked.
MIT