The lab used to run Traefik with its own CRDs (IngressRoute, Middleware,
and friends). Gateway API standardises routes; Envoy Gateway
is the controller here — one Helm install, three shared Gateways, and per-app HTTPRoute resources instead
of Traefik-only objects.
Traefik is excellent at the edge, but its CRDs are controller-specific. Moving to Gateway API was less about
feature envy and more about portability: the same HTTPRoute can be read by another implementation if you
ever switch controllers. Envoy Gateway is the implementation here; the routes stay standard Kubernetes objects.
Traefik habits vs Gateway API
| Traefik | Gateway API |
|---|---|
IngressRoute + entrypoints |
HTTPRoute + parentRefs on a Gateway listener |
| Per-app TLS / middleware CRDs | TLS on the Gateway; filters on HTTPRoute; Envoy policies when needed |
| Traefik host rules | hostnames and matches on gateway.networking.k8s.io/v1 |
Routes stay portable across controllers. Envoy-specific tuning uses
gateway.envoyproxy.io (BackendTrafficPolicy, ClientTrafficPolicy, …).
Three shared gateways
Envoy Gateway (Helm)
shared-gateway-external *.this-is-fine.io, *.this-is-fine.social
Let's Encrypt, LB .0.1
shared-gateway-internal *.this-is-fine.internal
Vault PKI, LB .0.2
shared-gateway-tailnet *.tif.internal
Vault PKI, LB .0.3
Pick the gateway by who connects: the internet, lab browsers on the internal CA, or
tailnet clients. Templates under k8s/templates/gateway-api/ repeat
the same HTTPRoute shape for each class.
Static LoadBalancer IPs (.0.1, .0.2, .0.3) come from a small pool advertised with
Cilium BGP. DNS and certificates differ per zone, but the dataplane
pattern is the same: one Envoy deployment, several logical gateways, many HTTPRoutes attached to listeners.
Headscale example (public API, private UI)
Headscale disables Helm ingress; exposure is all Gateway API.
| Surface | Hostname | Gateway | Backend |
|---|---|---|---|
| Tailscale clients / API | ts.this-is-fine.io |
shared-gateway-external |
:8080 |
| Web UI (operators) | ts.this-is-fine.internal |
shared-gateway-internal |
:8081 (UI sidecar) |
The external route allows CORS from https://ts.this-is-fine.internal. Control traffic needs WebSocket upgrades; a
BackendTrafficPolicy on that HTTPRoute enables tailscale-control-protocol and derp.
Splitting API and UI across external and internal DNS is deliberate. Tailscale clients and the public
internet need ts.this-is-fine.io with a certificate browsers and devices trust. Operators can open the same
logical service on ts.this-is-fine.internal with the lab’s internal CA, without publishing the admin UI to the
open internet.
Public API (lab pattern):
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: headscale-https-external
spec:
parentRefs:
- name: shared-gateway-external
namespace: envoy-gateway-system
sectionName: this-is-fine-io-https
hostnames:
- ts.this-is-fine.io
rules:
- backendRefs:
- name: headscale
port: 8080
Internal UI — same host label on the internal zone, different listener and port:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: headscale-ui-https-internal
spec:
parentRefs:
- name: shared-gateway-internal
namespace: envoy-gateway-system
sectionName: this-is-fine-internal-https
hostnames:
- ts.this-is-fine.internal
rules:
- backendRefs:
- name: headscale
port: 8081
Helm sets HEADSCALE_SERVER_URL=https://ts.this-is-fine.io so clients and the UI agree on the public URL while
operators open the UI on the internal name.
TODO: connect Pocket ID for Headscale OIDC (HEADSCALE_OIDC_* is
stubbed in chart values; not enabled yet). Same backlog as Zeroclaw UI and Vault.
Adding another app
- Expose the workload with a
Service. - Attach an
HTTPRoute(orTCPRoute) to the rightshared-gateway-*and hostname. - Let cert-manager issue TLS via the gateway
certificateRefs/ ClusterIssuers.
IRC on port 6697 uses TCPRoute on the public gateway — IRC post. VIPs
.0.1–.0.3 are advertised with Cilium BGP. GitOps context:
lab overview.