LUNAROPS · OPERATIONAL UPLINK 100% UPTIME 1,247d POSTS 893 JEFF.MOON@LUNAROPS.DEV UTC --:--:--

Traefik as a Kubernetes Ingress Controller: The Complete Guide

traefikkubernetesingressgateway-apicert-managertlsmiddlewaredevops
Contents

The Traefik complete guide covers Docker integration, Let’s Encrypt, the middleware ecosystem, and touches on Kubernetes. This post goes much deeper on the Kubernetes-specific story. It assumes you are already comfortable with Kubernetes fundamentals — pods, services, namespaces, RBAC, Helm — and want to understand how to run Traefik in a cluster properly, at production quality.

By the end you will have a clear mental model of how Traefik integrates with the Kubernetes API, why its CRDs are more expressive than standard Ingress resources, how to handle TLS with cert-manager, how to lock down RBAC, how to run multiple replicas without certificate-storage conflicts, and how to wire up real observability.

A full working production example ties everything together in the final section.


Part 1: Why Traefik for Kubernetes Ingress?

The ingress controller landscape

Several controllers compete for the same role. Choosing well saves significant operational pain later.

nginx-ingress (ingress-nginx) is the incumbent. It is battle-tested, has the largest community, and every Kubernetes tutorial reaches for it by default. Its configuration surface is wide — you can tune almost anything through annotations — but those annotations are stringly-typed, not validated by the API server, and invisible to kubectl explain. Reloading nginx requires restarting worker processes; under heavy load this causes a brief traffic disruption. Complex routing logic (header-based splits, traffic weights) requires NGINX Plus or community hacks.

HAProxy Ingress delivers raw performance that matters at very high request rates. Configuration is annotation-driven like nginx-ingress, with the same ergonomic downsides. It is an excellent choice when throughput is the dominant constraint and you want a familiar proxy underneath.

Contour pairs with Envoy and introduced its own CRD — HTTPProxy — that solves the annotation problem with real API objects. It has solid multi-team namespace isolation through delegation. The trade-off is that you now have two components to manage (Contour + Envoy DaemonSet/Deployment).

Envoy Gateway is the most ambitious entrant: a full implementation of the Kubernetes Gateway API backed by Envoy. It is production-ready as of v1.0 and will likely become the canonical implementation of the Gateway API spec. If you are starting a new cluster today and want to be on the standards track, Envoy Gateway deserves serious consideration.

Traefik sits in a different place from all of these. It was built for dynamic environments from the ground up. Its key differentiators for Kubernetes:

Feature Traefik nginx-ingress Contour Envoy Gateway
Live reconfiguration Yes, zero reload Partial (Lua, but limited) Yes Yes
CRDs for routing IngressRoute (rich) Annotations only HTTPProxy Gateway API
TCP routing via CRD Yes (IngressRouteTCP) Limited (SNI passthrough) No TCPRoute (alpha)
UDP routing via CRD Yes (IngressRouteUDP) No No No
Middleware as first-class objects Yes (Middleware CRD) Annotations No Policy attachments
Built-in dashboard Yes No No No
ACME built-in Yes No No No
Weighted traffic splitting Yes nginx-ingress-plus / canary annotations Yes Yes
Gateway API support Experimental/v3 v1.0 (ingress-nginx v4+) v1.0 v1.0

Where Traefik wins

Live reconfiguration without disruption. Traefik watches the Kubernetes API server and updates its routing table in memory. Adding a new IngressRoute is instantaneous — no process restart, no dropped connections. This matters for high-volume clusters where nginx’s reload-on-change causes measurable tail latency spikes.

CRDs as the configuration surface. Instead of opaque nginx.ingress.kubernetes.io/... annotations scattered across dozens of Ingress resources, Traefik configuration lives in versioned, validatable CRD objects. kubectl explain ingressroute.spec works. Admission webhooks can validate them. GitOps tools treat them as first-class resources.

Middleware reuse. Define a Middleware resource once (e.g., an rateLimit policy or a forwardAuth configuration pointing at Authentik) and reference it by name from any number of IngressRoute objects — even across namespaces with cross-namespace references.

TCP and UDP routing. Traefik can proxy raw TCP and UDP traffic through the same binary, the same Helm release, the same LoadBalancer IP. Routing a PostgreSQL port and a DNS port alongside HTTP routes is a first-class workflow. Other controllers require separate infrastructure for this.

The dashboard. It is a real-time view of every router, service, middleware, and TLS certificate in the cluster. Invaluable for debugging routing problems.

Where nginx-ingress still wins

Honest assessment: nginx-ingress has a larger ecosystem of battle-tested annotations, a bigger community, and more production case studies at very large scale. If you are joining an existing cluster that runs nginx-ingress and your team knows it well, the switching cost is rarely justified. nginx-ingress also has more mature support for some edge cases in the Gateway API implementation as of early 2026.

The Gateway API question

The Kubernetes Gateway API (gateway.networking.k8s.io) is the community’s answer to the fragmentation of controller-specific annotations and CRDs. It provides standard, portable resource types: GatewayClass, Gateway, HTTPRoute, TCPRoute, TLSRoute, UDPRoute, GRPCRoute.

Traefik v3 ships experimental Gateway API support. It covers HTTPRoute (v1, GA status in the spec) well. TCPRoute and UDPRoute remain alpha in both the spec and Traefik’s implementation.

The pragmatic position for 2026: use Traefik’s native IngressRoute CRDs for everything except cases where portability across controllers is a hard requirement. IngressRoute is more expressive today. If you are building infrastructure for an organization that wants to avoid controller lock-in and is willing to live at the cutting edge, use the Gateway API — but test carefully.

Both approaches are covered in this guide.


Part 2: Installation with Helm

Adding the Helm repository

1
2
helm repo add traefik https://traefik.github.io/charts
helm repo update

Anatomy of a production values.yaml

The official traefik/traefik chart is well-structured. A production values.yaml needs to address: replica count, resource limits, entrypoints, persistence for ACME state, the LoadBalancer service, RBAC, and observability hooks.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# values.yaml — production Traefik Helm configuration
# Chart: traefik/traefik  v32.x (Traefik v3.x)

# ─── Deployment ────────────────────────────────────────────────────────────────
deployment:
  replicas: 2               # see Part 9 for HA considerations
  podAnnotations:
    prometheus.io/scrape: "true"
    prometheus.io/port: "9100"

# ─── Resource limits ───────────────────────────────────────────────────────────
resources:
  requests:
    cpu: 100m
    memory: 128Mi
  limits:
    cpu: 500m
    memory: 512Mi

# ─── Entrypoints ───────────────────────────────────────────────────────────────
ports:
  web:
    port: 8000            # internal container port
    expose:
      default: true
    exposedPort: 80       # port on the LoadBalancer
    protocol: TCP
    redirectTo:
      port: websecure     # global HTTP→HTTPS redirect
  websecure:
    port: 8443
    expose:
      default: true
    exposedPort: 443
    protocol: TCP
    tls:
      enabled: true
  # Custom TCP entrypoint for PostgreSQL passthrough
  postgres:
    port: 5432
    expose:
      default: true
    exposedPort: 5432
    protocol: TCP
  # Custom UDP entrypoint for DNS
  dns-udp:
    port: 5353
    expose:
      default: true
    exposedPort: 53
    protocol: UDP
  # Traefik metrics endpoint (internal only, not exposed via LoadBalancer)
  metrics:
    port: 9100
    expose:
      default: false

# ─── Service (LoadBalancer) ─────────────────────────────────────────────────────
service:
  type: LoadBalancer
  annotations:
    # AWS NLB example — swap for your cloud provider's annotation
    service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
    service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true"
  spec:
    externalTrafficPolicy: Local   # preserves client source IP

# Use NodePort instead for bare-metal / MetalLB:
# service:
#   type: NodePort

# ─── RBAC ──────────────────────────────────────────────────────────────────────
rbac:
  enabled: true
  # namespaced: false → ClusterRole (can watch all namespaces)
  # namespaced: true  → Role per namespace (more restrictive)
  namespaced: false

serviceAccount:
  name: traefik

# ─── Persistence for built-in ACME ─────────────────────────────────────────────
# Only needed when using Traefik's built-in ACME (not cert-manager).
# With cert-manager this section can be omitted.
persistence:
  enabled: true
  storageClass: "standard"
  accessMode: ReadWriteOnce
  size: 128Mi
  path: /data

