You Don’t Need Three Ingress Controllers. But You Might Want to Know Why You Picked One.
Traefik wins for most k3s home labs, and it is already running. ingress-nginx retired in March 2026, so treat it as something you migrate off rather than onto. HAProxy wins on footprint if you accept a smaller community.
k3s ships with Traefik out of the box. That’s it. Done. You have an ingress controller. Your cluster works. Most home labs never need to think about this again.
But if you’re the kind of person who tinkers with load balancers at 11 PM, or you inherited a k3s cluster that’s screaming about resource limits, or you just want to understand why Traefik and not nginx or HAProxy, then buckle up. We’re about to compare three solid ingress controllers and figure out which one makes sense for your workload.
The real tension here isn’t “which is fastest?”: they’re all fast enough. It’s about control surface, configuration style, and operational overhead. Traefik auto-discovers routes. nginx needs you to write explicit manifests. HAProxy is the heavy hitter for complex routing. Pick the philosophy that matches how you like to operate.
One thing changed the shape of this comparison since the last time you read a post like it. The Kubernetes project retired ingress-nginx. Best-effort maintenance ended in March 2026. It still runs, plenty of clusters still have it, and the details below still matter if you inherited one, but it is no longer a thing you choose.
What Even Is an Ingress Controller?
Brief sanity check: an ingress controller is a reverse proxy that watches your Kubernetes cluster for Ingress objects (or custom resources like IngressRoute) and automatically updates its config. It’s the thing that sits at the edge and says “port 80/443 traffic for example.com goes to the web service in the default namespace.”
Without an ingress controller, Ingress objects are just YAML sitting in your cluster doing nothing. The controller watches them, builds a routing table, and reloads the proxy. It’s automation for the traditional nginx.conf edit → reload dance.
k3s assumes you want this and bundles Traefik by default. But you don’t have to use it.
Traefik (k3s Default)
k3s ships Traefik v3 by default. The cutover landed in k3s v1.32.2 in February 2025; v1.32.1 and everything before it shipped Traefik v2.11, so an inherited cluster may still be on v2 with older CRD API groups. Current k3s packages Traefik 3.7.12 through chart 41.4.2. Traefik is opinionated in a way that makes sense: it tries to auto-discover your services and expose them with minimal config.
Philosophy
Traefik uses explicit routes via IngressRoute CRDs instead of the standard Kubernetes Ingress resource (though it supports both). IngressRoute is where Traefik shines: it lets you define middlewares, entrypoints, and TLS inline without annotation horror.
The mental model: “Traefik watches for IngressRoute objects, builds a dynamic routing table, and reloads itself.” No nginx-reload scripts, no config files in ConfigMaps, just CRD declarations.
Install (Already There)
If you’re on a fresh k3s cluster:
k3s server --disable=traefik…disables the default. If you installed through the get.k3s.io script, the same switch goes in the config file instead:
disable: - traefikMost of the time you won’t need either. But if you do want to swap it out, that’s the switch.
If Traefik is already running:
kubectl get deployment -n kube-system traefikSee it? You’re running it.
Basic IngressRoute Example
IngressRoute is where Traefik gets interesting. Instead of Kubernetes Ingress + annotations, you declare routes, services, and middlewares as first-class objects:
apiVersion: traefik.io/v1alpha1kind: IngressRoutemetadata: name: my-app namespace: defaultspec: entryPoints: - websecure routes: - match: Host(`app.example.com`) kind: Rule services: - name: my-service port: 8080 tls: secretName: my-app-tlswebsecure is Traefik’s TLS entrypoint on :443. Listing web (:80) alongside it and then adding a tls: block is a common first mistake: Traefik will happily negotiate TLS on port 80 and no browser will follow you there.
Compare that to vanilla Ingress:
apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: my-app namespace: default annotations: cert-manager.io/cluster-issuer: "letsencrypt" traefik.ingress.kubernetes.io/router.entrypoints: "web,websecure"spec: rules: - host: app.example.com http: paths: - path: / pathType: Prefix backend: service: name: my-service port: number: 8080 tls: - hosts: - app.example.com secretName: my-app-tlsIngressRoute is cleaner. Standard Ingress is more portable (works on any cluster).
Traefik + Let’s Encrypt on k3s
Traefik has a native ACME client, but the k3s bundle does not switch it on. The packaged chart ships an empty certificatesResolvers and no persistent volume, so an IngressRoute that asks for certResolver: letsencrypt fails with a complaint about a non-existent resolver. You add the resolver yourself with a HelmChartConfig, which the k3s helm-controller merges into the bundled values:
apiVersion: helm.cattle.io/v1kind: HelmChartConfigmetadata: name: traefik namespace: kube-systemspec: valuesContent: |- certificatesResolvers: letsencrypt: acme: email: [email protected] storage: /data/acme.json httpChallenge: entryPoint: web persistence: enabled: true size: 128Mi path: /data deployment: initContainers: - name: volume-permissions image: busybox:latest command: ["sh", "-c", "touch /data/acme.json && chmod 600 /data/acme.json"] volumeMounts: - name: data mountPath: /dataThe persistence block is not optional. Without a volume, acme.json lives inside the pod and every restart re-requests certificates until Let’s Encrypt stops you at five duplicate certificates per week. The init container is there because Traefik refuses to read an acme.json with loose file permissions.
Now the resolver name resolves to something real. Redirecting plain HTTP to HTTPS is a separate job, and it wants its own route:
apiVersion: traefik.io/v1alpha1kind: Middlewaremetadata: name: https-redirect namespace: defaultspec: redirectScheme: scheme: https permanent: true---apiVersion: traefik.io/v1alpha1kind: IngressRoutemetadata: name: my-app-redirect namespace: defaultspec: entryPoints: - web routes: - match: Host(`app.example.com`) kind: Rule middlewares: - name: https-redirect services: - name: my-service port: 8080---apiVersion: traefik.io/v1alpha1kind: IngressRoutemetadata: name: my-app namespace: defaultspec: entryPoints: - websecure routes: - match: Host(`app.example.com`) kind: Rule services: - name: my-service port: 8080 tls: certResolver: letsencryptTwo routes, two entrypoints. The web route redirects, the websecure route serves and owns the cert resolver. Putting the redirect middleware and the TLS block on a single route is what produces the redirect loop people post about. Once the HelmChartConfig is in place you don’t need cert-manager for ingress certificates, though cert-manager is still the answer when you need certs for things that are not ingress, like webhook servers.
Pros & Cons
Pros:
- Comes with k3s. One less decision.
- IngressRoute is declarative and clean.
- Native ACME once you enable it, no second deployment to run.
- Dynamic, no manual reload cycles.
- Optional dashboard at
/dashboard/on Traefik’s internaltraefikentrypoint. The chart’s dashboard IngressRoute is disabled by default; enable it and keep it behind a firewall. - Actively released. Traefik 3.7.12 shipped in August 2026.
Cons:
- IngressRoute is a custom resource. Those manifests won’t port to other clusters without rewriting.
- Middleware ecosystem is smaller than nginx’s annotation set.
- The k3s copy is managed by the helm-controller, not by Helm.
helm list -n kube-systemshows nothing andhelm upgradewon’t touch it, which surprises people the first time. - If you’re used to nginx config files, the mental model takes adjustment.
Resource Footprint
Default Traefik deployment in k3s, measured on an idle single-node cluster:
- Roughly 50 to 80 MB resident, more under load
- One replica, so it’s a single point of failure on single-node clusters. The kubelet restarts it, which is not the same thing as high availability.
ingress-nginx (Retired)
Read this before you install anything. The Kubernetes project retired ingress-nginx. Best-effort maintenance ended in March 2026, and the last builds are controller v1.15.1 and chart 4.15.1, both dated 19 March 2026. No more releases, no bug fixes, and no patches for CVEs found from here on. Existing deployments keep working and the images stay on the registry, but nobody is minding them. Upstream’s own advice: if you are not already running it, don’t start; if you are, pick a Gateway API implementation and plan the move.
This section stays because a lot of k3s clusters still run it, and knowing what you inherited is half the job.
The heavyweight, and for most of a decade the default answer. ingress-nginx was the Kubernetes community’s standard ingress controller, and a large share of GKE, EKS, and AKS clusters still have it.
Philosophy
ingress-nginx takes the vanilla Kubernetes Ingress spec and extends it with annotations. Everything is configured through annotations on the Ingress object. The Ingress object itself ports anywhere. The annotations do not: nginx.ingress.kubernetes.io/limit-rps means nothing to Traefik or HAProxy, so moving controllers means retranslating every annotation you actually depend on.
The mental model: “I write standard Kubernetes Ingress YAML. The controller reads annotations for advanced features.”
Install
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginxhelm repo updatehelm install ingress-nginx ingress-nginx/ingress-nginx \ --namespace ingress-nginx \ --create-namespaceThis creates a deployment, a service, an IngressClass, and RBAC. The service is type LoadBalancer, and on k3s that works with no cloud provider because ServiceLB is built in.
The trap is how ServiceLB works. It schedules a pod that binds the service port as a hostPort on each node. Traefik already holds 80 and 443 there. The new service finds no node with those ports free, so it sits at EXTERNAL-IP: <pending> forever and logs nothing that explains why. Plan for it before you install, not after; the migration section below covers the options.
Basic Ingress Example
apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: my-app namespace: default annotations: cert-manager.io/cluster-issuer: "letsencrypt" nginx.ingress.kubernetes.io/ssl-redirect: "true"spec: ingressClassName: nginx rules: - host: app.example.com http: paths: - path: / pathType: Prefix backend: service: name: my-service port: number: 8080 tls: - hosts: - app.example.com secretName: my-app-tlsThe ingressClassName: nginx tells the cluster “use the nginx controller for this Ingress.” The annotations configure nginx-specific behavior.
Advanced Features via Annotations
Need request rate limiting? Annotation:
annotations: nginx.ingress.kubernetes.io/limit-rps: "10"Custom error page? Annotation:
annotations: nginx.ingress.kubernetes.io/custom-http-errors: "404,503" nginx.ingress.kubernetes.io/default-backend: error-backendCORS headers? Annotation:
annotations: nginx.ingress.kubernetes.io/enable-cors: "true" nginx.ingress.kubernetes.io/cors-allow-origin: "*"There are dozens of these. The ingress-nginx documentation is enormous, which is both a feature (you can do almost anything) and a burden (good luck remembering the annotation names). It is also now a historical record. That list will not grow.
Pros & Cons
Pros:
- Enormous installed base. A decade of Stack Overflow answers, blog posts, and third-party charts assume it.
- The Ingress object is standard Kubernetes. Only the annotations are nginx-specific.
- Deep annotation set: rate limiting, ModSecurity WAF, caching, external auth, custom headers.
- Configurable via ConfigMap for cluster-wide settings if you don’t want per-Ingress annotations.
Cons:
- Retired. No releases, no bug fixes, and no CVE patches after March 2026.
- Annotation soup. Config lives as strings on your Ingress objects. Easy to make typos, hard to validate.
- Heavier resource footprint than Traefik.
- Needs cert-manager or some external tool for TLS cert management.
- On k3s it collides with Traefik on ports 80 and 443 unless you plan the install.
Resource Footprint
Typical ingress-nginx deployment on an idle cluster:
- Roughly 100 to 150 MB resident
- The chart sets
replicaCount: 1. It is not highly available out of the box; scale it yourself. - Bigger attack surface than the other two, and now an unpatched one
HAProxy
The road less traveled. HAProxy is the old-school, dependable load balancer, and there are two separate Kubernetes controllers built on it. They are not interchangeable, and mixing up their documentation is the fastest way to waste an evening.
- haproxytech/kubernetes-ingress, from HAProxy Technologies themselves. Version 3.2.14 as of August 2026, released roughly monthly. Annotations use the
haproxy.org/prefix. It also ships a Gateway API controller. - jcmoraisjr/haproxy-ingress, the older community project. Version 0.16.1, last stable release May 2026. Annotations use the
haproxy-ingress.github.io/prefix.
The examples below use jcmoraisjr’s, because that is what most home lab guides reference. If you want the vendor-maintained path, install haproxytech and translate the annotation prefixes.
Philosophy
HAProxy is a traditional, high-performance load balancer. The HAProxy controller watches Ingress objects and generates native HAProxy config. It’s the choice for people who want maximal control over routing, connection pooling, and performance tuning.
The mental model: “I write Ingress YAML, but it gets compiled into HAProxy config. I can inject raw HAProxy directives if I need them.”
Install
helm repo add haproxy-ingress https://haproxy-ingress.github.io/chartshelm repo updatehelm install haproxy-ingress haproxy-ingress/haproxy-ingress \ --namespace ingress-haproxy \ --create-namespace \ --set controller.service.type=LoadBalancerThe /charts suffix on that URL is load-bearing. https://haproxy-ingress.github.io on its own serves the project’s documentation site, and helm repo add fails on the missing index.yaml. For the vendor build it’s a different repo:
helm repo add haproxytech https://haproxytech.github.io/helm-chartsEither one is lighter than ingress-nginx: single-container deployment, minimal overhead. And the same ServiceLB conflict applies. Type LoadBalancer on k3s means hostPort 80 and 443, and Traefik is sitting on them.
Basic Ingress Example
Standard Ingress YAML works:
apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: my-app namespace: defaultspec: ingressClassName: haproxy rules: - host: app.example.com http: paths: - path: / pathType: Prefix backend: service: name: my-service port: number: 8080 tls: - hosts: - app.example.com secretName: my-app-tlsBut HAProxy’s power is in annotations. For example, session stickiness:
annotations: haproxy-ingress.github.io/cookie: "SESSIONID" haproxy-ingress.github.io/cookie-strategy: "insert"Or backend timeouts:
annotations: haproxy-ingress.github.io/timeout-connect: "5000" haproxy-ingress.github.io/timeout-server: "30000"Pros & Cons
Pros:
- Lightest of the three. Runs on minimal resources.
- HAProxy itself dates to 2001 and has carried high-traffic production traffic for two decades.
- Fine-grained control over connection pooling, timeouts, retries, and health checks.
- Can inject raw HAProxy config when the annotations run out.
- The vendor build is actively released, and it speaks Gateway API.
Cons:
- Two competing controllers with incompatible annotation prefixes, so half the guides you find don’t apply to your install.
- Smaller community. Fewer blog posts, Stack Overflow answers, and third-party tools.
- Annotation ecosystem is smaller than nginx’s.
- HAProxy syntax is… not fun if you need to write raw config.
- Less common in cloud environments, where the ecosystem leaned nginx for years.
Resource Footprint
Minimal, on an idle cluster:
- Roughly 30 to 50 MB resident
- Single container, easy to run on edge machines
- Scales horizontally if needed
Comparison Matrix
| Feature | Traefik | ingress-nginx | HAProxy |
|---|---|---|---|
| Default on k3s? | Yes | No | No |
| Still maintained? | Yes (3.7.12, Aug 2026) | No. Retired March 2026 | Yes (haproxytech 3.2.14) |
| Config Style | IngressRoute CRD or Ingress | Annotations on Ingress | Annotations on Ingress |
| TLS Management | Native ACME, needs a HelmChartConfig on k3s | Needs cert-manager | Needs cert-manager |
| Portability | Ingress portable, IngressRoute not | Ingress portable, annotations not | Ingress portable, annotations not |
| Memory (idle) | 50-80 MB | 100-150 MB | 30-50 MB |
| Annotation Ecosystem | Medium | Large, and frozen | Small |
| Gateway API | Yes | No | Yes (haproxytech) |
| Learning Curve | Moderate | High (annotation soup) | Low (if familiar with HAProxy) |
| Best For | Almost anything on k3s | Clusters that already run it | Constrained hardware, deep tuning |
When to Swap Out Traefik
Stick with Traefik if:
- You like the auto-discovery philosophy.
- You want native ACME support without extra deployments.
- You’re on a single node or small cluster and memory matters (but not critically).
- IngressRoute syntax appeals to you.
- You don’t need to port Ingress YAML to other clusters.
Do not switch to ingress-nginx. It is retired. The only reason to be running it in late 2026 is that you already were. If that’s you, the questions are how long you’re comfortable serving traffic from unpatched edge software, and what you move to. Traefik and HAProxy both accept the same Ingress objects; the annotations are the part you rewrite.
Switch to HAProxy if:
- You want the lightest possible footprint.
- Your team knows HAProxy and wants to tune connection reuse and timeouts by hand.
- You’re running on constrained hardware (edge, single-board computers).
- You need dependable stability over feature richness.
Look at Gateway API if you’re rebuilding the edge anyway. It’s the successor to Ingress, Traefik and haproxytech both implement it, and it moves routing rules out of annotation strings into typed resources. Migrating off a retired controller is a reasonable moment to skip a generation.
Migration Path
Say you want to replace Traefik. The steps are the same whichever controller you land on; this example uses haproxytech.
-
Solve the port conflict first. Traefik holds hostPort 80 and 443 through ServiceLB. A second controller asking for a
LoadBalancerservice stays at<pending>until you free those ports or move the newcomer elsewhere. For a parallel test, put the new controller on different ports:Terminal window helm install haproxy haproxytech/kubernetes-ingress \--namespace ingress-haproxy --create-namespace \--set controller.service.type=LoadBalancer \--set controller.service.ports.http=8080 \--set controller.service.ports.https=8443Confirm it got an address before going further:
Terminal window kubectl get svc -n ingress-haproxy -wAn
EXTERNAL-IPstuck at<pending>means the ports are still taken. -
Convert one Ingress at a time by changing its class:
apiVersion: networking.k8s.io/v1kind: Ingressmetadata:name: my-appnamespace: defaultspec:ingressClassName: haproxy # was traefik# rest of spec stays the sameAnnotations need translating separately.
traefik.ingress.kubernetes.io/router.entrypointsmeans nothing to HAProxy, and it fails silently rather than erroring. -
Test against the temporary port instead of guessing from
kubectl get ingress:Terminal window curl -H 'Host: app.example.com' http://<node-ip>:8080/ -
Cut over. Scale Traefik to zero, then move the new controller onto 80 and 443:
Terminal window kubectl scale deployment -n kube-system traefik --replicas=0Treat that as temporary. The k3s helm-controller re-applies the bundled chart on the next k3s restart or upgrade and brings Traefik straight back. Fine while you test. Useless as a removal.
-
Remove it properly. k3s installs Traefik through a bundled HelmChart manifest, not a user-managed Helm release, so
helm uninstall traefikwon’t find it andhelm list -n kube-systemwon’t show it. Disable it at the k3s level: add--disable=traefikto your server args, or dropdisable: [traefik]into/etc/rancher/k3s/config.yaml, then restart k3s. Per the k3s docs,--disableactively uninstalls the AddOn and deletes the source manifest, so you get a clean removal rather than an orphan. Verify with:Terminal window kubectl get -n kube-system helmchartBoth
traefikandtraefik-crdHelmChart objects should be gone. If you took the.skipfile route instead, note that a.skipcreated after deployment changes nothing that already exists; you delete those two objects by hand.
The Ingress abstraction does hold up here. Your app manifests don’t care which controller is running. The Ingress object is the same, and only ingressClassName plus the annotations change.
The Real Decision
Pick on operational fit, not on benchmarks. Traefik is already installed, gets ACME from one HelmChartConfig, and covers what a home lab edge actually does. HAProxy is the answer on constrained hardware, or when you want to tune timeouts and connection reuse by hand. ingress-nginx is the thing you migrate away from, and Gateway API is worth a look while you’re in there.
For 95% of k3s home labs, Traefik does the job out of the box. But now you know what else exists, and when the default stops being the answer.
Common Questions
Is ingress-nginx safe to keep running after the retirement?
It keeps working, but nobody patches it. Best-effort maintenance ended in March 2026 at controller v1.15.1, so any vulnerability found after that date stays open. Running ingress-nginx behind a trusted network for a few more months is a judgment call. Running it on an internet-facing edge is a countdown.
Do I need cert-manager if I use Traefik on k3s?
No. Traefik’s built-in ACME client handles Let’s Encrypt once you add a certificatesResolvers block through a HelmChartConfig and turn on persistence for acme.json. Reach for cert-manager when you need certificates for things that are not ingress, such as webhook servers or mTLS between pods.
Why does my new ingress controller stay stuck in pending on k3s?
k3s ServiceLB implements LoadBalancer services with hostPorts. Traefik already binds 80 and 443 on every node, so a second controller requesting the same ports finds no node with them free and stays pending forever. Give the new controller different service ports, or disable Traefik first.
Can I run Traefik and ingress-nginx at the same time?
Yes, as long as they listen on different ports and every Ingress names one controller through ingressClassName. That is the normal way to migrate. An Ingress with no ingressClassName and no default IngressClass gets picked up by neither, which looks exactly like a broken controller.
Which HAProxy ingress controller should I install?
haproxytech/kubernetes-ingress, from HAProxy Technologies. It ships monthly releases and supports Gateway API. The jcmoraisjr/haproxy-ingress project still works but moves slower. The two use different annotation prefixes, haproxy.org/ versus haproxy-ingress.github.io/, so choose before you write any annotations.