Skip to content
Go back

Ingress Choices in k3s: Traefik vs ingress-nginx vs HAProxy

By SumGuy 18 min read
Ingress Choices in k3s: Traefik vs ingress-nginx vs HAProxy
Contents

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:

Terminal window
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:

/etc/rancher/k3s/config.yaml
disable:
- traefik

Most 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:

Terminal window
kubectl get deployment -n kube-system traefik

See 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/v1alpha1
kind: IngressRoute
metadata:
name: my-app
namespace: default
spec:
entryPoints:
- websecure
routes:
- match: Host(`app.example.com`)
kind: Rule
services:
- name: my-service
port: 8080
tls:
secretName: my-app-tls

websecure 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/v1
kind: Ingress
metadata:
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-tls

IngressRoute 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:

/var/lib/rancher/k3s/server/manifests/traefik-config.yaml
apiVersion: helm.cattle.io/v1
kind: HelmChartConfig
metadata:
name: traefik
namespace: kube-system
spec:
valuesContent: |-
certificatesResolvers:
letsencrypt:
acme:
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: /data

The 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/v1alpha1
kind: Middleware
metadata:
name: https-redirect
namespace: default
spec:
redirectScheme:
scheme: https
permanent: true
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: my-app-redirect
namespace: default
spec:
entryPoints:
- web
routes:
- match: Host(`app.example.com`)
kind: Rule
middlewares:
- name: https-redirect
services:
- name: my-service
port: 8080
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: my-app
namespace: default
spec:
entryPoints:
- websecure
routes:
- match: Host(`app.example.com`)
kind: Rule
services:
- name: my-service
port: 8080
tls:
certResolver: letsencrypt

Two 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:

Cons:

Resource Footprint

Default Traefik deployment in k3s, measured on an idle single-node cluster:

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

Terminal window
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update
helm install ingress-nginx ingress-nginx/ingress-nginx \
--namespace ingress-nginx \
--create-namespace

This 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/v1
kind: Ingress
metadata:
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-tls

The 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-backend

CORS 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:

Cons:

Resource Footprint

Typical ingress-nginx deployment on an idle cluster:

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.

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

Terminal window
helm repo add haproxy-ingress https://haproxy-ingress.github.io/charts
helm repo update
helm install haproxy-ingress haproxy-ingress/haproxy-ingress \
--namespace ingress-haproxy \
--create-namespace \
--set controller.service.type=LoadBalancer

The /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:

Terminal window
helm repo add haproxytech https://haproxytech.github.io/helm-charts

Either 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/v1
kind: Ingress
metadata:
name: my-app
namespace: default
spec:
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-tls

But 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:

Cons:

Resource Footprint

Minimal, on an idle cluster:

Comparison Matrix

FeatureTraefikingress-nginxHAProxy
Default on k3s?YesNoNo
Still maintained?Yes (3.7.12, Aug 2026)No. Retired March 2026Yes (haproxytech 3.2.14)
Config StyleIngressRoute CRD or IngressAnnotations on IngressAnnotations on Ingress
TLS ManagementNative ACME, needs a HelmChartConfig on k3sNeeds cert-managerNeeds cert-manager
PortabilityIngress portable, IngressRoute notIngress portable, annotations notIngress portable, annotations not
Memory (idle)50-80 MB100-150 MB30-50 MB
Annotation EcosystemMediumLarge, and frozenSmall
Gateway APIYesNoYes (haproxytech)
Learning CurveModerateHigh (annotation soup)Low (if familiar with HAProxy)
Best ForAlmost anything on k3sClusters that already run itConstrained hardware, deep tuning

When to Swap Out Traefik

Stick with Traefik if:

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:

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.

  1. Solve the port conflict first. Traefik holds hostPort 80 and 443 through ServiceLB. A second controller asking for a LoadBalancer service 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=8443

    Confirm it got an address before going further:

    Terminal window
    kubectl get svc -n ingress-haproxy -w

    An EXTERNAL-IP stuck at <pending> means the ports are still taken.

  2. Convert one Ingress at a time by changing its class:

    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
    name: my-app
    namespace: default
    spec:
    ingressClassName: haproxy # was traefik
    # rest of spec stays the same

    Annotations need translating separately. traefik.ingress.kubernetes.io/router.entrypoints means nothing to HAProxy, and it fails silently rather than erroring.

  3. Test against the temporary port instead of guessing from kubectl get ingress:

    Terminal window
    curl -H 'Host: app.example.com' http://<node-ip>:8080/
  4. 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=0

    Treat 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.

  5. Remove it properly. k3s installs Traefik through a bundled HelmChart manifest, not a user-managed Helm release, so helm uninstall traefik won’t find it and helm list -n kube-system won’t show it. Disable it at the k3s level: add --disable=traefik to your server args, or drop disable: [traefik] into /etc/rancher/k3s/config.yaml, then restart k3s. Per the k3s docs, --disable actively 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 helmchart

    Both traefik and traefik-crd HelmChart objects should be gone. If you took the .skip file route instead, note that a .skip created 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.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Previous Post
Compose-to-Helm Migration That Doesn't Break
Next Post
k3s Backups: etcd Snapshots + Velero

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts