Use error-pages with Traefik in Kubernetes
May 2, 2026 · View on GitHub
Important
I am not a Kubernetes expert. This guide is not a production-ready solution - it is a working starting point
and a cheat sheet for error-pages users. Everything described here has been tested and works, but every real
deployment is different: validate the configuration, review the security implications, and adapt it to your own
environment before using it in production.
Contributions and improvements are very welcome - feel free to open a PR and I will happily accept it.
This guide wires error-pages as the global error handler for Traefik when used as a Kubernetes Ingress controller.
When any backend returns a status code in the 400–599 range, Traefik's
errors middleware intercepts the response and forwards
it to error-pages, which returns a styled page in the format requested by the client.
The middleware is attached at the entrypoint level: a single configuration change automatically covers every
route on the web entrypoint, with no per-Ingress annotation required. Traefik also supports two narrower opt-in
approaches - see the alternatives below before starting if you prefer per-service wiring.
Alternative approaches
This guide uses the entrypoint approach - the closest equivalent to ingress-nginx's custom-http-errors, where a
single change applies to all backends at once. Traefik also supports two narrower alternatives that scope the
middleware to individual routes.
Per-Ingress annotation
Attach the middleware to a single standard Ingress resource via annotation. Only that specific route gets styled
error pages; everything else remains unaffected:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: httpbin
annotations:
# format: {middleware-namespace}-{middleware-name}@kubernetescrd
traefik.ingress.kubernetes.io/router.middlewares: "error-pages-error-pages@kubernetescrd"
spec:
ingressClassName: traefik
rules:
- host: httpbin.localtest.me
http:
paths:
- path: /
pathType: Prefix
backend: {service: {name: httpbin, port: {number: 80}}}
No helm upgrade for Traefik is required - Traefik picks up the annotation immediately. The trade-off is that every
Ingress that should use error-pages must explicitly include the annotation.
Note
Both alternatives reference a Middleware from the error-pages namespace within resources that live in a
different namespace. This requires providers.kubernetesCRD.allowCrossNamespace=true in Traefik - add
--set providers.kubernetesCRD.allowCrossNamespace=true to the initial helm upgrade --install traefik
command if you plan to use either approach.
See the Kubernetes Ingress routing reference for the full list of supported annotations.
IngressRoute CRD
Traefik's native IngressRoute CRD lets you reference a Middleware CRD directly in the route definition, without
annotations. This approach exposes more of Traefik's routing capabilities (priority, TCP/UDP routes, TLS options)
but requires authoring Traefik-specific CRDs instead of standard Kubernetes Ingress resources:
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: httpbin
namespace: default
spec:
entryPoints: [web]
routes:
- match: Host(`httpbin.localtest.me`)
kind: Rule
middlewares:
- name: error-pages
namespace: error-pages # cross-namespace ref; requires allowCrossNamespace: true on Traefik
services: [{name: httpbin, port: 80}]
The Middleware CRD created by the error-pages Helm chart (traefikMiddleware.enabled=true) works with both
approaches - no extra configuration of error-pages is required regardless of which routing style you choose.
See the IngressRoute CRD reference and the errors middleware reference for full details.
Local cluster setup
Follow the kind guide to install the prerequisites.
Install Traefik
Add the Helm repository and install Traefik as a DaemonSet with kind-compatible settings:
$ helm repo add traefik https://traefik.github.io/charts && helm repo update traefik
...Successfully got an update from the "traefik" chart repository
Update Complete. ⎈Happy Helming!⎈
Note
Get the latest chart version from the GitHub releases page.
$ helm upgrade --install traefik traefik/traefik \
--version 39.0.8 \
--namespace traefik \
--create-namespace \
--set deployment.kind=DaemonSet \
--set 'updateStrategy.rollingUpdate.maxSurge=0' \
--set 'updateStrategy.rollingUpdate.maxUnavailable=1' \
--set-string 'nodeSelector.ingress-ready=true' \
--set 'tolerations[0].key=node-role.kubernetes.io/control-plane' \
--set 'tolerations[0].operator=Exists' \
--set 'tolerations[0].effect=NoSchedule' \
--set 'ports.web.hostPort=80' \
--set 'ports.websecure.hostPort=443' \
--set service.type=NodePort \
--wait \
--timeout=90s
Release "traefik" does not exist. Installing it now.
NAME: traefik
LAST DEPLOYED: ...
NAMESPACE: traefik
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
A few kind-specific flags worth noting:
deployment.kind=DaemonSet- runs exactly one Traefik pod per node; in a single-node kind cluster, this means one pod total.hostPortbinding on a Deployment would also work, but a DaemonSet is the idiomatic choice when you want exactly one instance per node.updateStrategy.rollingUpdate.maxSurge=0/maxUnavailable=1- the Traefik chart defaults tomaxSurge=1, which tries to start a new pod before stopping the old one. In kind, both pods would compete for the same host port, and the new pod would get stuck inPending. SettingmaxSurge=0ensures the old pod is stopped first.ports.web.hostPort=80- binds port 80 directly on the kind node. Combined withextraPortMappings, this makes Traefik reachable onlocalhost:80.
Verify the DaemonSet pods are running:
$ kubectl get pods --namespace traefik
NAME READY STATUS RESTARTS AGE
traefik-8zvhf 1/1 Running 0 84s
Verify Traefik is reachable on port 80. There are no Ingress rules yet, so it returns its built-in 404:
$ curl -so /dev/null -w "%{http_code}" http://localhost/
404
$ curl -s http://localhost/
404 page not found
Deploy a test application
go-httpbin is a small HTTP testing service; its /status/{code} endpoint
returns any requested status code - handy for testing error page interception.
Save the following to httpbin.yaml:
# File: httpbin.yaml
apiVersion: apps/v1
kind: Deployment
metadata: {name: httpbin}
spec:
replicas: 1
selector: {matchLabels: {app: httpbin}}
template:
metadata: {labels: {app: httpbin}}
spec: {containers: [{name: httpbin, image: ghcr.io/mccutchen/go-httpbin:2.22, ports: [{containerPort: 8080}]}]}
---
apiVersion: v1
kind: Service
metadata: {name: httpbin}
spec:
selector: {app: httpbin}
ports: [{port: 80, targetPort: 8080}]
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata: {name: httpbin}
spec:
ingressClassName: traefik
rules:
- host: httpbin.localtest.me
http: {paths: [{path: /, pathType: Prefix, backend: {service: {name: httpbin, port: {number: 80}}}}]}
localtest.me is a public DNS wildcard: *.localtest.me always resolves to 127.0.0.1, so this works offline and
requires no /etc/hosts entry.
$ kubectl apply -f httpbin.yaml && kubectl rollout status deployment/httpbin --timeout=60s
deployment.apps/httpbin created
service/httpbin created
ingress.networking.k8s.io/httpbin created
deployment "httpbin" successfully rolled out
Verify the app responds normally:
$ curl -s http://httpbin.localtest.me/get
{
"args": {},
"headers": {
"Accept": [
"*/*"
],
"Host": [
"httpbin.localtest.me"
],
"User-Agent": [
"curl/8.11.1"
],
"...": ["..."]
},
"method": "GET",
"origin": "172.20.0.1",
"url": "http://httpbin.localtest.me/get"
}
Default error pages
Before error-pages is wired in, backend errors pass through as raw HTTP responses. go-httpbin's /status/{code}
returns the requested status code with an empty body - no message, no description:
$ curl -si http://httpbin.localtest.me/status/404
HTTP/1.1 404 Not Found
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *
Content-Length: 0
Content-Type: text/plain; charset=utf-8
Date: ...
$ curl -si http://httpbin.localtest.me/status/503
HTTP/1.1 503 Service Unavailable
Access-Control-Allow-Credentials: true
Access-Control-Allow-Origin: *
Content-Length: 0
Content-Type: text/plain; charset=utf-8
Date: ...
Requests that match no Ingress rule at all are served by Traefik's built-in 404 page:
$ curl -s http://unknown.localtest.me/ | head -n 5
404 page not found
🔥 Install error-pages
Install error-pages with traefikMiddleware.enabled=true - this creates a Middleware CRD in the error-pages
namespace that Traefik will use to intercept error responses:
Note
Get the latest chart version from ArtifactHub or the GitHub releases page.
$ helm install error-pages oci://ghcr.io/tarampampam/error-pages/charts/error-pages \
--version X.Y.Z \
--namespace error-pages \
--create-namespace \
--set config.htmlTemplate.name=l7 \
--set traefikMiddleware.enabled=true \
--wait \
--timeout=60s
NAME: error-pages
LAST DEPLOYED: ...
NAMESPACE: error-pages
STATUS: deployed
REVISION: 1
DESCRIPTION: Install complete
Verify the Middleware CRD was created:
$ kubectl get middleware --namespace error-pages
NAME AGE
error-pages 10s
Note
Unlike ingress-nginx, config.sendSameHttpCode=true is not required here. Traefik's errors middleware
preserves the original backend status code and only replaces the response body - it does not use the status
code returned by the error service.
Wire Traefik to error-pages
Attach the Middleware CRD to Traefik's web entrypoint as a static argument. Every route using this entrypoint
will then automatically have its 4xx/5xx responses intercepted and styled:
$ helm upgrade traefik traefik/traefik \
--version 39.0.8 \
--namespace traefik \
--reuse-values \
--set 'additionalArguments[0]=--entrypoints.web.http.middlewares=error-pages-error-pages@kubernetescrd' \
--wait \
--timeout=60s
Release "traefik" has been upgraded. Happy Helming!
NAME: traefik
LAST DEPLOYED: ...
NAMESPACE: traefik
STATUS: deployed
REVISION: 2
DESCRIPTION: Upgrade complete
The middleware identifier format is {namespace}-{name}@kubernetescrd: namespace error-pages, name
error-pages, provider kubernetescrd.
Verify custom error pages
The same requests that previously returned empty responses now return styled error pages:
$ curl -s -H "Accept: text/html" http://httpbin.localtest.me/status/404 | head -n 15
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="robots" content="nofollow,noarchive,noindex">
<title>Not Found</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- -->
<meta name="title" content="404: Not Found">
<meta name="description" content="The server can not find the requested page">
<meta property="og:title" content="404: Not Found">
<meta property="og:description" content="The server can not find the requested page">
<meta property="twitter:title" content="404: Not Found">
<meta property="twitter:description" content="The server can not find the requested page">
<style>
$ curl -s -H "Accept: application/json" http://httpbin.localtest.me/status/404
{
"error": true,
"code": 404,
"message": "Not Found",
"description": "The server can not find the requested page"
}
$ curl -s -H "Accept: application/xml" http://httpbin.localtest.me/status/503
<?xml version="1.0" encoding="utf-8"?>
<error>
<code>503</code>
<message>Service Unavailable</message>
<description>The server is temporarily overloading or down</description>
</error>
The middleware applies to every matched backend on the web entrypoint - any service deployed with
ingressClassName: traefik gets styled error pages with no additional configuration.
Requests that do not match any Ingress rule are handled differently. The errors middleware only runs when a matched route calls a backend that returns 4xx/5xx. When no route matches, Traefik handles the request internally and returns its own plain-text response - the middleware is never part of the processing chain:
$ curl -s http://unknown.localtest.me/
404 page not found
To cover unmatched hostnames as well, add a catch-all IngressRoute that forwards any request not claimed by a
specific Ingress directly to error-pages at very low priority:
# File: catch-all.yaml
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: error-pages-catch-all
namespace: error-pages
spec:
entryPoints: [web]
routes:
- match: HostRegexp(`.+`)
kind: Rule
priority: 1
services: [{name: error-pages, port: 8080}]
$ kubectl apply -f catch-all.yaml
ingressroute.traefik.io/error-pages-catch-all created
HostRegexp(.+) matches any hostname. The explicit priority: 1 ensures that specific Ingress rules always take
precedence - this route is only used when nothing else matches. Because the IngressRoute and the error-pages
service are in the same namespace, no allowCrossNamespace setting is required.
The catch-all routes requests directly to error-pages as a backend rather than through the errors middleware, so
set config.sendSameHttpCode=true on the error-pages release to return the correct HTTP status code instead of
200 OK:
$ helm upgrade error-pages oci://ghcr.io/tarampampam/error-pages/charts/error-pages \
--version X.Y.Z \
--namespace error-pages \
--reuse-values \
--set config.sendSameHttpCode=true
Unmatched hostnames now return a styled error page instead of Traefik's default fallback:
$ curl -s -H "Accept: application/json" http://unknown.localtest.me/
{
"error": true,
"code": 404,
"message": "Not Found",
"description": "The server can not find the requested page"
}
$ curl -so /dev/null -w "%{http_code}" http://unknown.localtest.me/
404
Cleanup
Delete the kind cluster to remove all resources at once:
kind delete cluster --name error-pages-test-cluster