← Learn··Updated 31 May 2026·10 min read

Routing into k3s from the edge

Connect the VPS edge to a k3s cluster's built-in Traefik ingress over the WireGuard tunnel, with a wildcard TLS certificate at Caddy, so one VPS fronts every service in the cluster and new apps cost only an Ingress manifest — no VPS or DNS changes.

Networking
Expose your homelab to the internetPart 6 of 7
#networking
#kubernetes
#k3s
#traefik
#caddy
#ai-assisted

level:Intermediate

In part 5 you pointed Caddy on the VPS at a single backend service over the tunnel: one reverse_proxy line, one app, automatic HTTPS. That works beautifully for one or two services. But the moment your homelab runs a cluster — many services, appearing and disappearing as you deploy — adding a Caddyfile block per service stops scaling. There is a better seam.

A k3s cluster already ships with its own ingress controller: Traefik. Its entire job is to look at the hostname on an incoming request and route it to the right Service inside the cluster. That is the same job Caddy was doing per-service on the edge — except Traefik does it inside the cluster, driven by Ingress manifests that live next to your apps. So instead of teaching the edge about every service, you point the edge at the cluster's ingress once and let Traefik fan out.

This post is part 6 of the Expose your homelab to the internet series — the "k3s carry-over" that connects the public edge you built in parts 1-5 to a real cluster.

What changes when the homelab is a cluster

What. In part 5 the picture was: Caddy on the VPS terminated TLS for app.example.dev and forwarded that one hostname over the tunnel to one container. Each new service meant a new Caddyfile site block and a new reverse_proxy target.

Why this needs rethinking. A cluster is a moving target. Pods come and go, services get IPs you do not pick, and the whole point of Kubernetes is that you describe what you want and the cluster wires up the how. Encoding that wiring a second time in a Caddyfile on the VPS duplicates work and drifts out of sync. The cluster already has a component whose only purpose is hostname-based HTTP routing — Traefik — so the clean design is to stop treating the edge as a per-service router and start treating it as a single, dumb front door into the cluster's ingress.

How. Caddy keeps doing the two things the edge is genuinely good at: facing the public internet and terminating TLS. Then it forwards the (now decrypted) request over the encrypted WireGuard tunnel to Traefik, and Traefik does the per-service routing from there. The edge learns the cluster's ingress address once. Everything after that is an Ingress manifest inside the cluster.

ℹ️ Note — Architecture recap from earlier parts, used consistently here: domain example.dev, wildcard *.example.dev resolves to the VPS at public IP 203.0.113.10 (from part 2). The WireGuard interface wg0 carries 10.8.0.0/24; the VPS is 10.8.0.1, the homelab node is 10.8.0.2. The homelab node runs k3s, and its Traefik ingress is reachable over the tunnel at 10.8.0.2:80. The home router has no port forwards and the home IP never appears in DNS.

Why the wildcard DNS record is the unlock

What. Back in part 2 you created a wildcard record: *.example.dev → 203.0.113.10. It matches any subdomain you have not given an explicit record — app.example.dev, grafana.example.dev, whoami.example.dev, anything — and sends it to the VPS.

Why it matters now. This is precisely what makes fronting a cluster painless. A cluster invents hostnames constantly: every app you deploy wants its own subdomain. With the wildcard already in place, none of those need a DNS record. They all resolve to the VPS, the VPS forwards them to Traefik, and Traefik — which knows about them because their Ingress manifests declare a host — routes each one to the right service. Without the wildcard you would be back in the registrar's panel adding an A record per app. With it, DNS is a solved problem you never touch again.

How. Nothing to do here — you already did it in part 2. It is worth re-running the check from that post against a name you have never created, to confirm the wildcard is live:

Confirm the wildcard still catches any subdomain (on your laptop)

dig +short whoami.example.dev
# expected: 203.0.113.10   — proves *.example.dev still resolves to the VPS

The TLS decision: terminate at the edge, or pass through

There are two honest ways to handle HTTPS once a cluster is behind the edge. They are worth understanding before you pick, because the choice shapes everything downstream.

Option A — terminate TLS at Caddy on the edge (recommended). Caddy holds the certificates, decrypts the request at the VPS, and forwards plain HTTP over the WireGuard tunnel to Traefik. This is the simplest model: one place issues and renews certs (Caddy), Traefik never deals with TLS at all, and the "plain HTTP" hop is not actually exposed — it travels inside the already-encrypted WireGuard tunnel between 10.8.0.1 and 10.8.0.2. Nothing on the public internet ever sees unencrypted traffic. This is what the rest of this post builds.

