OnCallReady

Lesson 16.19 · Kubernetes: Networking & Storage · 24 min read

Ingress: the resource, the controller, the rules

In plain words

An office building has one reception desk at the main door. Visitors say who they're here for ("Shop, the API department"), and the receptionist sends them to the right floor. The rules are written on a sheet: "Shop visitors go to floor 3, anyone asking for /api goes to floor 5". But the sheet on its own does nothing; if no receptionist is sitting at the desk, visitors just stand in the lobby.

An Ingress is that sheet of rules: host and path to Service. The Ingress controller (ingress-nginx, Traefik, and so on) is the receptionist, a normal Deployment that reads the rules and actually proxies HTTP. The IngressClass says which receptionist handles which sheet. No controller, or the wrong class, and the Ingress's ADDRESS stays empty forever.

One IP, many web sites

The shop has a web front end, an API, a blog and an admin page - four Services. A LoadBalancer per Service (16.6) costs one external IP (and, in a cloud, one paid load balancer) each, and gives users four addresses. What you actually want is one address, and a reverse proxy (9.23) behind it that looks at each HTTP request and sends it to the right Service. That is an Ingress.

What you need to know already: reverse proxies and load balancers (9.23), HTTP requests, the Host header, status codes and curl (9.21), TLS and SNI (9.15), Services, EndpointSlices (16.1), NodePort and LoadBalancer, externalTrafficPolicy (16.6), annotations (15.26).

The Ingress object

An Ingress describes HTTP(S) routing: for requests to this host (the name in the Host header) and this path (the part of the URL after the host), send them to that Service and port - the backend:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shop
  namespace: shop
spec:
  ingressClassName: nginx
  rules:
  - host: shop.lab
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: web
            port:
              number: 80
      - path: /api
        pathType: Prefix
        backend:
          service:
            name: api
            port:
              name: http          # a Service port NAME works too

Read it as: "requests for shop.lab whose path starts with /api go to Service api, port http; everything else for shop.lab goes to web:80". ingressClassName says which proxy should serve it (below); pathType says how the path is compared (further below).

The part that trips people up

The Ingress object does nothing by itself. It is configuration. Something has to read it and actually proxy the traffic: an Ingress controller, a reverse proxy that runs as a normal Deployment you install (ingress-nginx, Traefik, HAProxy, Contour...). It watches Ingress objects and reconfigures itself. A kubeadm cluster ships none.

