conndeck blog

Picking an Ingress Controller: nginx vs Traefik vs ALB

Last March I spent a full day migrating a client's ingress stack, and by 6 PM the score was: two controllers half-installed, one eleven-minute production outage, and a Slack thread titled "why did we do this" with 140 messages. I've since run ingress-nginx, Traefik, and the AWS Load Balancer Controller in production -- some at the same time in the same cluster, which I don't recommend but taught me a lot. Here's the comparison I wish existed.

Quick answer: On AWS with straightforward HTTP routing needs, use the AWS Load Balancer Controller -- ALBs are managed infrastructure, the ops burden is near zero, and IP target mode routes straight to pods. If you need rich traffic management (rate limiting, auth offloads, rewrites) or you're multi-cloud or on-prem, ingress-nginx is the default for a reason: massive community, every problem has a Stack Overflow answer. Traefik is the middle path -- cleaner dynamic configuration, friendly CRDs, smaller ecosystem. Pick based on your team's operational appetite, because you'll be living in this controller's annotation docs for years.

What an ingress controller actually does

An Ingress resource is inert YAML. Nothing in Kubernetes core reads it. The controller watches Ingress objects and programs a real proxy -- nginx, Envoy, Traefik, or an actual AWS ALB -- to match. That's why "which ingress" is really "which proxy, operated how, configured with what annotation dialect."

One consequence that bites everyone eventually: annotations are controller-specific. nginx.ingress.kubernetes.io/limit-rps: "50" means something to ingress-nginx and nothing to Traefik or an ALB. There is no warning when you use the wrong dialect. The Ingress just quietly doesn't do what you think.

Check which controller is actually serving a given Ingress:

$ kubectl get ingress -A
NAMESPACE   NAME    CLASS    HOSTS                  ADDRESS
shop        web     alb      shop.example.com       k8s-shop-web-1a2b3c.us-east-1.elb.amazonaws.com
api         gateway nginx    api.internal.example   10.0.4.91

If CLASS doesn't match the controller you think you configured, that's your bug. Plural controllers in one cluster is fine and sometimes correct -- internal ALB for one class, nginx for another -- as long as every Ingress names its class explicitly.

ingress-nginx: the default, warts included

ingress-nginx is a Kubernetes controller wrapped around the nginx proxy, configured by generating an nginx.conf from your Ingress objects and hot-reloading it. It's the most deployed ingress on earth, and its annotation surface reflects that: rate limiting, auth offloading, CORS, canary weights, custom timeouts, config snippets. If a traffic-shaping feature exists, there's an annotation for it.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web
  namespace: shop
  annotations:
    nginx.ingress.kubernetes.io/limit-rps: "20"
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
spec:
  ingressClassName: nginx
  rules:
    - host: shop.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 8080

The strengths are real: performance is excellent, the knowledge base is bottomless, and every tutorial assumes it. The warts are real too. Config reloads historically caused brief connection drops under heavy churn (largely fixed with lua-based dynamic updates, but old reputation dies hard). The annotation sprawl turns your Ingress resources into a dialect only this controller speaks, which makes future migration painful. And because nginx is the proxy, you occasionally hit raw nginx behavior -- 502s from upstream connect failures, client_max_body_size -- that the Kubernetes abstraction doesn't hide.

Traefik: the pleasant one

Traefik is a Go reverse proxy that was born dynamic -- it watches Kubernetes natively rather than generating config files for another process. Day two with Traefik is genuinely nicer: the dashboard shows discovered routers and services, config reloads are a non-event, and the CRD-based IngressRoute model beats annotation-crammed Ingress:

apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
  name: web
  namespace: shop
spec:
  entryPoints:
    - websecure
  routes:
    - match: Host(`shop.example.com`)
      kind: Rule
      services:
        - name: web
          port: 8080
      middlewares:
        - name: rate-limit

Middlewares as composable objects -- chain a rate limiter, a header stripper, a retry policy across routes -- beats stuffing everything into per-Ingress annotations. Traefik also handles TCP and UDP natively and speaks Gateway API.

The costs: the community is a fraction of nginx's, so obscure failure modes get fewer search results. Performance is good but a hair under tuned nginx at the extremes, which matters to almost nobody. And the v2-to-v3 config reshuffles left a trail of outdated tutorials with confidently wrong syntax. Read the docs pinned to your version, not the top Google result.

AWS Load Balancer Controller: the managed option

The AWS Load Balancer Controller is different in kind: it doesn't run a proxy in your cluster. It watches Ingress (and Gateway, and Service) objects and provisions actual AWS ALBs and NLBs via the AWS API. Your data plane is AWS's managed, autoscaled, patched-by-someone-else load balancer.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web
  namespace: shop
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/healthcheck-path: /healthz
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTPS": 443}]'
    alb.ingress.kubernetes.io/certificate-arn: arn:aws:acm:us-east-1:123456789012:certificate/abc-123
