rogerhamilton.me / homelab

A seven-machine homelab that runs a family's software

Five machines form a Docker Swarm. Two stand alone on purpose. Together they run more than thirty self-hosted services for photos, files, media, documents and DNS, built to be reliable and rebuildable from backups and a documented procedure.

7machines on the network
30+self-hosted services
5 + 2Swarm nodes + standalone hosts
4hardware classes: x86 PC, Pi 5, Pi 4, VM
The machines

Each box has one job it is good at

officeSwarm manager
Lenovo ThinkClient · i7-6700K · 32 GB

The workhorse. Dashboard, inventory, bookmarks, uptime monitoring, Paperless-ngx, OpenProject, Collabora, whiteboard, task tracking, offline Wikipedia, and the monitoring UI for every other node.

nextcloud-coreSwarm worker
Raspberry Pi 5 · 16 GB

Nextcloud on a custom-built image, with MariaDB and Redis behind a host-side nginx. The family's files, calendars and sync.

immichSwarm worker
Desktop PC · i7-6700 · 32 GB · NVMe

Photo and video library with face recognition and semantic search. The machine-learning jobs get a full 8-thread CPU, and the photos live on network storage, so this node can be rebuilt without touching them.

office-auxSwarm worker
Raspberry Pi 4 · 16 GB · 4 GB swap

The utility drawer: PDF tools, developer utilities, local file sharing and budgeting. Small, stateless apps that suit a small machine.

dns-monitorSwarm manager
Raspberry Pi 4

Network-wide DNS filtering with Pi-hole for every VLAN, plus a monitoring agent. It is also a Swarm manager.

jellyfinStandalone
Virtual machine · ~10 TB media

Jellyfin for video, Navidrome for music, and a Sonos bridge. Jellyfin is installed on the host itself; the rest runs in Docker Compose.

ocmeshStandalone
Raspberry Pi 5 · 16 GB

A separate Nextcloud for a separate organization, kept out of the family cluster.

Design contrasts

Where the setup deliberately goes two ways

Most of the architecture comes down to a handful of either/or choices. Here is each one, what sits on each side, and the reason for the split.

Swarm vs standalone

Swarm Five nodes

  • One deploy.sh per stack, same shape everywhere
  • Node labels decide what runs where, so a service finds the right machine on its own
  • Overlay networks link stacks across machines

Standalone Two hosts

  • Plain Docker Compose, managed on the host
  • Jellyfin left the Swarm. It is installed on the host itself, with its music server, Sonos bridge and tunnel in plain Compose beside it
  • The second Nextcloud belongs to a separate organization, so it stays outside the family cluster

The rule: use the cluster where scheduling helps, and stay out of it where it would only add moving parts.

Big x86 boxes vs small Pis

x86 office and immich

  • Anything memory- or CPU-hungry: OpenProject, Paperless and document editing on office, photo machine learning on immich
  • The two 32 GB machines

ARM Raspberry Pis

  • Single-purpose services with a known footprint: Nextcloud, Pi-hole, small utilities
  • Cheap, quiet and low-power, and easy to replace from a runbook

What changed it: OpenProject, Collabora and the whiteboard once ran on a Pi 4 that kept running out of memory. Moving them to the x86 box fixed it.

Public vs private access

Public A short list

  • Nextcloud, OpenProject, the Sonos bridge, and Home Assistant
  • Reached through Cloudflare tunnels, except Home Assistant, which uses its vendor's cloud relay

Private Everything else

  • Reachable only over Tailscale, with HTTPS from Tailscale's own certificates
  • Tailscale runs on the host, not in a container, which avoids Swarm networking quirks and keeps it working when the cluster does not

The rule: a service is private until there is a reason for a family member to open it from anywhere.

On the host vs in a container

Host Foundation

  • Tailscale, monitoring agents, Jellyfin, and the backup timers
  • These need to work when Docker does not, and they are what you use to fix it

Container Applications

  • Every application, defined in version control and redeployable in minutes
  • Pinned where a version matters, such as the search engine behind the bookmark manager

The rule: anything the cluster depends on stays outside it.

Then and now

What I changed, and what each change fixed

Before
Now
Reason
Docker-managed NFS volumes over NFSv3
Host mounts plus bind mounts, NFSv4 only
Volumes went stale after address changes, and NFSv3's extra ports are unreliable across a firewall between VLANs.
A heartbeat alert from every backup job
One weekly email report
One message covers the local archives and the network copy together.
Jellyfin as a Swarm worker
Standalone Docker host
Jellyfin runs on the host, so the scheduler had little to manage.
Heavy apps on a Raspberry Pi 4
Heavy apps on the x86 node
Repeated out-of-memory crashes.
Several monitoring tools
Netdata everywhere, one UI
One place to look, with a lightweight agent on every node.
Syncing the whole backup staging area offsite
Syncing only the Borg repository
Dumps and snapshots already live inside the archives, so the extra copy added cost and no safety.
One flat folder of stack files and scripts
A repository with one directory per stack
Every stack has the same files, so there is nothing to remember.
Keeping it alive

Backups first, then everything else

Service dataDatabases dumped, services paused briefly for a consistent copy
Borg + borgmaticEncrypted, deduplicated archives on each node
Network storageRepository synced to the NAS with rclone
OffsiteBackblaze B2 copy of the NAS datasets
  • Backups run from systemd timers, staggered across nodes so monitoring stays available during each window.
  • A restore always goes to a scratch location first. It is verified there before it touches production.
  • Passphrases and keys sit in a password manager, and are rotated only after a suspected exposure or a change in who has access.
  • Ansible configures every host, so a rebuilt Pi gets Docker, Tailscale, storage mounts and backup units from one command. I preview each run first with check and diff.
  • Rebuild-from-nothing runbooks exist for each node, kept next to the code.
  • The whole setup lives in one Git repository, with secrets kept out of it.
Lessons

Things the cluster taught me

Swarm ignores env_file

It does so silently. Declare every variable in the service's environment block and load the env file into the shell before deploying.

Labels vanish on reboot

Placement constraints depend on node labels, so one script reapplies them all. "No suitable node" almost always means a missing label.

Keep SQLite off NFS

Query timeouts appear under load. Anything backed by SQLite gets local disk.

Root squash blocks containers

With root squash on, a container writing as root fails without an error message. Map root on the NAS export before debugging the app.

Pin what can corrupt data

The bookmark manager's search engine stays on one exact version, because an upgrade breaks its index.

Stale overlay state looks like many failures

When several services error out at once with "no such container", restart Docker on that node before chasing individual apps.

Next

On the list