Ingress

An Ingress is a Kubernetes API object (networking.k8s.io/v1, kind: Ingress) that declares how external HTTP and HTTPS traffic should be routed to in-cluster Services — by hostname, by URL path, and (optionally) terminated against a TLS certificate stored in a Secret. The Ingress object is purely declarative state; it does nothing on its own. An Ingress Controller — a separately-installed Pod (or set of Pods) that runs a reverse proxy such as NGINX, Envoy, Traefik, HAProxy, or a cloud-vendor load balancer — watches Ingress objects in the API server and reconciles them into actual L7 proxying (kubernetes.io/docs — Ingress). The Ingress resource graduated to GA in Kubernetes 1.19 (the networking.k8s.io/v1 group/version carries FEATURE STATE: Kubernetes v1.19 [stable]) via KEP-1453, and has since been formally frozen. The Kubernetes documentation now states verbatim that “The Ingress API has been frozen” — meaning it remains generally available with no plans for removal, but “is no longer being developed, and will have no further changes or updates made to it”; the project explicitly “recommends using Gateway instead of Ingress” (kubernetes.io/docs — Ingress). New feature work happens in the successor Gateway API instead, which itself reached its v1.0 GA milestone in late 2023 and is at v1.5 as of February 2026 (Gateway API v1.5 — Kubernetes Blog 2026-04-21). Despite the freeze, Ingress remains the workhorse L7 entry point in a large fraction of production clusters today, because it predates Gateway API by half a decade and because every controller already implements it — though the late-2025 retirement of the reference ingress-nginx controller (see Failure Modes) is rapidly tilting that balance toward Gateway API. Understanding both the resource’s well-defined surface and its sharp edges (annotation-driven extension sprawl, controller-specific path-matching quirks) is unavoidable working knowledge for any platform engineer.

Mental Model

Ingress sits one layer above Services. A LoadBalancer Service exposes one cluster-internal Service to the outside world via a cloud LB IP per service; in a cluster with twenty HTTP backends that means twenty external IPs and twenty bills. The Ingress design consolidates this: one external IP (one cloud LB or one nodePort), one shared L7 proxy, and a many-to-one routing table that maps (host, path) tuples to the internal ClusterIP Services that should receive each request.

