An unattended run has nobody to answer a question, nobody to read a warning, and nobody to notice that it stopped. So the design starts from the worst thing that can happen — a scheduled run hanging on a prompt nobody will ever see — and works backwards from there.
Start with sloop's own service
Before writing a cron line, know that you probably do not need one. sloop registers itself with your machine's own service manager — systemd, launchd or the Service Control Manager — and takes the backups itself, with nobody logged in.
sloop service install runs once and needs an administrator. After that, two commands per database and there is no scheduler to configure at all:
$ sloop service attach ordersAttached. the service watches orders from its next round $ sloop service schedule orders --every 1d --keep 7 --keep-for-days 30Scheduled. orders is backed up every day the newest 7 are kept, and anything older than 30 days is pruned no cron line, no scheduled task — the service takes it It does the work with the same backup and backups prune you would have typed, with --yes and never --force, and it prunes only after a backup that worked. A missed run is caught up once and labelled late rather than a hundred and sixty-eight times, and a collision with a manual run exits 7 and stands aside.
And it reports itself in the same JSON every other command speaks:
$ sloop service status --json{ "command": "service status", "exit": 0, "ok": true, "result": { "last_seen": null, "running": true, "watching": [ { "attached_at": "2026-09-19T22:23:52+06:00", "days_of_history": 0, "label": "orders", "schedule": null, "seen_at": null } ] }}A scheduled run never stops to ask
The best illustration of the rule is a refusal that happens a day early. Scheduling a backup for a registry that has no encryption key yet would mean a first run at three in the morning that makes a key, needs to say something important about it, and finds nobody there. So it is refused now, while there is somebody to read it:
$ sloop service schedule orders --every 1d --keep 7 --keep-for-days 30error: this registry has no backup key yet, and the first scheduled backup would make one and then stop to ask about it with no terminal to ask at hint: `sloop backup <name>` once by hand makes the key and asks the question, or `sloop key export` makes it and writes it outThat is the shape of the whole contract. Not fail at the awkward moment, and never quietly guess — refuse at the moment somebody is watching, and name the thing that fixes it.
Saying yes, three different ways
The same run of restore, four times, with nothing attached to standard input. The first three are the argument:
# no terminal — this is a cron job, or CI, or a service$ sloop restore orderserror: replacing a database's contents needs its name typed, and there is no terminal to type at hint: pass --confirm orders to say it up front → exit 2 $ sloop restore orders -y# the same two lines, and the same exit 2 $ sloop restore orders --force# the same two lines again, and the same exit 2 $ sloop restore orders --confirm ordreserror: --confirm says ordres, and the database is orders hint: nothing was contacted and nothing was changed. The two have to match exactly $ sloop restore orders --confirm orders 3 of 3 tables matched ← exit 0-y did not do it. --force did not do it. Only the database's own name, typed out, did — and a typo in it stops the run before anything is contacted.
None of the three stands in for another, and that is what makes a scheduled line safe to read: a cron entry carrying --confirm orders says out loud which database it will replace, and cannot be repointed at a different one by editing a single flag.
The nine exit codes
Frozen at 1.0. Adding one is a feature; changing what one means is a breaking release, and a test in the source asserts every number and says so. Whatever calls sloop reads these and nothing else.
6 is why a bare success check is not enough. The dump finished, the file is on disk, the checksum is good — and the row counts on the two sides do not agree. Something that only tests for zero files that away as a success and tells you nothing; something that treats everything non-zero as a crash wakes somebody up for it at the same volume as a dead server. It is neither, and it has its own number so it can be treated as neither.
Which makes the calling side about six lines:
$ sloop backup --all --global -q --log-file /var/log/sloop.log$ status=$? $ case $status in$ 0) ;;$ 6) alert "sloop: it finished, and the row counts disagreed" ;;$ 7) ;; # an earlier run is still going$ *) alert "sloop failed with exit $status" ;;$ esac7 is the other one worth a branch, in the opposite direction: it means this run did not start, because an earlier one was still going. That is the lock working, not a failure, and alerting on a single one is how a team learns to ignore the alert.
One lockfile per database
An overrunning backup must not race the next scheduled one. Every run takes a lock named for the database it is working on, and a second run that finds it held says who holds it and stops:
# the nightly run is still going when the hourly one starts$ sloop backup orderserror: another sloop run is working on orders: `sloop backup`, process 18220, since 20260919T162205Z hint: exit 7 means this run did not start rather than that it failed. The lock is at /home/you/projects/acme-api/.sloop/locks/orders.lock, and it goes when that run doesThe lock names the command, the process and the moment it started, so a stale one — a machine that lost power mid-dump — is identifiable rather than mysterious. It is per database, not per machine: a long backup of one database never blocks another.
Output a script can read
Four flags, and they compose. Every command takes all of them, not a chosen few.
--json
One document, and it is the whole of standard output, so it parses without filtering anything out of it first. Anything the run wants to say about itself goes to standard error instead.
$ sloop backup orders --json{ "command": "backup", "exit": 0, "ok": true, "result": { "backups": [ { "bytes": 83724, "database": "orders", "directory": "/home/you/projects/acme-api/.sloop/backups/postgres/orders/20260919T162109Z", "encrypted": true, "engine": "postgres", "label": "orders", "rows": 12752, "seconds": 0.418, "sha256": "fc9f985fd6294d232fcf97a09302c74049d60dc0dcbade729295754559f184db", "tables": 3, "taken_local": "2026-09-19T22:21:09+06:00", "taken_utc": "2026-09-19T16:21:09Z" } ] }}A failure is the same document with the other half filled in — never a half-written one:
$ sloop backup orders --json{ "error": "the backup key has not been copied anywhere yet, and there is no terminal to ask at", "exit": 2, "hint": "run `sloop key export` once, then this run will go through", "ok": false}ok and exit are on every document sloop prints, successful or not, so a script can branch on the body as well as on the process's own code.
--quiet and --log-file
The pairing worth knowing: silent on the terminal, complete in the file.--quiet silences the running commentary and never a failure, so a job that says nothing is a job that worked — and the file still has all of it if you want to look.
$ sloop backup orders --quiet --log-file nightly.log# nothing. That is what --quiet means. $ cat nightly.logorders postgres://app@127.0.0.1:5451/orders✓ Connected postgres 17.9, 3 tables, 12752 rows✓ Dumped 3 tables 84.1 kB, encrypted✓ Checked 0b2b7d79c729 backed up to /home/you/projects/acme-api/.sloop/backups/postgres/orders/20260919T162303Z 82.1 KiB in 0.3s, sha256 0b2b7d79c729 taken 2026-09-19 22:23:03 +06:00 (2026-09-19T16:23:03Z)A sloop log is written to be pasted into a public issue: no colour, and anything that reads like a credential taken out on the way. Security shows a token going in and coming out redacted.
--dry-run
A real rehearsal rather than a guess. Every command that would write does all the reading and all the checking first, then stops at the point where it would write — so it resolves the name, opens the connection, counts the rows, and then does not.
$ sloop backup orders --dry-runorders postgres://app@127.0.0.1:5451/orders postgres 17.9, 3 tables, 12752 rowsdry run would back up orders to /home/you/projects/acme-api/.sloop — nothing was changed--all never stops at the first failure
The whole point of backing up everything on a schedule is that one unreachable database must not cost you the other nine. backup --all carries on to the end and then says exactly which ones failed:
$ sloop backup --allarchive postgres://app@127.0.0.1:5999/archive✗ archive: psql.exe failed against postgres://app@127.0.0.1:5999/archive: psql: error: connection to server at "127.0.0.1", port 5999 failed: Connection refused (0x0000274D/10061) hint: check the host, port, user and password sloop was given: `sloop db list` shows them and `sloop db test <name>` tries them nightly postgres://app@127.0.0.1:5451/orders✓ Connected postgres 17.9, 3 tables, 12752 rows✓ Dumped 3 tables 84.0 kB, encrypted✓ Checked 52549a64b0ee 82.0 KiB in 0.3s, sha256 52549a64b0ee orders postgres://app@127.0.0.1:5451/orders✓ Connected postgres 17.9, 3 tables, 12752 rows✓ Dumped 3 tables 84.1 kB, encrypted✓ Checked cfd64780e06e 82.1 KiB in 0.2s, sha256 cfd64780e06e backed up 2 of 3 — 1 failed: archive ← exit 3The process still exits non-zero, so a scheduler still knows something went wrong. What it does not do is throw away the work it had already done. In JSON, the same run separates what was taken from what was not:
$ sloop backup --all --json{ "command": "backup", "exit": 3, "ok": false, "result": { "attempted": 3, "backups": [ … two of them … ], "failed": [ { "error": "archive: psql.exe failed against …", "exit": 3, "name": "archive" } ] }}Passwords with nobody logged in
Here is the thing that catches people on the first night, and it is not sloop's doing: the OS keyring is a per-session store. A cron job has no session keyring, a headless Linux box has no Secret Service running, and a Windows task that runs as LocalSystem has no vault of yours to read. sloop's default route is the right one at a desk and the wrong one at three in the morning.
It says so, rather than failing obscurely:
error: the OS keyring is not usable here: platform secure storage failure hint: on a machine with no keyring — a headless Linux box, or a scheduled job — use encrypted-file, ${A_VARIABLE}, or command:<command line> insteadTwo of the four routes are built for exactly this, and neither needs a session:
--env SLOOP_DB_PW— read from an environment variable at the moment it is needed. Right for CI, where the variable comes from a secret store already.--password-from "op read op://vault/db/password"— run something and read the password off its output, through a pipe. Right for a team password manager, and for a machine where the secret should not sit in the environment of every child process.
A missing variable is the failure you will actually meet, and it is a 2 with the variable's name in it:
$ sloop backup nightlynightly postgres://app@127.0.0.1:5451/orderserror: SLOOP_DB_PW is not set, and the registry says the password is in it hint: export SLOOP_DB_PW before running this, or change the password route → exit 2Which route each record takes is in the listing, so it is never a guess — and a registry holds the route and never the password, so there is nothing there to print:
$ sloop db listnightly postgres://app@127.0.0.1:5451/orders project · the environment variable SLOOP_DB_PWorders postgres://app@127.0.0.1:5451/orders project · the OS keyring For a machine with no keyring at all, the fourth route is the encrypted file: --encrypted-file, opened with SLOOP_PASSPHRASE, or with SLOOP_PASSPHRASE_FILE pointing at a file only the service account can read. The second is what sloop's own units use, because a systemd unit is world-readable — systemctl cat sloop prints it to anybody with a shell — so what goes in the definition is a path, and the secret is behind it.
Driving it from cron or a systemd timer
If you already have a scheduler and would rather keep it, sloop is an ordinary command. Nothing below needs sloop service install; these are alternatives to it, not additions.
$ crontab -e # 03:00 every day. The route was set once, at db add:# sloop db add orders --url … --password-from "cat /etc/sloop/orders.pw"0 3 * * * /home/you/.local/bin/sloop backup --all --global -q --log-file /var/log/sloop.log-q so a working night is a silent night, and --log-file so there is still a record of it. cron mails you whatever the command printed, which with --quiet is nothing at all unless something failed — and then it is the failure and its hint, which is exactly the mail worth getting.
A systemd timer instead, if you would rather have one. Two files, and Persistent=true is the line that makes a machine which was off at three run the backup when it comes back:
# /etc/systemd/system/sloop-backup.service[Unit]Description=sloop nightly backup [Service]Type=oneshotUser=sloopEnvironment=SLOOP_PASSPHRASE_FILE=/etc/sloop/passphraseExecStart=/usr/local/bin/sloop backup --all --global -q --log-file /var/log/sloop.log# /etc/systemd/system/sloop-backup.timer[Unit]Description=Run sloop every night at three [Timer]OnCalendar=*-*-* 03:00:00Persistent=true [Install]WantedBy=timers.target $ systemctl enable --now sloop-backup.timerEnvironment=SLOOP_PASSPHRASE_FILE=… rather than the passphrase itself: the unit is world-readable, the file it points at is not.
Whichever drives it, the last thing worth doing is the smallest: sloop doctor exits 8 when a role is short of a privilege that will make its dumps quietly incomplete. Run it on the same schedule and you find that out on a Tuesday, rather than during a restore.