Homelab writeup · Aug 2026
Everything here runs on two machines: a little NAS that holds the disks, and a mini PC that does the actual work. Between them they run 43 containers — media, home automation, game servers, DNS, and a monitoring stack that mostly exists to tell me when I've broken something.
None of this is clever, exactly. It's just a lot of small decisions that each made sense at the time, plus a handful of rules I only follow because ignoring them cost me a weekend. Here's what's running and why.
The split is deliberate: the NAS stores things, the mini PC does things. The NAS is a low-power 4-core unit with the drive bays — great at sitting there serving files, miserable at transcoding video. So it runs the handful of services that need to be up whenever the network is up, and nothing else. Everything hungry lives on the other box and mounts the array over the network.
The one rule I'm strict about: app databases never touch the network share. They sit on the mini PC's local NVMe and get copied to the array nightly. Media goes the other way — written once, read forever, perfectly happy on spinning disks.
Downloads and the library also share one filesystem, deliberately — that way importing a finished download is a hardlink, not a copy. No second copy, no waiting for 40 GB to move across the network, and seeding continues from the same bytes the media server is reading.
SQLite over NFS corrupts. Not "might corrupt under load" — it will, and you won't find out until something refuses to open a database it was happily writing to yesterday. Every app that keeps state got moved to local disk after that.
There's exactly one way in from the internet: a reverse proxy on the NAS that handles TLS and certificate renewal. Everything public goes through it. What's behind it got split by who it's for.
Stuff other people use is public — the media server, the request portal, audiobooks, home automation. Friends and family shouldn't need a VPN client to watch a film.
Everything I use to run the place isn't. Container management, dashboards, DNS admin, the NAS's own UI, all the download tooling — I pulled every one of those off the public internet and put them behind a mesh VPN. They're only reachable from devices I've explicitly enrolled. It's a meaningfully smaller attack surface for about ten minutes of work, and the only cost is that my phone needs the VPN toggled on to fix things from the pub.
An identity provider sits in front and now speaks OpenID Connect properly, so apps that support it authenticate against one account with 2FA instead of each having its own password. Before that it worked by trusting a header from a whitelisted address — fine behind a single proxy hop, genuinely dangerous once a whole mesh network is upstream, because any device on it could just assert whoever it liked.
Rather than list it twice, here's the dashboard I actually open — every card is a live widget pulling from the service behind it, so queue lengths, disk usage and what's currently downloading are real numbers rather than a static page.
I maintain this in evenings, so anything I'd have to do twice gets automated, and anything that breaks in a predictable way gets taught to fix itself.
A health check runs every five minutes and does six things: are all the scrape targets up, are the containers running, are the network mounts alive, is there disk headroom, is the VPN actually passing traffic, and do the external probes pass. It writes its own results out as metrics, so the monitoring stack monitors the monitor.
When it finds a known failure it just fixes it. The common one: if the VPN gateway gets rebuilt, every container sharing its network stack is left stranded and has to be rebuilt too. That used to be me, at a terminal. Now it's a script, and each fix is rate-limited so a flapping service can't send it into a restart loop.
If something is still broken after two consecutive cycles, it hands off to an AI agent with a deliberately narrow, mostly read-only toolset, which investigates and writes what it found to the log. Not because it reliably fixes things — because coming back to a diagnosis beats coming back to "it's down".
That escalation writes to a log file and nothing else. There's no push notification, so if I'm not looking, I genuinely don't know. It's the most obvious hole in the whole setup and it's been on the list for months.
The rest is unglamorous: config backed up to the array nightly, quality profiles synced nightly, certificates renewed automatically — and separately probed for expiry, because trusting a renewer to tell you it failed is exactly how certificates lapse.
There's a tmux session on the mini PC that has been open since early July,
and that's the control plane. Inside it: Claude Code and
OpenCode, both pointed at the same set of notes I've written about
this estate — the topology, the deploy commands for each stack, and every gotcha
further down this page.
The useful part isn't the chat, it's the tool access. Both are wired to MCP servers for the container manager and for the metrics stack, so an agent can list what's running on either host, read a stack's compose file, redeploy it, and query PromQL directly instead of me pasting numbers at it. Asking "why is nothing downloading" and having it actually walk the chain — VPN egress, then indexer health, then per-release rejections — beats doing that by hand at 11pm.
Both tools read the same skill files, so the context lives in one place rather than being re-explained per tool. That's also what makes the health check's escalation worth anything: when it hands off a persistent fault, the agent already knows what this setup looks like and which failure modes are normal here.
There's no SSH into the NAS — it's an appliance. Everything I do on that box goes through the container manager's Docker API instead: exec into a running container to read a config, or spin up a throwaway one to poke at the host. Awkward at first, and now genuinely fine. It also forced every change on that machine to be scripted rather than typed, which turned out to be an accident worth keeping.
Three failure modes have happened often enough to be written up properly: the VPN tunnel dying in a way that doesn't self-heal, the network mounts losing a race with Docker at boot, and recovering from a power cut. They're checklists, not prose — the point is that 11pm-me doesn't have to re-derive anything that midnight-me already worked out once.
Every one of these is a rule now because it wasn't before.
Mount directories, not single files. Loads of programs save by writing a temp file and renaming it over the original. You can't rename across a single-file bind mount, so the save fails — and plenty of them carry on quite happily with the old contents. I had a tool silently failing to persist a refreshed auth token for two months this way. Everything worked right up until the moment the in-memory token would have expired.
Recreate, don't restart. Containers that share another container's network stack bind to that container's identity when they're created. Rebuild the one they're attached to and restarting them doesn't help — it fails outright, complaining about a container ID that no longer exists. They have to be recreated.
Bind to all interfaces, not one address. I had several services pinned to the LAN address. They worked perfectly from the LAN and were completely invisible over the VPN — connection refused, which looks exactly like a dead service. Took an embarrassingly long time to spot, because every test I ran was from a machine where it worked.
Port-remapping breaks host header checks. A download client kept
returning 401 Unauthorized over the VPN. It wasn't auth at all — the app
validates that the port in the Host header matches its own listening port,
and Docker was publishing it on a different one, so that check could never pass.
The error it reports for this is indistinguishable from a wrong password.
Watch what a repeated flag swallows. The escalation script passed a list of permitted tools and then the prompt as the final argument. That flag takes any number of values — so it ate the prompt as one more tool name, and the agent was invoked with nothing to do. It failed silently for seven days before I noticed. The prompt goes in on stdin now.
Check what the defaults actually do. The manga pipeline spent months producing greyscale files because the converter greyscales everything unless you pass one flag. The source had colour, the e-reader has a colour screen, and the pipeline was quietly throwing it away in the middle.
Roughly 3.9 TB of 7.3 TB usable, which gives me a while yet before the awkward conversation about more drives.
The honest gaps: there is no offsite backup. Config is copied between the two machines nightly, which covers a dead disk and does nothing at all about fire, theft or me running the wrong command with conviction. The media I could re-acquire; the configuration I'd rather not rebuild. That and the missing push notifications are the two things I'd fix first, and have said so for a while now.