# Disable built-in ACME when using cert-manager (recommended):
# certResolvers: {}

# ─── Providers ─────────────────────────────────────────────────────────────────
providers:
  kubernetesCRD:
    enabled: true
    allowCrossNamespace: true       # required for cross-namespace middleware refs
    allowExternalNameServices: true
  kubernetesIngress:
    enabled: true                   # keep enabled for legacy Ingress resources
    allowExternalNameServices: true
    publishedService:
      enabled: true                 # writes LoadBalancer IP back to Ingress status

# ─── Logs ──────────────────────────────────────────────────────────────────────
logs:
  general:
    level: INFO
  access:
    enabled: true
    format: json
    fields:
      general:
        defaultMode: keep
      headers:
        defaultMode: drop           # drop all headers by default
        names:
          User-Agent: keep
          X-Forwarded-For: keep
          X-Request-Id: keep

# ─── Metrics ───────────────────────────────────────────────────────────────────
metrics:
  prometheus:
    entryPoint: metrics
    addEntryPointsLabels: true
    addRoutersLabels: true
    addServicesLabels: true

# ─── Tracing ───────────────────────────────────────────────────────────────────
tracing:
  otlp:
    grpc:
      endpoint: "otel-collector.observability.svc.cluster.local:4317"
      insecure: true

# ─── Dashboard ─────────────────────────────────────────────────────────────────
ingressRoute:
  dashboard:
    enabled: false    # we create our own IngressRoute with auth (see Part 10)

# ─── API & Dashboard internal activation ───────────────────────────────────────
api:
  dashboard: true
  insecure: false   # never expose the API without auth

# ─── Global arguments ──────────────────────────────────────────────────────────
globalArguments:
  - "--global.checknewversion=false"
  - "--global.sendanonymoususage=false"

# ─── Additional arguments ──────────────────────────────────────────────────────
additionalArguments:
  - "--api.dashboard=true"
  - "--serversTransport.insecureSkipVerify=false"

# ─── Pod anti-affinity (see Part 9) ────────────────────────────────────────────
affinity:
  podAntiAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchExpressions:
            - key: app.kubernetes.io/name
              operator: In
              values:
                - traefik
        topologyKey: kubernetes.io/hostname

# ─── PodDisruptionBudget ───────────────────────────────────────────────────────
podDisruptionBudget:
  enabled: true
  minAvailable: 1

Installing into a dedicated namespace

1
2
3
4
5
6
7
kubectl create namespace traefik

helm upgrade --install traefik traefik/traefik \
  --namespace traefik \
  --values values.yaml \
  --version "32.1.0" \
  --wait

Verify:

1
2
3
kubectl -n traefik get pods
kubectl -n traefik get svc
# The LoadBalancer service should have an EXTERNAL-IP assigned within a minute

GitOps with Flux HelmRelease

If you manage your cluster with Flux, declare Traefik as a HelmRelease:

1
2
3
4
5
6
7
# infrastructure/traefik/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: traefik
  labels:
    pod-security.kubernetes.io/enforce: privileged  # Traefik needs hostPort or privileged for port < 1024 in some setups
1
2
3
4
5
6
7
8
9
# infrastructure/traefik/helmrepository.yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
  name: traefik
  namespace: flux-system
spec:
  interval: 1h
  url: https://traefik.github.io/charts
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# infrastructure/traefik/helmrelease.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: traefik
  namespace: traefik
spec:
  interval: 30m
  chart:
    spec:
      chart: traefik
      version: "32.x"
      sourceRef:
        kind: HelmRepository
        name: traefik
        namespace: flux-system
      interval: 12h
  install:
    remediation:
      retries: 3
  upgrade:
    cleanupOnFail: true
    remediation:
      strategy: rollback
      retries: 3
  values:
    deployment:
      replicas: 2
    # ... rest of values inline, or use valuesFrom:
  valuesFrom:
    - kind: ConfigMap
      name: traefik-values
      valuesKey: values.yaml
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# infrastructure/traefik/values-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: traefik-values
  namespace: traefik
data:
  values.yaml: |
    # paste production values.yaml content here
    deployment:
      replicas: 2
    resources:
      requests:
        cpu: 100m
        memory: 128Mi
      limits:
        cpu: 500m
        memory: 512Mi

Part 3: The Two Routing APIs

Traefik in Kubernetes supports two parallel routing mechanisms. Understanding the difference and when to use each avoids confusion when reading cluster configurations.

Kubernetes Ingress (classic)

The standard networking.k8s.io/v1 Ingress resource is supported by all ingress controllers. It handles the common case: HTTP/HTTPS routing by hostname and path prefix.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: my-app
  namespace: my-app
  annotations:
    # Traefik-specific behavior via annotations
    traefik.ingress.kubernetes.io/router.middlewares: my-app-secure-headers@kubernetescrd
    traefik.ingress.kubernetes.io/router.tls: "true"
spec:
  ingressClassName: traefik
  tls:
    - hosts:
        - app.example.com
      secretName: app-example-com-tls
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: my-app-svc
                port:
                  number: 8080

Limitations of Ingress:

  • Path matching is limited to Exact and Prefix (no regex, no header matching)
  • Middleware configuration requires annotations (stringly-typed, not validated)
  • No TCP or UDP routing
  • No traffic splitting for canary (requires separate Ingress resources with weights via annotations)
  • No native cross-namespace service references

The IngressRoute CRD is Traefik’s native routing API. It expresses everything the Ingress resource can, and much more:

  • Full rule language: Host(), PathPrefix(), Path(), Headers(), Query(), Method(), all composable with && and ||
  • Middleware references are typed, validated objects
  • TLS configuration inline
  • Traffic weights for canary
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: my-app
  namespace: my-app
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`app.example.com`)
      kind: Rule
      services:
        - name: my-app-svc
          port: 8080
      middlewares:
        - name: secure-headers
          namespace: my-app
  tls:
    secretName: app-example-com-tls

When to use which

Use Ingress when:

  • You are migrating an existing cluster incrementally and want to minimize changes
  • Tools in your stack generate Ingress resources and do not support custom CRDs (e.g., some Helm charts)
  • You need to remain portable across ingress controllers without customization

Use IngressRoute when:

  • You control the routing configuration directly
  • You need header-based routing, traffic splitting, TCP/UDP routing, or complex middleware chains
  • You want the configuration validated by the Kubernetes API server

Migrating from Ingress to IngressRoute

The migration is straightforward — map each Ingress rule to an IngressRoute route, convert annotations to Middleware resources, update TLS references, then delete the old Ingress. Run both in parallel during migration by giving them different names; Traefik applies all routing rules simultaneously.

1
2
3
4
5
# Verify both route to the same backend before deleting the Ingress
kubectl -n my-app get ingress,ingressroute

# Once IngressRoute is confirmed working:
kubectl -n my-app delete ingress my-app

Part 4: IngressRoute CRDs In Depth

IngressRoute — HTTP and HTTPS

The core routing object for HTTP traffic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: api-gateway
  namespace: api
spec:
  entryPoints:
    - websecure
  routes:
    # Route by hostname
    - match: Host(`api.example.com`)
      kind: Rule
      priority: 10
      services:
        - name: api-svc
          port: 8080
      middlewares:
        - name: api-rate-limit
          namespace: api
        - name: secure-headers
          namespace: shared-middleware

    # Route by hostname AND path prefix
    - match: Host(`api.example.com`) && PathPrefix(`/v2`)
      kind: Rule
      priority: 20          # higher priority wins when rules overlap
      services:
        - name: api-v2-svc
          port: 8080
      middlewares:
        - name: api-rate-limit
          namespace: api

    # Route by header — useful for internal admin endpoints
    - match: Host(`api.example.com`) && Headers(`X-Internal`, `true`)
      kind: Rule
      priority: 30
      services:
        - name: api-admin-svc
          port: 9090
      middlewares:
        - name: admin-ip-allowlist
          namespace: api

    # Weighted canary split — 90% stable, 10% canary
    - match: Host(`api.example.com`) && PathPrefix(`/feature-x`)
      kind: Rule
      services:
        - name: api-svc
          port: 8080
          weight: 90
        - name: api-canary-svc
          port: 8080
          weight: 10

  tls:
    secretName: api-example-com-tls    # cert-manager-managed Secret

