Homelab networking — every decision in one place
BalserosWorld network decision log. Each one carries a stable
NET-NNidentifier: cite it instead of repeating the explanation. Whendecisions/exists, eachNET-NNwill have its own ADR pointing here; in the meantime, this file is the reference.Updated: 2026-09-07 — added NET-15 (the docs-site hosting decision).
The technical detail of each piece lives in
concepts/dns-and-routing.md,concepts/docker-networks.md,concepts/tls-and-proxies.mdandservices/caddy.md.
Map of the four layers
Section titled “Map of the four layers”The recurring confusion in the homelab is treating as interchangeable four things that answer different questions. This is the real order:
LAYER PIECE ANSWERS NET- ───────────────────────────────────────────────────────────────────────── name → Cloudflare DNS what IP is this name? NET-04 network → LAN / Tailscale which path do I take? NET-01, NET-10 HTTP → Caddy which backend serves this? NET-08 docker → Docker DNS 127.0.0.11 what IP does the ctr have now? NET-07 app → the service which handler responds? —Zone 1 — Physical network and perimeter
Section titled “Zone 1 — Physical network and perimeter”NET-01 · A single flat LAN, fixed IPs by role
Section titled “NET-01 · A single flat LAN, fixed IPs by role”LAN 192.168.2.0/24Bridge vmbr0 ← joins the virtual NICs with the physical networkpve 192.168.2.200 ← the Proxmox host, not a VMVM 100 192.168.2.8 docker-hostVM 101 192.168.2.10 backup-managerWhy: a three-node homelab gains nothing from VLANs and does lose
debuggability. The IPs are fixed and memorable because they appear in the
Caddyfile, in authorized_keys, in the Borg repo and in the runbooks.
Accepted cost: there is no segmentation. A compromised service on VM 100 sees the whole LAN. It is the homelab’s known network debt — see Open questions at the end.
NET-02 · Native Proxmox firewall, never UFW
Section titled “NET-02 · Native Proxmox firewall, never UFW”INPUT: DROP ← everything closed by defaultOUTPUT: ACCEPTFORWARD: ACCEPT
Exceptions, only from 192.168.2.0/24: TCP/22 SSH TCP/8006 Proxmox GUIWhy: two overlapping firewalls on the same host produce contradictory rules that are impossible to debug exactly when something stopped responding.
NET-03 · Everything goes out, nothing comes in
Section titled “NET-03 · Everything goes out, nothing comes in”The pattern that unifies the rest of the design: there is not a single port open from the Internet towards the homelab. Everything that crosses the perimeter is initiated by a machine on the inside.
rclone → R2 (backups) outboundcloudflared → Cloudflare (publishing) outboundtailscale → tailnet (administration) outboundCaddy DNS-01 → API CF (certificates) outboundWhy it matters beyond security: it is what makes the homelab work behind CGNAT or behind a router you do not control, without port-forwarding. And it is the basis of the agent architecture (NET-14).
Zone 2 — Names and certificates
Section titled “Zone 2 — Names and certificates”NET-04 · Cloudflare authoritative, in DNS only mode
Section titled “NET-04 · Cloudflare authoritative, in DNS only mode”A records for vault, kuma, portainer, cloud, test →
192.168.2.8, without the orange proxy.
Why DNS only and not proxied: Cloudflare’s proxy would try to reach the origin in order to serve it, and the origin is an RFC1918 address unreachable from the Internet. It would also break direct access from the LAN.
What this means, and it is worth being clear about: publishing a name
does not publish the server. DNS translates name → IP; connectivity is
provided by the LAN, Tailscale or the Tunnel. A correct dig from outside
the house followed by a timeout is not a failure: it is the design.
NET-05 · TLS via DNS-01, not via HTTP-01
Section titled “NET-05 · TLS via DNS-01, not via HTTP-01”Caddy → Cloudflare DNS API → temporary TXT → Let's Encrypt validates │ no inbound port ────┘Why: HTTP-01 would require Let’s Encrypt to come in through port 80 from the Internet — exactly what NET-03 forbids. DNS-01 proves control of the domain without an inbound connection, and along the way allows certificates for services that will never be public.
Consequence: Caddy needs its own image with
caddy-dns/cloudflare, and a token with Zone → DNS → Edit limited to
balserosworld.net (never the Global API Key: it gives control of the whole
account).
Zone 3 — Docker internal network
Section titled “Zone 3 — Docker internal network”NET-06 · A single external proxy network
Section titled “NET-06 · A single external proxy network”Name: proxy Driver: bridgeSubnet: 172.22.0.0/16 Gateway: 172.22.0.1Created once, outside any stack; each stack declares it with
external: true — “the network already exists, reuse it, do not create a
private one with that name”. Without that line every stack would end up
isolated and Caddy would see nobody.
NET-07 · Reach by name, never by IP
Section titled “NET-07 · Reach by name, never by IP”Docker’s embedded resolver (127.0.0.11) translates vaultwarden to the
container’s current IP.
Why: the IP changes on every re-creation of the container; the name does not.
A 172.22.0.5 written by hand in the Caddyfile works until the first
docker compose up -d.
Inherited dependency: that resolver’s external servers are the
host’s, which on VM 100 are Tailscale’s (100.100.100.100,
MagicDNS). A MagicDNS hiccup already left Nextcloud AIO unable to
download an image in the middle of a backup. It is the least obvious
network dependency in the homelab.
NET-08 · Do not publish ports that only Caddy is going to use
Section titled “NET-08 · Do not publish ports that only Caddy is going to use”EXPOSE / expose → documents. Opens nothing.ports: → creates a real entry point from the host.If Caddy and the backend share the proxy network, the backend does not
need ports. Publishing it exposes surface on the LAN in exchange for nothing. When
you do need to publish without opening to the LAN: "127.0.0.1:8080:80".
NET-09 · localhost is the container’s namespace
Section titled “NET-09 · localhost is the container’s namespace”127.0.0.1 inside Caddy = Caddy127.0.0.1 inside Vaultwarden = Vaultwarden127.0.0.1 on docker-host = docker-hostThe initial Caddyfile used reverse_proxy 127.0.0.1:8000 expecting to reach
Vaultwarden and failed, looking inside Caddy itself. Origin of NET-06.
NET-09b · The documented exception: Nextcloud AIO
Section titled “NET-09b · The documented exception: Nextcloud AIO”cloud.balserosworld.net does not use Docker DNS: it points to
host.docker.internal:11000, with extra_hosts: host.docker.internal:host-gateway
in Caddy’s compose.
Why breaking the pattern was accepted: during bootstrap, the
nextcloud-aio-domaincheck container lives on the nextcloud-aio network and not
on proxy, so Caddy could not reach it (the classic 502) even though curl -I http://127.0.0.1:11000 returned 200 on the host. Going out to the host resolved the
domaincheck and stayed as the stable scheme. It is the only exception in the
Caddyfile and it is marked as such.
Zone 4 — Remote access
Section titled “Zone 4 — Remote access”NET-10 · Tailscale for administration, with a subnet route
Section titled “NET-10 · Tailscale for administration, with a subnet route”VM 100 advertises 192.168.2.0/24 and the route is approved in the console, so
from Windows or Android any LAN IP can be reached without installing
Tailscale on every machine.
docker-host 100.95.193.112 ← advertises the subnetbackup-manager 100.79.63.6Windows 100.90.123.48Android S23 100.89.101.62No exit node: the exit node routes all of the client’s Internet traffic, which is another problem. For administering the homelab it adds nothing.
NET-11 · Cloudflare Tunnel shut down by default
Section titled “NET-11 · Cloudflare Tunnel shut down by default”balserosworld-homelab, in /opt/stacks/cloudflared. It publishes a service
through an outbound connection, without port-forwarding.
Why shut down: the fact that something is technically publishable is not a reason to publish it. Proxmox and the administrative panels are not exposed.
NET-12 · The four are not substitutes
Section titled “NET-12 · The four are not substitutes”| Piece | What for | What it does NOT do |
|---|---|---|
| Cloudflare DNS | names + DNS-01 | does not provide connectivity |
| Tailscale | private administration | does not terminate TLS or act as a proxy |
| Cloudflare Tunnel | selective publishing | is not administration |
| Caddy | reverse proxy + TLS | is not an IP routing table |
Zone 5 — Known network traps
Section titled “Zone 5 — Known network traps”NET-13 · A VM clone duplicates its entire network identity
Section titled “NET-13 · A VM clone duplicates its entire network identity”Restoring the vzdump of VM 100 produces a machine with the same IP, the
same MAC (BC:24:11:94:CE:48) and the same Tailscale identity.
Before booting a clone: 1. disconnect or isolate the NIC 2. systemctl stop tailscaled && systemctl disable tailscaled 3. stop the containers with a restart policyPoint 3 is not cosmetic: Caddy starts on its own and publishes 80/443 on
0.0.0.0, and two Caddys requesting certificates for the same domain can
interfere with production’s DNS-01 renewal.
Status: the VM 199 test machine still exists. Destroying it is the homelab’s most urgent open item.
NET-13b · The Docker socket exposed to Uptime Kuma
Section titled “NET-13b · The Docker socket exposed to Uptime Kuma”The container monitor requires mounting /var/run/docker.sock into Kuma.
Whoever has that socket controls Docker. Accepted risk in exchange for
detecting Caddy going down — which is exactly the failure that blinds
every HTTP monitor.
Zone 6 — The agent
Section titled “Zone 6 — The agent”NET-14 · Hermes lives outside and never comes in
Section titled “NET-14 · Hermes lives outside and never comes in”Decided on 2026-09-04, while designing the self-feeding documentation.
INSIDE OUTSIDEcollector (systemd, read-only) │ facts.json ▼ outbound push ───────────────────► private git repo │ Hermes reads doc + facts, computes the drift, opens a PR │ a person mergesWhy in this direction and not the other way round: giving the agent SSH would be opening an inbound path from a web-exposed machine towards the three VMs —the opposite of NET-03— and it would also make the homelab’s facts depend on an LLM reading a command’s output correctly. The collector is deterministic; the agent only compares and writes.
Network consequence: Hermes does not need any homelab credential, nor Tailscale, nor a port. The worst case of a prompt injection is a PR that gets closed.
NET-15 · The docs site is built and served outside the homelab
Section titled “NET-15 · The docs site is built and served outside the homelab”Decided on 2026-09-07, when picking Starlight as the generator/theme
for this repo and deciding where docs.balserosworld.net actually lives.
First version used a GitHub Actions workflow calling wrangler pages deploy; replaced the same day by Cloudflare Pages’ own Git integration
once the repo was connected there directly — one build pipeline instead
of two racing each other.
push to master ──► Cloudflare Pages (its own build, Git-connected) │ root directory: site/ │ npm run build │ (assembles site/src/content/docs from the │ real repo root, then astro build) ▼ docs.balserosworld.net (CNAME, proxied)Why in this direction and not the other way round: the whole point of
having documentation is to still be able to read it when the homelab is
the thing that is broken. A self-hosted site (Caddy + a git pull timer
on docker-host) would go down exactly when it is needed most, and it
would add one more service to NET-01’s flat LAN for something that has
no reason to live there — the site has no dependency on anything inside
the homelab once it is built. The build itself runs on Cloudflare’s
infrastructure, not the homelab’s or GitHub’s, and reads only what is
already public in the repo. No CLOUDFLARE_API_TOKEN lives in GitHub
Actions secrets because there is no Action doing the deploy any more.
How this differs from NET-04: the homelab’s own hostnames stay in
DNS only mode because Caddy terminates TLS itself and nothing should
proxy traffic toward VM 100 that Caddy did not negotiate. docs is the
one deliberate exception — it is proxied (orange-cloud), because that
record does not point at the homelab at all: it points at Cloudflare
Pages, which requires the proxy to serve the site. No inbound path to
any VM is created by this.
Network consequence: no port opens on docker-host, no certificate is
issued for docs, and destroying the whole homelab would not take the
documentation down with it — including the very drift-detection PRs
Hermes opens against it (NET-14).
Open network questions
Section titled “Open network questions”| Topic | Status |
|---|---|
| Segmentation (VLAN or a separate Docker network for what is published) | not done — today a compromised service sees the whole LAN (NET-01) |
| IPv6 | not enabled, not evaluated |
| Management network separate from the services network | does not exist; SSH and GUI coexist with the apps’ traffic |
| Dependency on MagicDNS as VM 100’s resolver | known (NET-07), no plan B configured |