+ Traefik + Gateway API — Wildcard Routing Behind Nginx
A hands-on guide to exposing k3s workloads under a wildcard subdomain (*.k3s.ytamang.com) by running Traefik on a NodePort behind your existing Nginx, routing with the Gateway API (HTTPRoute>), and securing it with a wildcard TLS cert via certbot DNS-01.
You have
a single server running Nginx> that serves several static sites on ports80> and 443 (ytamang.com, learn.ytamang.com, softskills.ytamang.com). Now you want to run workloads in k3s — a lightweight Kubernetes — and reach them through a wildcard like *.k3s.ytamang.com, without breaking the Nginx sites.
The challenge is subtle: k3s ships with Traefik, its default ingress controller, and Traefik wants to listen on 80 and 443 too. Those ports are already taken by Nginx. So we can't just "turn Traefik on" — we have to teach it to live behind Nginx.
Here
is the whole request path we are building:app.k3s.ytamang.com*.k3s.ytamang.com → 5.75.145.2:443 terminates TLS, matches the wildcardNodePort :30080 its web entrypoint, reached only by NginxHTTPRoute matches the host, picks the service:443 and does TLS. It reverse-proxies *.k3s.ytamang.com to Traefik on a different port (:30080). Traefik then routes by the Host> header to the right workload using an HTTPRoute.Walk
the request path step by step:k3s> is a CNCF-certified, lightweight Kubernetes distribution packaged as a single binary. It's the same Kubernetes API you know, but trimmed for small servers and edge nodes: it uses containerd as its runtime and ships a few helpful components by default — including Traefik> as its ingress controller.
"Ingress controller" is the piece that watches for routing rules and turns them into live traffic routing. Traefik is one of several choices (others include ingress-nginx and HAProxy Ingress). Because Traefik is already bundled with k3s, we reuse it instead of installing another controller.
| Term | What | it meansAnal | ogy
|---|---|---|
| Ingress controller | Software | that routes external HTTP(S) traffic to services based on rulesThe building's front desk that directs visitors to the right office |
| Traefik | k3s's default ingress controller, supports both classic | A specific, well-equipped front desk that reads many kinds of signs |
| Gateway API | The modern, extensible successor to Ingress> for defining routing | A standardized sign language the front desk understands |
k
3s can be installed with--disable traefik to skip its bundled controller. That's exactly how this server was first set up (to avoid the port clash), which is why our first task was to re-enable it on a different port.
By
default, Traefik exposes itself as a KubernetesLoadBalancer service. In k3s, LoadBalancer services are fulfilled by a tiny built-in load balancer called klipper (or servicelb), which binds the service's ports directly on the host. Traefik's ports are 80> and 443 — the exact ports Nginx already owns.
:80 and :443 from under Nginx. The result: your static sites break, because external traffic to those ports gets redirected into Traefik instead.The fix is to expose Traefik as a NodePort> service on non-conflicting ports — 30080 (HTTP) and 30443 (HTTPS) — and let Nginx forward to 127.0.0.1:30080.
NodePort | LoadBalancer (with klipper) | |
|---|---|---|
| Port | A high port you choose, e.g. 30080 | The service's own ports, e.g. 80/443 |
| Who owns the host port | K | ubernetes routing (kube-proxy)klipper binds | it directly
| Clash with Nginx | No (different port) | Yes (same ports) |
| Use case here | Perfect | — Nginx is the only public entrypointConflicts, so we avoid it |
There
's a second, sneakier problem. Traefik's HTTP entrypoint is configured to redirect HTTP → HTTPS (redirectTo: websecure). If Nginx terminates TLS and forwards plain HTTP to Traefik, Traefik would answer with a 301 → https, sending the browser back to Nginx's :443 — which forwards again → an infinite redirect loop.
redirectTo> so it just routes plain HTTP from Nginx.The classic Kubernetes Ingress resource is simple but has limits — each controller interprets rules slightly differently, and it can't express modern needs like traffic splitting or cross-namespace routing cleanly. The Gateway API fixes this with a clear, role-based split of responsibilities.
| Resource | >Who creates it | What it declares |
|---|---|---|
GatewayClass> | Cluster | adminWhich controller implements routing (here |
Gateway> | Cluster | adminA listener — e.g. "accept HTTP on the web entrypoint" |
HTTPRoute | App developer | "Route host app.k3s.ytamang.com to this service" |
This separation
is the real win: the platform team owns the entrypoint (theGateway), while each app team owns their own routing (an HTTPRoute) without touching cluster-wide settings.
The Gateway and GatewayClass:
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
name: traefik
spec:
controllerName: traefik.io/gateway-controller
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: k3s
namespace: kube-system
spec:
gatewayClassName: traefik
listeners:
- name: web
protocol: HTTP
port: 8000
allowedRoutes:
namespaces:
from: All
A workload's HTTPRoute:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: hello
namespace: default
spec:
parentRefs:
- name: k3s
namespace: kube-system
sectionName: web
hostnames:
- app.k3s.ytamang.com
rules:
- backendRefs:
- name: hello
port: 80
Gateway listener uses port: 8000 — Traefik's internal entrypoint port. The external port is the NodePort 30080. They're different layers: container port vs. host port.gateway.networking.k8s.io) are shipped inside Traefik's traefik-crd chart. Do not install them separately — we learned this the hard way (see the gotchas).*.k3s.ytamang.com host. Let's Encrypt offers two challenge types to prove you control a name:
| HTTP- | 01DNS-01 | |
|---|---|---|
| How it proves control | Serves a token over HTTP on port 80 | Creates a >TXT record in your DNS |
| Works for wildcards? | No | Yes | >
| Needs port 80 open | Yes | >No |
| Needs DNS API access | No | >Yes |
A wildcard like *.k3s.ytamang.com can only be validated with DNS-01 — there's no single HTTP path that represents "all subdomains." So we use the certbot-dns-route53 plugin, which talks to Route 53 to create the challenge record.
cert
bot needs credentials with Route 53 access. Rather than hand it a full-admin key, we create a least-privilege IAM user with Terraform:resource "aws_iam_policy" "certbot_dns" {
name = "certbot-dns-route53"
description = "Least-privilege access for certbot DNS-01 challenge"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"route53:ListHostedZones",
"route53:GetChange"
]
Resource = "*"
},
{
Effect = "Allow"
Action = ["route53:ChangeResourceRecordSets"]
Resource = "arn:aws:route53:::hostedzone/${aws_route53_zone.ytamang.zone_id}"
}
]
})
}
The policy scopes record changes to a single hosted zone (ytamang.com). It can create/delete the challenge TXT record and check the change status — and nothing else.
Then cert
bot obtains the wildcard cert:certbot certonly \
--dns-route53 \
-d '*.k3s.ytamang.com' \
--email 48yogen+k3snginx@gmail.com \
--agree-tos \
--non-interactive
*.k3s.ytamang.com covers app.k3s.ytamang.com but not a.b.k3s.ytamang.com, and not the bare k3s.ytamang.com.The server was installed with --disable traefik. We remove that flag and restart k3s, which makes k3s deploy its bundled Traefik (plus its CRD chart).
A HelmChartConfig overrides Traefik's values: NodePort service, clear the redirect, enable the Gateway API provider.
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
service:
type: NodePort
ports:
web:
redirectTo: ""
nodePort: 30080
websecure:
nodePort: 30443
providers:
kubernetesGateway:
enabled: true
As
shown in section 4 — oneGatewayClass bound to Traefik's controller, and one Gateway> with a web listener.
N
ginx terminates TLS and forwards to Traefik's NodePort, preserving theHost header:
server {
listen 80;
server_name .k3s.ytamang.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name .k3s.ytamang.com;
ssl_certificate /etc/letsencrypt/live/k3s.ytamang.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/k3s.ytamang.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:30080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
HTTPRoute (section 4). No Nginx edits are needed per app.
These are
real bugs from doing this live. Each one cost a few minutes — knowing them up front saves you the same.k3s's
installer wrote the--disable traefik flag as two separate, single-quoted lines in the systemd unit:
ExecStart=/usr/local/bin/k3s \
server \
'--write-kubeconfig-mode' \
'0644' \
'--disable' \
'traefik' \
grep/sed/replace looking for the contiguous string --disable traefik never matched — the two words were on different lines. The reliable fix was to rewrite the whole unit with a clean ExecStart.We first
installed the Gateway API CRDs from the upstream manifest. But Traefik'straefik-crd chart also ships them. When the chart tried to install, Helm refused to adopt CRDs it didn't own:
CustomResourceDefinition "gatewayclasses.gateway.networking.k8s.io"
exists and cannot be imported into the current release:
missing key "meta.helm.sh/release-name"
traefik-crd chart own them.Even
withservice.type: NodePort in the Helm values, this Traefik chart version still rendered a LoadBalancer service — so klipper added an iptables rule that DNAT'd :80/443 into Traefik, clobbering Nginx. The fix was a direct patch:
kubectl -n kube-system patch svc traefik --type=merge \
-p '{"spec":{"type":"NodePort"}}'
After everything
is in place, we confirm the full path with a real request:curl -sk -i https://app.k3s.ytamang.com/
A working setup returns HTTP/2 200 with the workload's content, and DNS resolves correctly:
| Check | >Expected result |
|---|---|
GatewayClass traefik | ACCEPTED=True |
Gateway k3s | PROGRAMMED=True |
curl https://app.k3s.ytamang.com/ | HTTP/2 200 |
dig app.k3s.ytamang.com | resolves to |
HTTPRoute — no Nginx changes, no new certificates, no per-app load balancers.