☕ Coffee anyone ?
4 min read

Centralize, control, harden, simplify

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

Documents/ backup-folder on the home VM restic AES-256, client-side before a byte leaves over the internet S3-compatible storage EU region sees opaque blobs only Ansible control box, idempotent systemd timer nightly, one job per person deploys config, once triggers
Documents get encrypted before they ever leave the house. Ansible sets the machinery up once; a systemd timer runs it every night.
  • 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

one object stored in the bucket restic decrypt (passphrase supplied) no passphrase nothing to decrypt with the passphrase lease_agreement.pdf tax_return_2025.pdf passport_scan.jpg insurance_policy.pdf readable, exactly as filed the storage provider, without it a3 f0 91 e2 6c 5d 88 12 fa 0b c4 77 aa 2e 91 08 d5 63 f1 3a 5c 90 ee 42 17 b8 2d 9c 08 e1 5f 33 aa 70 c6 91 a bucket full of noise
Same object, two readings. One of them requires a passphrase that never touches the storage provider’s servers.

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 job
restic 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

Nextcloud VM Ansible control box destroyed, stolen, or just off — doesn’t matter no connection needed between them shared password entry bucket · endpoint read-only key · passphrase any computer, anywhere restic installed, internet on restic restore --no-lock S3-compatible storage the vault, unaffected documents, plaintext again
Delete the homelab from this picture entirely. The recovery path doesn’t route through it — that’s the whole design.

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.