IngressRouteTCP — TCP Routing

IngressRouteTCP routes raw TCP connections. The primary matching mechanism is SNI (Server Name Indication), which Traefik reads from the TLS ClientHello without terminating TLS.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
  name: postgres-route
  namespace: databases
spec:
  entryPoints:
    - postgres    # the custom TCP entrypoint defined in values.yaml
  routes:
    # SNI-based routing — the client connects with TLS and indicates the hostname
    - match: HostSNI(`db.example.com`)
      services:
        - name: postgresql-svc
          port: 5432
      # TLS passthrough — Traefik does NOT terminate TLS, passes it to the backend
  tls:
    passthrough: true

TCP routing without TLS (no SNI available — Traefik must use the entrypoint alone):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
  name: redis-route
  namespace: databases
spec:
  entryPoints:
    - redis-port   # dedicated entrypoint on port 6379
  routes:
    - match: HostSNI(`*`)   # match all connections on this entrypoint
      services:
        - name: redis-svc
          port: 6379

IngressRouteUDP — UDP Routing

UDP has no connection concept, so matching is purely by entrypoint. Each UDP entrypoint routes to exactly one service.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
apiVersion: traefik.io/v1alpha1
kind: IngressRouteUDP
metadata:
  name: dns-route
  namespace: dns
spec:
  entryPoints:
    - dns-udp     # the UDP entrypoint defined in values.yaml
  routes:
    - services:
        - name: coredns-external-svc
          port: 53

Game server UDP example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
apiVersion: traefik.io/v1alpha1
kind: IngressRouteUDP
metadata:
  name: game-server-udp
  namespace: game
spec:
  entryPoints:
    - game-udp    # custom UDP entrypoint on port 7777
  routes:
    - services:
        - name: game-server-svc
          port: 7777

TLSOption — Cipher Suites and TLS Versions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
  name: modern-tls
  namespace: traefik
spec:
  minVersion: VersionTLS12
  maxVersion: VersionTLS13
  cipherSuites:
    # TLS 1.2 ciphers (TLS 1.3 ciphers are fixed, not configurable)
    - TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
    - TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
    - TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
    - TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
    - TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
    - TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256
  curvePreferences:
    - CurveP521
    - CurveP384
  sniStrict: true    # reject connections with no SNI or wrong SNI

Reference from an IngressRoute:

1
2
3
4
5
6
spec:
  tls:
    secretName: my-cert-tls
    options:
      name: modern-tls
      namespace: traefik

For mTLS (client certificate authentication):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
apiVersion: traefik.io/v1alpha1
kind: TLSOption
metadata:
  name: mtls-required
  namespace: traefik
spec:
  minVersion: VersionTLS12
  clientAuth:
    secretNames:
      - client-ca-cert    # Secret containing the CA cert that signed client certs
    clientAuthType: RequireAndVerifyClientCert

TLSStore — Default Certificates

The TLSStore named default in any namespace sets the default certificate returned when no SNI-matched certificate exists:

1
2
3
4
5
6
7
8
apiVersion: traefik.io/v1alpha1
kind: TLSStore
metadata:
  name: default
  namespace: traefik
spec:
  defaultCertificate:
    secretName: wildcard-example-com-tls   # *.example.com wildcard cert

ServersTransport — Backend mTLS

ServersTransport configures how Traefik connects to backend services — useful when backends require mTLS or use self-signed certificates:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
apiVersion: traefik.io/v1alpha1
kind: ServersTransport
metadata:
  name: backend-mtls
  namespace: my-app
spec:
  serverName: "backend.internal"
  # Present a client certificate to the backend
  certificates:
    - secretName: traefik-client-cert   # Secret with tls.crt and tls.key
  # Trust a specific CA for the backend's certificate
  rootCAsSecrets:
    - backend-ca-cert
  insecureSkipVerify: false
  # Connection pool settings
  maxIdleConnsPerHost: 200
  dialTimeout: 5s
  responseHeaderTimeout: 30s

Reference from an IngressRoute service:

1
2
3
4
5
6
7
spec:
  routes:
    - match: Host(`secure-app.example.com`)
      services:
        - name: secure-backend-svc
          port: 8443
          serversTransport: backend-mtls    # references ServersTransport name

Part 5: Middleware CRDs

Middleware resources are the operational heart of Traefik. Define them once, reuse them everywhere.

basicAuth

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# First, create the Secret with htpasswd-encoded credentials
# Generate with: htpasswd -nB admin | kubectl create secret generic basic-auth-secret \
#   --from-file=users=/dev/stdin -n traefik
apiVersion: v1
kind: Secret
metadata:
  name: basic-auth-secret
  namespace: traefik
type: Opaque
stringData:
  users: |
    admin:$2y$10$WdOA4BYkuVMDieGL6OteneORi7uEP5lPgB8OJZm0z3GCvIoMqQ7Ly
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: basic-auth
  namespace: traefik
spec:
  basicAuth:
    secret: basic-auth-secret
    realm: "Restricted Area"
    removeHeader: true    # strip Authorization header before forwarding to backend

forwardAuth — Delegating Authentication to Authelia or Authentik

forwardAuth is the key middleware for single sign-on integration. Traefik forwards every request to an external authentication service. If the auth service returns 2xx, the request proceeds; otherwise the response from the auth service (typically a redirect to the login page) is returned to the client.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# ForwardAuth pointing at Authentik
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: authentik-forward-auth
  namespace: traefik
spec:
  forwardAuth:
    address: "http://authentik-server.authentik.svc.cluster.local/outpost.goauthentik.io/auth/traefik"
    trustForwardHeader: true
    authResponseHeaders:
      # Headers the auth service sends back that should be forwarded to the backend
      - X-authentik-username
      - X-authentik-groups
      - X-authentik-email
      - X-authentik-name
      - X-authentik-uid
      - X-authentik-jwt
      - Authorization
    authRequestHeaders:
      # Headers from the original request to include in the auth request
      - "Accept"
      - "Cookie"
      - "X-Forwarded-For"
      - "X-Forwarded-Host"
      - "X-Forwarded-Proto"
      - "X-Real-Ip"
      - "X-Request-Id"
    # TLS config for the auth service if it uses HTTPS
    tls:
      insecureSkipVerify: false
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# ForwardAuth pointing at Authelia
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: authelia-forward-auth
  namespace: traefik
spec:
  forwardAuth:
    address: "http://authelia.authelia.svc.cluster.local:9091/api/verify?rd=https://auth.example.com"
    trustForwardHeader: true
    authResponseHeaders:
      - Remote-User
      - Remote-Groups
      - Remote-Name
      - Remote-Email

rateLimit

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: api-rate-limit
  namespace: api
spec:
  rateLimit:
    average: 100        # requests per period
    period: 1m          # period duration
    burst: 200          # burst allowance above average
    sourceCriterion:
      ipStrategy:
        depth: 1        # use client IP (1 = direct IP, 2 = first X-Forwarded-For entry)
        excludedIPs:
          - "10.0.0.0/8"
          - "172.16.0.0/12"
          - "192.168.0.0/16"

Per-user rate limiting using a header:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: per-user-rate-limit
  namespace: api
spec:
  rateLimit:
    average: 60
    period: 1m
    burst: 100
    sourceCriterion:
      requestHeaderName: "X-User-ID"   # rate limit per authenticated user identity

redirectScheme — HTTP to HTTPS

1
2
3
4
5
6
7
8
9
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: redirect-to-https
  namespace: traefik
spec:
  redirectScheme:
    scheme: https
    permanent: true    # 301 redirect

Apply this to the HTTP (web) entrypoint’s IngressRoute to force HTTPS globally. Alternatively, the entrypoint-level redirect in values.yaml handles this without needing a Middleware at all — prefer the entrypoint-level approach for cluster-wide enforcement.

headers — Security Headers and CORS

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: secure-headers
  namespace: traefik
