Centralize, control, harden, simplify
Dev Log — nextcloud-s3
Notes on consolidating scattered sensitive documents into one encrypted, self-hosted vault.
This homelab already runs plenty of infrastructure — a Kubernetes cluster, weekly VM snapshots, a hardened reverse proxy. None of it touched a much more personal problem: sensitive documents scattered across a dozen different places, each with its own login and its own idea of who else gets to see the data.
Payslips, tax paperwork, every joyless bit of personal admin — every employer ships its own “digital safe,” each with its own retention policy and no real guarantee of data sovereignty. The goal was to pull all of it into one place: encrypted before it ever leaves the house, controlled by a passphrase nobody else holds.
The requirement
The setup covers two people, not one, and each needed independent control over their own documents — not shared, not dependent on the other. Not high availability either; plenty of that already exists elsewhere in this homelab. This is about ownership of a handful of files that actually matter.
Two people. Two separate archives, each fully controlled by its own passphrase.
That’s the actual requirement: work out what centralized, hardened, and simple storage looks like when it has to keep working independently of the homelab itself.
The mechanism
The shape of it is almost boringly simple, which is exactly the point — nothing clever to misremember under stress:
Fig. 1 — the chain
- restic handles the encryption — AES-256, client-side, before anything leaves the machine — plus deduplication, versioned snapshots, and integrity checks (
restic check), all for free from one well-audited tool. - S3-compatible object storage, hosted in the EU, is the destination — a real bucket from a provider that isn’t also selling smart speakers.
- Ansible, from a small always-on control box, deploys the restic install, credentials, and systemd units idempotently.
- systemd timers do the nightly work. No Kubernetes CronJob, no orchestration platform for a job this small.
One detail that matters: it’s not one backup job, it’s a list of jobs, one per person — separate bucket, system user, and passphrase each. If one passphrase is ever compromised, the other backup is structurally unreachable, not just “probably fine.”
Zero-knowledge, and what that promise actually costs
“Zero-knowledge” is usually a marketing word. Here it’s concrete: the storage provider only ever sees the encrypted object.
Fig. 2 — zero-knowledge, illustrated
If the storage provider gets breached, subpoenaed, or just has a curious employee, what they hold is indistinguishable from noise. No backdoor, no key escrow — that emergency door would be exactly the one an attacker wants too.
The honest part: this cuts both ways. Lose the passphrase and it’s gone for good — there’s no “forgot password” flow for math. That’s what the last section is about.
Field notes: the boring bugs
None of the interesting failures were about encryption — they were about the plumbing underneath it, where failures usually hide.
// systemd ate its own JSON
The backup and check services push a status notification via ExecStopPost=. First draft built the JSON payload inline in the unit file — curl fired fine, but the body was garbage every time. systemd’s own line parser escapes ExecStart=/ExecStopPost= lines before the shell ever sees them, silently mangling backslashes. Fix: an actual script file, not a shell one-liner in unit syntax.
// zero errors, zero permissions
A status dashboard kept reporting empty results for backups that had otherwise succeeded — no errors, nothing in any log. Cause: a temp file created at mode 0600, then renamed into place, computed a POSIX ACL mask that silently canceled the parent directory’s default ACL. The read permission existed in name, not in effect. Found by diffing getfacl before and after the rename.
// the username that didn’t exist
The original plan assumed a Nextcloud account matching the obvious name. It didn’t exist — the documents lived under a completely different account. “Obviously it’s called that” is a hypothesis, not a fact.
// a safety net doing exactly its jobrestic forget --keep-last 0 --prune, meant to clear a test snapshot, refused outright: “no policy was specified, no snapshots will be removed.” restic treats a bare 0 as “you forgot to configure this,” not “delete everything” — a good default, if a confusing one the first time you hit it.
The part that isn’t a bug at all
Here’s where the project stopped being an Ansible exercise.
For one person’s own documents, the decryption passphrase lives in their password manager — their risk, their documents, their call.
For the second person’s documents, tying the passphrase to someone else’s password manager breaks the whole point: separate archives are only meaningfully separate if each person actually controls their own.
The fix: the bucket name, endpoint, a read-only access key (never read-write — this credential can only recover, never delete), and the passphrase all live as one entry in a password manager, shared directly with the second person. A short note attached spells out the couple of restic restore commands needed — no terminal knowledge required.
Fig. 3 — the restore test
That’s the actual test: someone who’s never typed restic can get their own documents back, from any computer, without needing anyone else. Verified — a full restore from the read-only credentials came back byte-for-byte identical.
None of the interesting decisions here were about encryption strength or storage cost — those are solved problems. The interesting part was making control genuinely simple: one encrypted archive per person, one passphrase, no jargon required to get it back.
End of log.
Member discussion