Back to blog
Panel Machines de Tailscale: la tailnet con 42 máquinas — los subnet routers de Roma y Atenas (spinoza), los nodos de los clústeres y los VPS, todos conectados.

Building a homelab from scratch: networking - Homelab (02/06)

Case studyHomelabNetworkingWireGuard

In Post 1 we saw the big picture: four Proxmox nodes, a NAS, a few VPSes, and two Kubernetes clusters. But none of that is any use if they can't talk to each other.

This post is about the network layer — how traffic moves between two physical locations, how cloud servers reach the homelab without opening any ports, and how internet users get to services like zetesis.xyz. We'll look at WireGuard, Tailscale, Cloudflare, and Caddy, and the foundation everything runs on: the VyOS routers, the BGP between the clusters and the edge, and the subnet and DNS scheme.

If networking isn't your thing, don't worry. I'll start from the basics.

The problem

This is what needs to be solved:

  1. Two physical locations (Athens and Rome) need to share resources as if they were a single network
  2. The cloud VPSes need to reach internal services without exposing them to the internet
  3. Internet users need to reach web applications hosted inside the homelab
  4. All servers need to back up to a NAS that lives on one of the internal networks

And it all has to work without opening a single port on the home router. Port forwarding means exposing your IP and your services directly to the internet — a security risk that can be avoided entirely.

Layer 1: WireGuard site-to-site tunnel

WireGuard is a VPN (Virtual Private Network) protocol. A VPN is, simply put, an encrypted tunnel between two points — data goes in one end and comes out the other, and nobody in between can read it.

Each of the two local networks has a VyOS router (a free, Debian-based network operating system designed for the command line and automation). These routers maintain a permanent WireGuard tunnel:

RouterNetworkLAN subnetWireGuard IPDynamic DNS
temistoclesAthens10.1.0.0/2410.200.0.10atenas.zetesis.localhost
constantinoRome10.0.0.0/2410.200.0.1roma.zetesis.localhost

Both locations have residential internet with dynamic IPs (the public IP changes periodically). Each site runs a DDNS (dynamic DNS) service called marco-polo that keeps a hostname like roma.zetesis.localhost pointing at the current public IP. The routers use those hostnames to find each other.

Loading diagram...

The WireGuard tunnel creates a third, virtual network (10.200.0.0/24) shared by both routers. Each router knows how to forward traffic destined for the other site's subnet through the tunnel. So a device in Athens (10.1.0.x) can reach one in Rome (10.0.0.x) transparently — it works as if they were on the same LAN.

Configuration on VyOS, as code

The routers aren't configured by hand over a console: their configuration is born in the GitOps repository. Each router is composed of fragments — a shared firewall one and others specific to interfaces, NAT, protocols, services, and system — that are assembled and rendered with render.sh into a complete .boot file. Secrets (WireGuard keys, tokens) are injected from SOPS only at render time, never in the templates.

The workflow:

  1. You edit the fragments or the router.env of the corresponding router.
  2. render.sh generates the .boot and validates it — unresolved variables, empty placeholders, or unbalanced braces fail the build; CI renders both routers with test values on every pull request.
  3. You apply the result and, for risky changes, you use commit-confirm: the router rolls back on its own if you don't confirm in time.

The router thus becomes just another deployment: its desired state lives in Git and is reproducible from scratch. The tunnel configuration on temistocles looks like this:

Interfaces:

  • WAN (vtnet0) — gets its IP via DHCP from the ISP
  • LAN (vtnet1) — static IP 10.1.0.1/24, the gateway for all devices on the Athens network

Local WireGuard instance (wg0):

  • Listen port: 51820
  • Tunnel address: 10.200.0.10/24

WireGuard peer (Rome):

  • Endpoint: roma.zetesis.localhost:51820 (the DDNS hostname)
  • Allowed IPs: 10.0.0.0/24, 10.200.0.0/24
  • Keepalive: 25 seconds

The key point: traffic towards 10.0.0.0/24 (the Rome LAN) or 10.200.0.0/24 (the WireGuard network) is routed through the tunnel. The keepalive sends a packet every 25 seconds to keep the tunnel alive through NAT (Network Address Translation) — necessary when both sides have residential internet.