spec:
  headers:
    # HSTS — tell browsers to always use HTTPS
    stsSeconds: 63072000        # 2 years
    stsIncludeSubdomains: true
    stsPreload: true
    forceSTSHeader: true

    # Content security
    contentTypeNosniff: true
    browserXssFilter: true
    referrerPolicy: "strict-origin-when-cross-origin"
    permissionsPolicy: "camera=(), microphone=(), geolocation=(), payment=()"

    # Frame options — prevent clickjacking
    frameDeny: true
    # OR: customFrameOptionsValue: "SAMEORIGIN"

    # Custom headers to add
    customResponseHeaders:
      X-Robots-Tag: "noindex, nofollow"    # for internal apps
      X-Content-Type-Options: "nosniff"

    # Remove headers that leak server info
    customRequestHeaders:
      X-Powered-By: ""          # removes the header
      Server: ""

    # CORS configuration
    accessControlAllowMethods:
      - GET
      - POST
      - PUT
      - DELETE
      - OPTIONS
    accessControlAllowHeaders:
      - "Content-Type"
      - "Authorization"
      - "X-Request-Id"
    accessControlAllowOriginList:
      - "https://app.example.com"
      - "https://admin.example.com"
    accessControlMaxAge: 3600
    addVaryHeader: true

stripPrefix and addPrefix

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# Strip /api/v1 from the path before forwarding to the backend
# Client requests /api/v1/users → backend receives /users
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: strip-api-prefix
  namespace: api
spec:
  stripPrefix:
    prefixes:
      - /api/v1
      - /api/v2
    forceSlash: true    # ensure a leading / after stripping
1
2
3
4
5
6
7
8
9
# Add a prefix — useful when an internal service expects a specific path root
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: add-app-prefix
  namespace: my-app
spec:
  addPrefix:
    prefix: /app

retry

1
2
3
4
5
6
7
8
9
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: retry-policy
  namespace: my-app
spec:
  retry:
    attempts: 3
    initialInterval: 100ms   # wait before first retry; doubles each attempt

circuitBreaker

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: circuit-breaker
  namespace: my-app
spec:
  circuitBreaker:
    # Open the circuit when error rate exceeds 50% or latency exceeds 1s at p99
    expression: "ResponseCodeRatio(500, 600, 0, 600) > 0.50 || LatencyAtQuantileMS(99.0) > 1000"
    checkPeriod: 10s          # how often to evaluate the expression
    fallbackDuration: 30s     # how long to keep the circuit open
    recoveryDuration: 10s     # how long the half-open state lasts

ipAllowList

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: internal-only
  namespace: traefik
spec:
  ipAllowList:
    sourceRange:
      - "10.0.0.0/8"
      - "172.16.0.0/12"
      - "192.168.0.0/16"
    ipStrategy:
      depth: 1

Reusing Middleware Across Namespaces

When allowCrossNamespace: true is set in the provider configuration, you can reference middleware from any namespace using the namespace field:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# In namespace "app-team-a"
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: team-a-app
  namespace: app-team-a
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`team-a.example.com`)
      kind: Rule
      services:
        - name: team-a-svc
          port: 8080
      middlewares:
        - name: secure-headers          # defined in traefik namespace
          namespace: traefik
        - name: authentik-forward-auth  # defined in traefik namespace
          namespace: traefik
        - name: team-a-rate-limit       # defined locally
          namespace: app-team-a
  tls:
    secretName: team-a-example-com-tls

Part 6: TLS in Kubernetes

Running Traefik’s built-in ACME solver works well for single-replica deployments. For multi-replica HA deployments, the built-in ACME needs a shared storage backend (covered in Part 9). The simpler and more flexible solution is to delegate all certificate lifecycle management to cert-manager.

With cert-manager, Traefik becomes a pure proxy that consumes TLS secrets. cert-manager handles provisioning, renewal, and storage.

Install cert-manager:

1
2
3
4
5
6
7
8
helm repo add jetstack https://charts.jetstack.io
helm repo update

helm upgrade --install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true \
  --version "v1.17.x"

ClusterIssuers

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# ACME HTTP-01 challenge via Let's Encrypt production
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
      - http01:
          ingress:
            ingressClassName: traefik
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Staging issuer for testing (avoids rate limits)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: admin@example.com
    privateKeySecretRef:
      name: letsencrypt-staging-account-key
    solvers:
      - http01:
          ingress:
            ingressClassName: traefik

Certificate Resources

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Per-domain certificate
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: app-example-com
  namespace: my-app
spec:
  secretName: app-example-com-tls   # this Secret is what IngressRoute references
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  commonName: app.example.com
  dnsNames:
    - app.example.com
  duration: 2160h      # 90 days
  renewBefore: 360h    # renew 15 days before expiry

Wildcard Certificates with DNS-01

HTTP-01 challenges cannot issue wildcard certificates. For *.example.com you need DNS-01, which requires your DNS provider to have an API. cert-manager supports AWS Route 53, Cloudflare, Google Cloud DNS, Azure DNS, and many others.

1
2
3
4
5
6
7
8
9
# Cloudflare DNS-01 ClusterIssuer
apiVersion: v1
kind: Secret
metadata:
  name: cloudflare-api-token
  namespace: cert-manager
type: Opaque
stringData:
  api-token: "your-cloudflare-api-token-here"
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-dns-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@example.com
    privateKeySecretRef:
      name: letsencrypt-dns-prod-account-key
    solvers:
      - dns01:
          cloudflare:
            apiTokenSecretRef:
              name: cloudflare-api-token
              key: api-token
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# Wildcard certificate
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: wildcard-example-com
  namespace: traefik    # in traefik namespace for use as default cert
spec:
  secretName: wildcard-example-com-tls
  issuerRef:
    name: letsencrypt-dns-prod
    kind: ClusterIssuer
  commonName: "*.example.com"
  dnsNames:
    - "*.example.com"
    - "example.com"           # also cover the apex
  duration: 2160h
  renewBefore: 360h

Reference this as the default certificate in a TLSStore:

1
2
3
4
5
6
7
8
apiVersion: traefik.io/v1alpha1
kind: TLSStore
metadata:
  name: default
  namespace: traefik
spec:
  defaultCertificate:
    secretName: wildcard-example-com-tls

With the wildcard as the default certificate, any IngressRoute using websecure without specifying a secretName will automatically use the wildcard. This simplifies configuration dramatically.

Using Traefik’s Built-in ACME (single replica only)

When you do not want to install cert-manager and only run one Traefik replica:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# In values.yaml
certResolvers:
  letsencrypt:
    email: admin@example.com
    storage: /data/acme.json    # requires persistence.enabled: true
    httpChallenge:
      entryPoint: web
    # OR for DNS-01:
    # dnsChallenge:
    #   provider: cloudflare
    #   delayBeforeCheck: 0
    #   resolvers:
    #     - "1.1.1.1:53"
    #     - "8.8.8.8:53"

Then reference in IngressRoute:

1
2
3
spec:
  tls:
    certResolver: letsencrypt

TLS Passthrough

When the backend handles its own TLS — common for databases, some gRPC services, and hardware-backed PKI — use TCP routing with TLS passthrough:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
  name: tls-passthrough-route
  namespace: secure-app
spec:
  entryPoints:
    - websecure
  routes:
    - match: HostSNI(`secure.example.com`)
      services:
        - name: secure-app-svc
          port: 8443
  tls:
    passthrough: true   # Traefik does NOT decrypt; SNI is read from ClientHello

Part 7: Gateway API Support

The Kubernetes Gateway API provides a portable, standardized alternative to both the classic Ingress resource and controller-specific CRDs like IngressRoute. Traefik v3 implements the core Gateway API resources.

Enabling Gateway API in Traefik

1
2
3
4
# In values.yaml
providers:
  kubernetesGateway:
    enabled: true

Install the Gateway API CRDs first (they are not bundled with Traefik):

1
2
3
4
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/standard-install.yaml

# For experimental resources (TCPRoute, UDPRoute, TLSRoute):
kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.2.1/experimental-install.yaml

GatewayClass and Gateway

1
2
3
4
5
6
7
# GatewayClass — tells Kubernetes that Traefik is the implementation
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: traefik
spec:
  controllerName: traefik.io/gateway-controller
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# Gateway — the actual listener configuration
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: main-gateway
  namespace: traefik
spec:
  gatewayClassName: traefik
  listeners:
    - name: web
      protocol: HTTP
      port: 80
      allowedRoutes:
        namespaces:
          from: All   # accept HTTPRoutes from any namespace
    - name: websecure
      protocol: HTTPS
      port: 443
      tls:
        mode: Terminate
        certificateRefs:
          - name: wildcard-example-com-tls
            namespace: traefik
      allowedRoutes:
        namespaces:
          from: All
    - name: postgres
      protocol: TCP
      port: 5432
      allowedRoutes:
        namespaces:
          from: All