Option B — TLS passthrough to Traefik (layer-4). Here the VPS does not decrypt. It proxies the raw TLS bytes through to Traefik, which terminates TLS itself using certificates managed inside the cluster (typically by cert-manager). You would want this if you need genuine end-to-end TLS — encrypted not just to the edge but all the way into the cluster, with the certificate authority living in Kubernetes — or to satisfy a compliance rule that forbids decryption at the edge. It is meaningfully more complex: layer-4 stream proxying instead of HTTP reverse proxy, certificate machinery inside the cluster, and SNI-based routing. It is out of scope here, but it is the right tool when end-to-end encryption is a hard requirement.

💡 Tip — For a homelab, Option A is almost always the right call. The tunnel already gives you encryption on the wire between VPS and home, so terminating at the edge buys you simplicity with no real loss of security. Reach for passthrough only when something specifically demands the cert live in-cluster.

A wildcard certificate needs the DNS-01 challenge

What. To terminate TLS at the edge for any *.example.dev hostname, Caddy needs a wildcard certificate — one cert valid for *.example.dev rather than a separate cert per hostname. Let's Encrypt will issue wildcard certs, but only via the DNS-01 challenge.

Why DNS-01 specifically. Caddy's usual trick in part 5 was the HTTP-01 challenge: prove you control a hostname by serving a token at http://that-host/.well-known/.... That works for a concrete name, but it cannot validate a wildcard — there is no single host to serve the token from for *.example.dev. DNS-01 instead proves control by writing a TXT record into your domain's DNS. That covers the whole zone, so it is the only challenge type that can mint a wildcard cert. The trade-off: Caddy needs API credentials to edit your DNS automatically.

How. Two requirements, both honest caveats:

  1. You need the DNS-provider build of Caddy — the stock binary cannot talk to DNS APIs. Caddy distributes provider modules (for Cloudflare, Route 53, and many others); you build or download a Caddy that includes the module matching your DNS provider. The example below uses Cloudflare; swap in your provider's module and credentials if you use something else.
  2. You supply an API token scoped to edit DNS records for your zone, exposed to Caddy as an environment variable.

Here is the Caddyfile. Note the site label is the wildcard itself, *.example.dev — that single block now answers for every subdomain, replacing the per-service blocks from part 5:

/etc/caddy/Caddyfile (on the VPS)

*.example.dev {
    tls {
        dns cloudflare {env.CF_API_TOKEN}
    }
    reverse_proxy 10.8.0.2:80
}

Reading it line by line: *.example.dev { ... } is a wildcard site address, so this block handles app.example.dev, grafana.example.dev, and any other subdomain that reaches the VPS. The tls directive with dns cloudflare {env.CF_API_TOKEN} tells Caddy to obtain (and auto-renew) a wildcard certificate using the DNS-01 challenge via the Cloudflare module, reading the API token from the CF_API_TOKEN environment variable. And reverse_proxy 10.8.0.2:80 forwards every decrypted request — regardless of hostname — over the tunnel to the Traefik ingress listening on the homelab node's port 80. Caddy preserves the original Host header, which is exactly what Traefik needs to route.

⚠️ Warning — The stock caddy package from your distro almost certainly does not include the Cloudflare DNS module, and this Caddyfile will fail to load without it. Build a Caddy with the module (xcaddy build --with github.com/caddy-dns/cloudflare) or use a Caddy Docker image that bundles DNS providers, and make sure CF_API_TOKEN is set in Caddy's environment (for example via a systemd drop-in or the container's env) before reloading.

Confirm Traefik is reachable over the tunnel

What. k3s installs Traefik by default and exposes it on the node's ports 80 and 443 through its built-in load balancer (ServiceLB / klipper-lb). On your homelab node, that means something is already listening on :80, and over the tunnel that address is 10.8.0.2:80 — exactly the reverse_proxy target above.

Why check first. Caddy forwarding to a port that nothing answers produces a confusing 502 later. Confirming Traefik is reachable over the tunnel now isolates the variable: if this curl works and the public test later does not, the problem is in Caddy or DNS, not in the cluster.

How. From the VPS (which sits at 10.8.0.1 on the tunnel), hit Traefik directly. A bare request with no matching host returns Traefik's 404 page not found — and that 404 is a success signal: it means Traefik answered, it just has no route for an empty host yet.

Probe Traefik across the tunnel (on the VPS)

curl -s -o /dev/null -w "%{http_code}\n" http://10.8.0.2:80
# expected: 404   — Traefik answered; it just has no matching Ingress for this request yet
⚠️ Warning — The homelab host's firewall must allow the WireGuard interface (wg0) to reach Traefik's ports. If you hardened the node with a default-deny firewall, traffic arriving on wg0 to :80/:443 can be silently dropped, and the curl above will hang or refuse. Explicitly allow the tunnel subnet to those ports — for example with ufw: ufw allow in on wg0 to any port 80,443 proto tcp. The point is that only the tunnel (10.8.0.0/24) needs this access, not the public internet.