The constantino router has the mirror configuration, with a peer named "Atenas" pointing at atenas.zetesis.localhost.

Interface-based firewall

VyOS has an interface-based firewall. Each interface (LAN, WAN, WireGuard) has its own rule set controlling which traffic may pass:

Reglas WAN:
  - Bloquear todo el tráfico entrante (excepto UDP 51820 para WireGuard)

Reglas LAN:
  - Permitir todo el tráfico saliente hacia WAN
  - Permitir todo el tráfico saliente hacia WireGuard (wg0)

Reglas WireGuard (wg0):
  - Permitir tráfico desde 10.0.0.0/24 y 10.200.0.0/24 hacia LAN
  - Bloquear todo lo demás

The principle is simple: the LAN can reach everywhere, the WAN can reach nothing (except WireGuard), and WireGuard peers can only reach the LAN from known subnets. Much more restrictive than the default configuration, which allowed free traffic between all peers.

DHCP, subnets, and DNS

Each router also acts as the DHCP server for its network, with a static IP mapped to each device's MAC address:

dhcp-server {
    shared-network-name LAN {
        subnet 10.1.0.0/24 {
            default-router 10.1.0.1
            static-mapping socrates  { ip-address 10.1.0.10 }
            static-mapping spinoza   { ip-address 10.1.0.11 }
        }
    }
}

The complete subnet map looks like this:

  • 10.0.0.0/24 — Rome (production)
  • 10.1.0.0/24 — Athens
  • 10.200.0.0/24 — the WireGuard tunnel's virtual network
  • 10.0.120.0/24 — escipion's platform network: gateway at .1, node at .10 and a high range reserved for load balancing

DNS goes further than forwarding queries to 1.1.1.1. In Rome the full chain is:

  1. clients
  2. pdns10.0.0.1, cache on the router
  3. unboundcontainer on the router itself: RPZ filter + DNSSEC validation
  4. Quad9 over DNS-over-TLS

unbound runs as a container inside constantino and does two notable things. First, it applies an RPZ (Response Policy Zone, a zone of deliberately manipulated responses): advertising and tracking domains resolve to a dead address, so ad blocking happens at the DNS level for the whole network, without installing anything on the devices. Second, it validates DNSSEC locally with the root anchors, and only goes out to the internet over DoT (DNS over TLS) against Quad9 — queries travel encrypted even between the router and the public resolver.

Forwarding accepts queries from both networks — so devices in Rome can resolve DNS through the Athens router via the WireGuard tunnel, and vice versa.

BGP: Kubernetes services, reachable from the LAN

Inside a Kubernetes cluster, a ClusterIP only exists inside of the cluster. LoadBalancer-type Services need someone to assign them an external IP — and the rest of the network to learn how to reach it.

