Containerization Lesson 33 of 113

Kubernetes Ingress, In Depth: Controllers, Rules, TLS, IngressClass & the Gateway API

You have a handful of Services running inside a cluster, each one a stable internal address in front of some Pods. Now the real question arrives: how does traffic from a browser on the public internet reach the right Service? You could give every Service its own cloud load balancer, but that is one public IP — and one monthly bill — per app, with no shared TLS, no path routing, and no single front door. Ingress exists to solve exactly this: one entry point that routes HTTP and HTTPS traffic to many Services based on the hostname and URL path, terminating TLS once, in front of everything.

Ingress trips up newcomers because it has an unusual shape. The Ingress object you write is just a set of routing rules — a piece of configuration. On its own it does nothing at all. Something has to read those rules and actually do the proxying, and that something is an Ingress controller (NGINX, Traefik, HAProxy, a cloud load-balancer controller, and others). Kubernetes ships with the Ingress API but no controller — you install one yourself. Get that mental model right and the rest falls into place.

This lesson covers Ingress exhaustively: every field of the resource, every pathType, how IngressClass selects a controller, how TLS termination and cert-manager fit together, and the handful of annotations you will actually reach for. Then it covers the Gateway API — the newer, role-oriented, more expressive standard that the project positions as Ingress’s long-term successor — so you know which one to choose for new work. You will install a controller on a free local cluster, route two apps behind one address, and add TLS, all on your laptop.

In a nutshell

Picture an office tower with a single street address and one front door. Visitors do not wander the corridors — they tell the receptionist “I’m here for Sales on the third floor” or “where’s the mailroom?”, and the receptionist walks each person to the right place. Kubernetes Ingress is that front door for HTTP traffic. One public IP, one entry point, and a set of rules that send shop.example.com to one app and example.com/api to another — all behind the same door.

Now the one twist that trips up every newcomer, so learn it before anything else: the Ingress object you write is only the directory of rules taped to the desk — it is paper, not a person. The thing that actually reads those rules and walks each request to the correct Service is a separate program called the Ingress controller (usually an NGINX or Envoy reverse proxy running as a Pod). Kubernetes ships the rules format but not the receptionist. Apply an Ingress on a cluster with no controller installed and, quite literally, nothing happens — the rules just sit there in the database. Install a controller first, and those very same rules spring to life. If you remember only one sentence from this lesson, make it that one.

Why should a beginner care? Because this single pattern is how essentially every real website on Kubernetes reaches the outside world. It lets you run ten apps behind one cloud load balancer instead of ten (one bill, not ten), terminate HTTPS once at the edge for all of them, and route by hostname and URL path — the everyday plumbing of production traffic. Get the “rules vs. controller” split straight and the rest of this page is detail.

Level: Intermediate · Time: ~42 min read · You’ll need: a local cluster (kind or minikube) and basic kubectl comfort.

Learning objectives

By the end of this lesson you can:

Prerequisites & where this fits

You need a working local cluster and basic comfort with kubectl, plus an understanding of Services — especially ClusterIP and LoadBalancer — and label selectors, since Ingress routes to Services. If Services are still hazy, do Kubernetes Services & Networking, In Depth first; for the absolute basics of Pods and Deployments see Pods, ReplicaSets, Deployments & Services: The Core Objects. This is the edge-routing lesson of the Kubernetes Zero-to-Hero course: it sits just after storage and just before RBAC, and it is the foundation for everything user-facing — public APIs, web frontends, multi-tenant routing, and TLS at the edge.

Core concepts: Ingress vs Service, and the controller model

Start from what a Service already gives you and what it does not.

A ClusterIP Service is internal only — perfect for Pod-to-Pod traffic, useless for the public internet. A NodePort opens a high port (30000–32767) on every node — crude, ugly URLs, not for production. A LoadBalancer Service asks your cloud for a real external load balancer with a public IP — but it is Layer 4 (TCP/UDP): it forwards a port to one Service and knows nothing about HTTP. It cannot read the Host header, cannot route by URL path, and cannot terminate TLS for many hostnames. So “ten public apps” becomes “ten cloud load balancers, ten IPs, ten bills, ten places to manage certificates.”

Ingress is the Layer 7 (HTTP/HTTPS) answer. One Ingress controller sits behind a single LoadBalancer Service (or host network), and routes intelligently:

Here is the part that confuses everyone, stated plainly:

The Ingress resource is just rules — data in etcd. It does nothing by itself. An Ingress controller is a real Pod (usually a reverse proxy like NGINX) that watches all Ingress resources and reconfigures itself to actually route traffic. Kubernetes does not include a controller. A freshly installed cluster has the Ingress API but no controller, so your Ingress objects sit inert until you install one.

The flow end to end: Client → DNS → cloud LoadBalancer (one public IP) → Ingress controller Pod → (reads your Ingress rules) → ClusterIP Service → Pods. The controller is the only piece doing HTTP work; the Ingress object only tells it what to do.

There are many controllers, and the annotations and exact behaviour differ between them — a critical, often-missed fact. ingress-nginx (the community NGINX controller, the most common default) is not the same as NGINX Inc.'s commercial nginx-ingress; their annotations differ. Other popular controllers include Traefik, HAProxy, Contour (Envoy-based), and cloud-native ones like the AWS Load Balancer Controller, GKE Ingress, and Azure Application Gateway Ingress Controller (AGIC). The core Ingress fields are portable; the annotations are not.

The Ingress resource: every field

