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:
- write an Ingress with host and path rules, and pick the right pathType
- tell "no controller" (empty ADDRESS) from "no rule matched" (404) from "backend broken" (502/503)
- read the controller's access log line