/ Docs / 05 — Networking
Document 05 · Networking
OVH → WireGuard → MetalLB → nginx

The full network path from a browser hitting mfa2fa.com to a pod running inside the homelab cluster — every hop, every config, every gotcha.

Full traffic flow
Browser (HTTPS :443)
    │
    ▼
OVH VPS — 51.89.139.223
    nginx reverse proxy
    TLS termination (Let's Encrypt)
    Security headers (HSTS, CSP, X-Frame-Options)
    CSF firewall
    │
    ▼ proxy_pass http://10.99.101.xxx
    WireGuard tunnel (wg0)
    Encrypted — OVH → homelab LAN
    │
    ▼
MetalLB — Layer 2 load balancer
    Pool: 10.99.101.200–230
    ARP announcement to LAN
    │
    ├── 10.99.101.205 → wordpress6/wordpress (port 80)
    ├── 10.99.101.206 → cluster-dashboard/dashboard-nginx (port 80)
    └── 10.99.101.207 → mfa2fa-docs/docs-nginx (port 80)
    │
    ▼
Kubernetes pod (nginx / WordPress)
    Serves the response
    │
    ▼ (reverse)
Back through WireGuard → OVH nginx → Browser
OVH VPS — nginx config

The nginx vhost on the OVH box handles TLS termination and routing. Each path gets its own location block with a rewrite to strip the path prefix before proxying to the cluster:

server {
    listen 80;
    server_name mfa2fa.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name mfa2fa.com;

    ssl_certificate /etc/letsencrypt/live/mfa2fa.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/mfa2fa.com/privkey.pem;

    # Portal landing page — served locally from OVH box
    root /var/www/mfa2fa-portal;
    location = / {
        try_files /index.html =404;
    }

    # WordPress — strip /wp prefix
    location /wp {
        rewrite ^/wp(/.*)$ $1 break;
        proxy_pass http://10.99.101.205;
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-Proto $scheme;
    }

    # Cluster dashboard — status.json needs its own rewrite
    location /cluster/status.json {
        rewrite ^/cluster/status.json$ /status.json break;
        proxy_pass http://10.99.101.206;
        proxy_set_header Host $host;
    }

    location /cluster {
        rewrite ^/cluster(/.*)$ $1 break;
        rewrite ^/cluster$ / break;
        proxy_pass http://10.99.101.206;
        proxy_set_header Host $host;
    }

    # Docs
    location /docs {
        rewrite ^/docs(/.*)$ $1 break;
        rewrite ^/docs$ / break;
        proxy_pass http://10.99.101.207;
        proxy_set_header Host $host;
    }
}
Key point: The rewrite strips the path prefix before proxying. Without it, WordPress receives /wp/wp-content/... URLs which it doesn't recognise — everything breaks. Same applies to /cluster/ and /docs/.
WireGuard tunnel

The OVH VPS connects to the homelab over WireGuard. All MetalLB IPs are routed through wg0.

# /etc/wireguard/wg0.conf on OVH VPS
[Interface]
Address = 10.99.0.1/24
PrivateKey = <private-key>

[Peer]
PublicKey = <homelab-public-key>
AllowedIPs = 10.99.0.0/24
Endpoint = <homelab-public-ip>:51820
PersistentKeepalive = 25

Routes to MetalLB IPs are added via an external bash script called from rc.local:

#!/bin/bash
# /usr/local/bin/add-routes.sh
ip route add 10.99.101.205 dev wg0
ip route add 10.99.101.206 dev wg0
ip route add 10.99.101.207 dev wg0
exit 0
Gotcha: ip route add ... scope link does NOT work when called from a script at boot — only works when run manually from the CLI. Drop scope link entirely; the kernel infers scope from the interface.
MetalLB — Layer 2

MetalLB runs in Layer 2 mode — it uses ARP to announce which node owns each LoadBalancer IP. The speaker DaemonSet runs on all nodes and handles announcements.

IPServiceNamespacePort
10.99.101.200demo-nginxdefault80
10.99.101.201tomcat-webapp-basedefault8080
10.99.101.202tomcat-webapp-devdev8081
10.99.101.203tomcat-webapp-prodprod8086
10.99.101.204my-redis-master-externalredis6379
10.99.101.205wordpresswordpress680
10.99.101.206dashboard-nginxcluster-dashboard80
10.99.101.207docs-nginxmfa2fa-docs80
Gotcha — deprecated annotation: Use metallb.io/loadBalancerIPs not metallb.universe.tf/loadBalancerIPs. The old annotation still works in v0.14 but logs a deprecation warning.
Gotcha — externalTrafficPolicy: Local causes MetalLB healthcheck failures on this cluster — the IP gets assigned but never bound to a node interface, making it unreachable. Always use Cluster.
Gotcha — new IP not announced: If a new LoadBalancer service IP is not reachable after creation, delete and recreate the Service. This forces MetalLB to re-announce via ARP. A speaker restart alone is not always sufficient.
CSF firewall — BOGON list

The OVH box runs CSF (ConfigServer Firewall). MetalLB IPs in the 10.99.101.x range are private/bogon addresses — CSF's BOGON blocklist blocks them by default.

# /etc/csf/csf.conf — skip BOGON checks on wg0
LF_BOGON_SKIP = "wg0"

# /etc/csf/csf.ignore — whitelist the MetalLB subnet
10.99.101.0/24

# Apply changes
csf -r
Why wg0: All traffic to MetalLB IPs arrives via the WireGuard interface. Skipping BOGON checks on wg0 means any future MetalLB IP in the pool just works — no individual IP whitelisting needed.

To debug CSF blocking:

# Check if an IP is being blocked and by which rule
csf -g 10.99.101.206

# Temporarily allow an IP for testing
csf -a 10.99.101.206

# Check BOGON match in ipset
ipset list bl_BOGON | grep "10.99.101"