Worked example: deploy whoami and route it through the edge

What. traefik/whoami is a tiny HTTP server that echoes back the request it received — perfect for proving routing end to end. You will deploy it with three Kubernetes objects in one manifest: a Deployment (runs the pod), a Service (gives it a stable in-cluster address), and an Ingress (tells Traefik: requests for whoami.example.dev go to this service). These map directly onto the concepts from Kubernetes services and networking and Kubernetes ingress with Traefik.

Why one manifest. Keeping the three objects together makes the relationship legible: the Ingress names the Service, the Service selects the Deployment's pods by label, and the host on the Ingress is the only place the public hostname appears. Read it top to bottom before applying.

How. Save this on the machine where you run kubectl against the cluster (the homelab node, or your laptop with the cluster's kubeconfig):

whoami-ingress.yaml (on the homelab)

apiVersion: apps/v1
kind: Deployment
metadata:
  name: whoami
spec:
  replicas: 1
  selector:
    matchLabels:
      app: whoami
  template:
    metadata:
      labels:
        app: whoami
    spec:
      containers:
        - name: whoami
          image: traefik/whoami:latest
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: whoami
spec:
  selector:
    app: whoami
  ports:
    - port: 80
      targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: whoami
spec:
  rules:
    - host: whoami.example.dev
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: whoami
                port:
                  number: 80

The chain inside that file: the Deployment runs one traefik/whoami pod labelled app: whoami. The Service named whoami selects pods with that label and exposes them on port 80 as a stable cluster address. The Ingress declares that requests with host: whoami.example.dev and path / should be sent to the whoami Service on port 80. k3s's Traefik watches Ingress objects, so the moment this applies, Traefik knows the route.

Apply it:

Create the app and its route (on the homelab)

kubectl apply -f whoami-ingress.yaml
# deployment.apps/whoami created
# service/whoami created
# ingress.networking.k8s.io/whoami created

Now test from the public internet — your laptop, not the cluster. This exercises the entire path: DNS, the VPS, Caddy's TLS, the tunnel, Traefik, and the pod.

Hit the service through the public edge (on your laptop)

curl https://whoami.example.dev
# Hostname: whoami-<pod-id>
# IP: 10.42.x.x
# ...
# Host: whoami.example.dev

The Host: whoami.example.dev line in whoami's echo is the proof that Caddy preserved the original hostname all the way through, which is what let Traefik route it correctly.

Here is the full request path that single curl just traversed:

flowchart LR
    V["Visitor<br/><i>curl https://whoami.example.dev</i>"] -->|"*.example.dev → 203.0.113.10"| C["Caddy on VPS<br/><i>terminates TLS<br/>(wildcard via DNS-01)</i>"]
    C -->|"plain HTTP, Host preserved<br/>inside WireGuard tunnel<br/>10.8.0.1 → 10.8.0.2:80"| T["Traefik (k3s ingress)<br/><i>routes by Host header</i>"]
    T -->|"Ingress: whoami.example.dev"| S["whoami Service<br/><i>ClusterIP :80</i>"]
    S -->|"selector app=whoami"| P["whoami Pod<br/><i>10.42.x.x</i>"]

The visitor reaches the VPS via the wildcard DNS record; Caddy terminates TLS and forwards plain HTTP — with the original Host header intact — through the encrypted WireGuard tunnel to Traefik, which reads the Host, matches the Ingress, and routes to the Service and finally the Pod. The home IP appears nowhere.

The payoff: new apps are just an Ingress

This is the whole reason for the carry-over. With the wildcard DNS record pointing at the VPS and Caddy forwarding every *.example.dev request to Traefik, adding a new public service costs exactly one thing: an Ingress manifest with a new host.

Want grafana.example.dev? Deploy Grafana, give it a Service, and add an Ingress with host: grafana.example.dev. No DNS record — the wildcard already resolves it. No Caddyfile edit — Caddy already forwards every hostname to Traefik. No firewall change — the tunnel and ports are already open. The edge and DNS became fixed infrastructure you configured once; the cluster is where all future change happens, declaratively, next to your apps.

That separation is the point of the entire series. The public surface is small, static, and hardened. Everything dynamic lives behind the tunnel, inside the cluster, described in manifests.

A short close

You now have one VPS fronting an entire k3s cluster: a wildcard certificate at Caddy, a single reverse_proxy into Traefik over the tunnel, and a worked whoami route proving the full path. Every new service from here is an Ingress and nothing more.

The remaining question is operational, not architectural: this front door is now public, single, and load-bearing, so it deserves to be treated like production. In part 7, Hardening and operating a public edge, you lock down and observe the VPS — firewall posture, fail2ban, automatic updates, certificate and tunnel monitoring — so the edge you just built stays quietly reliable instead of becoming the thing that pages you at 3am.