I host a number of services at home on a custom-built Ansible-backed homelab. It is set up with full TLS and restricted access using Tailscale.

Historically this was straightforward: I exposed port 443 over Tailscale, pointed the A record at that host, and that let me resolve and serve the requested content from my home server.

As I have started to work more with local AI agents, such as OpenCode, llama.cpp, Whisper, and other applications, I have grown more concerned about how much of the homelab they can reach.

This post covers how I split subnets and the measures I have taken to isolate systems and keep authentication and authorization explicit.

The hosts

Today I have three nodes in my homelab:

  1. roc [prod]
    • This is a Beelink Ser8 Ryzen 7 8845HS node with 32GB of RAM
    • This is my primary node
  2. bluejay [prod]
    • This is a 2019 MacBook Pro (Core i9) with 32GB of RAM
    • This is my secondary node
  3. robin [ai]
    • This is an M1 Mac mini with 16GB of RAM
    • This is an AI node. It runs OpenCode and Whisper.

In the future I intend to add more secondary nodes for backup purposes, and a Mac Studio for local LLM inference.

The subnets

I split the homelab into clients, prod, and AI subnets in Tailscale. ACLs pin who can open which ports instead of relying on one flat LAN. For example:

Homelab network diagram showing Clients, Production (primary network), and AI (guest network), with Caddy on ports 443 and 8443 and a Docker network example

Prod subnet

The production subnet holds most of my servers. Right now this includes two nodes, roc and bluejay. roc is my primary node and bluejay is a secondary node.

Each server runs different services in Docker Compose projects. Each Compose project exposes the minimal set of ports. Notably, it does not use the ports stanza, which would bind services to the host network; it uses expose instead to document container ports without publishing them to the host.

Docker networks

On each host I use roughly a dozen user-defined Docker bridge networks.

The local Caddy instance is attached to a set of those networks, and each project attaches one of them to its service container. That lets Caddy reach each service without being able to reach, for example, a database container on that project’s internal-only network.

Docker Compose network setup

I first tried one network per Compose project so each stack and Caddy had an isolated connection. That was easy to reason about, but Caddy paid for it at startup: more networks meant more attachments and more internal DNS to settle, and restarts took far too long.

So I moved to a smaller set of shared bridges, e.g. monitoring_net, games_net, instead of one network per project. On that kind of bridge, containers can still reach peers by container IP on any port a process is listening on.

Overall it is a workable middle ground: enough segmentation to matter in practice, without making every Caddy restart a long-running event (during which the homelab was inaccessible).

Secondary nodes

This is more of an aside, but the question may already occur to readers: isn’t the primary node a single point of failure?

Today, the primary host is a single point of failure. The A record points to this node exclusively, and it must be up to resolve requests, even when it only reverse-proxies to a secondary node.

In the future I would like the A record to cover secondary nodes as well. That would require Caddy changes so any node can route a request to the right host. Today, only the primary host knows how to reach services on secondaries.

Although I experimented with that approach early on, open questions remained about routing when the same service runs on multiple hosts (for example, Backrest runs on all nodes for local backups).

Future work should allow me to better distribute resolution to reduce dependency on a primary node.

AI subnet

When a host is dedicated to AI agents, I take extra precautions around access to and from that machine:

More importantly, I run MCP servers and other services (for example, a Docker registry) that I want to expose selectively without granting access to everything.

I run a “Proxy” Caddy instance on the prod host, listening on port 8443, which is set up for that restricted surface: it reverse-proxies to the main Caddy on port 443 for mcp-* hosts and the registry. Everything else returns 404.

Clients subnet

The client subnet is simple: it is the Tailscale nodes that are not servers. For example, my iPhone, my Kobo e-reader, and my MacBook.

Those nodes can reach production on port 443. On selected nodes, I also allow extra ports for game servers.

Conclusion

None of this replaces careful service configuration, but it stacks sensible defaults:

If you are in a similar spot and want to run local models and agents, the useful move is not “more VPN,” but narrower paths:

This is not bulletproof, but it is a reasonable trade-off for the amount of infrastructure I run. I will keep iterating as I add nodes. Next up is a Mac Studio for local LLMs and, if I go further with unsupervised agents, the same minimal-access pattern as above.