HTTPRoute

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: my-app
  namespace: my-app
spec:
  parentRefs:
    - name: main-gateway
      namespace: traefik
      sectionName: websecure   # bind to the HTTPS listener
  hostnames:
    - "app.example.com"
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: my-app-svc
          port: 8080
          weight: 100

Traffic splitting with HTTPRoute:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: canary-split
  namespace: my-app
spec:
  parentRefs:
    - name: main-gateway
      namespace: traefik
      sectionName: websecure
  hostnames:
    - "app.example.com"
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: my-app-stable-svc
          port: 8080
          weight: 90
        - name: my-app-canary-svc
          port: 8080
          weight: 10

TCPRoute (experimental)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: gateway.networking.k8s.io/v1alpha2
kind: TCPRoute
metadata:
  name: postgres-tcp
  namespace: databases
spec:
  parentRefs:
    - name: main-gateway
      namespace: traefik
      sectionName: postgres
  rules:
    - backendRefs:
        - name: postgresql-svc
          port: 5432

Gateway API vs IngressRoute: Practical Guidance

Use Gateway API when:

  • You need portability — the ability to swap Traefik for another controller without rewriting all routing config
  • Your organization standardizes on Gateway API across multiple clusters with different controllers
  • You are on a greenfield cluster and want to be on the standards track

Use IngressRoute when:

  • You want the full Traefik feature set today (Middleware reuse, all matching rules, ServersTransport)
  • You need TCP/UDP routing that is stable (Gateway API TCP/UDP is still experimental in both the spec and Traefik)
  • You want the simplest, most documented path

Mixing both is fine. Traefik processes all routing sources simultaneously.


Part 8: RBAC and Security

What Traefik Needs

Traefik watches the Kubernetes API to discover routes, middleware, secrets, and services. It needs read access to these resource types across the namespaces it manages.

The Helm chart generates the correct RBAC resources by default. Understanding what it creates helps you audit and restrict it.

Generated ClusterRole (annotated)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: traefik
rules:
  # Watch services and endpoints to build the upstream pool
  - apiGroups: [""]
    resources: ["services", "endpoints"]
    verbs: ["get", "list", "watch"]

  # Watch Secrets for TLS certificates
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["get", "list", "watch"]

  # Watch classic Ingress resources
  - apiGroups: ["networking.k8s.io"]
    resources: ["ingresses", "ingressclasses"]
    verbs: ["get", "list", "watch"]

  # Update Ingress status (writes the LoadBalancer IP to Ingress.status)
  - apiGroups: ["networking.k8s.io"]
    resources: ["ingresses/status"]
    verbs: ["update"]

  # Watch Traefik CRDs
  - apiGroups: ["traefik.io"]
    resources:
      - "ingressroutes"
      - "ingressroutetcps"
      - "ingressrouteudps"
      - "middlewares"
      - "middlewaretcps"
      - "serverstransports"
      - "serverstransporttcps"
      - "tlsoptions"
      - "tlsstores"
      - "traefikservices"
    verbs: ["get", "list", "watch"]

  # Gateway API resources (if enabled)
  - apiGroups: ["gateway.networking.k8s.io"]
    resources:
      - "gatewayclasses"
      - "gateways"
      - "httproutes"
      - "tcproutes"
      - "tlsroutes"
    verbs: ["get", "list", "watch"]

  # Update Gateway and HTTPRoute status
  - apiGroups: ["gateway.networking.k8s.io"]
    resources:
      - "gatewayclasses/status"
      - "gateways/status"
      - "httproutes/status"
    verbs: ["update"]

Namespace-Scoped RBAC (more restrictive)

For clusters where Traefik should only manage specific namespaces, use namespaced: true in values.yaml. This generates a Role + RoleBinding per namespace rather than a cluster-wide ClusterRole. You must then list the watched namespaces explicitly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# values.yaml
rbac:
  enabled: true
  namespaced: true

providers:
  kubernetesCRD:
    enabled: true
    namespaces:
      - traefik
      - my-app
      - api
  kubernetesIngress:
    enabled: true
    namespaces:
      - traefik
      - my-app
      - api

Least-Privilege Secrets Access

The broadest attack surface in Traefik’s RBAC is the secrets read permission — it needs it to read TLS certificates. One mitigation is to keep TLS secrets in the same namespace as the workloads and let namespace-scoped RBAC naturally limit which secrets Traefik can reach. Another is to use ExternalSecret (External Secrets Operator) to mirror only the needed secrets into namespaces Traefik watches.

Running Traefik in a Dedicated Namespace

All examples in this guide deploy Traefik into the traefik namespace. This is important for:

  • Isolating Traefik’s ServiceAccount permissions
  • Making network policies easier to reason about
  • Separating operational concerns from workload namespaces

Network Policies

By default, pods in a Kubernetes cluster can communicate freely. Network policies restrict this. Apply a policy that allows:

  • Inbound traffic from everywhere on ports 80/443 (the entrypoints)
  • Outbound traffic to all namespaces on arbitrary backend ports
  • Inbound traffic from Prometheus on port 9100 (metrics)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
# Allow inbound traffic to Traefik from the outside world and from Prometheus
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: traefik-ingress-policy
  namespace: traefik
spec:
  podSelector:
    matchLabels:
      app.kubernetes.io/name: traefik
  policyTypes:
    - Ingress
    - Egress
  ingress:
    # Allow all inbound (external traffic hits the LoadBalancer first)
    - ports:
        - protocol: TCP
          port: 8000    # web entrypoint
        - protocol: TCP
          port: 8443    # websecure entrypoint
        - protocol: TCP
          port: 5432    # postgres entrypoint
        - protocol: UDP
          port: 5353    # dns-udp entrypoint
    # Allow Prometheus scraping
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: monitoring
      ports:
        - protocol: TCP
          port: 9100
  egress:
    # Allow outbound to all namespaces — Traefik needs to reach backend services
    - {}
    # More restrictive: enumerate specific backend namespaces
    # - to:
    #     - namespaceSelector:
    #         matchLabels:
    #           traefik-backend: "true"
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Allow backend pods to receive traffic from Traefik
# Apply this in each workload namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-traefik-ingress
  namespace: my-app
spec:
  podSelector: {}    # applies to all pods in the namespace
  policyTypes:
    - Ingress
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: traefik

Part 9: High Availability

Running a single Traefik pod is fine for development. Production requires multiple replicas and attention to a few key concerns.

Replica Count and Anti-Affinity

Two replicas is the minimum for HA. Three is better for rolling updates (Kubernetes terminates one pod at a time during rollout, keeping two alive).

The anti-affinity rule in values.yaml (shown in Part 2) ensures Traefik pods land on different nodes. If one node fails, the other replica continues serving traffic.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# values.yaml — force pods onto different nodes
affinity:
  podAntiAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
      - labelSelector:
          matchExpressions:
            - key: app.kubernetes.io/name
              operator: In
              values:
                - traefik
        topologyKey: kubernetes.io/hostname

Use preferredDuringSchedulingIgnoredDuringExecution instead of required if you are on a small cluster where strict anti-affinity might cause unschedulable pods.

PodDisruptionBudget

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: traefik-pdb
  namespace: traefik
spec:
  minAvailable: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: traefik

With two replicas and minAvailable: 1, node drains during cluster upgrades are safe — Kubernetes evicts one Traefik pod at a time, only when one replacement is running.

HPA — Horizontal Pod Autoscaling

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: traefik-hpa
  namespace: traefik
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: traefik
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300    # avoid thrashing
      policies:
        - type: Pods
          value: 1
          periodSeconds: 60
    scaleUp:
      stabilizationWindowSeconds: 60
      policies:
        - type: Pods
          value: 2
          periodSeconds: 60

Shared ACME Storage with Redis

When using Traefik’s built-in ACME resolver across multiple replicas, each replica independently tries to solve ACME challenges and store the resulting certificates. This causes certificate duplication, race conditions, and rate-limit exhaustion. The solution: a shared storage backend.

Option 1: cert-manager (recommended). Use cert-manager as covered in Part 6. Each replica reads the TLS Secret from Kubernetes; cert-manager handles renewal. No shared ACME state needed.

Option 2: Traefik with Redis. If you must use the built-in ACME:

