Skip to content

Homelab networking — every decision in one place

BalserosWorld network decision log. Each one carries a stable NET-NN identifier: cite it instead of repeating the explanation. When decisions/ exists, each NET-NN will 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.md and services/caddy.md.

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? —

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/24
Bridge vmbr0 ← joins the virtual NICs with the physical network
pve 192.168.2.200 ← the Proxmox host, not a VM
VM 100 192.168.2.8 docker-host
VM 101 192.168.2.10 backup-manager

Why: 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 default
OUTPUT: ACCEPT
FORWARD: ACCEPT
Exceptions, only from 192.168.2.0/24:
TCP/22 SSH
TCP/8006 Proxmox GUI

Why: 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) outbound
cloudflared → Cloudflare (publishing) outbound
tailscale → tailnet (administration) outbound
Caddy DNS-01 → API CF (certificates) outbound

Why 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).


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.

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).


Name: proxy Driver: bridge
Subnet: 172.22.0.0/16 Gateway: 172.22.0.1

Created 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.

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 = Caddy
127.0.0.1 inside Vaultwarden = Vaultwarden
127.0.0.1 on docker-host = docker-host

The 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.


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 subnet
backup-manager 100.79.63.6
Windows 100.90.123.48
Android S23 100.89.101.62

No 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.

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

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 policy

Point 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.


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 OUTSIDE
collector (systemd, read-only)
│ facts.json
▼
outbound push ───────────────────► private git repo
│
Hermes reads doc + facts,
computes the drift,
opens a PR
│
a person merges

Why 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).


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