A backup producer somewhere writes a dump, an archive or a snapshot to disk. That machine has finite space, so something has to move the artifact off it and something has to delete the original. retnd is the half of that job that runs on the NAS: it discovers finished artifacts on the remote server over SFTP, pulls them, verifies them, commits them durably, records that it did, and only then removes the remote copy.
It is a standalone Go binary that embeds pinned rclone Go packages. It does not fork rclone, and it does not shell out to the rclone command line for normal data movement.
Installing it
Two commands, no arguments, on any machine with Docker:
curl -fsSLO https://raw.githubusercontent.com/retnd/retnd/main/scripts/install/install_docker_host.py
python3 install_docker_host.py install
Output
==> Installed.
Web UI: http://10.0.0.10:8080
Compose: docker compose -p retnd --env-file /home/you/retnd/.env -f /home/you/retnd/compose.yaml -f /home/you/retnd/compose.image.yaml
No config.yaml was written, on purpose. Issue #176 shipped a first-run setup flow
precisely so that a fresh install does not need one hand-written before it starts.
Open this link and follow it:
retnd-web: no administrator account exists yet. Open http://10.0.0.10:8080/enroll?token=4zj7VCpcYLIeVNN1oZJZPaCErYXOc6s6 to create one (valid 30 minutes, single use).
If it lapses before you get to it, `enroll-link` issues another.
That is the whole of it. The installer picks every path for you (~/retnd, with backups, state, config and secrets under it), generates the SSH key the engine will use, pins the exact image it was built against, and refuses before it changes anything if the machine cannot run it. Nothing needs deciding up front, and everything it chose can be changed afterwards.
The last lines are the ones that matter, and they are output rather than something to run. The engine mints the enrolment token during startup and writes the notice to its own log, so the installer waits for that line and hands it over itself (#803): a fresh install ends with the link, and opening it is the next step. Nothing has to be fetched by hand, and if the notice had not been written by the time the installer stopped waiting — on a deployment already behaving oddly — the install prints the one command that reads it back out of the engine's log instead, with every path in it the one it just chose.
The address in the link is this machine's own rather than localhost, because the link is read from another machine on the same network: localhost over SSH on a laptop is the laptop, which is what the installer used to print. Open it, create the administrator — a username, a password, a recovery email address and the mail server to reach it through, which enrolment proves by sending a confirmation message before it creates anything — and the first-run tutorial picks up from there. Have the SMTP details to hand before you open the link: the form will not finish without a working one.
The token lasts 30 minutes and works once, and nothing re-issues it while the engine keeps running. One command mints a fresh one:
python3 install_docker_host.py enroll-link
Output
==> Restarting retnd, which is what mints a token
==> Waiting up to 90s for the new notice
retnd-web: no administrator account exists yet. Open http://10.0.0.10:8080/enroll?token=jdurSlionp6jyoHTzP_5fHYfLy3Kk6tq to create one (valid 30 minutes, single use).
Valid 30 minutes, and it works once. Every link printed before this one is now dead,
including any still in your scrollback.
It reads back the newest notice rather than the first, because the container keeps its log across the restart and the earlier link is the one that restart just killed. On a deployment that already has an administrator it refuses instead, with its own exit code: enrolment is a one-time door and it closed when that account was created, so sign in. Forgotten the password rather than lapsed a link? That is Forgot password on the sign-in page, not this command: it mails a single-use reset link to the account's recovery address.
Command line only, no web interface
Same two commands, one flag on the second one:
curl -fsSLO https://raw.githubusercontent.com/retnd/retnd/main/scripts/install/install_docker_host.py
python3 install_docker_host.py install --cli-only
Output
==> Installed. Nothing is running yet, and that is what --cli-only means here:
`retnd daemon` is refused rather than started without a config.yaml, and a CLI-only
install has no first-run wizard to write one. Creating the first backup set writes
the first config.yaml with it:
/home/you/retnd/bin/retnd backup-set create <source>/<backup-set> \
--host HOST --user USER --remote-path /remote/path --local-path /backups/path \
--ssh-key-file /etc/retnd/id_ed25519 --trust-host-key \
--completion-strategy stable
Then start the scheduler:
docker compose -p retnd --env-file /home/you/retnd/.env -f /home/you/retnd/compose.yaml -f /home/you/retnd/compose.image.yaml up -d --no-build retnd
CLI: /home/you/retnd/bin/retnd
Compose: docker compose -p retnd --env-file /home/you/retnd/.env -f /home/you/retnd/compose.yaml -f /home/you/retnd/compose.image.yaml
No enrolment link there, and nothing to click: a CLI-only deployment has no Web UI to enrol into.
That runs the engine as retnd daemon rather than retnd-web serve, never starts the web-ui container, and publishes no port on this host at all, so nothing in the deployment serves HTTP and the retnd-web binary is never executed. What you drive it with is the retnd wrapper the installer writes to ~/retnd/bin/retnd, which takes every command in the table below.
There is no first-run wizard either, which is why a fresh CLI-only host stages everything, starts nothing, and prints the one command that writes the first configuration: creating the first backup set writes the first config.yaml along with it. Re-running the installer later with no flags keeps the deployment this shape, and --no-cli-only converts it to the full stack.
If you would rather see what either of those would do before it does it, python3 install_docker_host.py preflight runs every check and installs nothing.
What it sets up
There is one product here, not eleven. Every supported platform wraps the same multi-architecture image and the same Compose topology, and the differences between them are host paths and metadata formats.
Two services, one image. retnd runs the engine, the scheduler, local authentication and /api/v1 in one process, with no published port at all. web-ui serves the static interface and reverse-proxies the API to it, and it is the only service with a LAN-facing port. They meet on a private project-scoped bridge network, which is what makes the engine's isolation a topology rather than a convention.
The installer above already does this. If you are standing the stack up by hand instead: the runtime image is distroless and has no root step, and a bind mount does not chown its source, so create the host directories and chown them to the container's uid and gid before the first docker compose up, or the engine will not be able to write its own configuration, keys or journal.
The configuration directory in particular is mounted writable, as a directory rather than a single file, because a directory is the only shape that can honestly be empty. That is what makes an install that has never been configured reach the setup screen instead of refusing to start. The first-run tutorial picks up from there.
Setting one up for the first time means a one-time enrolment link the install hands you, an administrator account that is not your NAS login — with a recovery email address and the SMTP details to reach it, both proved by a confirmation message before the account exists — and an eight-step wizard whose answers become a config.yaml you will live with. The first-run walkthrough shows every screen, with an example for every field and a sentence on what that entry actually causes the product to do.
Every screen of the web interface with a picture and a table of what each control does, every one of the twenty-four retnd commands, and every subcommand and flag of the installer with its default. The command table and the flag table are both checked against the code on every run of the gate, so a command or a flag that lands without a row fails the build rather than going quietly stale.
Bash scripts around every backup: the five stages and the one order they run in, why a .local.sh hook executes outside the engine's container, why a .remote.sh hook needs a credential your SFTP account deliberately is not, the environment and secret model, the shell verification that refuses a save rather than a run, and what a crash leaves behind — a blocked backup set with two ways out and no dismiss.
What 0.4.0 added
0.4.0 is one idea and two corrections. The idea is that a destination is an instance of a registered backend rather than one of a fixed pair, and nearly everything new here follows from taking that seriously. The corrections are what running 0.3.3 on real hardware turned up, and the first of them is a reason to upgrade rather than a nicety.
A backend is described by a manifest the build carries — data, not code — and the manifest is what decides which fields a destination has, which steps its connection test runs, and what the steps it skips say instead. Two are registered: local_volume, a directory on a disk this machine can see, and s3, a bucket on Amazon's service or anything that speaks its API. The drive backups already land on is an instance too, seeded on a fresh install as a declared destination called local, so it is a row in the same list with the same controls rather than an unnamed default the interface had no way to talk about. A deployment can hold two volumes and two buckets now, and the list can tell them apart.
Two wizards come with it. Add a destination is three steps — choose a backend, name the instance, confirm — and writes nothing at all, which the confirm step says in as many words. The backends it offers come from GET /api/v1/backends, so there is no list of backend names anywhere in the page, and a backend this build understands with no manifest behind it is drawn and refused rather than quietly left out. Configure is the manifest-driven half: one renderer, not a form per backend, and nothing in the page knows what a local volume asks for. It fills in what the manifest declares, proves it, and only then writes. That order is enforced rather than advised — editing any field makes an earlier pass stale, the screen says so where the edit was made, and Save configuration stays out of reach until a check against the values now on screen has passed — and the consequence an operator can use is that no retention tier can be pointed at a destination nobody has proved.
Handing the Default mark from one destination to another is its own confirmation, because only one of the two things it does is the thing that was clicked: the destination taking the mark stops being removable, and the one giving it up becomes removable. Nothing already written moves. Declaring a destination moves nothing either; backups arrive somewhere only once a retention tier names it, which stays a separate decision.
The first correction is #662, measured on 0.3.3 on the NAS this installer was proven on. A completed local copy could be recorded as zero bytes: whatever the backend reported for the read-back went into the journal and into the sidecar recovery manifest — the record a catalog rebuild trusts when the journal is gone — without ever being checked against the file. Reconciliation then found two journal fields disagreeing with each other and quarantined the intact file over it, without reading the file. And there was no way back out: retry and fetch looped, the artifact was re-discovered as already known, and the refusal named no verb that would resolve it. Commit now records the bytes it actually made durable, a transfer that reports less than its object does not carry the zero forward, a disagreement between two records is treated as a fault in the records rather than as a verdict about a file, and from a deployment already in that state some documented verb ends with the artifact at a durable restore point.
The second is the surface that reported it. The activity feed keyed a run's severity on the destination it was writing to, so a real failure could read as ordinary; it keys on the transition now, and a FAILED backup has a control that reaches a recovery verb instead of a message recommending a command the same window called nonexistent.
The last piece is the first ten minutes. What the installer prints is transcribed from the installer on every run of the gate rather than written beside it (#714), so the output blocks above are the real ones; and the enrolment link names this machine's own address rather than localhost, which on a laptop reading the log over SSH is the laptop (#688). enroll-link reissues one when the thirty minutes have passed.
The destinations card, its four controls with what each one costs, and the three chips that are each a claim about something different.
Read the destinations → ReferenceThe manifest-driven configure wizard field by field, and the probe as a step list with the manifest's own reason printed beside every step this backend skips.
Prove it before you write it →The zero-byte record above is not cosmetic. The file on disk was intact and both records that describe it said it was empty, which is the pair a rebuild would have to choose between. 0.4.0 stops producing that state and leaves a way out of one already reached; 0.3.3 has neither.
The rule everything else serves
A remote backup artifact must not be deleted until a verified and durably committed NAS copy exists. If state is uncertain, the remote copy is preserved.
Every design decision below is either enforcing that rule or admitting where the enforcement does not exist yet. It is why the delete comes last, why the journal is written before the delete rather than after it, and why a changed SSH host key halts a backup set instead of carrying on.
What one artifact goes through
The stages below are the ones the interface names, and the order is fixed. Deletion of the remote source is the last thing that happens, after the local copy has been written, verified, fsynced and recorded.
| Stage | What happens |
|---|---|
| Discovered | The artifact appears in the remote folder and matches the include patterns. Nothing is copied until the completion method says the producer has finished writing it. |
| Transferred | Pulled to the NAS destination over SFTP. |
| Verified | Transfer verification, then a SHA-256 checksum, then the application validator if the backup set names one. A validator that rejects an artifact quarantines it and leaves the remote copy alone. |
| Committed | The local copy is made durable, and a sidecar recovery manifest is written beside it so the catalog can be rebuilt if the state database is ever lost. |
| Safe state persisted | The lifecycle journal records that a good NAS copy exists. This write happens before the delete, not after, so a crash in between leaves two copies rather than none. |
| Remote artifact deleted | The remote source is removed, with a check that it is still the same file that was verified. |
Where a copy goes after that, and how long it is kept, is the retention policy's business. The reference page covers the destinations and the tiers that name them.
The command line
Everything the web interface does, and several things it does not, are reachable from retnd. Every command except version takes --config, defaulting to /etc/retnd/config/config.yaml, which is a file inside the config directory rather than a file mounted on its own; pass the directory and it resolves config.yaml inside it.
| Command | What it does |
|---|---|
run | perform one processing cycle and exit |
daemon | repeat the processing cycle at poll_interval |
check | validate config and the state database, then exit |
status | report process and backup-set health, exiting non-zero unless every set is healthy |
sources | list configured sources and backup sets |
backup-set | create, patch, remove and test-connection one backup set, and take or release the hold the web editor takes on it |
activity | list recorded lifecycle events, or tail the live feed with --follow |
artifacts | list journal artifacts, optionally filtered by source and backup set |
fetch | run one backup set's cycle on demand |
retention | preview retention decisions, with per-run policy overrides; retention apply carries a previewed plan out |
medium | the storage destinations: list, show, add, edit, remove, default, import-credentials and test-connection |
settings | read the deployment's retention and capacity settings, and patch them in place |
reconcile | reconcile the journal against what is actually on disk, for every backup set |
validate | re-check one artifact's durable copy, wherever it is; --content downloads a copy on a storage medium and re-hashes it |
catalog | catalog rebuild reconstructs a lost or corrupted state database from the sidecar recovery manifests |
quarantine | revalidate, retry or reinstate one quarantined artifact |
retry | put one FAILED artifact back into the pipeline; failed is not quarantined, so it is its own verb and nothing does it automatically |
unconfigured | list the backup sets the journal remembers and the configuration no longer names, and what they still occupy; unconfigured clear ends those rows and removes the .partial residue a removal stranded |
restore | ask the storage provider to make one archived copy readable again; billed and slow, so --acknowledge is required |
version | report the binary, Go and embedded rclone versions |
The README's copy of this table is checked against the binary's own dispatch table on every run of the repository's gate, so it cannot quietly go stale. The copy above is a transcription of it and is not checked, so if the two ever disagree, the README is right.
The reference page carries the same table with the subcommands and the interesting flags filled in, plus where a configuration change is written and what each exit code means. That copy is checked.
What has not been proven
This section exists because the README has one, and a documentation site that quietly drops the uncomfortable half is worse than no site.
- Nothing destructive can be done over HTTP, in any deployment that exists. The API's destructive operations are behind a deployment-level gate, and the only implementation of that gate this repository ships is
NotYetImplementedGate, whosePassed()returns false unconditionally (apps/common/webhost/gate.go:114). There is no flag, environment variable or constructor argument that makes it true. So every destructive route fails closed everywhere, by construction rather than by configuration, until issue #92 supplies a real gate. - No workflow run can be started from a browser, so none of the ones that were tested was. The gate above is why:
run_backup_setis a destructive operation, so every workflow run the end-to-end suite observed was one the scheduler started, and the hook-bypass an administrator is allowed in principle lives on that same refused route. Remote hooks are worse than untested-looking: they had never executed at all between the feature landing and issue #919 being found by that suite and fixed, because the engine handed the remote-exec channel a step id its own token rule refused, and the refusal was mislabelled as a lost connection while a token-less capability probe went on reporting the channel healthy. The workflows guide lists the rest, including the two things the feature explicitly does not guarantee. - The 0.4.0 image is not on the registry. This release is cut and not pushed:
canonical.jsonrecordsimage.published: falseand the release manifest records a null digest per architecture, which are held together so neither can move alone.0.3.3is the newest tag that resolves, and it is signed. Until 0.4.0 is pushed, install it by building the image yourself or side-loading one. - No acceptance procedure has been run on real hardware. The procedures for TrueNAS, Unraid, OpenMediaVault, Synology and Proxmox VE are written, reviewed and specific, and nobody working on the repository has one of those machines to execute them on. Every one of those platforms is build-supported and uncertified.
- There is no restore execution.
retnd restoreasks a storage provider to make an archived copy readable again, which is a different thing. Getting a backup back onto a server is a documented manual procedure, and that procedure is the whole of it. - There is no metrics endpoint. A Prometheus renderer exists in the tree and nothing imports it. No listener serves
/metrics. - Every picture on this site comes from a mock. Every screenshot and every clip was recorded against the development server's in-memory fixture API, not against a running engine. The layout, the copy, the flow and the interaction are the real ones. The data is not, and each page says so.
- The rename is complete on this site and nowhere a store reviewer looks. This product was called
backupduntil #885. Everything with a picture on this site was re-recorded against the renamed interface through the capture scripts indocs/site/tools/: the 20 first-run and enrolment stills, the 15 reference screens, the 8 web-interface clips, the 2 SSH clips and the 10 workflow clips — 55 files, every one of them reproducible by re-running the script that made it. What was not re-captured, and cannot be here, is the store material: the screenshots each provider's listing shows are of the application running on that provider's own hardware, so every one that has ever been captured shows the old name, and everyStore screenshotsrow in the submission preflight is recorded outstanding rather than passing. Substituting one of the pictures above would be substituting a mock into a store listing, whichdocs/epic-checklist.md§10 forbids and which is the one place on this site where "the data is not real" could not be written under the image. No provider store listing has been re-reviewed since the rename either: the listing copy, the icons and the release notes were rewritten in the tree, and whether each store accepts the listing under a new product name is an external decision nothing in the repository can record. The wordmark itself was re-fitted rather than re-lettered — six glyphs became four, so the optical spacing and the aspect ratio were rebuilt and the accent stayed on the trailingd— and the reasoning, including the 16-pixel favicon decision, is in the brand-asset manifest. That manifest also records the two things about the art that are not proven: the lockup is a geometric construction rather than a designer's drawing, and the named human acceptance step — somebody looking at every asset and confirming none of them depicts the old name — has not been performed.