1
2
3
4
5
# values.yaml
additionalArguments:
  - "--certificatesresolvers.letsencrypt.acme.storage=redis://redis-master.redis.svc.cluster.local:6379/0"
  - "--certificatesresolvers.letsencrypt.acme.email=admin@example.com"
  - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web"

Deploy a Redis instance in the cluster (a single-replica Redis is fine for ACME storage — the data is reproducible by re-running the ACME challenge):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
  namespace: traefik
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
        - name: redis
          image: redis:7-alpine
          ports:
            - containerPort: 6379
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 100m
              memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
  name: redis-master
  namespace: traefik
spec:
  selector:
    app: redis
  ports:
    - port: 6379
      targetPort: 6379

Recommendation: Use cert-manager. It is simpler, more robust, and separates concerns cleanly.

Topology Spread Constraints

For clusters with multiple availability zones:

1
2
3
4
5
6
7
8
# values.yaml
topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.kubernetes.io/zone
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels:
        app.kubernetes.io/name: traefik

Part 10: Observability in Kubernetes

Access Logs

Traefik produces structured JSON access logs when configured as shown in values.yaml. In Kubernetes these flow to stdout/stderr and are collected by your log aggregation stack (Loki, Elasticsearch, Splunk).

A Loki PodLogs resource to scrape Traefik logs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
apiVersion: monitoring.grafana.com/v1alpha2
kind: PodLogs
metadata:
  name: traefik-logs
  namespace: traefik
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: traefik
  pipelineStages:
    - json:
        expressions:
          status: ClientHost
          method: RequestMethod
          host: RequestHost
          path: RequestPath
          duration: Duration
          status_code: DownstreamStatus
    - labels:
        status_code:
        method:
        host:

Prometheus Metrics with ServiceMonitor

The Helm chart generates a Service for the metrics port. Create a ServiceMonitor to tell Prometheus to scrape it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: traefik-metrics
  namespace: traefik
  labels:
    release: kube-prometheus-stack   # must match your Prometheus operator selector
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: traefik
  namespaceSelector:
    matchNames:
      - traefik
  endpoints:
    - port: metrics
      path: /metrics
      interval: 30s
      scrapeTimeout: 10s

PrometheusRule for Alerting

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: traefik-alerts
  namespace: traefik
  labels:
    release: kube-prometheus-stack
spec:
  groups:
    - name: traefik.rules
      interval: 30s
      rules:
        # Alert when Traefik error rate exceeds 5% over 5 minutes
        - alert: TraefikHighErrorRate
          expr: |
            sum(rate(traefik_router_requests_total{code=~"5.."}[5m])) by (router)
            /
            sum(rate(traefik_router_requests_total[5m])) by (router)
            > 0.05
          for: 2m
          labels:
            severity: warning
          annotations:
            summary: "Traefik router {{ $labels.router }} has high error rate"
            description: "Error rate is {{ $value | humanizePercentage }} over the last 5 minutes."

        # Alert when a backend is entirely down (100% 5xx for 2 minutes)
        - alert: TraefikBackendDown
          expr: |
            sum(rate(traefik_service_requests_total{code=~"5.."}[2m])) by (service)
            /
            sum(rate(traefik_service_requests_total[2m])) by (service)
            == 1
          for: 2m
          labels:
            severity: critical
          annotations:
            summary: "Traefik service {{ $labels.service }} is returning 100% errors"
            description: "All requests to {{ $labels.service }} are failing."

        # Alert when P99 latency exceeds 2 seconds
        - alert: TraefikHighLatency
          expr: |
            histogram_quantile(0.99,
              sum(rate(traefik_router_request_duration_seconds_bucket[5m])) by (router, le)
            ) > 2
          for: 5m
          labels:
            severity: warning
          annotations:
            summary: "Traefik router {{ $labels.router }} has high P99 latency"
            description: "P99 latency is {{ $value | humanizeDuration }}."

        # Alert when TLS certificate is expiring within 14 days
        - alert: TraefikTLSCertExpiringSoon
          expr: |
            traefik_tls_certs_not_after - time() < 14 * 24 * 3600
          for: 1h
          labels:
            severity: warning
          annotations:
            summary: "TLS certificate expiring soon"
            description: "Certificate for {{ $labels.sans }} expires in less than 14 days."

        # Alert when no Traefik pods are ready
        - alert: TraefikDown
          expr: |
            kube_deployment_status_replicas_ready{namespace="traefik", deployment="traefik"} == 0
          for: 1m
          labels:
            severity: critical
          annotations:
            summary: "Traefik is down — no ready replicas"
            description: "The Traefik deployment in namespace traefik has no ready replicas."

Securing the Dashboard

Never expose the Traefik dashboard without authentication. Create a dedicated IngressRoute with forwardAuth or basicAuth:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# Secret for dashboard basic auth
apiVersion: v1
kind: Secret
metadata:
  name: dashboard-auth-secret
  namespace: traefik
type: Opaque
stringData:
  # Generate: htpasswd -nB admin
  users: "admin:$2y$10$WdOA4BYkuVMDieGL6OteneORi7uEP5lPgB8OJZm0z3GCvIoMqQ7Ly"
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: dashboard-auth
  namespace: traefik
spec:
  basicAuth:
    secret: dashboard-auth-secret
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: dashboard-ip-allowlist
  namespace: traefik
spec:
  ipAllowList:
    sourceRange:
      - "10.0.0.0/8"
      - "172.16.0.0/12"
      - "192.168.0.0/16"
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: traefik-dashboard
  namespace: traefik
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`traefik.internal.example.com`) && (PathPrefix(`/dashboard`) || PathPrefix(`/api`))
      kind: Rule
      services:
        - name: api@internal
          kind: TraefikService
      middlewares:
        - name: dashboard-ip-allowlist
          namespace: traefik
        - name: dashboard-auth
          namespace: traefik
  tls:
    secretName: wildcard-example-com-tls

Distributed Tracing with OpenTelemetry

Traefik v3 ships native OpenTelemetry support. Configure it in values.yaml:

1
2
3
4
5
6
7
tracing:
  otlp:
    grpc:
      endpoint: "otel-collector.observability.svc.cluster.local:4317"
      insecure: true    # set false and configure TLS if the collector uses TLS
    http:
      endpoint: "http://otel-collector.observability.svc.cluster.local:4318"

With this configuration, Traefik adds spans to every request processed and propagates trace context (traceparent header) to backends that support it. Traces appear in Grafana Tempo, Jaeger, or any OTLP-compatible backend.


Part 11: Advanced Patterns

Weighted Traffic Splitting for Canary Deployments

The pattern: two Kubernetes Deployments (stable and canary) behind separate Services, with an IngressRoute splitting traffic by weight.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
# Stable deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app-stable
  namespace: my-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: my-app
      track: stable
  template:
    metadata:
      labels:
        app: my-app
        track: stable
    spec:
      containers:
        - name: my-app
          image: my-app:v1.5.0
          ports:
            - containerPort: 8080
---
# Canary deployment (smaller replica count)
apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app-canary
  namespace: my-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: my-app
      track: canary
  template:
    metadata:
      labels:
        app: my-app
        track: canary
    spec:
      containers:
        - name: my-app
          image: my-app:v1.6.0-rc1
          ports:
            - containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: my-app-stable-svc
  namespace: my-app
spec:
  selector:
    app: my-app
    track: stable
  ports:
    - port: 8080
      targetPort: 8080
---
apiVersion: v1
kind: Service
metadata:
  name: my-app-canary-svc
  namespace: my-app
spec:
  selector:
    app: my-app
    track: canary
  ports:
    - port: 8080
      targetPort: 8080
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
# IngressRoute with weighted split
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: my-app-canary-split
  namespace: my-app
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`app.example.com`)
      kind: Rule
      services:
        - name: my-app-stable-svc
          port: 8080
          weight: 90    # 90% of traffic to stable
        - name: my-app-canary-svc
          port: 8080
          weight: 10    # 10% to canary
  tls:
    secretName: wildcard-example-com-tls

Promote the canary by updating weights: 90/1050/500/100, then point everything at the (now promoted) stable service.

Header-Based Canary Routing

For internal testing before exposing the canary to real traffic:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: my-app-header-canary
  namespace: my-app