A minimal but complete Ingress (apiVersion networking.k8s.io/v1, stable since Kubernetes 1.19):

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop-ingress
  namespace: web
spec:
  ingressClassName: nginx          # which controller handles this Ingress
  defaultBackend:                  # optional: catches anything no rule matches
    service:
      name: fallback
      port:
        number: 80
  rules:
  - host: shop.example.com         # optional; omit to match any host
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: shop-frontend
            port:
              number: 80
      - path: /api
        pathType: Prefix
        backend:
          service:
            name: shop-api
            port:
              number: 8080

The full field matrix — what every key does:

Field What it does Values Default When to set Gotcha
spec.ingressClassName Names the IngressClass (and thus the controller) that should serve this Ingress A string matching an IngressClass name None (falls back to default class, if any) Almost always set it explicitly If unset and there is no default class, no controller picks it up and nothing happens
spec.defaultBackend Where to send requests that match no rule A service (name + port) or a resource ref Controller’s own default (often a 404 page) When you want a custom catch-all/landing page Per-Ingress default backends are honoured inconsistently across controllers; many use the controller-wide one
spec.rules[] The list of host-based routing rules Array Empty (then only defaultBackend applies) The heart of every real Ingress An empty rules with no default backend routes nothing
rules[].host The HTTP hostname this rule matches (virtual host) FQDN, or wildcard like *.example.com Omitted = match all hosts One rule per public hostname Wildcards match one label only: *.example.com matches a.example.com, not a.b.example.com or bare example.com
rules[].http.paths[] The path rules within a host Array (at least one) Always Order in the file does not decide precedence — longest match does (see pathType)
paths[].path The URL path to match A path string, e.g. /, /api — (required) Always With Prefix, /api matches /api and /api/... but not /apil (element-wise, not string prefix)
paths[].pathType How path is matched Exact, Prefix, ImplementationSpecific Required (no default) Always — pick deliberately Older extensions/v1beta1 had no pathType; on networking.k8s.io/v1 it is mandatory
paths[].backend.service.name The Service to route matched traffic to A Service name in the same namespace Always (unless using resource) Must be in the same namespace as the Ingress — Ingress is namespaced and cannot point cross-namespace
paths[].backend.service.port.number / .name The Service port (by number or named port) Port number, or a named port Always Use name if your Service names its ports; use number otherwise — not both
paths[].backend.resource Route to a non-Service object (e.g. a storage/object backend via a custom resource) A TypedLocalObjectReference Rarely (e.g. static assets via a CRD) Mutually exclusive with backend.service; controller-dependent
spec.tls[] TLS termination config (see TLS section) Array of {hosts, secretName} None (HTTP only) Whenever you serve HTTPS The Secret must exist, be type kubernetes.io/tls, and live in the same namespace
metadata.annotations Controller-specific behaviour (rewrite, auth, limits…) Key/value strings None For anything beyond basic routing Not portablenginx.ingress.kubernetes.io/* means nothing to Traefik
status.loadBalancer.ingress[] Read-only: the external IP/hostname the controller published Filled by the controller You read it, never set it Stays empty until a controller adopts the Ingress and a LoadBalancer is provisioned

Two structural rules worth memorising: an Ingress is namespaced, and its backends must be Services in the same namespace — you cannot route from an Ingress in team-a to a Service in team-b. And an Ingress can have defaultBackend only, rules only, or both; with neither, it does nothing.

pathType: Exact vs Prefix vs ImplementationSpecific

pathType is mandatory on networking.k8s.io/v1 and decides how path is compared to the incoming request. Get this wrong and traffic silently goes to the wrong place.

pathType Matching rule Example path Matches Does NOT match When to use
Exact The URL path must equal path exactly, case-sensitive /api /api /api/, /api/v1, /Api A single specific endpoint; health-check URLs
Prefix Split both into path elements (by /); match element-by-element /api /api, /api/, /api/v1, /api/v1/users /apifoo, /apis The default choice for “this service owns this subtree”
ImplementationSpecific Whatever the controller decides — often regex/glob /api/.* (nginx) controller-defined controller-defined Only when you need controller-native features (regex paths)

The element-wise subtlety of Prefix is the classic exam trap. path: /api with Prefix matches /api and /api/anything, but it does not match /apifoo, because matching is per path segment, not raw string prefix. A trailing-slash request /api/ does match. The special path / with Prefix matches everything — the catch-all root.

Precedence when multiple paths match: the controller picks the longest matching path, and Exact is preferred over Prefix at equal length. So given / (Prefix) and /api (Prefix), a request to /api/v1 goes to the /api backend because it is the longer match. Order in the YAML is irrelevant. With ImplementationSpecific, precedence is whatever the controller defines (ingress-nginx, for instance, has its own ordering and a canary/priority system via annotations).

A wildcard host also affects precedence: a request is matched against the most specific host first. api.example.com (exact host) beats *.example.com (wildcard host) for api.example.com.

IngressClass: binding an Ingress to a controller

Before IngressClass, you chose a controller with the annotation kubernetes.io/ingress.class. That is deprecated. The modern mechanism is the IngressClass resource plus spec.ingressClassName on the Ingress.

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: nginx
  annotations:
    ingressclass.kubernetes.io/is-default-class: "true"   # makes this the default
spec:
  controller: k8s.io/ingress-nginx       # which controller implements this class
  # parameters:                          # optional: controller-wide config object
  #   apiGroup: k8s.example.com
  #   kind: IngressNginxParams
  #   name: nginx-global
Field What it does Values Default When to set Gotcha
metadata.name The class name you reference in ingressClassName A string Always Must match exactly what Ingresses put in ingressClassName
spec.controller Identifies which controller owns this class A controller string, e.g. k8s.io/ingress-nginx, traefik.io/ingress-controller Set by the controller’s install Immutable after creation — to change controllers, make a new class
annotation is-default-class Marks this class as the cluster default "true" / absent No default Set on at most one class If two classes claim default, behaviour is undefined — keep it to one
spec.parameters Points to a controller-specific config object (scope Cluster or Namespace) A typed reference None For controllers that read global config from a CRD Schema is entirely controller-defined

How selection works, in order: (1) if the Ingress sets spec.ingressClassName, the controller owning that class serves it; (2) if it is unset, the default IngressClass (the one annotated is-default-class: "true") serves it; (3) if it is unset and there is no default, nothing serves it — the single most common “my Ingress does nothing” cause. You can run multiple controllers in one cluster (e.g. an internal-only NGINX and an external one) by giving each its own class and labelling Ingresses accordingly.

TLS: terminating HTTPS at the edge

To serve HTTPS, you give the Ingress a TLS Secret and list the hostnames it covers. The controller then terminates TLS (decrypts) at the edge and forwards plain HTTP to your Service inside the cluster.

spec:
  tls:
  - hosts:
    - shop.example.com
    - www.example.com
    secretName: shop-tls          # a Secret of type kubernetes.io/tls
  rules:
  - host: shop.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service: { name: shop-frontend, port: { number: 80 } }

The Secret must be type kubernetes.io/tls with two keys, tls.crt and tls.key:

kubectl create secret tls shop-tls \
  --cert=tls.crt --key=tls.key -n web
TLS concept What it means Detail / gotcha
spec.tls[].hosts The hostnames this certificate serves Should match the cert’s SAN entries and the rules[].host; mismatch ⇒ browser warning
spec.tls[].secretName The kubernetes.io/tls Secret holding cert + key Must be in the same namespace as the Ingress; controller reads it live
SNI (Server Name Indication) TLS extension carrying the hostname in the handshake Lets one IP serve many certs — the controller picks the right cert per hostname. This is what makes name-based HTTPS virtual hosting possible
Termination TLS is decrypted at the controller, not the Pod Pod-to-controller traffic is plain HTTP unless you configure re-encryption/mTLS (controller-specific)
Default certificate A fallback cert for requests whose host has no matching tls entry Often a self-signed “Kubernetes Ingress Controller Fake Certificate” — seeing it means your TLS host didn’t match
Passthrough TLS is not terminated; bytes pass straight to the Pod Not a core Ingress feature; ingress-nginx supports it via ssl-passthrough annotation, with caveats

cert-manager: automating certificates

Hand-managing certificates does not scale. cert-manager is the de-facto add-on that issues and auto-renews TLS certificates from issuers like Let’s Encrypt (free, ACME). The pattern:

  1. Install cert-manager (a set of controllers + CRDs).
  2. Create an Issuer (namespaced) or ClusterIssuer (cluster-wide) describing the ACME server and a solver (HTTP-01 via your Ingress, or DNS-01).
  3. Annotate the Ingress with cert-manager.io/cluster-issuer: letsencrypt-prod.
  4. cert-manager watches the Ingress, performs the ACME challenge, and creates the kubernetes.io/tls Secret named in spec.tls[].secretName for you — then renews it automatically before expiry.
metadata:
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  tls:
  - hosts: [shop.example.com]
    secretName: shop-tls       # cert-manager creates and renews this Secret

You write the Ingress and the annotation; cert-manager fills in the Secret. This is the standard production approach for free, auto-renewing TLS.

Annotations: the useful (non-portable) ones

Anything the core Ingress API can’t express lives in annotations, which are specific to your controller. The examples below are ingress-nginx; Traefik, HAProxy and the cloud controllers use different keys. This non-portability is a deliberate trade-off and a frequent gotcha.

Need ingress-nginx annotation What it does Gotcha
Rewrite the path before forwarding nginx.ingress.kubernetes.io/rewrite-target: /$2 Strip/transform the URL (often with a regex capture group in path) Needs use-regex / a capture-group path; easy to misconfigure
Force HTTP → HTTPS nginx.ingress.kubernetes.io/ssl-redirect: "true" Redirect plaintext to TLS (on by default when a TLS block exists) force-ssl-redirect is needed if no TLS block but you still want redirect
Max request body size nginx.ingress.kubernetes.io/proxy-body-size: 50m Raise upload limit (default ~1m) Symptom of the default is mysterious 413 errors on uploads
Rate limiting nginx.ingress.kubernetes.io/limit-rps: "10" Requests per second per client IP Coarse; for real quotas use a gateway/API manager
Basic auth nginx.ingress.kubernetes.io/auth-type: basic + auth-secret Username/password gate at the edge Stores creds in a Secret; not SSO
External auth (forward-auth) nginx.ingress.kubernetes.io/auth-url: https://auth.example.com/verify Delegate auth to an external service (OIDC/OAuth2 proxy) Adds a hop per request
Sticky sessions nginx.ingress.kubernetes.io/affinity: cookie Cookie-based session affinity Defeats even load spreading
Backend protocol nginx.ingress.kubernetes.io/backend-protocol: HTTPS Talk to the Pod over HTTPS/gRPC For re-encryption or gRPC backends
Custom timeouts nginx.ingress.kubernetes.io/proxy-read-timeout: "120" Lengthen slow-backend timeouts Default ~60s causes 504 on long requests

The annotation lock-in trap. A wall of nginx.ingress.kubernetes.io/* annotations means your routing is tied to ingress-nginx. Migrating controllers later means rewriting all of them. This pain — config smuggled into opaque, vendor-specific annotation strings — is precisely the problem the Gateway API was designed to fix.

Kubernetes Ingress & Gateway API

The diagram contrasts the two models: on the left, a client reaches one LoadBalancer and Ingress controller that fans out by host/path to Services; on the right, the Gateway API’s GatewayClassGatewayHTTPRoute chain shows the same traffic split across infrastructure-owned and app-owned objects.

The Gateway API: Ingress’s successor

The Gateway API is a newer, official Kubernetes networking standard (under the gateway.networking.k8s.io group, GA for HTTP since v1.0 in late 2023) designed to replace Ingress for new work. It keeps the same job — get external traffic to Services — but fixes Ingress’s three structural weaknesses: it is role-oriented, expressive without annotations, and extensible (HTTP, gRPC, TCP, TLS, UDP). Ingress is not deprecated and remains supported, but the project steers new, advanced use cases to the Gateway API.

It splits the one Ingress object into several resources owned by different roles — the central design idea:

Resource Owned by (persona) What it represents Analogue
GatewayClass Infrastructure provider / cluster admin The type of gateway, backed by a controller (like a StorageClass for networking) IngressClass
Gateway Cluster operator An actual deployed load balancer / listener — IP, ports, protocols, TLS, allowed routes The LB + controller that an Ingress implies
HTTPRoute Application developer The routing rules — hosts, paths, headers, methods, backends, traffic splitting The rules inside an Ingress
TLSRoute / TCPRoute / GRPCRoute / UDPRoute App developer Routing for non-HTTP protocols (No Ingress equivalent)
ReferenceGrant Namespace owner Explicitly permits cross-namespace references (e.g. a Route in ns A targeting a Service/Gateway in ns B) (Ingress cannot cross namespaces at all)

A minimal Gateway + HTTPRoute:

apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: prod-gateway
  namespace: infra
spec:
  gatewayClassName: nginx              # which GatewayClass (controller)
  listeners:
  - name: https
    protocol: HTTPS
    port: 443
    tls:
      mode: Terminate
      certificateRefs:
      - name: shop-tls                 # the kubernetes.io/tls Secret
    allowedRoutes:
      namespaces:
        from: Selector                 # which namespaces may attach Routes
        selector:
          matchLabels: { team: web }
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: shop-route
  namespace: web
spec:
  parentRefs:
  - name: prod-gateway                 # attach to the Gateway above
    namespace: infra
  hostnames: ["shop.example.com"]
  rules:
  - matches:
    - path: { type: PathPrefix, value: /api }
      headers:                         # match on headers — no annotations needed
      - name: x-canary
        value: "true"
    backendRefs:
    - name: shop-api
      port: 8080
      weight: 90                       # built-in traffic splitting
    - name: shop-api-canary
      port: 8080
      weight: 10

Why the role separation matters: the platform team owns the Gateway (the shared, security-sensitive entry point — IPs, certificates, which namespaces may attach), while each app team owns its own HTTPRoute in its own namespace. The Gateway controls who may attach via allowedRoutes; cross-namespace links require an explicit ReferenceGrant. Capabilities that needed proprietary annotations in Ingress — header/method matching, request mirroring, weighted traffic splitting, header rewrites, redirects, request/response header modification — are first-class, typed, portable fields here.

When to use which

Situation Recommended Why
Simple host/path routing, existing setup Ingress Mature, ubiquitous, every controller supports it; don’t migrate for nothing
Brand-new platform, advanced routing Gateway API Portable header/traffic-split features, clean role separation
Multi-team / multi-tenant cluster Gateway API Platform owns Gateway; teams own HTTPRoute; explicit cross-ns grants
Canary / blue-green by weight or header Gateway API Built-in weight and header matching — no annotation hacks
Non-HTTP (raw TCP/UDP/TLS) routing Gateway API TCPRoute/UDPRoute/TLSRoute; Ingress is HTTP-only
Heavy reliance on existing nginx annotations Ingress (for now) Migration means re-expressing every annotation as Gateway fields

The honest summary: Ingress is the present and is going nowhere soon; the Gateway API is the future and the right default for new, non-trivial routing. Many controllers (ingress-nginx, Traefik, Contour/Envoy Gateway, cloud controllers) already implement both.

Hands-on lab

You will install the ingress-nginx controller on a local cluster, route two apps behind one address by path, then add TLS — all free, no cloud.

1. Create a cluster with an ingress-ready port mapping (kind)

kind needs explicit port mapping so the controller is reachable from your laptop:

cat <<'EOF' | kind create cluster --name ingress-lab --config=-
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
  kubeadmConfigPatches:
  - |
    kind: InitConfiguration
    nodeRegistration:
      kubeletExtraArgs:
        node-labels: "ingress-ready=true"
  extraPortMappings:
  - containerPort: 80
    hostPort: 80
    protocol: TCP
  - containerPort: 443
    hostPort: 443
    protocol: TCP
EOF

(On minikube instead: minikube start then minikube addons enable ingress — it installs ingress-nginx for you; skip step 2.)

2. Install the ingress-nginx controller

kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml

# Wait until the controller is ready
kubectl wait --namespace ingress-nginx \
  --for=condition=ready pod \
  --selector=app.kubernetes.io/component=controller \
  --timeout=120s

Confirm the IngressClass was created:

kubectl get ingressclass
# NAME    CONTROLLER             PARAMETERS   AGE
# nginx   k8s.io/ingress-nginx   <none>       30s

3. Deploy two tiny apps + Services

kubectl create deployment web   --image=hashicorp/http-echo --replicas=1 -- /http-echo -text="hello from WEB"
kubectl create deployment apiapp --image=hashicorp/http-echo --replicas=1 -- /http-echo -text="hello from API"

kubectl expose deployment web    --port=80 --target-port=5678
kubectl expose deployment apiapp --port=80 --target-port=5678
kubectl wait --for=condition=available deployment/web deployment/apiapp --timeout=60s

4. Create the Ingress (path routing, one host)

cat <<'EOF' | kubectl apply -f -
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: demo
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  ingressClassName: nginx
  rules:
  - host: demo.localdev.me        # resolves to 127.0.0.1 automatically
    http:
      paths:
      - path: /api
        pathType: Prefix
        backend:
          service: { name: apiapp, port: { number: 80 } }
      - path: /
        pathType: Prefix
        backend:
          service: { name: web, port: { number: 80 } }
EOF

5. Test routing

curl http://demo.localdev.me/        # -> hello from WEB
curl http://demo.localdev.me/api     # -> hello from API

*.localdev.me is a public DNS name that resolves to 127.0.0.1, so no /etc/hosts edit is needed. The /api request hits the longer-matching /api Prefix rule; everything else falls to /.

6. Inspect and validate

kubectl get ingress demo            # ADDRESS should populate (localhost)
kubectl describe ingress demo       # see rules, backends, and any events

kubectl describe is your first debugging stop — it shows the parsed rules, the resolved backends, and warning events (e.g. a missing Service).

7. Add TLS (self-signed, for the lab)

openssl req -x509 -nodes -days 365 -newkey rsa:2048 \
  -keyout tls.key -out tls.crt \
  -subj "/CN=demo.localdev.me" -addext "subjectAltName=DNS:demo.localdev.me"

kubectl create secret tls demo-tls --cert=tls.crt --key=tls.key

kubectl patch ingress demo --type merge -p \
  '{"spec":{"tls":[{"hosts":["demo.localdev.me"],"secretName":"demo-tls"}]}}'

curl -k https://demo.localdev.me/    # -k accepts the self-signed cert -> hello from WEB

In production you would replace this self-signed Secret with a cert-manager ClusterIssuer + the cert-manager.io/cluster-issuer annotation, and the Secret would be created and renewed for you.

Cleanup

kind delete cluster --name ingress-lab
# (minikube: minikube delete)
rm -f tls.key tls.crt

Cost note: entirely free — kind/minikube and the controller run in local containers. No cloud load balancer is provisioned, so there is no bill. In a real cloud, the controller’s one LoadBalancer Service is the cost, shared across all your Ingresses — which is the whole economic point of Ingress.

Going deeper

Everything above is enough to ship. This section is for the reader who wants to know how the receptionist actually works, where it breaks at scale, and why the whole model is quietly being replaced. None of it is required to pass the exam — but it is what separates “I can write an Ingress” from “I run the edge for a platform.”

What the controller actually does: the reconcile loop

An Ingress controller is a control loop, exactly like the built-in Kubernetes controllers. It opens watches against the API server for several object kinds at once — Ingress, IngressClass, Service, EndpointSlice (the Pod IPs behind each Service), Secret (for TLS), and often ConfigMap (its own tunables) — and reacts to every add/update/delete. On each change it rebuilds an in-memory model of “which host + path should go to which set of Pod IPs,” renders that model into a proxy configuration, and applies it. For ingress-nginx the rendered artifact is literally an nginx.conf; for Traefik or Contour it is Envoy/Traefik’s dynamic config.

The subtlety that matters in production is what triggers a reload versus a hot update:

Change ingress-nginx behaviour Why it matters
A Pod scales up/down (EndpointSlice change) Hot — the embedded Lua (OpenResty) updates the upstream server list in memory, no nginx reload Endpoint churn is constant in a busy cluster; reloading on every Pod change would be catastrophic
A new/edited Ingress, host, path, or most annotations Full nginx -s reload — regenerate and re-exec workers Reloads drain old worker processes; in-flight long connections and some keep-alives can drop
A TLS Secret’s cert rotates Cert is re-read into the SNI map, typically without a structural reload cert-manager renewals therefore don’t cause churn

This is why a “reload storm” — hundreds of Ingress edits landing at once (a big GitOps sync, a namespace-wide rollout) — can spike controller CPU and momentarily disrupt connections. The mitigations are real operational levers: batch/debounce config changes, raise worker-shutdown-timeout so draining is graceful, and shard Ingresses across multiple controllers (below). Controllers run multiple replicas for availability, but only one holds a leader-election lease to write status.loadBalancer.ingress back onto your Ingress objects — so an empty ADDRESS can also mean “the leader can’t reach the API,” not just “no LoadBalancer.”

Running multiple controllers (and why you would)

IngressClass is not just paperwork — it is the sharding key. A common production layout runs two controllers: an internet-facing one and an internal-only one (private LB, reachable only from the VPC), each with its own class:

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: external-nginx
spec:
  controller: k8s.io/ingress-nginx
---
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: internal-nginx
spec:
  controller: k8s.io/internal-ingress-nginx   # a 2nd install with its own --controller-class

The second controller install must be given a distinct --controller-class value (here k8s.io/internal-ingress-nginx) so the two do not fight over the same Ingresses; each adopts only the class whose spec.controller matches its own. An app then chooses its blast radius purely by setting ingressClassName: internal-nginx or external-nginx. Other sharding dimensions: --watch-namespace (a controller per tenant), and label/annotation selectors. Sharding is how large clusters keep any single controller’s config small enough to render and reload quickly.

TLS, SNI and cert-manager, one layer down

When a browser opens https://shop.example.com, the hostname travels in cleartext inside the TLS ClientHello as the SNI extension — before any certificate is chosen. The controller keeps an in-memory map of hostname → certificate built from every spec.tls[] block across all Ingresses, looks up the SNI value, and presents the matching cert. No SNI match ⇒ it serves the default certificate, which on a fresh ingress-nginx is the self-signed “Kubernetes Ingress Controller Fake Certificate.” Seeing that name in a browser is a near-certain sign that your tls.hosts, rules.host, and the certificate’s SAN do not all agree — not that certificate generation failed.

cert-manager automates the cert half via ACME, and the choice of solver has real trade-offs worth knowing before you pick one:

ACME challenge How it proves ownership Can do wildcards? Needs Typical use
HTTP-01 Serves a token at http://<host>/.well-known/acme-challenge/… via your Ingress No — one exact host at a time Port 80 reachable from the internet The default; simplest for public single hosts
DNS-01 Publishes a TXT record under _acme-challenge.<host> Yes (*.example.com) API credentials for your DNS provider Wildcards, or when port 80 is closed / behind a private LB

The flow: you create an Issuer (namespaced) or ClusterIssuer (cluster-wide) pointing at Let’s Encrypt, annotate the Ingress with cert-manager.io/cluster-issuer, and cert-manager watches it, solves the challenge, writes the kubernetes.io/tls Secret named in spec.tls[].secretName, and renews it ~30 days before expiry — at which point the controller hot-reloads the new cert into its SNI map with no downtime.

pathType precedence, exactly

The spec’s rule is: among all matching paths across all rules, the controller picks the longest matching path; at equal length, Exact beats Prefix. But ImplementationSpecific deliberately hands precedence back to the controller, and this is where portability quietly ends. In ingress-nginx, Prefix/Exact are translated into ordered location blocks, while regex routing is only enabled when you use rewrite-target or use-regex: "true" — and once regex is in play, order and specificity are nginx’s, not the spec’s. Two more real edge cases: matching is case-sensitive for Exact; and a request to /api/ (trailing slash) matches a Prefix: /api but a bare Exact: /api will not match /api/. If precedence ever surprises you, kubectl exec into the controller Pod and read the generated nginx.conf — the truth is always in the rendered config, not the Ingress YAML.

Annotations are a config-smuggling escape hatch

Every feature the core API cannot express is stuffed into a string annotation the controller parses. Two that are worth understanding mechanically, because the Gateway API replaces exactly them:

The pattern to notice: both are legitimate needs expressed as untyped, unvalidated, controller-specific strings. That is the core design smell the successor API sets out to fix.

Default backend, custom error pages

defaultBackend catches requests that match no rule. Beyond a friendly 404, controllers can route specific status codes to a custom Service via custom-http-errors, letting you serve branded, monitored error pages:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop-ingress
  namespace: web
  annotations:
    nginx.ingress.kubernetes.io/custom-http-errors: "404,503"
spec:
  ingressClassName: nginx
  defaultBackend:
    service:
      name: error-pages
      port:
        number: 80
  rules:
  - host: shop.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: shop-frontend
            port:
              number: 80

Note the honesty gap flagged in the field table earlier: per-Ingress defaultBackend is honoured inconsistently — some controllers only respect a single controller-wide default backend set at install time. Test yours; don’t assume.

The migration story: Ingress → Gateway API

Here is the state of play, stated plainly. The Ingress API is effectively frozen — it is stable at networking.k8s.io/v1, but SIG Network adds no new features to it; all new networking work goes to the Gateway API. Ingress is not deprecated and will not be removed — it is far too widely deployed — but it is done growing. For moving existing config, the community ships ingress2gateway (under kubernetes-sigs/), a CLI that converts Ingress resources — and some provider-specific annotations — into equivalent Gateway + HTTPRoute objects you can review and apply. It gets you 80% of the way; the annotation-heavy 20% (custom snippets, exotic rewrites) still needs hand-translation. For the full mechanics of GatewayClass/Gateway/HTTPRoute, weighted splits, and a staged migration, see the dedicated lesson: Adopting the Kubernetes Gateway API.

Why Ingress is being superseded — the honest three: (1) annotations are an unversioned, untyped, per-controller config channel that defeats portability and validation; (2) a single object conflates roles — the LB/IP/cert concerns a platform team owns are mixed with the routes an app team owns, which is painful in multi-tenant clusters; (3) it is HTTP(S)-only with no first-class traffic splitting, header/method matching, or request mirroring. The Gateway API answers all three with typed fields and role-separated resources. That is the whole case — not that Ingress is bad, but that it hit its ceiling.

Scale and security notes for the edge

At scale, the numbers to watch are: count of Ingresses per controller (config render + reload time grows with it), reload frequency (debounce it), SNI map size (thousands of certs is a lot of memory), and EndpointSlice churn (the hot-path that dynamic reconfiguration exists to protect). On security, the controller is your internet edge and has historically been a juicy target: snippet annotations (configuration-snippet, server-snippet) let an Ingress inject raw nginx config, which is an injection/RCE surface — recent ingress-nginx releases default allow-snippet-annotations to false and add --enable-annotation-validation precisely because of this. The March 2025 “IngressNightmare” vulnerabilities (CVE-2025-1974 and related) allowed unauthenticated remote code execution through the ingress-nginx admission controller, forcing emergency patching across the industry — a standing reminder to pin the controller version, watch its CHANGELOG, restrict who may create Ingresses, and keep the edge patched.

Common mistakes & troubleshooting

Symptom Likely cause Fix
Ingress created but ADDRESS stays empty and nothing routes No controller installed, or ingressClassName set to a class no controller owns, or no default class Install a controller; set ingressClassName to the real class (kubectl get ingressclass)
404 Not Found from “nginx” on every path Request Host doesn’t match any rules[].host, or no rule/path matches and no default backend Match the host exactly, or add a host-less rule / defaultBackend
/apifoo unexpectedly 404s under a /api Prefix Prefix matches path elements, not raw string prefixes Use the correct path; add an explicit rule for the other path
Browser shows “Fake Certificate” / cert warning TLS host didn’t match a spec.tls[].hosts entry, so the controller served its default cert Ensure tls.hosts, rules.host, and the cert SAN all agree
413 Request Entity Too Large on uploads Default body-size limit (~1m on nginx) nginx.ingress.kubernetes.io/proxy-body-size: 50m
504 Gateway Time-out on slow endpoints Default proxy read timeout (~60s) Raise proxy-read-timeout / proxy-send-timeout
Two Ingresses for the same host conflict Overlapping host/path across Ingresses Consolidate, or use distinct paths; check controller merge behaviour
Annotation has no effect Wrong controller prefix (e.g. nginx.* on Traefik) or typo Use your controller’s annotation namespace; verify spelling
Backend Service “not found” in describe Service in a different namespace, wrong name, or wrong port Ingress backends must be same-namespace; fix name/port

Common beginner mistakes

The table above is symptom → fix. This list is different: these are the mental-model errors — the wrong belief underneath the symptom. Fix the belief and the symptom never comes back.

Best practices

Security notes

Interview & exam questions

  1. Why isn’t a LoadBalancer Service enough for ten public web apps? It is Layer 4: one LB/IP per Service, no Host/path routing, no shared TLS. You’d pay for ten load balancers. Ingress gives one Layer-7 entry point routing by host and path with shared TLS.

  2. What’s the relationship between an Ingress resource and an Ingress controller? The resource is just rules (data); it does nothing alone. The controller is a running proxy that watches Ingress resources and reconfigures itself to route traffic. Kubernetes ships the API but no controller — you install one.

  3. Explain the three pathType values. Exact = the path must equal exactly. Prefix = match by path elements (so /api covers /api and /api/x but not /apifoo). ImplementationSpecific = the controller decides (often regex). On networking.k8s.io/v1, pathType is mandatory.

  4. Given / (Prefix) and /api (Prefix), where does /api/v1 go, and why? To the /api backend — controllers pick the longest matching path; YAML order is irrelevant.

  5. How does an Ingress select its controller, and what’s the most common reason “nothing happens”? Via spec.ingressClassName → an IngressClass → a controller; if unset, the default class is used. If ingressClassName is unset and there’s no default class, no controller adopts it. (The old kubernetes.io/ingress.class annotation is deprecated.)

  6. How does one IP serve HTTPS for many hostnames? SNI — the client sends the hostname in the TLS handshake, so the controller selects the right certificate per host. That enables name-based HTTPS virtual hosting behind a single IP.

  7. What must a TLS Secret for Ingress look like, and where must it live? Type kubernetes.io/tls with keys tls.crt and tls.key, in the same namespace as the Ingress.

  8. What does cert-manager do for Ingress? It watches annotated Ingresses, performs ACME (e.g. Let’s Encrypt) challenges, and creates and auto-renews the kubernetes.io/tls Secret named in spec.tls, giving free, automatic certificates.

  9. Why are Ingress annotations a portability risk? They’re controller-specific (nginx.ingress.kubernetes.io/* is meaningless to Traefik). Heavy annotation use locks you to one controller and complicates migration.

  10. What problems does the Gateway API solve over Ingress? Role separation (GatewayClass/Gateway/HTTPRoute owned by different personas), typed/portable features (header & method matching, weighted traffic splitting, rewrites) instead of annotations, multi-protocol support (TCP/UDP/TLS/gRPC), and controlled cross-namespace routing via ReferenceGrant.

  11. Map the Gateway API objects to their Ingress equivalents. GatewayClassIngressClass; Gateway ≈ the LB+controller an Ingress implies; HTTPRoute ≈ the rules in an Ingress. ReferenceGrant has no Ingress equivalent (Ingress can’t cross namespaces).

  12. Is Ingress deprecated now that the Gateway API is GA? No. Ingress is stable and widely supported; the Gateway API is the recommended path for new and advanced use cases. Many controllers implement both.

Quick check

  1. True or false: applying an Ingress with no controller installed will start routing traffic.
  2. Which pathType matches by URL path elements: Exact, Prefix, or ImplementationSpecific?
  3. With rules / (Prefix) and /shop (Prefix), which backend serves /shop/cart?
  4. What two keys must a kubernetes.io/tls Secret contain?
  5. In the Gateway API, which object does an application developer typically own?

Answers

  1. False. The Ingress is inert until an Ingress controller is installed to read and act on it.
  2. Prefix — element-wise matching (so /api/apifoo).
  3. The /shop backend — longest matching path wins, regardless of YAML order.
  4. tls.crt and tls.key.
  5. The HTTPRoute (the routing rules); the platform team owns the Gateway, the provider owns the GatewayClass.

Practice challenges

Work these in order — each builds on the last. Try to answer before opening the solution. They escalate from “prove you understand the model” to “run canary and multi-controller setups.” Commands assume the lab cluster from the Hands-on section and a namespace shop (kubectl create ns shop).

Challenge 1 (beginner) — Diagnose “nothing routes.” You applied an Ingress and kubectl get ingress shows an empty ADDRESS. Name the single command that most quickly reveals the root cause, and give the two-line fix.

<details> <summary>Solution</summary>

kubectl get ingressclass    # empty output => no controller is installed

If it returns no rows, no controller exists, so nothing can adopt your Ingress. Fix: install a controller (e.g. the ingress-nginx kind manifest from the lab), then ensure your Ingress sets ingressClassName: nginx. Why: the Ingress is inert rules; the empty ADDRESS means no controller ever claimed it. </details>

Challenge 2 (beginner) — Write a one-host, one-path Ingress. In namespace shop, route host store.localdev.me, path / (Prefix) to Service web on port 80.

<details> <summary>Solution</summary>

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: store
  namespace: shop
spec:
  ingressClassName: nginx
  rules:
  - host: store.localdev.me
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: web
            port:
              number: 80

Why: / with Prefix is the catch-all root; ingressClassName is set explicitly so a controller adopts it regardless of any default class. </details>

Challenge 3 (intermediate) — Predict pathType routing. An Ingress has three paths on one host: / (Prefix → web), /api (Prefix → api), /api/health (Exact → health). For each request, name the backend: /api, /api/health, /api/health/, /apixyz.

<details> <summary>Solution</summary>

Why: longest matching path wins, Exact beats Prefix at equal length, and Prefix matches whole path segments — the classic exam trap. </details>

Challenge 4 (intermediate) — Add TLS and force HTTPS. Extend the store Ingress to serve HTTPS for store.localdev.me using an existing Secret store-tls, and redirect all HTTP to HTTPS.

<details> <summary>Solution</summary>

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: store
  namespace: shop
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
  - hosts:
    - store.localdev.me
    secretName: store-tls
  rules:
  - host: store.localdev.me
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: web
            port:
              number: 80

Why: the tls block turns on HTTPS termination for that host (ingress-nginx redirects HTTP→HTTPS automatically once a TLS block exists; the annotation makes the intent explicit). The Secret must be type kubernetes.io/tls and live in shop. </details>

Challenge 5 (advanced) — Canary by weight, two ways. Send 10% of traffic for api.localdev.me to api-v2 and 90% to api, using ingress-nginx annotations. Then show the Gateway API equivalent and note what changed.

<details> <summary>Solution</summary>

ingress-nginx needs two Ingress objects for the same host/path — a stable one and a canary one:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-stable
  namespace: shop
spec:
  ingressClassName: nginx
  rules:
  - host: api.localdev.me
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: api
            port:
              number: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-canary
  namespace: shop
  annotations:
    nginx.ingress.kubernetes.io/canary: "true"
    nginx.ingress.kubernetes.io/canary-weight: "10"
spec:
  ingressClassName: nginx
  rules:
  - host: api.localdev.me
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: api-v2
            port:
              number: 80

The Gateway API expresses the same split as one typed route with weight fields:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: api
  namespace: shop
spec:
  parentRefs:
  - name: prod-gateway
  hostnames: ["api.localdev.me"]
  rules:
  - backendRefs:
    - name: api
      port: 80
      weight: 90
    - name: api-v2
      port: 80
      weight: 10

Why: in Ingress the split is smuggled into annotations across two objects; in the Gateway API it is a first-class weight field in a single route — the exact portability win the successor API was built for. </details>

Challenge 6 (advanced) — Two controllers, one cluster. You need an internet-facing edge and an internal-only edge in the same cluster. Sketch the two IngressClass objects and explain how an app selects the internal one.

<details> <summary>Solution</summary>

apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: external-nginx
spec:
  controller: k8s.io/ingress-nginx
---
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
  name: internal-nginx
spec:
  controller: k8s.io/internal-ingress-nginx

Install two controllers; give the second a distinct --controller-class (k8s.io/internal-ingress-nginx) so it only adopts the internal-nginx class. An app then chooses its exposure with a single field: ingressClassName: internal-nginx. Why: IngressClass is the sharding key — each controller owns only the class whose spec.controller matches its own controller value, so the two never fight over the same Ingress. </details>

Exercise

On a fresh local cluster:

  1. Install ingress-nginx and deploy three Services: web, api, and admin.
  2. Create a single Ingress on host app.localdev.me that routes /web, /apiapi (Prefix), and /adminadmin (use Exact on /admin and observe that /admin/ then misses — explain why).
  3. Add a custom defaultBackend that serves a “not found” Service, and verify an unmatched path hits it.
  4. Add TLS with a self-signed cert and force an HTTP→HTTPS redirect via the appropriate annotation; confirm with curl -kvL.
  5. Stretch: re-express the same routing using the Gateway API — install Envoy Gateway (or your controller’s Gateway implementation), create a Gateway in an infra namespace and an HTTPRoute in the app namespace, attach them with parentRefs, and add a 90/10 weighted split between api and a new api-v2. Note which features needed annotations in Ingress but are native fields in the Gateway API.

Certification mapping

Glossary

Next steps

KubernetesIngressGateway APITLSNetworkingcert-manager
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments