arthur@homelab:~$ whoami

Arthur Sommer

/home/arthur · homelab operator · software builder · perpetual tinkerer

arthur@homelab:~$ cat ~/posts/tailscale-caddy-homelab-network.md

A Three-Edge Homelab with Tailscale and Caddy

How I separate public, tailnet, and LAN ingress while giving every service container a host-independent network identity.

mtime 2025-03-14 · 3 min read

I want one service name—service.example.com—to work from the internet, the tailnet, or the LAN without sending every request through the same path. I also want to decide, service by service, which of those audiences can reach it.

The architecture I settled on uses three Caddy roles and split DNS:

public client ---- public DNS ----> caddy-external ---- Tailscale ----+
                                                                      |
tailnet client --- split DNS -----> caddy-internal ---- Tailscale ----+--> service
                                                                      |
LAN client ------- LAN DNS -------> caddy-local ----------- LAN ------+

The three proxies are not redundant copies. Each represents a trust boundary.

The three Caddy roles

caddy-external runs at a public cloud edge. It owns the public IP, terminates internet-facing TLS, and proxies only explicitly published services across Tailscale.

caddy-internal serves clients connected to the tailnet. Private split DNS points selected names at this proxy, allowing a service to keep the same hostname without becoming public.

caddy-local serves LAN clients and reaches backends over the local network. It keeps local services useful even when the internet—or the Tailscale control plane—is having a bad day.

This is more infrastructure than one universal reverse proxy, but the request path is easy to explain. Public traffic enters through the public edge. Tailnet traffic stays on the tailnet. LAN traffic stays on the LAN.

Give the workload its own identity

The most important convention came later: every service container gets its own Tailscale node identity.

A reverse proxy should target the service container’s tailnet address or MagicDNS name, not the Incus host’s Tailscale address and not a host-bound port forward. That makes deployment placement irrelevant. If Incus moves a container from one cluster member to another, the service keeps the same tailnet identity and upstream configuration.

Host forwarding can be useful as a migration bridge, but it should have a removal plan. Otherwise “temporary” glue quietly turns the cluster host into a permanent dependency.

DNS decides the path

Public DNS maps public service names to the public Caddy address. A wildcard record can simplify that edge, but Caddy still decides which hostnames receive a route.

Tailnet clients use an authoritative split zone. That detail matters: once the private DNS server is authoritative for example.com, missing records do not fall through to public DNS. Every name that tailnet clients need—including an apex or www redirect—must exist in the private zone.

LAN DNS points local names at caddy-local. Depending on the router, that can be a set of host overrides or another authoritative DNS view.

DNS is therefore part of access design, not merely name decoration.

TLS without distributing every credential

The public edge performs ACME DNS-01 and stores the resulting wildcard certificate on protected shared storage. The internal and local Caddy servers consume that certificate without receiving the DNS provider credential.

That division keeps one issuer and two consumers:

RolePublic DNS credentialACME issuerServes TLS
Public edgeyesyesyes
Tailnet edgenonoyes
LAN edgenonoyes

I eventually wrote a small Caddy certificate-loader module so consumers could treat the shared files as a live certificate source instead of relying on timers that reloaded Caddy when a serial number changed.

A service rollout checklist

For each new service I answer these questions:

  1. Does it need public, tailnet, LAN, or multiple entry points?
  2. Which Caddy instance owns each permitted route?
  3. Does the service container have its own Tailscale identity?
  4. Do all authoritative DNS views contain the required name?
  5. Is the backend reachable only on the intended interface and port?
  6. What health check proves each path independently?

Then I test from each network zone rather than assuming one successful browser request proves the architecture.

The result is not the smallest possible homelab network. It is a network whose boundaries remain legible as services move, fail, and become public or private.