kubectl create ingress generates one: --class sets ingressClassName and --rule="HOST/PATH=SERVICE:PORT" adds a rule (/* = a Prefix path):

$ k create ingress shop -n shop --class=nginx --rule="shop.lab/*=web:80"
ingress.networking.k8s.io/shop created
$ k get ingress -n shop
NAME   CLASS   HOSTS      ADDRESS   PORTS   AGE
shop   nginx   shop.lab             80      2m

The columns: CLASS (which controller it is for), HOSTS (the host names in its rules), ADDRESS (where the controller serves it - filled in by the controller), PORTS (80, plus 443 once TLS is configured).

Accepted by the API, stored, and ADDRESS is empty forever: no controller claimed it. An empty ADDRESS is the first thing to check.

The controller

ingress-nginx (nginx, the web server, plus a program that rewrites nginx's config from Ingress objects) is installed here in its bare-metal flavour: a Deployment, a NodePort Service to reach it (in a cloud: a LoadBalancer Service), and an IngressClass:

# once ingress-nginx is installed (the next mission)
k get pods,svc -n ingress-nginx
NAME                                            READY   STATUS    RESTARTS   AGE
pod/ingress-nginx-controller-46sbqdl68l-5lx6c   1/1     Running   0          2m
NAME                                         TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)                      AGE
service/ingress-nginx-controller             NodePort    10.96.142.147   <none>        80:30080/TCP,443:30443/TCP   2m
service/ingress-nginx-controller-admission   ClusterIP   10.96.173.130   <none>        443/TCP                      2m
k get ingressclass
NAME    CONTROLLER             PARAMETERS   AGE
nginx   k8s.io/ingress-nginx   <none>       2m

Node ports 30080 (HTTP) and 30443 (HTTPS) lead to the controller pod. The -admission Service is for the webhook (end of this lesson).

The path of a request from oncall-lab. curl -H 'Host: shop.lab' sends the request to a node IP but with the Host header set to shop.lab:

curl -H 'Host: shop.lab' http://10.64.0.11:30080/
  -> node port 30080 (kube-proxy) -> the controller pod's port 80
  -> nginx matches Host + path against every Ingress it serves
  -> proxies to a POD IP of Service web, read from its EndpointSlice

That last step matters: the controller does not go through the ClusterIP. It reads EndpointSlices and balances across pod IPs itself. So the Service's sessionAffinity (16.6) is ignored (ingress-nginx has its own cookie-based affinity), and NetworkPolicies (16.29) must allow traffic from the controller pods.

IngressClass

An IngressClass is the contract between Ingresses and controllers. It names a class (nginx) and the controller that implements it (spec.controller: k8s.io/ingress-nginx). An Ingress with ingressClassName: nginx is served by the controller started with the matching --controller-class.

An Ingress without a class is ignored - unless one IngressClass carries the annotation ingressclass.kubernetes.io/is-default-class: "true". Then the API server fills in ingressClassName when the Ingress is created. Marking a default later does not fix existing Ingresses.

The controller tells you in its log when it ignores something:

I0922 20:00:14.201114       7 store.go:369] "Ignoring ingress because of error while validating ingress class" ingress="shop/noclass" error="ingress does not contain a valid IngressClass"

Testing without DNS

shop.lab does not resolve anywhere yet. Three ways to send the right Host:

curl -H 'Host: shop.lab' http://10.64.0.11:30080/          # fake the header
curl --resolve shop.lab:30080:10.64.0.11 http://shop.lab:30080/   # pin the name
echo '10.64.0.11 shop.lab' | sudo tee -a /etc/hosts         # for every tool

-H adds a header by hand. --resolve NAME:PORT:IP tells curl "for this name and port, use this IP" - curl then uses the name everywhere. The last line adds an /etc/hosts entry (8.16) so every program on the box resolves it.

--resolve is the best habit: the Host header and the TLS SNI name (9.15) are right, which matters as soon as HTTPS is involved.

Matching rules

Host: exact (shop.lab), or a wildcard for exactly one label - one part between dots (*.shop.lab matches api.shop.lab, not shop.lab, not a.b.shop.lab). Rules without a host catch every host.

pathType - required, and the source of most "why is this 404":

Exact                   /api matches /api only. Not /api/, not /api/orders.
Prefix                  element-wise on "/": /api matches /api, /api/, /api/orders -
                        NOT /apis. / matches everything.
ImplementationSpecific  up to the controller. ingress-nginx: a plain string prefix
                        (/api matches /apis too), or a regex with use-regex.

"Element-wise" means the path is compared piece by piece between slashes, so /api is a prefix of /api/orders but not of /apis.

The longest match wins; Exact beats Prefix of the same length. kubectl create ingress has its own trap: --rule="shop.lab/api=api:80" creates Exact; only a trailing star (/api*) gives Prefix.

curl -s -o /dev/null -w '%{http_code}\n' throws the page away (-o /dev/null) and prints only the status code (-w, 9.27):

# with ingress-nginx installed and the shop Ingress above
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: shop.lab' http://10.64.0.11:30080/api/orders
404

(Exact /api, and no / rule to fall back on.)

What the controller answers

404 Not Found               no host/path matched (the controller's default server)
503 Service Temporarily     matched, but the Service has no ready endpoints, or
    Unavailable             does not exist, or the port name/number is wrong
502 Bad Gateway             matched, endpoint picked, the pod refused the connection
                            (wrong targetPort, app not listening)
504 Gateway Time-out        the pod did not answer in time (or a NetworkPolicy drops
                            the controller's connection)

The controller's default server (or default backend) is what answers when no rule matches: a plain 404 page. The rest are the proxy status codes of 9.23, now with a Kubernetes cause for each.

Every answer is in the controller's log, one line per request. logs deploy/NAME reads one pod of that Deployment:

k logs -n ingress-nginx deploy/ingress-nginx-controller --tail=2
10.244.1.1 - - [22/Sep/2026:20:00:16 +0000] "GET /hostname HTTP/1.1" 200 20 "-" "curl/8.14.1" 95 0.002 [shop-web-80] [] 10.244.2.192:8080 20 0.002 200 c84a09757ebaad3dbdc07d455da0b624
2026/09/22 20:00:16 [error] 38#38: *1002 connect() failed (111: Connection refused) while connecting to upstream, client: 10.244.1.1, server: shop.lab, request: "GET /api/x HTTP/1.1", upstream: "http://10.244.2.155:8080/api/x", host: "shop.lab"

The first line is an access log line (like nginx's in 7.2). Fields that matter:

10.244.1.1                 the client address - here the node's tunnel IP, because
                           the NodePort SNATed it (externalTrafficPolicy: Local on
                           the controller Service keeps the real one, 16.6)
"GET /hostname ..." 200    the request and the status sent to the client
[shop-web-80]              which backend the rule chose: namespace-service-port;
                           [upstream-default-backend] = nothing matched
10.244.2.192:8080 ... 200  the upstream (the pod IP:port) and its own status

The second line is an error log line: the pod at 10.244.2.155:8080 refused the connection - that request got a 502.

Annotations and the admission webhook

Controller-specific behaviour lives in annotations (nginx.ingress.kubernetes.io/...): rewrite-target (change the path before passing it on), ssl-redirect, proxy-read-timeout, backend-protocol: HTTPS (talk HTTPS to the pods), affinity: cookie, whitelist-source-range (allowed client IPs). rewrite-target with regex paths is the classic:

metadata:
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /$2
spec:
  rules:
  - host: shop.lab
    http:
      paths:
      - path: /api(/|$)(.*)          # regex: /api/orders -> /orders at the backend
        pathType: ImplementationSpecific

$2 is the second bracketed group of the regex (7.3): everything after /api/.

Once any Ingress for a host uses regex, ingress-nginx treats all paths of that host as case-insensitive regexes - a surprise for the other teams sharing the host.

The controller also runs a validating admission webhook: the API server calls the controller (through the -admission Service) before storing any Ingress, and the controller can refuse it. It rejects Ingresses nginx could not load, and duplicates:

Error from server (BadRequest): error when creating "dup.yaml": admission webhook "validate.nginx.ingress.kubernetes.io" denied the request: host "shop.lab" and path "/" is already defined in ingress shop/shop

And when the controller is down, it blocks every Ingress change: failed calling webhook "validate.nginx.ingress.kubernetes.io" ... no endpoints available for service "ingress-nginx-controller-admission".

ingress-nginx is retired

The Kubernetes project announced the retirement of ingress-nginx in November 2025; best-effort maintenance ended in March 2026. Since then: no more releases, no bugfixes, no security fixes, and its code repositories are read-only.

Existing installs keep working - and there are a lot of them, so you will operate it for years - but new platforms should use a Gateway API implementation (16.26) or another maintained controller.

The Ingress API itself is not deprecated: it is stable, and in wide use. Do not confuse the object with the project.

What you can now do:

Why it helps

On an app platform, most user-facing traffic crosses an ingress controller, so its errors are your pages: a 404 means no rule matched (often an Exact pathType created by kubectl create ingress), a 503 means the Service has no ready endpoints, a 502 means the pod refused, a 504 means it timed out or a NetworkPolicy dropped the controller. Reading one controller log line tells you which backend was picked and what it answered.

You'll review Ingress manifests constantly, and the traps are specific: missing ingressClassName, regex paths that silently change matching for the whole host, and a down admission webhook that blocks every Ingress change in the cluster. And since ingress-nginx is retired, you'll be part of migrations to Gateway API; you need to know what the old setup did before you can move it.

FAQ

I created an Ingress and nothing happens. Why?

The Ingress object is only configuration. Something has to read it: an Ingress controller, which a kubeadm cluster does not ship. Check k get ingressclass and the controller pods. If a controller exists, check ingressClassName matches a class it serves; an Ingress without a class is ignored unless a default IngressClass existed when it was created. An empty ADDRESS column is the first sign.

What's the difference between Prefix, Exact and ImplementationSpecific?

Exact matches only that path: /api is not /api/ or /api/orders. Prefix matches element-wise on /: /api matches /api, /api/ and /api/orders but not /apis. ImplementationSpecific is up to the controller; ingress-nginx treats it as a plain string prefix (so /api matches /apis) or a regex. Longest match wins, Exact beats Prefix at equal length.

Why does kubectl create ingress give me a 404 on /api/orders?

Because --rule="shop.lab/api=api:80" creates an Exact path. Only a trailing star, /api*, produces pathType: Prefix. So /api works, /api/orders matches nothing and the controller's default server answers 404. Check the generated YAML with --dry-run=client -o yaml before relying on it.

Does the ingress controller use the Service's ClusterIP?

No. ingress-nginx reads the Service's EndpointSlices and proxies straight to pod IPs, balancing itself. Consequences: Service sessionAffinity is ignored (the controller has its own cookie affinity), readiness still matters because only ready endpoints are used, and NetworkPolicies on the backend must allow traffic from the controller pods, not from the Service.

ingress-nginx is retired, so is Ingress deprecated?

No. The ingress-nginx controller project was retired by the Kubernetes project in March 2026: no more releases or security fixes. The Ingress API (networking.k8s.io/v1) is GA, stable and widely used, and other controllers keep implementing it. New platforms should prefer a Gateway API implementation; existing ingress-nginx installs should plan a migration, often starting with ingress2gateway.

In an interview Junior

What is the difference between an Ingress and an Ingress controller?

The link is the IngressClass: ingressClassName: nginx says which controller serves it; an Ingress without a class is ignored unless a default class exists.

How it fails tells you where to look: an empty ADDRESS = no controller claimed it; 404 = no host/path rule matched (check pathType); 503 = matched, but the Service has no ready endpoints; 502 = the pod refused or broke the connection. The controller's access log has a line per request with the upstream it used.

Also asked: Users get a 503 from the ingress for one app while others work. How do you debug it? · What is the difference between pathType Exact and Prefix? · How do you test an Ingress for a hostname that does not resolve yet?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.