Skip to content
Dimitri Montgomery

Atlas 02 of 07

homecloud

A file server that checks its own backups are readable, and tells you when they are not.

Shipped — Finished, or finished enough that the write-up is about what it does. Personal · 2026

733
Tests
strict
Typed
6
Defects found by running it
01

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.
02

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 wrongWhy no test caught it
A failing verification retried every five secondsEvery test drove the scheduler a fixed number of times
Error reasons reached the phone as raw JSONThe mock never produced the tool’s real stderr
Then the unwrapped sentence was 900 characters of hexNothing asserted on length, only on content
The restore drill counted directories as restored dataThe mock returned a plausible file count and no bytes
One corrupt repository raised two identical alertsEach half was tested alone, and each was correct alone
The screen lagged a quarter of an hour behind the buttonNo 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.pdf on 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.
03

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

  1. 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.
  2. 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.
  3. 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.
04

Built with

Service
Python 3.13FastAPIpydantic v2asynciostructlog
Watches
resticSyncthingVaultwardenDockersmartctl
Discipline
mypy --strictruffpytestJSON Schema conformance