flowchart TB
    CLIENT[External HTTPS client<br/>https://shop.example.com/cart]

    subgraph CLOUD["Cloud / data-center edge"]
        DNS[DNS<br/>shop.example.com<br/>api.example.com → LB IP]
        LB["Single cloud LB or NodePort<br/>(one IP per Ingress controller,<br/>NOT per Ingress object)"]
    end

    subgraph CLUSTER["Kubernetes cluster"]
        subgraph CTRL["Ingress Controller (e.g. ingress-nginx Deployment)"]
            PROXY[Reverse proxy<br/>nginx / envoy / traefik<br/>L7 routing rules]
        end

        subgraph APIOBJS["Declarative state in etcd"]
            IC[IngressClass<br/>name: nginx<br/>controller: k8s.io/ingress-nginx]
            ING1[Ingress: shop<br/>host: shop.example.com<br/>path: /cart → svc/cart-api<br/>path: /checkout → svc/checkout]
            ING2[Ingress: api<br/>host: api.example.com<br/>path: /v1 → svc/v1-api<br/>tls: secretName: api-tls]
            SEC[Secret: api-tls<br/>tls.crt + tls.key]
        end

        SVC1[Service: cart-api<br/>ClusterIP]
        SVC2[Service: checkout<br/>ClusterIP]
        SVC3[Service: v1-api<br/>ClusterIP]
        PODS[Backend Pods]
    end

    CLIENT --> DNS
    CLIENT --> LB
    LB --> PROXY

    IC -. selects .-> PROXY
    ING1 -. watched by .-> PROXY
    ING2 -. watched by .-> PROXY
    SEC  -. watched by .-> PROXY

    PROXY -- "Host: shop.example.com<br/>/cart" --> SVC1
    PROXY -- "Host: shop.example.com<br/>/checkout" --> SVC2
    PROXY -- "Host: api.example.com<br/>/v1" --> SVC3
    SVC1 --> PODS
    SVC2 --> PODS
    SVC3 --> PODS

What the diagram shows. External traffic terminates at one cloud LB (or, more precisely, at the Ingress Controller’s single L4 entry point — usually one LoadBalancer Service fronting the controller’s Pods). The Ingress Controller’s proxy reads Host: and request path from every connection and looks them up in the rule table assembled by watching all Ingress objects whose ingressClassName references its IngressClass. The matched backend is an in-cluster ClusterIP Service, which kube-proxy then DNATs to a Pod (see Service (Kubernetes)). The insight to extract: Ingress is not itself a proxy. It is a piece of declarative L7 routing state. The proxy is the controller’s reverse-proxy process; the API object is just configuration the controller compiles. Two consequences flow from this: (1) without an installed controller, creating an Ingress is a no-op — kubectl get ingress will show it, but no traffic flows; (2) Ingress’s capabilities are bounded by what controllers chose to implement and how they chose to expose extensions — historically, via free-form annotations, which is the central design tension that motivated Gateway API.

Mechanical Walk-through

Anatomy of the resource

An Ingress has a small core schema and one big design choice (the path-type semantics):

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop
  namespace: prod
spec:
  ingressClassName: nginx           # which controller fulfils this Ingress
  rules:                            # ordered list of host-rules
  - host: shop.example.com          # optional; if omitted, matches any host
    http:
      paths:                        # ordered list of (path, pathType, backend)
      - path: /cart
        pathType: Prefix            # Exact | Prefix | ImplementationSpecific
        backend:
          service:
            name: cart-api
            port:
              number: 80            # or `name: http` for a named port
  tls:                              # optional; opt-in TLS termination
  - hosts: [shop.example.com]
    secretName: shop-tls            # Secret of type kubernetes.io/tls
  defaultBackend:                   # optional; serves anything not matched
    service:
      name: catch-all
      port:
        number: 80

There are only five non-trivial sub-resources to understand: rules, pathType, backend, tls, and defaultBackend. The rest is metadata. Annotations (metadata.annotations) are where controllers smuggle in everything the Ingress spec did not standardize — see the annotation-sprawl pain point below.

pathType — the three matching modes

pathType is required on every path and controls how the controller interprets path (Kubernetes — Ingress):

  • Exact — match the URL path string exactly and case-sensitively. /foo matches /foo and only /foo; /foo/ is a different path.
  • Prefix — match by URL-path element-wise prefix, split on /. /foo matches /foo, /foo/, /foo/bar — but not /foobar. This is the modern recommended default; the element-wise rule is the load-bearing detail that distinguishes it from a string-prefix match.
  • ImplementationSpecific — the controller decides. Historical escape hatch from before Exact/Prefix were standardized; on ingress-nginx this enables regex-style matching, on cloud LBs it falls back to the LB’s own path semantics. The reason it exists is that no two controllers agreed on path semantics before GA, and the GA effort needed a “kept compatible” bucket.

The order within paths[] and across rules[] is significant: the first matching rule wins. Some controllers further re-sort by specificity (“longest path first”); ingress-nginx famously does so, AWS ALB does not. Reading the controller’s docs is unavoidable.

TLS termination

spec.tls[] is opt-in: list the hostnames you want HTTPS for, and the name of a Secret of type kubernetes.io/tls in the same namespace containing tls.crt and tls.key. The controller pulls the Secret, configures its proxy with the certificate, and presents it via SNI. Wildcard hostnames (*.example.com) are supported in TLS hosts and rule hosts but only one level deep — *.*.example.com is not allowed by the spec. TLS-only certificates can also be auto-issued via cert-manager which writes the Secret on the cluster’s behalf.

defaultBackend

spec.defaultBackend is the Ingress-object-scoped catch-all: any request that does not match any rule on this Ingress is routed to it. Most controllers also have a cluster-wide default backend (configured at controller install time) for requests that hit the proxy with no matching Ingress at all — those usually return 404 if no default is set. The two defaults are independent.

IngressClass — the controller-selection mechanism

Before 1.18, you told a controller “this Ingress is yours” by setting the annotation kubernetes.io/ingress.class: nginx. This was a multi-vendor convention, not a spec, and it had no schema. In Kubernetes 1.18 the IngressClass resource was added and the annotation was deprecated (Kubernetes Blog 2020-04-02 — Improvements to the Ingress API). In 1.19 Ingress (including IngressClass and the ingressClassName field) went GA under KEP-1453, Google Open Source Blog — Kubernetes Ingress goes GA.

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: nginx
  annotations:
    ingressclass.kubernetes.io/is-default-class: "true"   # cluster default
spec:
  controller: k8s.io/ingress-nginx                        # opaque controller-id string
  parameters:                                             # optional; controller-specific
    apiGroup: k8s.example.com
    kind: IngressParameters
    name: external-config

spec.controller is an opaque identifier that the controller agrees to recognize as “this is the IngressClass I should be reconciling.” k8s.io/ingress-nginx is the official id for the SIG-Network ingress-nginx controller; AWS Load Balancer Controller uses ingress.k8s.aws/alb; Traefik uses traefik.io/ingress-controller. Multiple controllers can coexist in the same cluster — each watches the IngressClasses naming its own controller-id and ignores the rest. This is how a cluster can run, say, an internal Traefik for service-mesh-entrypoint duty and an external AWS ALB for public ingress at the same time.

The cluster’s default IngressClass is whichever one is labelled with ingressclass.kubernetes.io/is-default-class: "true"; Ingresses that omit ingressClassName get the default. Conceptually exactly one default is intended; if more than one IngressClass is marked default the behavior is implementation-defined and you must own the discipline of keeping it to one.

The version history here is well established. The IngressClass resource and the ingressClassName field were both introduced in Kubernetes 1.18, at which point the older kubernetes.io/ingress.class annotation — which “was never formally defined, but … was widely supported by Ingress controllers” — became, in the project’s own words, “formally deprecated” (Kubernetes Blog 2020-04-02 — Improvements to the Ingress API). One minor later, in 1.19, the whole Ingress API including IngressClass and ingressClassName graduated to GA. Crucially, deprecated does not mean removed: the annotation has never been deleted from any Kubernetes minor and many controllers (ingress-nginx among them) still honored it for backward compatibility for years. The current Ingress documentation continues to carry a “Deprecated annotation” section describing it as the legacy mechanism superseded by the ingressClassName field (kubernetes.io/docs — Ingress). Treat it as tolerated-but-discouraged: write ingressClassName, never the annotation, for new resources.

Resource backends (CRD destinations)

A backend can also be a Kubernetes Resource — i.e., a CRD — rather than a Service:

backend:
  resource:
    apiGroup: k8s.example.com
    kind: StorageBucket
    name: static-assets

This is rarely used because controller support is spotty; AWS Load Balancer Controller and Contour are the major ones that recognize resource backends. The motivating use case was routing directly to an S3 bucket or a cloud-managed object store without putting a Service in between.

The reconcile loop, end-to-end

When a user kubectl applys an Ingress:

  1. The apiserver validates and persists the Ingress object in etcd.
  2. The Ingress Controller’s informer (a standard list-watch) receives a MODIFIED event for the new/changed Ingress.
  3. The controller filters: does spec.ingressClassName reference an IngressClass whose spec.controller matches my controller-id? If not, ignore.
  4. If yes, the controller resolves each paths[].backend.service.name to an EndpointSlice (transitively to Pod IPs), reads any referenced Secrets for TLS, and compiles its proxy config — nginx config file, Envoy xDS push, cloud LB API call, etc.
  5. The proxy reloads (graceful where possible) and starts honoring the new rules.
  6. The controller writes the externally-reachable address back into status.loadBalancer.ingress[] so kubectl get ingress shows the LB IP/hostname.

All of this is asynchronous; the user sees kubectl apply succeed immediately. Convergence time is controller- and infrastructure-dependent: ingress-nginx in seconds, AWS ALB in tens of seconds to minutes (ALB provisioning is the slow path).

Configuration / API Surface

A representative production-grade Ingress, with line-by-line commentary:

apiVersion: networking.k8s.io/v1                          # only stable group/version
kind: Ingress
metadata:
  name: api
  namespace: prod
  annotations:
    # Annotation-driven extension, controller-specific. Below: ingress-nginx examples.
    nginx.ingress.kubernetes.io/rewrite-target: /$2       # rewrite captured group
    nginx.ingress.kubernetes.io/use-regex: "true"
    nginx.ingress.kubernetes.io/proxy-body-size: 20m      # raise default 1m body limit
    nginx.ingress.kubernetes.io/ssl-redirect: "true"      # force HTTPS
    cert-manager.io/cluster-issuer: letsencrypt-prod      # auto-provision TLS
spec:
  ingressClassName: nginx                                 # match the IngressClass
  tls:
  - hosts: [api.example.com]
    secretName: api-example-com-tls                       # cert-manager writes this
  rules:
  - host: api.example.com                                 # canonical host
    http:
      paths:
      - path: /v1/users(/|$)(.*)                          # regex due to use-regex
        pathType: ImplementationSpecific                  # required when using regex
        backend:
          service:
            name: users-api
            port:
              name: http                                  # named port preferred
      - path: /v1/orders(/|$)(.*)
        pathType: ImplementationSpecific
        backend:
          service:
            name: orders-api
            port:
              name: http
  defaultBackend:                                         # 404-page service for misses
    service:
      name: nice-404
      port:
        number: 80

A few less-obvious points:

  • Cross-namespace references are not allowed. The Secret in spec.tls[].secretName and every backend.service.name must live in the same namespace as the Ingress. This is one of the explicit complaints Gateway API set out to address with ReferenceGrant.
  • backend.service.port must be either number or name — exactly one, not both. The named-port form is the recommended idiom: it survives Pod port renumbering as long as the named containerPort is preserved in the Pod spec.
  • Annotations are not portable across controllers. nginx.ingress.kubernetes.io/* works only with ingress-nginx; AWS ALB uses alb.ingress.kubernetes.io/*; Traefik uses traefik.ingress.kubernetes.io/*. Migrating between controllers usually means rewriting every Ingress.
  • hostname casing is case-insensitive per RFC 1123, but path matching is case-sensitive. Real bug: /API/users is not /api/users. Most controllers honor this.
  • TLS without spec.tls: a controller can still accept HTTPS by virtue of its own listener config — but the cert presented will be the controller’s default (“Kubernetes Ingress Controller Fake Certificate” with ingress-nginx). Always populate spec.tls.

Failure Modes

Ingress exists, no traffic flows. The single most common Ingress symptom. Walk the chain in order: (1) Is an Ingress Controller installed and running? kubectl get pods -n ingress-nginx (or wherever). (2) Does an IngressClass exist and is its spec.controller value the one the controller honors? (3) Does the Ingress’s spec.ingressClassName match an IngressClass that is bound to this controller? Or is no ingressClassName set and there’s no default IngressClass? (4) Does the referenced Service exist in the same namespace and have non-empty Endpoints? Each of these can independently silence the path.

Annotation typos. Annotations are unstructured strings — the apiserver does not validate them. nginx.ingress.kuberntes.io/rewrite-target: / (note the typo: kuberntes) is happily accepted and silently ignored by the controller; the user sees the un-rewritten path arriving at the backend and concludes “rewrite is broken.” The fix is to read the controller logs (it logs unrecognized annotations at warning level on ingress-nginx) and to write annotations into a Helm values file the team reviews, not into ad-hoc kubectl.

Default-backend conflicts. Two Ingresses both setting spec.defaultBackend for the same host (or for no host) — the controller picks one, and the choice is implementation-defined. ingress-nginx picks the lexicographically-first Ingress name. Use the cluster-wide default-backend (set at controller install time) instead.

Path-type confusion. A team writes pathType: Prefix with path: /api and is surprised that /api2 does not match. Element-wise prefix means /api matches only /api, /api/, and /api/.... The fix is either to broaden to pathType: ImplementationSpecific with a regex (controller-dependent) or to make sure URL boundaries are at /.

Path collision across Ingresses. Two teams each create an Ingress under the same host with overlapping paths — team-a claims /v1, team-b later claims /v1/users. Behavior is again implementation-defined. ingress-nginx accepts both and routes by longest match; AWS ALB requires explicit per-Ingress group-ordering annotations or fails to attach the second Ingress to the same ALB. The hygiene answer is administrative: don’t let two teams own the same hostname without coordination, or move to Gateway API which has explicit route-attachment semantics (parentRefs).

TLS renewal race. A cert-manager-renewed Secret is updated atomically (delete-then-create on some implementations) and ingress-nginx briefly serves the controller default cert. Fix: pin cert-manager to the in-place-update reconcile mode, or use replaceWithDeleteAndCreate=false if your version exposes it.

Body-size 413 errors. ingress-nginx defaults proxy-body-size: 1m. Any larger request (file upload, batch API) returns HTTP 413 from nginx, before it reaches the application. The application logs nothing. The annotation nginx.ingress.kubernetes.io/proxy-body-size raises the limit, or set it cluster-wide via the controller’s ConfigMap.

Migrating off ingress-nginx — the project is being retired. This is no longer a “winding down” rumor: on 2025-11-11 SIG-Network and the Security Response Committee announced the retirement of Ingress NGINX. The stated timeline is best-effort maintenance until March 2026, after which there will be “no further releases, no bugfixes, and no updates to resolve security vulnerabilities”; the GitHub repositories move to kubernetes-retired and become read-only, while existing Helm charts and container images stay downloadable so running clusters keep working. The official recommendation is to “begin migration to Gateway API or another Ingress controller immediately.” Note that InGate — the would-be successor controller the maintainers had floated — is itself being abandoned: the announcement says plainly that “InGate development never progressed far enough to create a mature replacement; it will also be retired.” So the forward path is the Gateway API (the Kubernetes ingress2gateway tool exists to mechanize the conversion) or one of the alternative third-party Ingress controllers. The migration is non-trivial because of annotation sprawl — nginx.ingress.kubernetes.io/* has no portable equivalent — and because of subtle behavioral quirks that a naive translation gets wrong (Before You Migrate — Kubernetes Blog 2026-02-27).

Alternatives and When to Choose Them

  • Gateway API for new deployments. Gateway API solves Ingress’s central pain points: the annotation-driven extension model (replaced by typed CRDs such as HTTPRoute and GRPCRoute, both GA in the Standard channel), the lack of L4 support (TCPRoute/UDPRoute, plus TLSRoute for SNI-based routing — TLSRoute and ReferenceGrant were promoted to the Standard channel in Gateway API v1.5, February 2026, per Kubernetes Blog 2026-04-21), the cross-namespace impossibility (ReferenceGrant), and the conflated platform-vs-app roles (split between Gateway, owned by the platform team, and *Route, owned by app teams). With ingress-nginx now retired (above), new clusters should default to Gateway API unless a forcing constraint (a controller that only implements Ingress, an org-wide template library, a delivery toolchain) makes it impractical.
  • Multiple LoadBalancer Services. If you have one or two HTTPS endpoints and no path-routing requirement, raw type: LoadBalancer Services may be simpler than installing an Ingress controller. The break-even is roughly three services; past that, the LB-per-service cost (both dollar and operational) usually justifies an Ingress.
  • External API gateway (Kong, Tyk, AWS API Gateway, Apigee). When the gateway also needs rate limiting, API keys, OAuth, transformation, or developer-portal-style features, a dedicated API gateway product is usually a better fit than stretching Ingress with annotations. Many of these products also ship Ingress controllers, so the choice is “do I want the gateway concepts represented as Ingress annotations or as the gateway’s native CRDs?”
  • Service mesh gateways. Istio’s IngressGateway and Linkerd’s multicluster ingress are mesh-native entry points; if you’ve already adopted a mesh, leveraging its gateway gives you mTLS and observability for free at the entry point.
  • DIY with NodePort + cloud LB. Manually placing a cloud LB in front of every node’s NodePort works, scales poorly, and is what people did pre-Ingress. It exists historically; do not pick it.

Production Notes

  • One Ingress controller per IngressClass per cluster is the norm. Most production clusters run a single ingress-nginx (or Traefik, or AWS LBC) instance. Multi-controller clusters arise when there is a meaningful segregation — public vs internal, regulated vs non-regulated, legacy vs greenfield. Don’t run two of the same controller for no reason.
  • Pin controller versions. ingress-nginx ships breaking changes between minor versions (rewrite-target semantics changed at 0.22.0; behavior around pathType: ImplementationSpecific changed at 1.0). Pin to a known-good version in your Helm chart; upgrade deliberately with a staging cluster.
  • Don’t let Ingress hostnames drift from DNS. A common ops mistake: a team creates an Ingress for foo.example.com but forgets to add the DNS record pointing to the controller’s LB IP/hostname. The Ingress object looks healthy; nothing resolves it. Add an external-dns operator that synthesizes DNS records from Ingress objects to prevent this.
  • Don’t mix path-types in a single Ingress unless you understand each controller’s merge semantics. A single pathType per Ingress is far easier to reason about.
  • TLS-cert lifecycle is the most operationally painful Ingress concern. Adopt cert-manager from day one and let it own the Secret. Rotating certs by hand at scale produces predictable outages.
  • Annotation sprawl is real debt. A single ingress-nginx deployment can accumulate dozens of annotations across hundreds of Ingresses, each subtly tweaking nginx config. A migration to Gateway API has to translate every one of them. The earlier you cap the annotation surface (lint the Ingress YAML, require explicit approval to add new annotation keys), the cheaper that migration becomes.
  • Failure stories. Recurring patterns from k8s.af and engineering blogs include: nginx config reloads taking longer than the LB health-check interval and causing brief 503s (mitigation: worker-shutdown-timeout); a misconfigured default-backend returning 200 for all 404s and breaking client retries; AWS ALB controller failing to recognize an Ingress because the IngressClass’s spec.controller mismatch was a single-character typo; ingress-nginx’s auth-url annotation pointing to an external auth service that became a global SPOF when that service was migrated.

See Also