ingress-nginx
ingress-nginx is the Kubernetes-community-maintained Ingress controller built on NGINX (technically OpenResty — NGINX plus an embedded Lua runtime), and it is by a wide margin the most commonly deployed one: a controller Pod watches the API server for Ingress, Service, EndpointSlice, and Secret objects, and turns them into a live, reloadable NGINX configuration that actually terminates and routes HTTP(S) traffic into the cluster. Everything past "install it" is really about two things: the handful of real Kubernetes objects you write (IngressClass, Ingress, TLS Secrets) and the large annotation surface that covers everything the plain Ingress spec deliberately leaves out — rewrites, body-size limits, rate limiting, canary weighting. This page covers the controller's architecture, the manifests and annotations you'll actually write, TLS termination, path routing semantics, the commands worth knowing cold, the gotchas that catch people in production, and — because it matters for anything you build today — where the project's own 2025 announcement about winding down leaves you relative to the Gateway API.
Picture a huge apartment building with hundreds of units but only one street-facing front door, and a doorman standing right there. Every visitor hands over a slip with two things written on it: which building they're looking for — shop.example.com, that's the Host — and sometimes an errand, like "delivery for the kitchen," that's the path, like /api. The doorman reads only that slip. He doesn't wander the halls guessing, and he never bothers a resident on a floor the slip didn't mention — he checks his clipboard (the rules from every Ingress object) and walks the visitor straight to the correct apartment door. If the slip names a building that isn't on the clipboard at all, no guessing happens — a plain "nobody here by that name" sign lights up instead. ingress-nginx is that doorman for your whole cluster: it sits at the one address the internet can actually reach, reads only the Host and path on each request, and hands it straight to the right Service inside — no apartment ever needs its own separate street door.
What the controller actually is, and the request's real path through it
☺ Like you're 10: One Pod watches the clipboard of rules, writes them into NGINX's own settings file, and reloads NGINX — the same NGINX that then does the actual door-answering.
The ingress-nginx controller is an ordinary Deployment (or DaemonSet) running one process that does two jobs at once. First, a Go control loop — the same watch-and-reconcile pattern behind every controller covered in The Kubernetes API & the Controller Pattern — subscribes to changes in Ingress, Service, EndpointSlice, Secret, and ConfigMap objects. Second, that loop renders those objects into a real nginx.conf from a Go template and reloads (or dynamically reconfigures) an embedded NGINX process running in the same Pod. The detail worth remembering: NGINX proxies straight to backend Pod IPs it reads from each Service's EndpointSlices, not to the Service's ClusterIP. Routing through the ClusterIP would mean every request gets load-balanced twice — once by ingress-nginx, once again by kube-proxy — so the controller skips that second hop entirely and balances directly across the real Pods.
Because the controller balances across live Pod IPs instead of a Service abstraction, your Pod's readiness probe is doing double duty: it's not just gating the rolling update covered in the controller pattern, it's the exact signal that adds or removes that Pod from the EndpointSlice ingress-nginx is reading. A Pod that reports ready before it can actually serve traffic gets real production requests routed straight at it, with no Service-level buffer in between.
IngressClass and the Ingress objects you write
☺ Like you're 10: One object says "I am the nginx doorman," and every other object points at that name to say "route through him, specifically."
When more than one Ingress controller might exist in a cluster — a cloud provider's own plus ingress-nginx, say — something has to say which one a given Ingress is for. That's IngressClass: a small, cluster-scoped object naming the controller (k8s.io/ingress-nginx) that owns it, optionally marked as the cluster's default.
# IngressClass — created once by the Helm chart's default values
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: nginx
annotations:
ingressclass.kubernetes.io/is-default-class: "true"
spec:
controller: k8s.io/ingress-nginxEvery Ingress object then references it through spec.ingressClassName. The now-deprecated kubernetes.io/ingress.class annotation still works on older versions but doesn't belong in anything written today; the two are mutually exclusive shims for the same idea, and recent controller versions ignore the annotation entirely.
# Ingress — host + path routing, TLS, and a first taste of annotations
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop
namespace: storefront
annotations:
nginx.ingress.kubernetes.io/ssl-redirect: "true"
nginx.ingress.kubernetes.io/proxy-body-size: 25m
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: nginx
tls:
- hosts: [shop.example.com]
secretName: shop-tls
rules:
- host: shop.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service: {name: api, port: {number: 8080}}
- path: /
pathType: Prefix
backend:
service: {name: web, port: {number: 80}}Nothing above is nginx-specific — this same object shape, host/path rules plus a tls block, is the portable core the plain Ingress spec models, exactly as Services & Networking covers it for the CKA blueprint. What's controller-specific is everything in metadata.annotations, which is where the next section lives.
Annotations: the escape hatch for everything the spec doesn't model
☺ Like you're 10: The rulebook only covers "which door" — every other instruction gets written as a sticky note on the slip instead.
The Ingress resource intentionally models almost nothing beyond host/path routing and TLS. Rewrites, request size limits, rate limiting, IP allow-lists, sticky sessions, canary weighting — anything past basic routing goes through nginx.ingress.kubernetes.io/* annotations, and there are well over a hundred of them documented upstream. A handful cover most real usage:
| Annotation | What it does |
|---|---|
rewrite-target | Rewrites the matched path before proxying — usually paired with use-regex: "true" and a capturing path like /api(/|$)(.*), rewriting to /$2 |
ssl-redirect | Forces plain HTTP requests to 308-redirect to HTTPS — on by default whenever a tls block exists |
proxy-body-size | Max request body size; defaults to 1m, and file uploads over that fail with a silent 413 until this is raised |
limit-rps / limit-connections | Per-source-IP request-rate and concurrent-connection caps, enforced at the NGINX layer |
whitelist-source-range | Comma-separated CIDRs allowed to reach this Ingress — a coarse, edge-level NetworkPolicy substitute for one host |
affinity: cookie | Sticky sessions via an NGINX-managed cookie, for backends that assume one client keeps hitting the same Pod |
canary / canary-weight | Splits a percentage of traffic to a second Ingress sharing the same host and path — see the caveat below |
# canary Ingress — 10% of shop.example.com/ traffic to the new version.
# Requires a non-canary "primary" Ingress already routing that same host + path (the `shop` Ingress above).
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop-canary
namespace: storefront
annotations:
nginx.ingress.kubernetes.io/canary: "true"
nginx.ingress.kubernetes.io/canary-weight: "10"
spec:
ingressClassName: nginx
rules:
- host: shop.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service: {name: web-v2, port: {number: 80}}This page covers ingress-nginx — kubernetes/ingress-nginx, community-maintained under Kubernetes SIG Network, annotation prefix nginx.ingress.kubernetes.io/. F5/NGINX Inc. separately ships NGINX Ingress Controller (nginx/kubernetes-ingress, OSS and Plus editions), with a different annotation prefix (nginx.org/) and its own CRDs for advanced routing. The two are not interchangeable, their annotations don't cross over, and "which nginx ingress controller" is worth confirming explicitly before copying a manifest from a blog post or a Helm values file from a coworker.
TLS termination and cert-manager
☺ Like you're 10: The Ingress just names which certificate goes with which website name — a separate helper is what actually gets the certificate and keeps it fresh.
TLS termination itself is plain: an Ingress's tls block names one or more hosts and the Secret (type kubernetes.io/tls, holding tls.crt and tls.key) that carries the certificate for them. On a fresh connection, NGINX picks the right certificate per host using TLS SNI before the request's path is even visible, which is what lets one controller terminate TLS for dozens of unrelated hostnames on the same IP and port. What ingress-nginx doesn't do is issue or renew that certificate — that's cert-manager's job entirely. The cert-manager.io/cluster-issuer annotation on the shop Ingress above is cert-manager watching for exactly that annotation, requesting a certificate from the named ClusterIssuer (commonly Let's Encrypt via ACME), and writing the result into shop-tls — the same Secret name the Ingress already references — well before it expires.
One consequence worth knowing before it surprises you: if an Ingress asks for a host with no matching TLS Secret yet — a fresh cert-manager request still mid-ACME-challenge, say — ingress-nginx serves that host a bundled, self-signed fake certificate rather than refusing the connection. That's deliberate (it keeps the controller from crashing on a temporarily-missing Secret), but it means a browser TLS warning during a cert rollout is a genuinely different problem than a routing failure, and the two get diagnosed differently.
Path-based routing: pathType, priority, and rewrites
☺ Like you're 10: "Starts with" and "exactly matches" are different promises, and when two rules could both answer, the more specific one wins.
Every path in an Ingress rule declares a pathType, and the three values mean genuinely different things. Exact matches the URL path character-for-character, case-sensitive. Prefix matches on /-delimited path segments — /api matches /api and /api/v1/orders, but not /apiary. ImplementationSpecific hands interpretation to the controller itself, which for ingress-nginx means regex is allowed when nginx.ingress.kubernetes.io/use-regex: "true" is set — that's what makes the capturing pattern /api(/|$)(.*) paired with rewrite-target: /$2 legal, stripping the /api prefix before the request ever reaches the backend.
When multiple rules could match the same request, Kubernetes' own Ingress spec says the longest matching path wins, and an exact match beats a prefix match of equal length. A subtler production trap sits one level up: two separate Ingress objects that both declare the same host get merged by the controller into a single generated NGINX server block for that host, path rules from both combined. That's genuinely useful — it's how one team can own shop.example.com/api and another can own shop.example.com/ without either editing the other's manifest — but it also means a bad annotation on one team's Ingress can affect the whole shared host's server block, not just their own path. Namespacing a host to one team, or reviewing what else already claims it with kubectl get ingress -A --field-selector spec.rules[0].host=shop.example.com-style filtering, is worth doing before assuming a path is isolated.
Day-to-day commands
☺ Like you're 10: Install it, ask it what rules it knows about, then ask it to show you the real settings file it actually wrote.
# --- install and locate the controller ---
$ helm install ingress-nginx ingress-nginx \
--repo https://kubernetes.github.io/ingress-nginx \
--namespace ingress-nginx --create-namespace
$ kubectl -n ingress-nginx get pods -l app.kubernetes.io/component=controller
$ kubectl get ingressclass # which controller(s) exist, and which is default
# --- inspect what an Ingress actually produced ---
$ kubectl -n storefront describe ingress shop # events surface sync/annotation errors immediately
$ kubectl -n ingress-nginx exec deploy/ingress-nginx-controller -- nginx -T | less
# the real generated config, straight from the Pod
$ kubectl -n ingress-nginx logs -l app.kubernetes.io/component=controller --tail=50 -f
# --- prove routing before you trust DNS ---
$ curl -sk -H 'Host: shop.example.com' https:///api/health
$ kubectl -n storefront get endpointslices -l kubernetes.io/service-name=api
# exactly what the controller is balancing across
# --- when an apply gets silently rejected ---
$ kubectl get validatingwebhookconfigurations ingress-nginx-admission
$ kubectl -n ingress-nginx logs -l app.kubernetes.io/component=controller --tail=80 | grep -i admission The nginx -T line is the single most useful habit here: annotations are easy to get subtly wrong, and reading the config they actually produced settles an argument about "what should be happening" faster than guessing from the YAML alone.
Gotchas that catch people in production
☺ Like you're 10: A few surprises show up almost every time: silent size limits, a shared front door, and a settings file that reloads more than people expect.
The default 1MB body limit. proxy-body-size defaults to 1m. An upload endpoint with no explicit override fails every request over roughly a megabyte with a 413 the client and the backend both had no part in producing — it never reaches the Service at all.
An unmatched host isn't an error, it's the default backend. A request for a host no Ingress claims doesn't fail loudly — it lands on the controller's own built-in default backend (or whatever Ingress.spec.defaultBackend or the --default-backend-service flag points to) and gets a plain 404. That 404 coming back with the right status code but zero application logs is the single most common "the Ingress isn't working" report that turns out to be a typo'd host field.
Not every change is a full reload. Pure endpoint churn — a Deployment scaling up or down — is applied dynamically through the embedded Lua balancer with no reload at all. Structural changes — a new host, a new TLS Secret, changed annotations — do trigger a full NGINX reload, which briefly interrupts in-flight connections. A pipeline that creates and tears down a fresh Ingress per preview environment many times an hour can turn that occasional reload into a steady background cost worth watching for under load.
The admission webhook is a real attack surface, not paperwork. Before any Ingress change is persisted, a ValidatingWebhookConfiguration has the controller Pod render the proposed config through an actual nginx -t test, rejecting anything that wouldn't parse. That mechanism is exactly what a 2025 vulnerability chain publicly reported as "IngressNightmare" (tracked as CVE-2025-1974 and several related CVEs) exploited: crafted, attacker-controlled Ingress content reaching that config-test path enabled remote code execution inside the controller Pod for anyone with basic network reach to the webhook's Service — no RBAC permission to create Ingress objects required. The concrete mitigations are unglamorous and worth doing regardless of any specific CVE: keep the controller on a patched, supported version, and restrict network reach to the admission webhook's Service with a NetworkPolicy rather than assuming cluster-internal traffic is automatically trustworthy.
ingress-nginx vs. the alternatives — and the Gateway API direction
☺ Like you're 10: Other doormen exist, and the club that runs Kubernetes itself has said the long-term plan is a completely redesigned front-door job description.
| Controller | Where it fits |
|---|---|
| ingress-nginx | The default choice for most self-managed clusters — free, ubiquitous, huge annotation surface, this page |
| Traefik | Config-as-CRDs rather than annotation strings, popular for its own dashboard and middleware chaining |
| NGINX Ingress Controller (F5/NGINX Inc.) | A genuinely different project sharing the "nginx" name — see the warning above — with a Plus tier and its own CRDs |
| Cloud-native (AWS Load Balancer Controller, GKE Ingress, App Gateway) | Provisions a real cloud L7 load balancer per Ingress instead of a proxy Pod — no controller Pod to run or patch, but locked to that cloud |
| Gateway API implementations (Cilium, Envoy Gateway, and others) | The typed, role-split successor family — see below |
In 2025, ingress-nginx's own maintainers, under Kubernetes SIG Network, publicly announced the project is winding down toward retirement: no new features, a limited window of critical-CVE-only fixes, and an explicit recommendation to plan migration toward Gateway API implementations instead. The stated reasons line up with what this page has already covered — a shrinking volunteer maintainer pool maintaining a large annotation surface that was never type-checked or portable across controllers, and the IngressNightmare CVE chain underscoring the real cost of that complexity sitting inside a network-reachable admission path. None of that makes ingress-nginx disappear from clusters running it today, and support timelines for projects like this one have shifted before — treat this as a firm signal to start planning a Gateway API migration path deliberately, not a reason to panic-migrate this week, and check kubernetes/ingress-nginx's own repository for the current status before making a firing decision either way. The Gateway API's role-split model — a cluster-operator-owned Gateway, application-team-owned HTTPRoutes, typed weight fields replacing the canary-weight annotation above — is covered end to end in Services & Networking and, past this course's Kubestronaut scope, in Platform Engineering's Networking & Service Connectivity and the sibling Golden Astronaut course.
Project retirement timelines, CVE details, and Gateway API implementation maturity all move faster than any static page can track. This is independent, unofficial study material, not affiliated with the CNCF, the Linux Foundation, or the ingress-nginx maintainers — before making a real migration decision, read the current announcement directly from kubernetes/ingress-nginx on GitHub rather than trusting a snapshot written on any single date.
On a kind cluster with a node labeled ingress-ready=true and ports 80/443 mapped out, install ingress-nginx via its kind-specific manifest, then apply the shop Ingress from this page (swap in Services you actually have running). Confirm routing with curl -H 'Host: shop.example.com' http://localhost/, then deliberately request a host no Ingress claims and confirm you get the default backend's 404, not a connection error. Run kubectl exec deploy/ingress-nginx-controller -n ingress-nginx -- nginx -T | grep -A5 shop.example.com and match what you see against your YAML line for line. Finally, scale one backend Deployment to zero and watch curl return a 503 instead — same controller, a completely different failure than the 404 you saw a moment ago, and telling the two apart on sight is most of what real ingress troubleshooting is.
"Two failures get reported as 'the ingress is broken' and they are not the same bug. A 404 means the doorman never found a matching room on the clipboard — wrong host, wrong path, missing IngressClass, a typo in spec.rules[0].host. A 503 means the doorman found the room just fine and nobody answered — the Service has no ready Endpoints, or every backend Pod is crash-looping. I check kubectl describe ingress first for the former and kubectl get endpointslices for the latter, and I almost never need a third command before I know which half of the system to blame.
Pip the Hummingbird: Every request, one job: read the Host and the path, hand it to the right door. I don't care what's behind the door, I just care that I never guess.
Foxy: Which is exactly why "the ingress is down" is never enough information for me. Show me the status code first. 404 and 503 point at completely different halves of the system.
Gizmo: Or — hot tip — just slap whitelist-source-range: 0.0.0.0/0 and proxy-body-size: 0 on everything so nothing ever gets blocked or rejected again. Problem permanently solved!
Timmy the Turtle: 0.0.0.0/0 isn't an allow-list, Gizmo, it's the absence of one — you've just spent an annotation to write down that you didn't restrict anything. And an unlimited body size on a public upload endpoint is an invitation, not a fix.
Benny the Beaver: The annotations are genuinely useful, though — I'd rather write one rewrite-target line than hand-build a reverse-proxy config for every service we ship.
Ellie the Elephant: Just don't forget they're controller-specific. I've watched a whole Ingress migration stall for a week because half the annotations had no equivalent on the new controller, and nobody had checked before the cutover started.
Pip: Which is the whole pitch behind the Gateway API move, honestly — typed fields instead of a hundred controller-flavored strings. I'll still be the one answering the door either way.
1. Why does the ingress-nginx controller route directly to backend Pod IPs instead of through a Service's ClusterIP, and what does that mean for how you should think about your readiness probe? 2. What's the difference between pathType: Prefix and pathType: Exact, and which one wins when both could match the same request? 3. A client gets a plain 404 for shop.example.com/nonsense. Is that the application's 404, or something else — and how would you tell? 4. What's the practical difference between the two projects both commonly called "the nginx ingress controller"? 5. Which is responsible for issuing and renewing a TLS certificate — ingress-nginx or cert-manager — and what does ingress-nginx actually do with the resulting Secret? 6. Why did ingress-nginx's own maintainers recommend planning a migration toward the Gateway API, and does that mean you should rip out ingress-nginx immediately?
Check your answers
- Routing through the ClusterIP would mean traffic gets load-balanced twice — once by ingress-nginx, once by kube-proxy — so the controller reads Pod IPs straight from each Service's EndpointSlice and skips that second hop. Because of that, your readiness probe directly controls whether a Pod is in that live routing set, not just whether a rolling update proceeds.
Prefixmatches on whole/-delimited path segments (/apimatches/api/v1but not/apiary);Exactmatches the full path character-for-character. When multiple rules could match, the longest matching path wins, and an exact match beats a prefix match of equal length.- It's the controller's own built-in default backend responding, not the application — no
Ingressobject claims that host or path at all. Confirm withkubectl describe ingressand check for a matchinghost/path, and compare against a503, which means a matching rule was found but the backend Service had no ready Endpoints. kubernetes/ingress-nginx(this page, community-maintained,nginx.ingress.kubernetes.io/annotations) and F5/NGINX Inc.'s separatenginx/kubernetes-ingressproject (nginx.org/annotations, its own CRDs, an OSS and a Plus tier) are two different codebases that happen to share "nginx" in the name — their annotations and manifests are not interchangeable.- cert-manager owns issuance and renewal end to end, watching for annotations like
cert-manager.io/cluster-issuerand writing the resulting certificate into akubernetes.io/tlsSecret. ingress-nginx never talks to a certificate authority itself — it only reads that Secret and uses TLS SNI to serve the right certificate per hostname. - The stated reasons were a shrinking maintainer pool supporting a large, non-portable annotation surface, and security incidents like the IngressNightmare CVE chain underscoring the cost of that complexity sitting on a network-reachable admission path — with the Gateway API's typed, role-split object model as the intended long-term replacement. It does not mean an immediate rip-and-replace: existing clusters keep working, support timelines have shifted before, and the right move is planning a deliberate migration path while checking the project's current status directly rather than acting on a single snapshot in time.