Kubernetes for beginners. Part 18: How do Ingress and Gateway API work?
All parts of the series “Kubernetes for beginners”In the previous part MetalLB gave a LoadBalancer service the address 192.168.10.200 from its pool, and the pod became reachable from outside the cluster. But one address is one service: with ten web applications we would need ten addresses, ten DNS records and ten places where TLS is configured. Today we put a single reverse proxy in front of the applications: it accepts all HTTP and HTTPS on 192.168.10.200 and distributes requests to services by site name and path. Kubernetes has two APIs to configure it — the old Ingress and the new Gateway API — and we look at both on one controller, Traefik 3.7. Services and kube-proxy from part 15 and DNS from part 16 will come in handy along the way.
The demo namespace with the three-pod hello Deployment is rebuilt from part 11, the hello service from part 15, and MetalLB 0.16.1 in L2 mode with the 192.168.10.200–220 pool from part 17.
Why routing at L7
A LoadBalancer service works at layer four: MetalLB announces the address, kube-proxy rewrites the packet's destination to a pod address, and that's it. Neither the site name, nor the request path, nor the headers are visible at this layer — only addresses and ports. Hence the limitations:
- one address per service: only one service can listen on
192.168.10.200:443, and two sites on one address cannot be told apart; - TLS in every application: every pod has to hold its own certificate and key, renew them and pick ciphers itself;
- no common policy: redirecting HTTP to HTTPS, security headers, rate limiting, an access log — every application does it its own way or not at all.
HTTP is an application-layer protocol over TCP: the client sends a request (method, path, headers, including Host with the site name), the server answers with a status code, headers and a body; in HTTP/2 the same requests travel as binary frames, many at once over one connection. A reverse proxy is a server that accepts the client's connection, reads the request and opens its own connection to the right backend; since it sees the whole request, it can pick the backend by Host and path, terminate TLS and add headers. More on proxies in part 20 of the networking series.
For HTTPS the proxy has to know which certificate to present before anything is decrypted. That is what SNI (Server Name Indication) is for — a TLS extension in which the client names the server in clear text in the first handshake message (ClientHello). The proxy picks the certificate by SNI, and after the handshake it reads the decrypted request and routes by Host and path.
This is the setup we will build:
admin laptop 192.168.10.50
│ https://hello.apps.example.com/ (DNS: *.apps.example.com → 192.168.10.200)
▼
192.168.10.200:443 MetalLB (L2): a node answers ARP for the address
│
▼
kube-proxy, nftables: DNAT to a Traefik pod, port 8443
│
▼
┌─────────────────────── Traefik pod ───────────────────────┐
│ TLS by SNI: certificate *.apps.example.com │
│ route: Host = hello.apps.example.com, path / → hello │
│ middleware: headers, rate limit, access log │
└───────────────────────────┬───────────────────────────────┘
│ HTTP to a pod address from EndpointSlice
▼
hello pod (172.20.x.x:8080) in namespace demo
All *.apps.example.com sites point to one address in DNS; the proxy tells them apart. Only one LoadBalancer is needed — for the proxy itself.
Ingress: the object
Ingress is an object of the networking.k8s.io/v1 API group that describes rules: "send requests with this Host and this path to this Service on this port". By itself an Ingress does nothing — there is no built-in component in the cluster that reads it. That job belongs to an ingress controller, described below.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hello
namespace: demo
spec:
ingressClassName: traefik
tls:
- hosts:
- hello-ingress.apps.example.com
secretName: hello-ingress-tls
rules:
- host: hello-ingress.apps.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hello
port:
number: 80
- path: /healthz
pathType: Exact
backend:
service:
name: hello
port:
name: http
rules is a list of rules, each with an optional host and a list of paths. host is either exact (hello.apps.example.com) or has a wildcard in the first label (*.apps.example.com); the wildcard covers exactly one label, so neither a.b.apps.example.com nor apps.example.com itself matches it. A rule without host applies to any name.
pathType is mandatory and comes in three kinds:
pathType |
How it compares | /foo matches |
|---|---|---|
Exact |
the whole path, case-sensitive | only /foo (not /foo/) |
Prefix |
by elements separated by / |
/foo, /foo/, /foo/bar, but not /foobar |
ImplementationSpecific |
as the controller decides | depends on the controller |
By the specification Prefix compares path elements, not strings: /aaa/bb does not match /aaa/bbb. If several paths match, the longest one wins, and with equal length — Exact. Better avoid ImplementationSpecific: the manifest is no longer portable between controllers, and the behaviour has to be looked up in the documentation of a particular controller. But controllers don't always follow the specification for Prefix either: by default Traefik compares it as a string, so for it /health matches /healthz. Below we switch on strict matching and check the difference on the lab.
backend is a Service and its port, by number (number) or by name (name). An Ingress refers only to services in its own namespace. defaultBackend at the spec level says where to send requests that match no rule; without it the controller answers with its own 404.
tls is a list of names and a Secret of type kubernetes.io/tls with the certificate and key for them. The Secret must live in the same namespace as the Ingress. This means every team keeps a copy of the key — including the key of a wildcard certificate if it is shared by all sites. Remember this: Gateway API solves it differently. One more thing: a tls section does not forbid plain HTTP. What to do with a request on port 80 for such a site is up to the controller; without extra annotations Traefik serves it over HTTP just as over HTTPS.
IngressClass binds an Ingress to a particular controller. Several controllers can run in a cluster, and each needs to know which Ingresses are its own:
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: traefik
annotations:
ingressclass.kubernetes.io/is-default-class: "true"
spec:
controller: traefik.io/ingress-controller
spec.controller is the string the controller looks for: Traefik serves classes with traefik.io/ingress-controller. An Ingress picks its class with the ingressClassName field. The is-default-class annotation makes the class the default: the API server admission plugin DefaultIngressClass (admission plugins validate and complete objects before they are written to etcd) sets it in new Ingresses without ingressClassName. The old kubernetes.io/ingress.class annotation on the Ingress itself is deprecated and is not used in new manifests.
Everything beyond host + path + TLS — redirects, path rewriting, timeouts, headers, authorization — is not part of Ingress. Every controller added it with its own annotations: nginx.ingress.kubernetes.io/... for ingress-nginx, traefik.ingress.kubernetes.io/... for Traefik. Annotations are not validated by the API schema, are not portable between controllers and pile up over the years. The Ingress API is frozen: it stays GA and nobody plans to remove it from Kubernetes, but it will not be developed any further, and the Kubernetes documentation recommends Gateway API.
The ingress controller
An ingress controller is an ordinary application in the cluster (a Deployment or a DaemonSet) with two roles: it is a controller in the sense of part 3, and it is also the proxy the traffic flows through.
As a controller it opens watches to the API server on the resources it needs: IngressClass, Ingress, Service, EndpointSlice, Secret (for certificates) and Namespace (for rules that select namespaces by label), and with Gateway API enabled also GatewayClass, Gateway, HTTPRoute, GRPCRoute, ReferenceGrant and other resources of that group. For that its ServiceAccount gets a ClusterRole with get, list, watch on these resources and update on the status subresource of Ingresses, gateways and routes. The client library keeps a local cache of objects (an informer): first a list, then a stream of ADDED, MODIFIED, DELETED events. On every event the controller rebuilds its routing model and applies it.
As a proxy it listens on ports and routes requests by that model. An important detail: most controllers, Traefik included, do not go through the service's ClusterIP. They need the Service only to find the pods: the addresses of ready pods are taken from EndpointSlice, and Traefik balances between them itself. kube-proxy is not involved on this leg, and a pod that stops passing its readiness probe disappears from EndpointSlice and right away from the proxy's routes.
How new configuration is applied depends on the proxy. nginx-based controllers historically generated nginx.conf and reloaded, which on large clusters means frequent restarts of worker processes. Traefik keeps its configuration in memory and changes it on the fly, without a reload and without dropping connections.
Finally, the controller writes back to the API: into the Ingress status.loadBalancer.ingress — the address it is reachable at (copied from the status of its own LoadBalancer service), into the status of a Gateway and of routes — conditions, described below. Traefik writes no Events for Ingresses, Gateways or routes: everything it has to say is in status and in its log.
There are many controllers. ingress-nginx is a controller based on the nginx web server, created within the Kubernetes project itself as the reference implementation of Ingress. Traefik is a reverse proxy written in Go by Traefik Labs; it reads its configuration from the Kubernetes API itself and supports Ingress, Gateway API and its own CRDs. Envoy Gateway is a Gateway API implementation on the Envoy proxy (a high-performance proxy from the CNCF — the Cloud Native Computing Foundation, where Kubernetes and related projects are developed; many service meshes are built on Envoy as well). NGINX Gateway Fabric is F5's Gateway API implementation on nginx. Cilium is an eBPF-based CNI plugin (eBPF programs are run by the Linux kernel on events such as a packet arriving) with its own built-in Gateway API implementation. The choice is a matter of taste and requirements; in our series it is Traefik.
What happened to ingress-nginx
For many years ingress-nginx was the most widespread controller. Its flexibility turned into debt: "snippet" annotations that insert arbitrary nginx directives came to be considered dangerous over time, and for years the project was maintained by one or two people in their spare time. In November 2025 SIG Network and the Kubernetes Security Response Committee announced that the project was being retired: best-effort maintenance until March 2026, then no releases, no bug fixes and no fixes for vulnerabilities. In January 2026 the Steering Committee and the Security Response Committee repeated the statement with a direct warning: staying on ingress-nginx after that leaves you open to attack. The last controller releases, 1.15.1 and 1.14.5, came out on March 19, 2026, after which the kubernetes/ingress-nginx repository was archived, read-only.
What this means in practice:
- existing installations keep working, images and charts remain available — nothing breaks overnight, but nobody will fix vulnerabilities any more;
- one command tells you whether it runs in your cluster:
kubectl get pods -A -l app.kubernetes.io/name=ingress-nginx; - there is no drop-in replacement: the recommended path is Gateway API, and SIG Network built the
ingress2gatewaytool for moving manifests, which in version 1.0 (March 2026) understands more than thirty ingress-nginx annotations and warns about the ones that cannot be translated; - the Ingress API itself is not being removed: you can stay on Ingress and switch controllers. Traefik even has a separate provider for that, which reads Ingresses with ingress-nginx annotations.
Not to be confused with F5's NGINX Ingress Controller — a separate product with a similar name that has nothing to do with the retirement of ingress-nginx.
Gateway API
Gateway API is a set of CRDs from SIG Network that grew out of the experience with Ingress. It is not built into Kubernetes but installed separately, like any CRDs, and has its own releases: version 1.0 came out in 2023, and at the time of writing the current one is 1.6.3. Resources are split into two channels: Standard — stable, with a compatibility guarantee, and Experimental — things that may still change.
The main idea is separation of roles. In Ingress one team describes in one object which address accepts traffic, which certificate is presented and where requests go. In Gateway API these are three levels, each owned by a different person:
GatewayClass "traefik" ◄── infrastructure provider:
controllerName: which controller implements
traefik.io/gateway-controller the gateways
▲
│ gatewayClassName
Gateway "apps" (ns traefik) ◄── cluster administrator:
listener http :8000 ports, protocols, names,
listener https :8443 + TLS certificates, who is let in
▲
│ parentRefs
HTTPRoute "hello" (ns demo) ◄── application team:
hostnames, matches, where its own requests go
filters, backendRefs ────────────► Service hello
GatewayClass is a cluster-scoped resource, the counterpart of IngressClass: the class name and a controllerName by which a controller recognizes its classes. Through parametersRef a class can refer to an object with implementation-specific settings.
Gateway is the traffic entry point: a set of listeners. Each listener has a name, a protocol (HTTP, HTTPS, TLS, TCP, UDP), a port, an optional hostname, TLS settings and allowedRoutes — routes from which namespaces and of which kinds may attach to it. The certificate is specified here, on the gateway, and lives in the administrator's namespace; application teams need no access to the key.
Routes describe where traffic goes:
- HTTPRoute — HTTP and HTTPS routing: hostnames, conditions (
matches) on path, headers, query parameters and method, filters and weighted backends. - GRPCRoute — for gRPC. gRPC is a remote procedure call framework from Google: calls run over HTTP/2, and messages are encoded in Protocol Buffers, a compact binary format with a schema. GRPCRoute matches requests by gRPC service and method name rather than by URL paths.
- TLSRoute — routing by SNI without decryption: in
Passthroughmode the gateway looks only at the name in the ClientHello and hands the encrypted stream to the backend as is; the certificate stays with the application itself. TLSRoute became stable in Gateway API 1.5. - TCPRoute and UDPRoute — forwarding TCP and UDP ports without parsing the protocol; they joined Standard in version 1.6.
Not every implementation supports every kind: Traefik 3.7 serves HTTPRoute, GRPCRoute and TLSRoute, TCPRoute only with the experimentalChannel option and the Experimental channel CRDs, and does not support UDPRoute at all. A listener itself reports what it accepts in status — the supportedKinds field, which we will see below.
Attachment is two-way. A route refers to a Gateway through parentRefs (it can name a particular listener — sectionName), and the listener decides through allowedRoutes whether to accept it. By default a listener accepts routes only from its own namespace; others can be allowed all at once (from: All) or by namespace labels (from: Selector). This way the administrator controls who may publish sites on the shared address, and teams cannot "steal" someone else's gateway. Hostnames are intersected: a route with hello.apps.example.com attaches to a listener with *.apps.example.com, while a route with hello.other.example.com does not.
ReferenceGrant is a permission to reference across a namespace boundary. In Gateway API you can refer to objects in another namespace only when the owner of that namespace has explicitly allowed it. For example, a Gateway in traefik wants to take its certificate from a Secret in the certs namespace:
apiVersion: gateway.networking.k8s.io/v1
kind: ReferenceGrant
metadata:
name: allow-traefik-gateways
namespace: certs # namespace of the object being REFERENCED
spec:
from:
- group: gateway.networking.k8s.io
kind: Gateway
namespace: traefik
to:
- group: ""
kind: Secret
name: apps-example-com-tls
Without such an object the listener gets the condition ResolvedRefs: False with reason RefNotPermitted — we will check this on the lab. The same applies to an HTTPRoute that wants to send traffic to a Service in another namespace. In Gateway API 1.5 ReferenceGrant got the v1 version.
Why Gateway API is better than Ingress:
| Ingress | Gateway API | |
|---|---|---|
| Roles | one object for everything | class, gateway and routes have different owners |
| Certificates | a Secret in every application namespace | on the gateway, in the administrator's namespace |
| Redirects, headers, rewriting, mirroring, weights | annotations of a particular controller | standard filters and fields |
| Route conditions | host and path | path, headers, query parameters, method |
| Protocols | HTTP and HTTPS | HTTP, gRPC, TLS, TCP, UDP |
| Cross-namespace references | forbidden | via an explicit ReferenceGrant |
| Feedback | an address in status |
conditions on the gateway, listeners and every route |
| Development | frozen | new releases several times a year |
The price is verbosity: a simple site in Gateway API is two objects instead of one, and three with an HTTPS redirect.
Installing Traefik
Gateway API is not part of Kubernetes, so first we install its CRDs with the official Standard channel manifest. Traefik 3.7 supports specification 1.6.3:
$ kubectl apply --server-side -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.6.3/standard-install.yaml
customresourcedefinition.apiextensions.k8s.io/backendtlspolicies.gateway.networking.k8s.io serverside-applied
customresourcedefinition.apiextensions.k8s.io/gatewayclasses.gateway.networking.k8s.io serverside-applied
customresourcedefinition.apiextensions.k8s.io/gateways.gateway.networking.k8s.io serverside-applied
customresourcedefinition.apiextensions.k8s.io/grpcroutes.gateway.networking.k8s.io serverside-applied
customresourcedefinition.apiextensions.k8s.io/httproutes.gateway.networking.k8s.io serverside-applied
customresourcedefinition.apiextensions.k8s.io/listenersets.gateway.networking.k8s.io serverside-applied
customresourcedefinition.apiextensions.k8s.io/referencegrants.gateway.networking.k8s.io serverside-applied
customresourcedefinition.apiextensions.k8s.io/tcproutes.gateway.networking.k8s.io serverside-applied
customresourcedefinition.apiextensions.k8s.io/tlsroutes.gateway.networking.k8s.io serverside-applied
customresourcedefinition.apiextensions.k8s.io/udproutes.gateway.networking.k8s.io serverside-applied
validatingadmissionpolicy.admissionregistration.k8s.io/safe-upgrades.gateway.networking.k8s.io serverside-applied
validatingadmissionpolicybinding.admissionregistration.k8s.io/safe-upgrades.gateway.networking.k8s.io serverside-applied
Besides the resources we already know, the Standard channel has two more: BackendTLSPolicy — how the gateway should verify the backend's certificate when TLS runs all the way to the pod, and ListenerSet — listeners that can be added to someone else's Gateway as a separate object in your own namespace. The safe-upgrades pair is a ValidatingAdmissionPolicy rule (checks in the CEL language that the API server runs itself, without a webhook): it prevents installing Experimental channel CRDs over Standard ones and rolling Gateway API back to a version older than 1.5.
The project itself recommends --server-side: with a plain apply kubectl stores a copy of the object in the last-applied-configuration annotation, and annotations are limited to 256 KiB. The Experimental channel CRDs no longer fit, and the largest Standard channel CRD, httproutes, takes about 240 KB of compact JSON on the lab — almost at the limit.
Traefik itself is distributed by the project as a Helm chart, and installing it as a pile of manifests (Deployment, ServiceAccount, ClusterRole, CRDs, Service) is inconvenient. So here we use one Helm command — Helm is a package manager for Kubernetes, where a package (chart) consists of manifest templates and the parameters come from a values file. How charts, releases and rollbacks work is covered in detail in part 27. The traefik-values.yaml file:
deployment:
replicas: 2
providers:
kubernetesIngress:
enabled: true
strictPrefixMatching: true # Prefix by path elements, as in the spec
kubernetesCRD:
enabled: true # Traefik CRDs: Middleware and others
kubernetesGateway:
enabled: true # Gateway API
ingressClass:
enabled: true
isDefaultClass: true
gateway:
enabled: false # we describe the Gateway ourselves, as a manifest
service:
annotations:
metallb.io/loadBalancerIPs: 192.168.10.200
spec:
externalTrafficPolicy: Local
accessLog:
enabled: true
And the installation with a pinned chart version (chart 42.1.3 installs Traefik v3.7.16):
$ helm repo add traefik https://traefik.github.io/charts
$ helm install traefik traefik/traefik --version 42.1.3 \
--namespace traefik --create-namespace -f traefik-values.yaml
From its crds/ directory the chart installs 25 CRDs — Traefik's own resources in the traefik.io group (Middleware, IngressRoute, TLSOption and others) and resources of the commercial Traefik Hub edition in the hub.traefik.io group; the Gateway API CRDs are no longer part of the chart — we installed them ourselves. The result:
$ kubectl -n traefik get deploy,svc
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/traefik 2/2 2 2 45s
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/traefik LoadBalancer 10.103.63.116 192.168.10.200 80:30201/TCP,443:31723/TCP 46s
$ kubectl get ingressclass
NAME CONTROLLER PARAMETERS AGE
traefik (default) traefik.io/ingress-controller <none> 45s
$ kubectl get gatewayclass
NAME CONTROLLER ACCEPTED AGE
traefik traefik.io/gateway-controller True 44s
(default) on the IngressClass is that is-default-class annotation. The chart creates the GatewayClass even with gateway.enabled: false: only the default gateway is switched off. ACCEPTED True means Traefik has recognized the class as its own.
A few things the chart does well by default and that are worth knowing. Inside the pod Traefik listens on unprivileged ports 8000 (the web entry point) and 8443 (websecure), and the Service maps 80 and 443 to them; so the container needs no NET_BIND_SERVICE capability and runs as UID 65532 with a read-only root filesystem and all capabilities dropped. On websecure TLS is on for all routes at once (--entryPoints.websecure.http.tls=true). The Traefik dashboard is enabled but not published; you can look at it through kubectl port-forward, and publishing it without authentication is not a good idea.
Strict Prefix. strictPrefixMatching in the values is not decoration: without it Traefik's Ingress provider compares pathType: Prefix character by character, as a string, and the /health rule takes over /healthz and /healthy/x. With the option the comparison goes by path elements, as the specification requires. We will check both behaviours on the lab.
The client address. externalTrafficPolicy: Local tells kube-proxy not to forward traffic from node to node, but to hand it only to pods on the node where it arrived. With the Cluster policy kube-proxy spreads connections over Traefik pods on all nodes and replaces the source address with the address of the node the packet arrived at — even when the pod is on that same node. On the lab with Cluster, all requests from the laptop showed up in the logs of both Traefik pods with the address 192.168.10.21 — the leader node k8s-node-2, while with Local the log shows the laptop's own address. In L2 mode MetalLB has a consequence: one node announces the address, and only a node with a ready Traefik pod may announce it, so all traffic goes to the pod on one node while the second pod waits in case of failure. How this works when the address is announced over BGP and traffic is shared by all nodes with Traefik pods is covered in detail in part 7 of the series on Kubernetes over BGP.
Gateway and certificate
Real certificates will be issued by cert-manager in the next part. For now let's create a self-signed wildcard certificate so there is something to show over HTTPS:
$ openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes -days 30 \
-subj "/CN=*.apps.example.com" -addext "subjectAltName=DNS:*.apps.example.com" \
-keyout apps.key -out apps.crt
$ kubectl -n traefik create secret tls apps-example-com-tls --cert=apps.crt --key=apps.key
secret/apps-example-com-tls created
The Secret lives in the traefik namespace next to the gateway, and application teams need no access to it. Now the gateway:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: apps
namespace: traefik
spec:
gatewayClassName: traefik
listeners:
- name: http
protocol: HTTP
port: 8000
hostname: "*.apps.example.com"
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway.example.com/apps: "allowed"
- name: https
protocol: HTTPS
port: 8443
hostname: "*.apps.example.com"
tls:
mode: Terminate
certificateRefs:
- kind: Secret
name: apps-example-com-tls
allowedRoutes:
namespaces:
from: Selector
selector:
matchLabels:
gateway.example.com/apps: "allowed"
For Traefik, listener ports are the ports of its entry points inside the pod, 8000 and 8443, not the service's 80 and 443: if a listener port matches no entry point, Traefik marks the listener as not accepted with reason PortUnavailable (we will see this on the lab too). Routes are accepted only from namespaces labeled gateway.example.com/apps=allowed; let's label demo:
$ kubectl label namespace demo gateway.example.com/apps=allowed
namespace/demo labeled
Namespace labels can be set only by someone with rights on the Namespace object itself — usually the administrator, not the application team. That keeps publishing on the shared address under control.
HTTPRoute and the redirect to HTTPS
The demo namespace runs the hello Deployment and the hello Service with port 80, which leads to port 8080 of the pods. The route for HTTPS:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: hello
namespace: demo
spec:
parentRefs:
- name: apps
namespace: traefik
sectionName: https
hostnames:
- hello.apps.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: hello
port: 80
backendRefs can list several backends with a weight field — that is how a canary release is done: 90 % of traffic to hello, 10 % to hello-v2, with no annotations and no extra objects.
On the http listener we hang a second route that proxies nothing and only redirects:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: hello-redirect
namespace: demo
spec:
parentRefs:
- name: apps
namespace: traefik
sectionName: http
hostnames:
- hello.apps.example.com
rules:
- filters:
- type: RequestRedirect
requestRedirect:
scheme: https
statusCode: 301
The RequestRedirect filter changes the scheme, and by the specification the port in Location is then the well-known port of the new scheme — 443, so port 8443 inside the pod does not leak outside; the path and query are preserved. The default code is 302 (a temporary redirect); for "always use HTTPS" a permanent one, 301, is more appropriate.
There is a second way — enabling the redirect on the whole web entry point in the Traefik values (ports.web.http.redirections.entryPoint with to: websecure). It is simpler, but it affects all sites and all APIs at once, and it has a subtlety with issuing certificates over HTTP that we will discuss in the next part. A route with a filter is more explicit and portable between controllers.
For comparison — the same with Ingress. The TLS certificate for an Ingress has to live in demo, and Traefik annotations set the entry point and TLS — otherwise, as we have seen, the site also opens over HTTP:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hello
namespace: demo
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
spec:
ingressClassName: traefik
tls:
- hosts:
- hello-ingress.apps.example.com
secretName: hello-ingress-tls
rules:
- host: hello-ingress.apps.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hello
port:
number: 80
The HTTP redirect for such an Ingress is a second Ingress on the web entry point with the redirectScheme middleware — what a middleware is, right below:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: redirect-https
namespace: demo
spec:
redirectScheme:
scheme: https
permanent: true
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: hello-redirect
namespace: demo
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: web
traefik.ingress.kubernetes.io/router.middlewares: demo-redirect-https@kubernetescrd
spec:
ingressClassName: traefik
rules:
- host: hello-ingress.apps.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: hello
port:
number: 80
With permanent: true Traefik picks the code by the request method: it answers a GET with 301 (Moved Permanently) and every other method with 308 (Permanent Redirect); without permanent it is 302 and 307 respectively. Both codes are permanent; the difference is the method: 301 allows the client to repeat a POST as a GET, while 308 requires it to keep the method. For a GET this changes nothing, so a browser opening the page gets the familiar 301.
Middleware: headers and rate limiting
A middleware in Traefik is a processing step between receiving a request and sending it to the backend: it can change the request or the response, or refuse. Middlewares are described by the Middleware resource from Traefik's CRDs (group traefik.io, version v1alpha1), which the chart installed along with itself. Let's create two in the demo namespace.
Response security headers:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: secure-headers
namespace: demo
spec:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
contentTypeNosniff: true
frameDeny: true
referrerPolicy: strict-origin-when-cross-origin
customResponseHeaders:
X-Powered-By: ""
stsSeconds adds the Strict-Transport-Security header (HSTS): the browser remembers for a year that the site opens only over HTTPS and won't even try HTTP. contentTypeNosniff is X-Content-Type-Options: nosniff, which forbids the browser from guessing the content type. frameDeny is X-Frame-Options: DENY, which forbids embedding the site in a frame on someone else's page. referrerPolicy is the Referrer-Policy header: to other sites the browser sends only the site's origin in Referer, without the path. An empty value in customResponseHeaders removes the header: there is no need to tell visitors what the application is written in. Enable HSTS only when HTTPS really works: browsers will remember a mistake in it for the whole period.
Rate limiting:
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: rate-limit
namespace: demo
spec:
rateLimit:
average: 20
period: 1s
burst: 40
This is a "token bucket": 20 requests per second on average, bursts up to 40; beyond that Traefik answers 429 Too Many Requests without bothering the application. Counting is done per client address — and this is where externalTrafficPolicy: Local pays off: without it all clients would come from the node address and share one bucket. If there is another proxy in front of Traefik, the address has to be taken from X-Forwarded-For (sourceCriterion.ipStrategy.depth), trusting that header only from known addresses. And one more thing: the counters live in the memory of each Traefik pod separately; a common limit across several replicas requires Redis — an in-memory key-value store where the replicas keep shared counters (the redis field of the same middleware).
Attaching to an Ingress — with an annotation referring to <namespace>-<name>@kubernetescrd (the suffix is the name of the provider the middleware comes from):
metadata:
annotations:
traefik.ingress.kubernetes.io/router.middlewares: demo-secure-headers@kubernetescrd,demo-rate-limit@kubernetescrd
Attaching to an HTTPRoute — in two ways. For headers Gateway API has the standard ResponseHeaderModifier filter, which is portable to any controller. There is no rate limiting in the standard, and for it there is the ExtensionRef filter — a reference to an implementation object, which for Traefik is a Middleware from the same namespace:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: hello
namespace: demo
spec:
parentRefs:
- name: apps
namespace: traefik
sectionName: https
hostnames:
- hello.apps.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
filters:
- type: ResponseHeaderModifier
responseHeaderModifier:
set:
- name: Strict-Transport-Security
value: max-age=31536000; includeSubDomains
- name: X-Content-Type-Options
value: nosniff
- name: X-Frame-Options
value: DENY
remove:
- X-Powered-By
- type: ExtensionRef
extensionRef:
group: traefik.io
kind: Middleware
name: rate-limit
backendRefs:
- name: hello
port: 80
The standard HTTPRoute filters are RequestHeaderModifier and ResponseHeaderModifier (headers), RequestRedirect, URLRewrite (rewrite the hostname or path), RequestMirror (a copy of the request to another backend whose response is discarded), CORS (permission headers for requests from other sites, stable since 1.5) and ExtensionRef. The rule is simple: whatever is in the standard goes through the standard, the rest through ExtensionRef, keeping in mind that this part of the manifest is tied to Traefik.
Hands-on
Gateway and route status
Gateway API reports every step with conditions, and this is the first place to look when something doesn't open:
$ kubectl -n traefik get gateway apps
NAME CLASS ADDRESS PROGRAMMED AGE
apps traefik 192.168.10.200 True 6m59s
$ kubectl -n traefik get gateway apps -o yaml
…
status:
addresses:
- type: IPAddress
value: 192.168.10.200
conditions:
- lastTransitionTime: "2026-10-29T12:47:09Z"
message: Gateway successfully scheduled
observedGeneration: 1
reason: Accepted
status: "True"
type: Accepted
- lastTransitionTime: "2026-10-29T12:47:09Z"
message: Gateway successfully programmed
observedGeneration: 1
reason: Programmed
status: "True"
type: Programmed
listeners:
- attachedRoutes: 1
conditions:
- lastTransitionTime: "2026-10-29T12:47:09Z"
message: No error found
observedGeneration: 1
reason: Accepted
status: "True"
type: Accepted
- lastTransitionTime: "2026-10-29T12:47:09Z"
message: No error found
observedGeneration: 1
reason: ResolvedRefs
status: "True"
type: ResolvedRefs
- lastTransitionTime: "2026-10-29T12:47:09Z"
message: No error found
observedGeneration: 1
reason: Programmed
status: "True"
type: Programmed
name: http
supportedKinds:
- group: gateway.networking.k8s.io
kind: HTTPRoute
- group: gateway.networking.k8s.io
kind: GRPCRoute
- attachedRoutes: 1
…
name: https
…
Accepted means the controller has recognized the gateway as its own and the configuration is valid; Programmed — it has been applied to the proxy; ResolvedRefs on a listener — the certificate was found and read; attachedRoutes — how many routes have attached (hello-redirect on http, hello on https); supportedKinds — which kinds of routes the listener accepts. Traefik copied the address from the status of its service.
The route:
$ kubectl -n demo get httproute
NAME HOSTNAMES AGE
hello ["hello.apps.example.com"] 6m46s
hello-redirect ["hello.apps.example.com"] 6m46s
$ kubectl -n demo get httproute hello -o jsonpath='{.status.parents[0]}' | jq
{
"conditions": [
{
"lastTransitionTime": "2026-10-29T12:41:05Z",
"message": "",
"observedGeneration": 2,
"reason": "Accepted",
"status": "True",
"type": "Accepted"
},
{
"lastTransitionTime": "2026-10-29T12:41:05Z",
"message": "",
"observedGeneration": 2,
"reason": "ResolvedRefs",
"status": "True",
"type": "ResolvedRefs"
}
],
"controllerName": "traefik.io/gateway-controller",
"parentRef": {
"group": "gateway.networking.k8s.io",
"kind": "Gateway",
"name": "apps",
"namespace": "traefik",
"sectionName": "https"
}
}
(jq is a utility for parsing and formatting JSON.) A route's status is per parent: one route can hang on several gateways and be accepted on one and not on another. observedGeneration: 2 means the status refers to the second version of the route, the one where we added the filters.
Requests
There may be no DNS record for hello.apps.example.com yet, and that doesn't matter: --resolve tells curl which address to go to, while the name still goes into both SNI and Host, as if DNS had found it. --cacert is our self-signed certificate as a trusted root. A request from the laptop, with the progress meter lines omitted:
$ curl -v --resolve hello.apps.example.com:443:192.168.10.200 \
--cacert apps.crt https://hello.apps.example.com/
* Added hello.apps.example.com:443:192.168.10.200 to DNS cache
* Hostname hello.apps.example.com was found in DNS cache
* Trying 192.168.10.200:443...
* Connected to hello.apps.example.com (192.168.10.200) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
* CAfile: apps.crt
* CApath: /etc/ssl/certs
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS change cipher, Change cipher spec (1):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_128_GCM_SHA256 / X25519 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate:
* subject: CN=*.apps.example.com
* start date: Oct 29 12:35:46 2026 GMT
* expire date: Nov 28 12:35:46 2026 GMT
* subjectAltName: host "hello.apps.example.com" matched cert's "*.apps.example.com"
* issuer: CN=*.apps.example.com
* SSL certificate verify ok.
* Certificate level 0: Public key type EC/prime256v1 (256/128 Bits/secBits), signed using ecdsa-with-SHA256
* TLSv1.3 (IN), TLS handshake, Newsession Ticket (4):
* using HTTP/2
* [HTTP/2] [1] OPENED stream for https://hello.apps.example.com/
* [HTTP/2] [1] [:method: GET]
* [HTTP/2] [1] [:scheme: https]
* [HTTP/2] [1] [:authority: hello.apps.example.com]
* [HTTP/2] [1] [:path: /]
* [HTTP/2] [1] [user-agent: curl/8.5.0]
* [HTTP/2] [1] [accept: */*]
> GET / HTTP/2
> Host: hello.apps.example.com
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/2 200
< content-type: text/plain; charset=utf-8
< date: Thu, 29 Oct 2026 12:47:29 GMT
< strict-transport-security: max-age=31536000; includeSubDomains
< x-app-version: 1.0
< x-content-type-options: nosniff
< x-frame-options: DENY
< content-length: 34
<
* Connection #0 to host hello.apps.example.com left intact
hello from hello-6d5c6db6b4-nz5pj
Everything is visible in order: the TLS handshake with the certificate chosen by SNI, HTTP/2 negotiated through ALPN (a TLS extension in which client and server agree on the application protocol), the headers added by the filter (x-app-version comes from the application itself), and the pod's answer. Plain HTTP/1.1 was used to the backend: the protocol between client and proxy and between proxy and pod are chosen independently.
The redirect — path and query are preserved, and port 8443 does not end up in Location:
$ curl -sI --resolve hello.apps.example.com:80:192.168.10.200 http://hello.apps.example.com/
HTTP/1.1 301 Moved Permanently
Location: https://hello.apps.example.com/
Date: Thu, 29 Oct 2026 12:47:29 GMT
Content-Length: 17
$ curl -sI --resolve hello.apps.example.com:80:192.168.10.200 'http://hello.apps.example.com/a/b?x=1' | grep -i location
Location: https://hello.apps.example.com/a/b?x=1
A request for a name that is in no route is served by Traefik itself with a 404 — and over TLS also with its own self-signed TRAEFIK DEFAULT CERT, because it has no matching certificate for a foreign SNI:
$ curl -sk -o /dev/null -w '%{http_code}\n' --resolve nope.example.com:443:192.168.10.200 https://nope.example.com/
404
$ curl -skv --resolve nope.example.com:443:192.168.10.200 https://nope.example.com/ 2>&1 | grep -E 'subject:|issuer:'
* subject: CN=TRAEFIK DEFAULT CERT
* issuer: CN=TRAEFIK DEFAULT CERT
Rate limiting — a hundred requests in twenty parallel streams:
$ seq 100 | xargs -P 20 -I{} curl -s -o /dev/null -w '%{http_code}\n' \
--resolve hello.apps.example.com:443:192.168.10.200 --cacert apps.crt \
https://hello.apps.example.com/ | sort | uniq -c
43 200
57 429
43 requests got through — forty thanks to burst and a few more thanks to the bucket refilling while the test ran; the rest got 429. A sequential curl loop will most likely not reach the limit: every request with a new TLS handshake takes time, and the bucket has time to refill.
The access log
Traefik writes it to the pod's stdout, by default in its own extended CLF (Common Log Format — the classic one-line web server log format). With Local in L2 mode all traffic goes to the Traefik pod on the leader node k8s-node-2, so we read its log — here are two lines after the HTTPS request and the redirect:
$ kubectl -n traefik logs traefik-6b7fb4b765-w9l4s --tail=2
192.168.10.50 - - [29/Oct/2026:12:47:29 +0000] "GET / HTTP/2.0" 200 34 "-" "-" 20 "httproute-demo-hello-gw-traefik-apps-ep-websecure-0-e329ef369d5777264fcf@kubernetesgateway" "http://172.20.211.14:8080" 0ms
192.168.10.50 - - [29/Oct/2026:12:47:29 +0000] "HEAD / HTTP/1.1" 301 17 "-" "-" 21 "httproute-demo-hello-redirect-gw-traefik-apps-ep-web-0-997f60aee86fcd0c0434@kubernetesgateway" "-" 0ms
The fields: the client address — the laptop's real address 192.168.10.50, not the node address, thanks to Local; the request and the status code; the size; Referer and User-Agent are dashes because by default the chart does not log request headers (accesslog.fields.headers.defaultmode=drop); the request number since the pod started; the Traefik router name — it shows which object the route came from (@kubernetesgateway — HTTPRoute, @kubernetes — Ingress, @kubernetescrd — Traefik CRDs); the backend address — the pod address straight from EndpointSlice, not the ClusterIP, and the pod 172.20.211.14 runs on k8s-node-1 while Traefik is on k8s-node-2; the processing time. The redirect has no backend — Traefik answered itself. A rate-limit refusal looks the same, without a backend:
$ kubectl -n traefik logs traefik-6b7fb4b765-w9l4s | grep ' 429 ' | tail -1
192.168.10.50 - - [29/Oct/2026:12:47:46 +0000] "GET / HTTP/2.0" 429 17 "-" "-" 122 "httproute-demo-hello-gw-traefik-apps-ep-websecure-0-e329ef369d5777264fcf@kubernetesgateway" "-" 0ms
For shipping to a log system accessLog.format: json is more convenient; more on that in part 33.
When something is wrong
A route in the sandbox namespace, which has no gateway.example.com/apps=allowed label:
$ kubectl -n sandbox get httproute test -o jsonpath='{.status.parents[0].conditions}' | jq
[
{
"lastTransitionTime": "2026-10-29T12:45:06Z",
"message": "",
"observedGeneration": 1,
"reason": "NotAllowedByListeners",
"status": "False",
"type": "Accepted"
},
{
"lastTransitionTime": "2026-10-29T12:45:06Z",
"message": "Cannot load HTTPBackendRef sandbox/hello: getting service: service \"hello\" not found",
"observedGeneration": 1,
"reason": "BackendNotFound",
"status": "False",
"type": "ResolvedRefs"
}
]
The listener did not let the route in (NotAllowedByListeners), and Traefik also reported that there is no hello service in sandbox: backendRefs without namespace looks for the service in the route's namespace. A request for test.apps.example.com gets a 404 from Traefik itself.
A typo in the service name in an allowed namespace:
$ kubectl -n demo get httproute typo -o jsonpath='{.status.parents[0].conditions}' | jq
[
{
"lastTransitionTime": "2026-10-29T12:45:06Z",
"message": "",
"observedGeneration": 1,
"reason": "Accepted",
"status": "True",
"type": "Accepted"
},
{
"lastTransitionTime": "2026-10-29T12:45:06Z",
"message": "Cannot load HTTPBackendRef demo/helo: getting service: service \"helo\" not found",
"observedGeneration": 1,
"reason": "BackendNotFound",
"status": "False",
"type": "ResolvedRefs"
}
]
$ curl -s -w ' %{http_code}\n' --resolve typo.apps.example.com:443:192.168.10.200 --cacert apps.crt https://typo.apps.example.com/
500
The route is accepted but the reference is not resolved, and requests through it get a 500 with an empty body: the specification requires this so the error is noticeable instead of turning into someone else's 404 page.
A certificate from another namespace without a ReferenceGrant — the certs-test gateway with certificateRefs pointing to a Secret in certs:
$ kubectl -n traefik get gateway
NAME CLASS ADDRESS PROGRAMMED AGE
apps traefik 192.168.10.200 True 5m13s
certs-test traefik 192.168.10.200 False 4s
$ kubectl -n traefik get gateway certs-test -o jsonpath='{.status.listeners[0].conditions}' | jq -c '.[] | {type,status,reason,message}'
{"type":"ResolvedRefs","status":"False","reason":"RefNotPermitted","message":"Cannot reference CertificateRef certs/apps-example-com-tls: missing ReferenceGrant"}
{"type":"Programmed","status":"False","reason":"Invalid","message":"Invalid CertificateRefs"}
The gateway itself is then Accepted: False with reason ListenersNotValid — it has no valid listener. After kubectl apply of the ReferenceGrant from the Gateway API section the same conditions change within a few seconds:
$ kubectl -n traefik get gateway certs-test -o jsonpath='{.status.listeners[0].conditions}' | jq -c '.[] | {type,status,reason,message}'
{"type":"Accepted","status":"True","reason":"Accepted","message":"No error found"}
{"type":"ResolvedRefs","status":"True","reason":"ResolvedRefs","message":"No error found"}
{"type":"Programmed","status":"True","reason":"Programmed","message":"No error found"}
And a listener on a port that is not among Traefik's entry points — the port-test gateway with port: 80:
$ kubectl -n traefik get gateway port-test -o jsonpath='{.status.listeners[0].conditions}' | jq -c '.[] | {type,status,reason,message}'
{"type":"Accepted","status":"False","reason":"PortUnavailable","message":"Cannot find entryPoint for Gateway: no matching entryPoint for port 80 and protocol \"HTTP\""}
Ingress on the lab
For Ingress the status is poorer — a single address:
$ kubectl -n demo get ingress
NAME CLASS HOSTS ADDRESS PORTS AGE
hello traefik hello-ingress.apps.example.com 192.168.10.200 80, 443 5m5s
hello-redirect traefik hello-ingress.apps.example.com 192.168.10.200 80 60s
The first version of the hello Ingress from the section about the object — with tls but without Traefik annotations — answered on both ports: over HTTPS with its certificate, and over HTTP with the pod's ordinary answer:
$ curl -s -o /dev/null -w '%{http_code}\n' --resolve hello-ingress.apps.example.com:80:192.168.10.200 http://hello-ingress.apps.example.com/
200
With the annotations router.entrypoints: websecure, router.tls: "true" and the secure-headers and rate-limit middlewares, port 80 answers 404 for this name, and after the second Ingress hello-redirect — with a redirect. The code in the output is 308, not 301, because curl -I sends a HEAD request, and redirectScheme answers 301 only to a GET; a browser opening the page with a GET gets 301. The RequestRedirect filter above has its code set explicitly, independent of the method, so there even the HEAD got a 301:
$ curl -sI --resolve hello-ingress.apps.example.com:80:192.168.10.200 http://hello-ingress.apps.example.com/
HTTP/1.1 308 Permanent Redirect
Location: https://hello-ingress.apps.example.com/
Date: Thu, 29 Oct 2026 12:46:22 GMT
Content-Length: 18
$ curl -sI --resolve hello-ingress.apps.example.com:443:192.168.10.200 --cacert hello-ingress.crt https://hello-ingress.apps.example.com/
HTTP/2 200
content-type: text/plain; charset=utf-8
date: Thu, 29 Oct 2026 12:46:22 GMT
referrer-policy: strict-origin-when-cross-origin
strict-transport-security: max-age=31536000; includeSubDomains
x-app-version: 1.0
x-content-type-options: nosniff
x-frame-options: DENY
content-length: 34
Prefix as a string and for real. The prefix-test Ingress without ingressClassName, with one rule: host: prefix.apps.example.com, path: /health, pathType: Prefix. The admission plugin set its class:
$ kubectl -n demo get ingress prefix-test -o jsonpath='{.spec.ingressClassName}{"\n"}'
traefik
Requests to /health, /health/, /healthz, /healthy/x and /. The hello application itself answers 404 on /health, so we tell who answered from the log: a router name and a pod address mean the request reached the application, dashes mean Traefik answered itself. First Traefik without strictPrefixMatching:
$ kubectl -n traefik logs traefik-6bdf66f55f-cgqpw --tail=5
192.168.10.50 - - [29/Oct/2026:12:43:00 +0000] "GET /health HTTP/1.1" 404 19 "-" "-" 220 "demo-prefix-test-prefix-apps-example-com-health@kubernetes" "http://172.20.172.37:8080" 0ms
192.168.10.50 - - [29/Oct/2026:12:43:00 +0000] "GET /health/ HTTP/1.1" 404 19 "-" "-" 221 "demo-prefix-test-prefix-apps-example-com-health@kubernetes" "http://172.20.172.42:8080" 0ms
192.168.10.50 - - [29/Oct/2026:12:43:00 +0000] "GET /healthz HTTP/1.1" 200 3 "-" "-" 222 "demo-prefix-test-prefix-apps-example-com-health@kubernetes" "http://172.20.211.14:8080" 0ms
192.168.10.50 - - [29/Oct/2026:12:43:00 +0000] "GET /healthy/x HTTP/1.1" 404 19 "-" "-" 223 "demo-prefix-test-prefix-apps-example-com-health@kubernetes" "http://172.20.172.42:8080" 0ms
192.168.10.50 - - [29/Oct/2026:12:43:00 +0000] "GET / HTTP/1.1" 404 19 "-" "-" 224 "-" "-" 0ms
The /health rule took both /healthz (the application answered ok to it) and /healthy/x — the comparison is character by character. The same requests after enabling strictPrefixMatching: true:
$ kubectl -n traefik logs traefik-6b7fb4b765-w9l4s | grep 'GET /'
192.168.10.50 - - [29/Oct/2026:12:44:11 +0000] "GET /health HTTP/1.1" 404 19 "-" "-" 1 "demo-prefix-test-prefix-apps-example-com-health@kubernetes" "http://172.20.211.14:8080" 0ms
192.168.10.50 - - [29/Oct/2026:12:44:11 +0000] "GET /health/ HTTP/1.1" 404 19 "-" "-" 2 "demo-prefix-test-prefix-apps-example-com-health@kubernetes" "http://172.20.172.42:8080" 0ms
192.168.10.50 - - [29/Oct/2026:12:44:11 +0000] "GET /healthz HTTP/1.1" 404 19 "-" "-" 3 "-" "-" 0ms
192.168.10.50 - - [29/Oct/2026:12:44:11 +0000] "GET /healthy/x HTTP/1.1" 404 19 "-" "-" 4 "-" "-" 0ms
192.168.10.50 - - [29/Oct/2026:12:44:11 +0000] "GET / HTTP/1.1" 404 19 "-" "-" 5 "-" "-" 0ms
Now /healthz and /healthy/x do not belong to the rule, as in the table from the specification.
defaultBackend. The fallback Ingress without rules, with only spec.defaultBackend pointing to the hello service (port http):
$ kubectl -n demo get ingress fallback
NAME CLASS HOSTS ADDRESS PORTS AGE
fallback traefik * 192.168.10.200 80 3s
$ curl -s -w ' %{http_code}\n' --resolve nope.example.com:80:192.168.10.200 http://nope.example.com/
hello from hello-6d5c6db6b4-zwzsp
200
$ curl -s -w ' %{http_code}\n' --resolve prefix.apps.example.com:80:192.168.10.200 http://prefix.apps.example.com/
hello from hello-6d5c6db6b4-7tjjb
200
$ kubectl -n traefik logs traefik-6b7fb4b765-w9l4s --tail=4
192.168.10.50 - - [29/Oct/2026:12:44:32 +0000] "GET / HTTP/1.1" 200 34 "-" "-" 6 "default-router@kubernetes" "http://172.20.172.42:8080" 0ms
192.168.10.50 - - [29/Oct/2026:12:44:32 +0000] "GET / HTTP/2.0" 200 34 "-" "-" 7 "websecure-default-router@kubernetes" "http://172.20.211.14:8080" 0ms
192.168.10.50 - - [29/Oct/2026:12:44:32 +0000] "GET / HTTP/1.1" 200 34 "-" "-" 8 "default-router@kubernetes" "http://172.20.172.37:8080" 0ms
192.168.10.50 - - [29/Oct/2026:12:44:32 +0000] "GET / HTTP/1.1" 301 17 "-" "-" 9 "httproute-demo-hello-redirect-gw-traefik-apps-ep-web-0-997f60aee86fcd0c0434@kubernetesgateway" "-" 0ms
Traefik's fallback router (default-router on web and websecure-default-router on websecure) gets everything that matched no rule: a foreign name over both HTTP and HTTPS (the second line is the same nope.example.com through curl -k), and the / path, which is not in the prefix-test rules. Routes with an exact name match, like hello-redirect, are left alone. In a shared cluster such an Ingress takes all "nobody's" traffic, so we deleted it after the experiment.
An Ingress pointing to a missing service. The broken Ingress with a rule to the nope service:
$ kubectl -n demo describe ingress broken | sed -n '/Rules/,$p'
Rules:
Host Path Backends
---- ---- --------
broken.apps.example.com
/ nope:80 (<error: services "nope" not found>)
Annotations: <none>
Events: <none>
$ kubectl -n traefik logs traefik-6b7fb4b765-w9l4s | grep 'Cannot create service'
2026-10-29T12:44:43Z ERR Cannot create service error="service not found" ingress=broken namespace=demo providerName=kubernetes serviceName=nope servicePort=&ServiceBackendPort{Name:,Number:80,}
2026-10-29T12:44:43Z ERR Cannot create service error="service not found" ingress=broken namespace=demo providerName=kubernetes serviceName=nope servicePort=&ServiceBackendPort{Name:,Number:80,}
The error in describe was computed by kubectl itself when it looked up the service — there is nothing from the controller in the Ingress, and no events either. Traefik still set the address in status, and a request for broken.apps.example.com gets a 404: Traefik simply did not create the route. The reason can be found only in its log. The same log also has lines Error while updating ingress status … the object has been modified — both Traefik replicas write the status of the same Ingress, and one of them loses the race; this is harmless.
And finally — which configuration sources Traefik reads from the API. Each is a separate provider with its own watch, and the startup log shows which ones are running:
$ kubectl -n traefik logs traefik-6b7fb4b765-w9l4s | grep 'Starting provider'
2026-10-29T12:43:32Z INF Starting provider aggregator *aggregator.ProviderAggregator
2026-10-29T12:43:32Z INF Starting provider *traefik.Provider
2026-10-29T12:43:32Z INF Starting provider *crd.Provider
2026-10-29T12:43:32Z INF Starting provider *ingress.Provider
2026-10-29T12:43:32Z INF Starting provider *gateway.Provider
2026-10-29T12:43:32Z INF Starting provider *acme.ChallengeTLSALPN
*ingress.Provider reads Ingresses, *crd.Provider — Traefik CRDs, *gateway.Provider — Gateway API; *traefik.Provider is Traefik's own internal routes (dashboard, health check), and *acme.ChallengeTLSALPN is the handler of ACME challenges for when Traefik issues certificates itself.
Sources
- Kubernetes: Ingress — rules,
pathTypeand the matching table, hostname wildcards,defaultBackend, TLS, IngressClass and the default class, the API freeze. - Kubernetes: Ingress Controllers — the list of controllers and several controllers in one cluster.
- Kubernetes: Gateway API — the resource model and roles.
- Kubernetes: Admission Control — the
DefaultIngressClassplugin. - Ingress NGINX Retirement: What You Need to Know, Ingress NGINX: Statement from the Kubernetes Steering and Security Response Committees and Announcing Ingress2Gateway 1.0 — the retirement of ingress-nginx and the migration tool; kubernetes/ingress-nginx — the archived repository; ingress2gateway.
- Gateway API — the project; API Overview and Roles and Personas — resources and roles; Versioning — the Standard and Experimental channels; Security Model —
allowedRoutesand selecting namespaces by label. - Gateway API: Gateway, HTTPRoute, TLSRoute, ReferenceGrant and BackendTLSPolicy — the resource reference, status conditions, backend weights.
- Gateway API: HTTP redirects and rewrites, TLS and HTTP CORS — the
RequestRedirectfilter and codes, TLS modes, the CORS filter. - Gateway API: Migrating from Ingress and Migrating from Ingress-NGINX — moving manifests.
- Gateway API: Implementations and support for version 1.6 — implementations and their conformance; Gateway API v1.6.3 — the release and the
standard-install.yamlmanifest. - Traefik: Kubernetes Ingress provider —
strictPrefixMatching,allowEmptyServices, publishing the address instatus; Ingress routing configuration — thetraefik.ingress.kubernetes.io/router.*annotations. - Traefik: Kubernetes Gateway API provider — the supported version 1.6.3 and route kinds,
experimentalChannel; Gateway API routing configuration —ExtensionRefto a Middleware. - Traefik: Headers, RateLimit and RedirectScheme — the middlewares from the article; pkg/middlewares/redirect/redirect.go — how
redirectSchemeandredirectRegexchoose the redirect code: withpermanent: true301 for GET and 308 for other methods, without it 302 and 307 (the documentation does not spell this out); Logs and Access Logs — the access log format and field filters. - Traefik Helm Chart — the chart, its values and CRDs.
- RFC 6066 — SNI; RFC 7301 — ALPN; RFC 6797 — HSTS; RFC 9110 — HTTP semantics, codes 301, 302, 307 and 308; RFC 9113 — HTTP/2.
One address, one proxy, routes by name and path, a redirect to HTTPS and common policies — but the certificate is still self-signed and issued by hand for a month.
Next — cert-manager: how the cluster obtains certificates by itself from Let's Encrypt over the ACME protocol and from its own internal certificate authority, which objects appear in the API along the way, and how they are renewed without human involvement.
Only registered users can leave a comment.
Sign in or register to leave a comment.