spec:
  ingressClassName: alb
  rules:
    - host: shop.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web
                port:
                  number: 8080

The killer detail is target-type: ip. In the default instance mode, the ALB targets node IPs on the service's NodePort and kube-proxy does a second hop, possibly to a pod on another node. In ip mode, pod IPs register directly as ALB targets -- one hop, client IP preserved, health checks hit actual pods. Use ip mode.

What you give up: ALB is a commodity HTTP load balancer, so no rate limiting beyond AWS WAF, no config snippets, and every change means target registration churn that's slower than an in-cluster proxy reload. Also, you're married to AWS, and ALB pricing is per-LCU, so high-traffic services can cost more than running nginx yourself. One more quirk: by default each Ingress gets its own ALB. Use alb.ingress.kubernetes.io/group.name to share one ALB across many Ingresses, or watch your bill grow with your service count.

Pick it when you're on EKS, your routing needs are vanilla HTTP(S), and you'd rather pay AWS than run a proxy fleet.

The honest decision matrix

My shorthand after running all three:

  • EKS, simple routing, low ops appetite: AWS Load Balancer Controller, ip target mode, group sharing on.
  • Need auth/rate-limit/canary/rewrite richness, any cloud, on-prem: ingress-nginx. Accept the annotation dialect as technical debt.
  • Small platform team, values config hygiene, wants TCP/UDP and Gateway API in one box: Traefik.
  • Multi-cluster or multi-cloud with a service mesh already: evaluate Gateway API with your mesh's gateway implementation instead of any of the above.

And whichever you pick: run one controller, standardize the class name, write down the annotation dialect in your team docs, and keep the escape hatch (a second controller class) in your back pocket. The eleven-minute outage I opened with came from two controllers both thinking they owned the same Ingress. Explicit ingressClassName on every object is the whole fix.

When you're debugging why an Ingress has no ADDRESS or why backends are unhealthy, you end up bouncing between kubectl describe ingress, controller logs, and endpoint objects -- the kind of cross-resource spelunking that conndeck's unified view genuinely speeds up, though the CLI gets you there with enough patience.

Frequently asked questions

Which Kubernetes ingress controller should I use?

If you're on AWS and want minimal moving parts, the AWS Load Balancer Controller with ALB ingress is the low-ops choice. If you need rich per-service behavior like rate limiting, auth, and rewrites, or you're multi-cloud or on-prem, ingress-nginx is the battle-tested default with the largest knowledge base. Traefik sits in between: easier dynamic config and a nicer day-two experience, with a smaller community. There is no universally best answer, only the best fit for your team's appetite for YAML.

Is ingress-nginx deprecated or going away?

No. The retirement announcement that caused panic in 2025 concerned the Ingress API itself being frozen in favor of Gateway API, and separately some confusion around the older kubernetes/ingress-nginx maintenance status. The ingress-nginx controller still works and is still widely deployed, but new investment is going into Gateway API implementations. Plan a Gateway API migration on a normal timeline, not an emergency one.

What is the difference between ALB target type instance and ip?

Instance mode routes ALB traffic to the NodePort on each node, and kube-proxy forwards it from there to a pod, possibly on another node, adding a hop and losing the client IP unless you set externalTrafficPolicy Local. IP mode registers pod IPs directly as ALB targets, so traffic goes straight from the load balancer to the pod with source IP preserved. IP mode needs the AWS VPC CNI with security groups for pods to be a good citizen, and it's the mode you should use unless you have a reason not to.

Why are my ingress-nginx annotations being ignored?

The two most common causes are that your Ingress is missing the kubernetes.io/ingress.class annotation or an ingressClassName field pointing at the right IngressClass, so the controller never adopts it, or you're running two controllers and reading the docs for the other one. The annotation sets for ingress-nginx, Traefik, and the AWS Load Balancer Controller are completely different namespaces and none of them warn you when you use a foreign annotation. Check which controller class your Ingress references first.

Should I migrate from Ingress to Gateway API?

If you're setting up something new in 2026, yes, start with Gateway API since Ingress is frozen and every major controller now ships a Gateway implementation. If you have hundreds of working Ingress resources, there's no fire: Ingress support will exist for years. Migrate incrementally per namespace or per app when you touch them anyway, and don't do a big-bang rewrite of something that isn't broken.

The parting advice

An ingress controller is a proxy fleet you're choosing to operate, or in the ALB case, choosing to rent. The APIs look the same, but the annotation dialects, failure modes, and bills are all different. Choose deliberately, write it down, and pin every Ingress to an explicit class.

All three options are fine. What's not fine is inheriting one from a tutorial and finding out three years later, at 6 PM on migration day, what it can't do.