Atlas 02 of 07
homecloud
A file server that checks its own backups are readable, and tells you when they are not.
- 733
- Tests
- strict
- Typed
- 6
- Defects found by running it
What it does
A file server for a household that treats the backup as the product rather than the afterthought — because the failure it is built around is a backup that reports success every night over an archive nobody can read.
- Serves files over the LAN
- Syncthing for devices, a password vault alongside it. The service itself never reads file contents and never emits a filename, path or thumbnail in any event, log line or notification.
- Backs up on a schedule
- restic to a destination, with the job ledger visible — queued, running, succeeded, failed — rather than a cron line nobody has looked at since installing it.
- Verifies the archive is readable
- Periodic `check --read-data-subset`, plus a restore drill that pulls real bytes back and compares them. An exit code of 0 from a backup tool means the command ran, not that the data survived.
- Watches the machine underneath
- Container states, disk health and host conditions — including the one where under-voltage drops a USB drive and looks exactly like a failing disk.
How it works
What it does
A Raspberry Pi that keeps files synced across every device and hosts a password vault, plus a service that watches the storage, verifies the backups, and puts all of it on one screen in a phone app.
The service is a control plane, never in the data path. Syncthing moves the files, restic
makes the backups, smartctl reads the drive. This software configures, observes, reports, and
triggers. If a change would put it between a byte and its destination, the change is wrong —
writing a file-sync engine is a multi-year project with a silent failure mode.
One decision, explained properly
- The obvious thing
- Schedule the next verification from the last time one succeeded.
- Why it fails
- That field only moves on success, so a verification that keeps failing is due forever.
- Instead
- Schedule from the last attempt. Success still decides health; it just stops driving the clock.
This is the field the whole feature turns on, which is exactly why reaching for it here is so natural. But it only advances when a verification succeeds, so the moment one starts failing the job becomes permanently due — the only thing that would clear it is the success that is not happening.
Measured on a real run: 21 verification jobs in 30 seconds. With restic actually installed, each of those is a full integrity check against a repository that is already in trouble, which on a metered offsite destination is expensive as well as useless.
The cost of the fix is that a transient failure now waits a full interval before retrying. That only became acceptable once the module could be told to retry by hand — which is why this change and the control surface landed together.
What running it taught me
The service passed 645 tests before it had ever executed restic or touched a Docker socket.
Installing both and pointing it at a deliberately corrupted repository found six defects, and
not one of them was visible by reading the code:
| What was wrong | Why no test caught it |
|---|---|
| A failing verification retried every five seconds | Every test drove the scheduler a fixed number of times |
| Error reasons reached the phone as raw JSON | The mock never produced the tool’s real stderr |
| Then the unwrapped sentence was 900 characters of hex | Nothing asserted on length, only on content |
| The restore drill counted directories as restored data | The mock returned a plausible file count and no bytes |
| One corrupt repository raised two identical alerts | Each half was tested alone, and each was correct alone |
| The screen lagged a quarter of an hour behind the button | No test looked at the screen after pressing it |
What it deliberately does not do
- Serve file contents. Not one endpoint, enforced by a test.
- Read the vault. It backs Vaultwarden up as opaque bytes and holds no credential that would decrypt it.
- Name a file. No event, log line, or push payload contains a filename or a path. Counts and
sizes only —
divorce-settlement-draft.pdfon a lock screen is the failure that rule exists to prevent. - Auto-update anything. It reports that an update exists. Deciding to apply one at 3am, unattended, to the process holding your password vault, is not the software’s call.
Testing
733 passing · ruff and mypy --strict clean
The whole suite runs on a Mac with no Pi, no drives, no Docker and no restic. Every subsystem sits behind a Protocol with a mock twin, so the thing under test is always the judgement rather than the hardware.
What the tests defend
- The load-bearing test describes a backup that succeeds every night over an archive that cannot be read. A mock repository can be told to succeed at backing up and fail at verifying, which is the one situation you cannot produce on demand with a real one.
- A structural test fails the build if any API module so much as mentions FileResponse. The rule is that this service never becomes a second way to read your files, and a grep is the bluntest possible way to make breaking it a deliberate act.
- Another is exhaustive over the event enum and feeds every renderer a payload stuffed with filenames, to prove none of them surfaces one. Adding an event type without meeting the rule fails the build rather than shipping.
Built with
- Service
- Python 3.13FastAPIpydantic v2asynciostructlog
- Watches
- resticSyncthingVaultwardenDockersmartctl
- Discipline
- mypy --strictruffpytestJSON Schema conformance