The answer is that the clusters speak eBGP (Border Gateway Protocol, the routing protocol between the internet's autonomous systems) with the site's router, through Cilium, the CNI that provides their networking. Each one has its own autonomous system number:

  • constantino (the router) — AS64512
  • alejandro — AS64513
  • escipion — AS64514, peered with constantino over its platform network

The rules of the game are strict: only Services explicitly labeled for advertisement get an IP from the load-balancing pool — on escipion, the high range 10.0.120.128/25 of its /24—, and the cluster advertises it to the router as a host route /32. No layer-2 extension, no tunnels: the router learns the route and any device on the LAN reaches the service directly, without NodePort or port forwarding.

The timers are conservative —30 seconds of hold, 10 of keepalive, graceful restart— and both ends export their BGP metrics to the observability hub: if a session drops, it shows up in Grafana.

Layer 2: Tailscale, mesh VPN

WireGuard connects the two physical locations, but the cloud VPSes need another approach. They're hosted with different providers, don't have static IPs, and we don't want to manage point-to-point tunnels between every pair of servers.

That's where Tailscale comes in. It's a mesh VPN built on WireGuard. Instead of configuring each connection by hand, you install Tailscale on every device and they find each other and connect automatically through a coordination server. Each device gets a stable IP on the Tailscale network (100.x.x.x) and can reach any other directly.

Three use cases in the homelab

1. VPS access to spinoza for backups

All VPSes run a backup agent (tolstoi) that needs to reach MinIO on spinoza (10.1.0.11:9000). But spinoza is on the Athens LAN — it isn't directly reachable from the internet.

The solution: spinoza runs Tailscale and advertises itself as a subnet router, making 10.1.0.0/24 reachable from the tailnet. Each VPS runs a sidecar container called cervantes — a Tailscale client with --accept-routes that joins the mesh and gets access to spinoza. The backup container shares cervantes' network stack:

# services/tolstoi/docker-compose.yaml (configuración base)
services:
  resticker-base:
    image: mazzolino/restic:1.8.2
    environment:
      RESTIC_REPOSITORY: s3:http://10.1.0.11:9000/deployment-restic
      BACKUP_CRON: "0 3 * * *"
    # comparte red con cervantes

  cervantes-s3-gateway-base:
    image: tailscale/tailscale:v1.94.2
    environment:
      - TS_STATE_DIR=/var/lib/tailscale
      - TS_AUTHKEY=${TS_AUTHKEY}
      - TS_EXTRA_ARGS=--accept-routes
      - TS_USERSPACE=false
    devices:
      - /dev/net/tun:/dev/net/tun
    cap_add:
      - net_admin

The line network_mode: "service:cervantes-s3-gateway" in each deployment makes the backup container route all its traffic through cervantes. From the backup agent's perspective, 10.1.0.11:9000 is a normal IP — Tailscale handles the routing transparently.

2. Routing from von-braun to the Kubernetes clusters

The von-braun VPS runs Caddy (the reverse proxy) and needs to forward web traffic to the Kubernetes clusters inside the homelab. It uses Tailscale to reach Traefik on the alejandro cluster.

Note the Caddy container's DNS configuration:

services:
  caddy:
    dns:
      - 100.100.100.100    # MagicDNS de Tailscale
      - 1.1.1.1            # Fallback de Cloudflare

100.100.100.100 is Tailscale's built-in DNS resolver. It resolves Tailscale hostnames (like zetesis-prod-typesense-api) to their 100.x.x.x addresses. The Caddy on von-braun forwards zetesis.xyz traffic over Tailscale to the alejandro cluster.

3. Kubernetes services exposed via Tailscale

Inside the clusters, the Tailscale operator can expose Services and Ingresses directly to the tailnet. There are two patterns.

For TCP services like PostgreSQL, annotations on the Service are used:

apiVersion: v1
kind: Service
metadata:
  name: postgres-tailscale
  annotations:
    tailscale.com/expose: "true"
    tailscale.com/hostname: "zetesis-prod-postgres"
    tailscale.com/tags: "tag:alejandro"
spec:
  selector:
    cnpg.io/cluster: postgres
    role: primary
  ports:
    - port: 5432

This creates a Tailscale node named zetesis-prod-postgres that acts as a TCP proxy to the PostgreSQL pod. Any device on the tailnet can connect — handy for database management tools from a laptop.

For HTTP services that need TLS, a Tailscale Ingress is used:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: typesense-api
  annotations:
    tailscale.com/tags: "tag:alejandro"
spec:
  ingressClassName: tailscale
  tls:
    - hosts:
        - zetesis-prod-typesense-api

Tailscale automatically provisions a TLS certificate and makes the service accessible at https://zetesis-prod-typesense-api.tailf1ac6e.ts.net.

ACLs: who can talk to whom

Tailscale supports access control lists (ACLs) — rules that restrict which devices can reach which. The ACL policy lives in Git (tailscale/policy.hcl) and is deployed automatically with GitHub Actions:

"grants": [
    // El admin tiene acceso total
    { "src": ["autogroup:admin"], "dst": ["*"], "ip": ["*"] },

    // Los VPS solo pueden alcanzar MinIO para backups
    {
        "src": ["tag:von-braun", "tag:escohotado", "tag:unamuno"],
        "dst": ["10.1.0.11"],
        "ip":  ["9000"],
    },

    // von-braun puede alcanzar los clústeres K8s para proxying HTTP
    { "src": ["tag:von-braun"], "dst": ["tag:alejandro"], "ip": ["80", "443"] },
]

Least privilege: each VPS can only reach exactly the ports it needs. If a VPS is compromised, the attacker can't pivot to the rest of the homelab — they can only reach MinIO on port 9000 or HTTP on the clusters.

The GitHub Action runs on every push that modifies the policy file:

# .github/workflows/tailscale-acl.yml
- name: Deploy ACL
  if: github.ref == 'refs/heads/main'
  uses: tailscale/gitops-acl-action@v1
  with:
    api-key: ${{ secrets.TAILSCALE_API_KEY }}
    tailnet: ${{ secrets.TAILSCALE_TAILNET }}
    policy-file: tailscale/policy.hcl
    action: apply

On pull requests it runs in test (dry-run) mode to validate the policy without applying it.

Layer 3: Cloudflare

Cloudflare sits between the internet and the homelab. It serves three functions: it manages the DNS for all the *.zetesis.localhost and *.zetesis.xyz; it absorbs malicious traffic before it reaches the servers (DDoS protection; and it hides the servers' real IPs by acting as a proxy — users connect to Cloudflare's edge, which in turn connects to the origin.

Dynamic DNS with marco-polo

Both physical locations have dynamic IPs. Each runs a service called marco-polo that keeps Cloudflare's DNS records up to date:

services:
  cloudflare-ddns:
    image: favonia/cloudflare-ddns:1.15.1
    network_mode: host
    read_only: true
    cap_drop: [all]
    cap_add: [SETUID, SETGID]
    environment:
      - CLOUDFLARE_API_TOKEN=${CF_API_TOKEN}
      - DOMAINS=roma.zetesis.localhost
      - PROXIED=false   # DNS only: este registro es el endpoint del túnel WireGuard

This container checks the public IP every few minutes and updates the DNS record if it has changed. This record is published with PROXIED=false ("DNS only") because it is the endpoint of the WireGuard tunnel between the routers: Cloudflare can only proxy web traffic, so if the tunnel hostname went through the CDN it would return Cloudflare IPs and the tunnel would never establish. The proxy (PROXIED=true) is reserved for web service records — those do go through the CDN, and not even the DNS record reveals the real IP.

Cloudflare Tunnels

The VPSes also use Cloudflare Tunnels (formerly Argo Tunnel) as an alternative entry path. A tunnel creates an outbound connection from the server to Cloudflare's edge — no inbound ports. Some VPS services use tunnels instead of exposing ports directly.

Layer 4: reverse proxy with Caddy

Caddy is the web server in charge of TLS termination and request routing. There are two Caddy instances with different roles.

von-braun (internet edge)

The Caddy on von-braun is the main entry point for public internet traffic into the homelab. It runs as a custom Docker image with three plugins:

FROM caddy:2.10-builder AS builder
RUN xcaddy build \
    --with github.com/caddy-dns/cloudflare \
    --with github.com/hslatman/caddy-crowdsec-bouncer/http \
    --with github.com/mholt/caddy-ratelimit

FROM caddy:2.10.0
COPY --from=builder /usr/bin/caddy /usr/bin/caddy

caddy-dns/cloudflare obtains TLS certificates via Cloudflare's DNS challenge (no need to expose port 80 for the HTTP challenge). caddy-crowdsec-bouncer integrates with CrowdSec to block malicious IPs. caddy-ratelimit limits requests per source IP.

The Caddyfile uses reusable snippets to keep security consistent across all sites:

(web_security) {
    crowdsec
    rate_limit {
        zone per_ip {
            key {remote_host}
            events 1000
            window 1m
        }
    }
    encode zstd gzip
    import security_headers
}

zetesis.xyz {
    import cf_tls
    import web_security
    reverse_proxy http://zetesis-proxy:80
}

auth.zetesis.xyz {
    import cf_tls
    import auth_security
    reverse_proxy http://zetesis-proxy:80
}

The upstream zetesis-proxy is a Tailscale proxy that forwards traffic to Traefik inside the alejandro cluster. The full traffic path:

  1. User
  2. Cloudflare CDN
  3. Caddyvon-braun
  4. Tailscale
  5. Traefikalejandro
  6. Pod

CrowdSec runs as a sidecar container, reading Caddy's access logs and comparing traffic patterns against community-maintained threat intelligence. If it detects an attack (brute force, CVE exploit attempts, credential stuffing), it tells Caddy to block the IP.

trajano (local network)

The second Caddy instance, trajano, runs on the aristoteles VM on the Rome network. Its job is internal: it serves the *.zetesis.localhost subdomains for local services — Proxmox and TrueNAS web interfaces, router dashboards, the Zigbee2MQTT UI, and the like:

# Reverse proxy interno para servicios locales
kepler.zetesis.localhost {
    import cf_tls
    reverse_proxy http://10.0.0.7:3000
}

spinoza.zetesis.localhost {
    import cf_tls
    reverse_proxy 10.1.0.11:443 {
        transport http { tls; tls_insecure_skip_verify }
    }
}

rothbard.zetesis.localhost {
    import cf_tls
    reverse_proxy 10.1.0.7:8082
}

trajano can reach services on both networks (10.0.0.x and 10.1.0.x) thanks to the WireGuard tunnel between the routers. It uses Cloudflare's DNS challenge for TLS, so all internal services have HTTPS even though they aren't exposed to the internet.

The full journey

Let's trace a request end to end. A user visits zetesis.xyz:

Loading diagram...
  1. The browser resolves zetesis.xyz to Cloudflare's IP (not ours)
  2. Cloudflare terminates TLS, applies its protections, and forwards the request to von-braun
  3. Caddy on von-braun consults CrowdSec and applies rate limits
  4. Caddy forwards over Tailscale to the alejandro cluster
  5. Traefik (the Kubernetes ingress controller) routes by Host header to the correct pod
  6. The response travels back along the same path

At no point is a home IP or an internal port exposed. The only publicly accessible servers are the VPSes and Cloudflare's edge.

Compare that with an internal request — accessing the Proxmox dashboard from a laptop on the Rome network:

  1. Laptop10.0.0.x
  2. Caddy/trajano10.0.0.7
  3. pitagoras Proxmox10.0.0.3:8006

Two hops, all on the local network. And for cross-site access:

  1. Laptop10.0.0.x
  2. constantino router
  3. WireGuard tunnel
  4. temistocles router
  5. spinoza10.1.0.11

The WireGuard tunnel is transparent — the laptop doesn't know it's crossing a tunnel. It simply routes towards 10.1.0.11 and the routers take care of the rest.

Why no port forwarding?

Traditional homelabs open ports on the home router: port 443 goes to a reverse proxy, 51820 to WireGuard, and so on. I avoid it entirely for three reasons.

Security. An open port is an open door. Even with a firewall, you're trusting that every service behind that port is secure. With Tailscale there are no inbound connections — everything is outbound.

CGNAT. Some ISPs use Carrier-Grade NAT, which means you share a public IP with other customers and can't open ports even if you want to. Tailscale and Cloudflare tunnels work through CGNAT because they only make outbound connections.

Simplicity. No port forwarding rules to maintain, no NAT reflection to configure, no worrying about an IP change breaking something.

The only exception is WireGuard port 51820 between the two routers — but that's specifically for the site-to-site tunnel, not for public-facing services. If an ISP applies CGNAT and that port stops being reachable, Tailscale remains as the site-to-site fallback.

Recommendations

If you're building a homelab, this is the network stack I recommend:

WireGuard for site-to-site tunnels between physical locations. It's fast, simple, and built into most router operating systems. Tailscale for connecting cloud servers and remote devices. The free tier is generous, the ACLs are powerful, and you never need to open a port. Cloudflare for DNS, DDoS protection, and hiding your real IP. The free tier covers everything a homelab needs. Caddy as a reverse proxy with automatic TLS. Simpler than Nginx, a good plugin ecosystem, and the Caddyfile format is understandable just by reading it.

The most important principle: never expose your home IP or your internal services directly to the internet. Use Tailscale and Cloudflare as a shield. Traffic enters through Cloudflare to a VPS, and the VPS reaches the homelab through Tailscale. Your home network stays invisible.

In the next post we'll look at what runs on top of this network: Kubernetes with Talos Linux, GitOps with ArgoCD, and the lightweight CD system (ptolomeo) that keeps the Docker Compose deployments in sync.


Next: Post 3 - Kubernetes and GitOps | Previous: Post 1 - The overview

The complete series