spec:
  entryPoints:
    - websecure
  routes:
    # Requests with X-Canary: true header go to canary
    - match: Host(`app.example.com`) && Headers(`X-Canary`, `true`)
      kind: Rule
      priority: 20
      services:
        - name: my-app-canary-svc
          port: 8080

    # All other requests go to stable
    - match: Host(`app.example.com`)
      kind: Rule
      priority: 10
      services:
        - name: my-app-stable-svc
          port: 8080
  tls:
    secretName: wildcard-example-com-tls

TCP Routing for Databases

Running databases inside a Kubernetes cluster and needing to access them from outside (e.g., a developer connecting via a database GUI, or a legacy application not yet in the cluster):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Entrypoint in values.yaml
ports:
  mysql:
    port: 3306
    expose:
      default: true
    exposedPort: 3306
    protocol: TCP
  postgresql:
    port: 5432
    expose:
      default: true
    exposedPort: 5432
    protocol: TCP
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# MySQL routing (no TLS, relies on network security)
apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
  name: mysql-route
  namespace: databases
spec:
  entryPoints:
    - mysql
  routes:
    - match: HostSNI(`*`)
      services:
        - name: mysql-svc
          port: 3306
          terminationDelay: 400    # ms to wait after FIN before forcing close
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# PostgreSQL routing with TLS passthrough (PostgreSQL handles its own TLS)
apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
  name: postgres-route
  namespace: databases
spec:
  entryPoints:
    - postgresql
  routes:
    - match: HostSNI(`db.example.com`)
      services:
        - name: postgresql-svc
          port: 5432
  tls:
    passthrough: true

UDP Routing for DNS and Game Servers

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
# External DNS resolver — expose an in-cluster CoreDNS or Pi-hole externally
apiVersion: traefik.io/v1alpha1
kind: IngressRouteUDP
metadata:
  name: external-dns
  namespace: dns
spec:
  entryPoints:
    - dns-udp
  routes:
    - services:
        - name: pihole-dns-svc
          port: 53
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Game server with multiple ports
apiVersion: traefik.io/v1alpha1
kind: IngressRouteUDP
metadata:
  name: game-server-1
  namespace: games
spec:
  entryPoints:
    - game-udp-7777
  routes:
    - services:
        - name: game-server-1-svc
          port: 7777
          weight: 1

Using Traefik as an Internal Cluster Ingress

Not all traffic originates externally. For internal service-to-service routing where you want middleware (auth, rate limiting, observability) applied, create a separate Traefik deployment configured as an internal cluster ingress — only accessible within the cluster.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Internal Traefik instance — ClusterIP service, not LoadBalancer
service:
  type: ClusterIP

ports:
  web:
    port: 80
    expose:
      default: true
    exposedPort: 80
  websecure:
    port: 443
    expose:
      default: true
    exposedPort: 443

Internal services use the ClusterIP service’s DNS name (traefik-internal.traefik.svc.cluster.local) as their target, routing through Traefik’s middleware pipeline without leaving the cluster.

Cross-Namespace IngressRoutes

When Traefik has allowCrossNamespace: true, an IngressRoute in namespace A can route to a Service in namespace B:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: cross-ns-route
  namespace: frontend
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`app.example.com`) && PathPrefix(`/api`)
      kind: Rule
      services:
        # Service is in a different namespace
        - name: api-svc
          namespace: backend    # cross-namespace reference
          port: 8080
      middlewares:
        - name: strip-api-prefix
          namespace: frontend
  tls:
    secretName: wildcard-example-com-tls

Use cross-namespace routing deliberately. It can make it harder to reason about what is hitting a service. Consider whether the backends should instead be exposed via their own IngressRoute in their own namespace.


Part 12: Complete Production Example

This section assembles everything into a single working deployment: Traefik via Helm, cert-manager for TLS, a sample application, rate limiting, ForwardAuth with Authentik, Prometheus metrics, and alerting rules.

Directory Layout (GitOps structure)

infrastructure/
  cert-manager/
    helmrelease.yaml
    clusterissuer-prod.yaml
    clusterissuer-staging.yaml
    wildcard-cert.yaml
  traefik/
    namespace.yaml
    helmrepository.yaml
    helmrelease.yaml
    values-configmap.yaml
    middleware/
      authentik-forward-auth.yaml
      secure-headers.yaml
      rate-limit-api.yaml
      redirect-https.yaml
  monitoring/
    servicemonitor-traefik.yaml
    prometheusrule-traefik.yaml

apps/
  sample-app/
    namespace.yaml
    deployment.yaml
    service.yaml
    ingressroute.yaml
    middleware-rate-limit.yaml
    certificate.yaml

Step 1: Install cert-manager

1
2
3
4
5
6
helm upgrade --install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --set crds.enabled=true \
  --version "v1.17.x" \
  --wait

Step 2: Create ClusterIssuers

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# infrastructure/cert-manager/clusterissuer-prod.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
      - dns01:
          cloudflare:
            apiTokenSecretRef:
              name: cloudflare-api-token
              key: api-token

Step 3: Issue Wildcard Certificate

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# infrastructure/cert-manager/wildcard-cert.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: wildcard-example-com
  namespace: traefik
spec:
  secretName: wildcard-example-com-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  commonName: "*.example.com"
  dnsNames:
    - "*.example.com"
    - "example.com"
  duration: 2160h
  renewBefore: 360h

Step 4: Install Traefik

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
# infrastructure/traefik/values-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: traefik-values
  namespace: traefik
data:
  values.yaml: |
    deployment:
      replicas: 2
      podAnnotations:
        prometheus.io/scrape: "true"
        prometheus.io/port: "9100"

    resources:
      requests:
        cpu: 100m
        memory: 128Mi
      limits:
        cpu: 500m
        memory: 512Mi

    ports:
      web:
        port: 8000
        expose:
          default: true
        exposedPort: 80
        redirectTo:
          port: websecure
      websecure:
        port: 8443
        expose:
          default: true
        exposedPort: 443
        tls:
          enabled: true
      metrics:
        port: 9100
        expose:
          default: false

    service:
      type: LoadBalancer
      spec:
        externalTrafficPolicy: Local

    rbac:
      enabled: true
      namespaced: false

    providers:
      kubernetesCRD:
        enabled: true
        allowCrossNamespace: true
        allowExternalNameServices: true
      kubernetesIngress:
        enabled: true
        publishedService:
          enabled: true

    logs:
      general:
        level: INFO
      access:
        enabled: true
        format: json

    metrics:
      prometheus:
        entryPoint: metrics
        addEntryPointsLabels: true
        addRoutersLabels: true
        addServicesLabels: true

    tracing:
      otlp:
        grpc:
          endpoint: "otel-collector.observability.svc.cluster.local:4317"
          insecure: true

    ingressRoute:
      dashboard:
        enabled: false

    api:
      dashboard: true
      insecure: false

    globalArguments:
      - "--global.checknewversion=false"
      - "--global.sendanonymoususage=false"

    affinity:
      podAntiAffinity:
        requiredDuringSchedulingIgnoredDuringExecution:
          - labelSelector:
              matchExpressions:
                - key: app.kubernetes.io/name
                  operator: In
                  values:
                    - traefik
            topologyKey: kubernetes.io/hostname

    podDisruptionBudget:
      enabled: true
      minAvailable: 1
1
2
3
4
5
6
7
kubectl create namespace traefik

helm upgrade --install traefik traefik/traefik \
  --namespace traefik \
  --values infrastructure/traefik/values-configmap.yaml \
  --version "32.1.0" \
  --wait

Step 5: Set TLSStore Default Certificate

1
2
3
4
5
6
7
8
9
# infrastructure/traefik/tlsstore-default.yaml
apiVersion: traefik.io/v1alpha1
kind: TLSStore
metadata:
  name: default
  namespace: traefik
spec:
  defaultCertificate:
    secretName: wildcard-example-com-tls

Step 6: Shared Middleware

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
# infrastructure/traefik/middleware/secure-headers.yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: secure-headers
  namespace: traefik
spec:
  headers:
    stsSeconds: 63072000
    stsIncludeSubdomains: true
    stsPreload: true
    forceSTSHeader: true
    contentTypeNosniff: true
    browserXssFilter: true
    referrerPolicy: "strict-origin-when-cross-origin"
    frameDeny: true
    permissionsPolicy: "camera=(), microphone=(), geolocation=()"
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# infrastructure/traefik/middleware/authentik-forward-auth.yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: authentik-forward-auth
  namespace: traefik
spec:
  forwardAuth:
    address: "http://authentik-server.authentik.svc.cluster.local/outpost.goauthentik.io/auth/traefik"
    trustForwardHeader: true
    authResponseHeaders:
      - X-authentik-username
      - X-authentik-groups
      - X-authentik-email
      - X-authentik-name
      - X-authentik-uid
      - Authorization
    authRequestHeaders:
      - "Accept"
      - "Cookie"
      - "X-Forwarded-For"
      - "X-Forwarded-Host"
      - "X-Forwarded-Proto"
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# infrastructure/traefik/middleware/rate-limit-api.yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: rate-limit-api
  namespace: traefik
spec:
  rateLimit:
    average: 100
    period: 1m
    burst: 200
    sourceCriterion:
      ipStrategy:
        depth: 1
        excludedIPs:
          - "10.0.0.0/8"

Step 7: Deploy the Sample Application

1
2
3
4
5
6
7
# apps/sample-app/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: sample-app
  labels:
    kubernetes.io/metadata.name: sample-app
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
# apps/sample-app/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: sample-app
  namespace: sample-app
spec:
  replicas: 2
  selector:
    matchLabels:
      app: sample-app
  template:
    metadata:
      labels:
        app: sample-app
    spec:
      containers:
        - name: sample-app
          image: nginx:1.27-alpine
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: 50m
              memory: 64Mi
            limits:
              cpu: 200m
              memory: 128Mi
          livenessProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 5
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 2
            periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: sample-app-svc
  namespace: sample-app
spec:
  selector:
    app: sample-app
  ports:
    - port: 80
      targetPort: 80
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# apps/sample-app/ingressroute.yaml
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: sample-app
  namespace: sample-app
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`app.example.com`)
      kind: Rule
      services:
        - name: sample-app-svc
          port: 80
          # Health check: remove unhealthy backends from rotation
          healthCheck:
            path: /healthz
            interval: 30s
            timeout: 5s
      middlewares:
        # Auth — all traffic requires Authentik login
        - name: authentik-forward-auth
          namespace: traefik
        # Rate limiting
        - name: rate-limit-api
          namespace: traefik
        # Security headers
        - name: secure-headers
          namespace: traefik
  tls:
    # Using the default wildcard cert from TLSStore — no secretName needed here
    {}

Step 8: ServiceMonitor and PrometheusRule

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# infrastructure/monitoring/servicemonitor-traefik.yaml
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: traefik-metrics
  namespace: traefik
  labels:
    release: kube-prometheus-stack
spec:
  selector:
    matchLabels:
      app.kubernetes.io/name: traefik
  namespaceSelector:
    matchNames:
      - traefik
  endpoints:
    - port: metrics
      path: /metrics
      interval: 30s
      scrapeTimeout: 10s
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# infrastructure/monitoring/prometheusrule-traefik.yaml
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: traefik-alerts
  namespace: traefik
  labels:
    release: kube-prometheus-stack
spec:
  groups:
    - name: traefik.critical
      rules:
        - alert: TraefikDown
          expr: kube_deployment_status_replicas_ready{namespace="traefik",deployment="traefik"} == 0
          for: 1m
          labels:
            severity: critical
          annotations:
            summary: "Traefik has no ready replicas"
            runbook: "https://wiki.example.com/runbooks/traefik-down"

        - alert: TraefikBackendDown
          expr: |
            sum(rate(traefik_service_requests_total{code=~"5.."}[2m])) by (service)
            /
            sum(rate(traefik_service_requests_total[2m])) by (service) == 1
          for: 2m
          labels:
            severity: critical
          annotations:
            summary: "Traefik service {{ $labels.service }} is 100% failing"

    - name: traefik.warnings
      rules:
        - alert: TraefikHighErrorRate
          expr: |
            sum(rate(traefik_router_requests_total{code=~"5.."}[5m])) by (router)
            /
            sum(rate(traefik_router_requests_total[5m])) by (router) > 0.05
          for: 2m
          labels:
            severity: warning
          annotations:
            summary: "High error rate on router {{ $labels.router }}"

        - alert: TraefikCertExpiringSoon
          expr: traefik_tls_certs_not_after - time() < 14 * 24 * 3600
          for: 1h
          labels:
            severity: warning
          annotations:
            summary: "TLS cert expiring in < 14 days"
            description: "Certificate {{ $labels.sans }} expires soon."

Step 9: Dashboard IngressRoute

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# infrastructure/traefik/dashboard-ingressroute.yaml
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: dashboard-auth
  namespace: traefik
spec:
  basicAuth:
    secret: dashboard-auth-secret
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: traefik-dashboard
  namespace: traefik
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`traefik.internal.example.com`) && (PathPrefix(`/dashboard`) || PathPrefix(`/api`))
      kind: Rule
      services:
        - name: api@internal
          kind: TraefikService
      middlewares:
        - name: dashboard-auth
          namespace: traefik
        - name: secure-headers
          namespace: traefik
  tls: {}

Verification

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Check all Traefik pods are running
kubectl -n traefik get pods -o wide

# Verify the LoadBalancer got an external IP
kubectl -n traefik get svc traefik

# Check cert-manager issued the wildcard cert
kubectl -n traefik get certificate wildcard-example-com
kubectl -n traefik describe certificate wildcard-example-com

# Verify IngressRoute is registered
kubectl -n sample-app get ingressroute sample-app -o yaml

# Watch Traefik logs for any routing errors
kubectl -n traefik logs -l app.kubernetes.io/name=traefik -f

# Test the endpoint
curl -I https://app.example.com

# Check Prometheus scraping
kubectl -n traefik get servicemonitor traefik-metrics

# Port-forward to the dashboard (secured with basic auth in the IngressRoute)
kubectl -n traefik port-forward svc/traefik 9000:9000 &
# Then open: http://localhost:9000/dashboard/

Operational Tips and Common Pitfalls

IngressRoute not routing? The most common causes:

  1. The entryPoints field names must match the names defined in values.yaml exactly. websecure not web-secure.
  2. Middleware referenced in an IngressRoute must exist before the IngressRoute is applied. Traefik will skip a route whose middleware it cannot find.
  3. Cross-namespace middleware references require allowCrossNamespace: true in the provider config and the namespace field on the middleware ref.
  4. Check the Traefik dashboard — it shows route status and any errors in the “Routers” and “Middlewares” panels.

Certificate not being served?

  1. Verify the Certificate resource is in Ready state: kubectl get certificate -A
  2. Verify the TLS Secret exists: kubectl -n <ns> get secret <secretName>
  3. If using a TLSStore for the default cert, confirm Traefik has read access to the Secret’s namespace.

ForwardAuth redirect loop? The Authentik or Authelia auth endpoint itself must not be protected by the same forwardAuth middleware. Create a separate IngressRoute for the auth service with no forwardAuth middleware.

TCP routing with HostSNI(*) sends all connections on that entrypoint to the single service. If you want to multiplex multiple backends on one TCP port, each must use TLS with SNI-based routing — there is no other information in a plain TCP stream to distinguish clients.

Wildcard cert not being used as default? Confirm the TLSStore resource is named exactly default. Only the TLSStore named default is treated as the default; any other name has no effect on fallback behavior.

Prometheus alerts not firing? Verify the release label on ServiceMonitor and PrometheusRule matches your kube-prometheus-stack Helm release name: kubectl -n monitoring get prometheus -o yaml | grep serviceMonitorSelector.


Summary

Traefik earns its place as the Kubernetes ingress controller of choice for teams that want expressive, maintainable routing configuration. The IngressRoute CRD system replaces annotation-driven configuration with validated, reusable, composable API objects. The Middleware CRD separates cross-cutting concerns cleanly. TCP and UDP routing from the same binary eliminates the need for separate infrastructure for non-HTTP workloads.

The full production setup — Helm with HA values, cert-manager for certificate lifecycle, shared middleware in the traefik namespace, per-application IngressRoute resources with ForwardAuth and rate limiting, Prometheus ServiceMonitor with alerting rules — gives you a production-grade ingress layer that can grow with the cluster.

The operational investment is low once the initial configuration is in place. Adding a new service means writing a single IngressRoute and referencing existing middleware — no nginx config to edit, no certificate cron jobs to maintain, and no reload disruptions to worry about.

Comments