
Ingress: Rules, Classes and the Controller That Reads Them
Ingress: Rules, Classes and the Controller That Reads Them
Part 11 of the CKA roadmap Β· Phase 3 β Services & Networking Β· 20%
A Service routes by port. It works at layer 4 and knows nothing about HTTP. An Ingress routes by host and path, at layer 7. That lets one entry point send /app1 to one Service and /app2 to another, where you would otherwise need a load balancer per Service.
Gateway API is the successor, and it has its own post next in the roadmap. But Ingress is still in the curriculum, and still in most clusters you will meet. And the lesson it teaches best carries straight over to Gateway API: a routing rule is only data until something reads it.
The one idea to hold
Three objects, three jobs:
| Object | What it is | What it does on its own |
|---|---|---|
| Ingress | Routing rules: host, path, backend Service | Nothing. The API server stores it, and that is all |
| IngressClass | A name that points at one controller | Nothing. It lets a controller recognise its own Ingresses |
| Ingress controller | A proxy running as pods in the cluster | Reads the Ingresses that name its class, and routes traffic |
No controller, no routing β and no error either. The whole first half of this lab is about seeing that happen, so you recognise it later.
Which controller
Two projects are both called "NGINX Ingress Controller", and they are not interchangeable:
| Project | Annotation prefix |
|---|---|
ingress-nginx β the Kubernetes community project, kubernetes/ingress-nginx | nginx.ingress.kubernetes.io/* |
| F5 NGINX Ingress Controller β from NGINX / F5 | nginx.org/* |
Each one ignores the other's annotations, silently. Copy an example written for one onto the other, and every annotation in it does nothing.
This lab uses ingress-nginx. Its annotations are the ones you meet most often in examples.
ingress-nginx is retired
Kubernetes SIG Network announced in November 2025 that ingress-nginx would be retired in March 2026. After that date there are no more releases, and no more security fixes.
That is fine for a lab cluster you tear down afterwards. Don't put it in front of anything real. The long-term replacement is Gateway API.
In an exam-style environment
The controller is normally already installed. So don't reach for Helm β start with kubectl get ingressclass to learn the class name. The install steps below exist so you can see why an Ingress stays silent without a controller, not so you can memorise them.
The task
In namespace
ing-lab, two Deployments,app1andapp2, each sit behind a Service on port 80. Expose both through one Ingress: requests forlab.example.com/app1and anything below it go toapp1, and/app2and anything below it go toapp2. Use the cluster's NGINX IngressClass.Then explain why an Ingress created without a class receives no traffic. And show which requests an
Exactpath matches that aPrefixpath does not.
Written from scratch for this series, like every task in it.
Step 0 β Two backends you can tell apart
hashicorp/http-echo answers every path with one fixed string. So when you curl through the Ingress, the reply tells you exactly which Service you reached. And a 404 can only come from the Ingress β never from a backend that happens to lack that path.
hostname
kubectl get nodes -o wideNote the INTERNAL-IP of worker. You will send requests to it.
kubectl create ns ing-lab
kubectl config set-context --current --namespace=ing-lab
kubectl create deploy app1 --image=hashicorp/http-echo:1.0 -- /http-echo -text=app1
kubectl create deploy app2 --image=hashicorp/http-echo:1.0 -- /http-echo -text=app2
kubectl expose deploy app1 --port=80 --target-port=5678
kubectl expose deploy app2 --port=80 --target-port=5678Prove both Services work before putting an Ingress in front of them. That way a Service problem can never pass for an Ingress problem:
kubectl run tmp --rm -it --image=busybox:1.36 --restart=Never -- sh -c 'wget -qO- -T 2 app1; wget -qO- -T 2 app2'It should print app1, then app2.
Step 1 β An Ingress with no controller
Confirm there is nothing to read an Ingress yet:
kubectl get ingressclass
kubectl get pods -A | grep -i ingressBoth come back empty. Create two Ingresses anyway β one naming a class that does not exist yet, and one naming no class at all:
kubectl create ingress demo --class=nginx --rule="/app1*=app1:80" --rule="/app2*=app2:80"
kubectl create ingress demo-noclass --rule="/echo*=app1:80"The --rule syntax is <host>/<path>=<service>:<port>:
- Leave out the host and the rule matches any host.
- A path ending in
*becomespathType: Prefix. - A path without
*becomespathType: Exact.
kubectl get ingress
kubectl describe ingress demoThe API server accepted both, even though no class called nginx exists. ADDRESS is empty, and describe shows no events. Each Ingress is a record in etcd that nothing is reading.
Save that state to compare against in a moment:
kubectl get ingress -o wide > ingress-before.txtStep 2 β Install the controller, and watch what changes
In a second SSH session, watch the Ingresses:
kubectl get ingress -n ing-lab -wIn the first, install the controller. A kubeadm cluster has no cloud load balancer, so expose the controller as a NodePort Service on fixed ports:
helm upgrade --install ingress-nginx ingress-nginx \
--repo https://kubernetes.github.io/ingress-nginx \
--version 4.15.1 \
-n ingress-nginx --create-namespace \
--set controller.service.type=NodePort \
--set controller.service.nodePorts.http=31080 \
--set controller.service.nodePorts.https=31443kubectl wait -n ingress-nginx --for=condition=Available deploy/ingress-nginx-controller --timeout=180s
kubectl get pods,svc -n ingress-nginx
kubectl get ingressclassAn IngressClass called nginx now exists. Within a few seconds, the watch in the second session shows demo gaining an ADDRESS. With this chart's defaults, and no load balancer to report, that address is the cluster IP of the controller's own Service. It proves the controller picked the Ingress up. It is not an address you can reach from outside the cluster.
Predict before you look: does demo-noclass get an address too?
kubectl get ingress -o wide
diff ingress-before.txt <(kubectl get ingress -o wide)Now send requests through the controller's NodePort. Replace <WORKER_INTERNAL_IP> with the address you noted:
W=<WORKER_INTERNAL_IP>
curl -s http://$W:31080/app1
curl -s http://$W:31080/app2
curl -s -o /dev/null -w '%{http_code}\n' http://$W:31080/echo
curl -s -o /dev/null -w '%{http_code}\n' http://$W:31080/What happens
/app1and/app2answerapp1andapp2. The controller is routing.demo-noclassstays without an address, and/echoreturns404. ingress-nginx handles only Ingresses whoseingressClassNamematches its class. One with no class is ignored, with nothing in its status or events to say so./returns404from the controller's default backend, because no rule matches it.
The exception: if an IngressClass is marked as the cluster default, it shows (default) in kubectl get ingressclass. The API server then fills in ingressClassName on every new Ingress created without one. Nothing is marked default here, so nothing was filled in.
Fix the ignored Ingress by giving it the class:
kubectl patch ingress demo-noclass -p '{"spec":{"ingressClassName":"nginx"}}'
curl -s http://$W:31080/echoWhen an Ingress does nothing β the order to check
kubectl get ingressclassβ does a class exist, and what is its name?kubectl get ingress <name> -o jsonpath='{.spec.ingressClassName}'β does the Ingress name that class?kubectl get pods -n <controller-namespace>β is the controller running?kubectl describe ingress <name>β do the backends resolve? See Step 3c.
Step 3 β Two paths, two Services, one host
demo already routes two paths, but for any host. Now build what the task actually asks for β the same routing, restricted to lab.example.com:
kubectl create ingress two-paths --class=nginx \
--rule="lab.example.com/app1*=app1:80" \
--rule="lab.example.com/app2*=app2:80"
kubectl describe ingress two-pathsIn describe, the Rules table has one line per path: host, path, and the backend Service with its pod endpoints.
3a. Sending a request with a host
There is no DNS record for lab.example.com. So you tell curl which host you mean. There are two ways:
curl -s -H "Host: lab.example.com" http://$W:31080/app1
curl -s --resolve lab.example.com:31080:$W http://lab.example.com:31080/app2-H sets the header by hand. --resolve makes curl believe the name resolves to that IP, which is closer to what a real client does.
Now a request with no host:
curl -s http://$W:31080/app1Predict: does this reach app1?
What happens
It does β but not through two-paths. It matched demo, which has no host and so matches every host.
All the Ingresses a controller reads are merged into one routing table. Delete demo and run the same request again, and it returns 404.
3b. Write the spec by hand
kubectl create ingress is fast, but you also need to be able to write and fix the YAML. Many tasks hand you a broken manifest instead of a blank page. Look at what the command produced:
kubectl get ingress two-paths -o yaml | sed -n '/^spec:/,/^status:/p'Then check you can reproduce it without looking:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: two-paths
namespace: ing-lab
spec:
ingressClassName: nginx
rules:
- host: lab.example.com
http:
paths:
- path: /app1
pathType: Prefix
backend:
service:
name: app1
port:
number: 80
- path: /app2
pathType: Prefix
backend:
service:
name: app2
port:
number: 803c. A backend that does not exist
kubectl create ingress broken --class=nginx --rule="/x*=nope:80"
kubectl describe ingress broken | grep -A3 Rules
curl -s -o /dev/null -w '%{http_code}\n' http://$W:31080/x
kubectl delete ingress broken- The API server does not stop you. An Ingress pointing at a Service that doesn't exist is created without complaint.
- Only
describetells you. It shows<error: services "nope" not found>against the rule. - The request fails with
503. A route exists, but there is nothing behind it.
Step 4 β Exact versus Prefix
This is where most wrong answers come from. Create one rule of each type:
kubectl create ingress paths --class=nginx \
--rule="paths.local/exact=app1:80" \
--rule="paths.local/prefix*=app2:80"
kubectl get ingress paths -o jsonpath='{range .spec.rules[0].http.paths[*]}{.path}{" "}{.pathType}{"\n"}{end}'/exact is Exact and /prefix is Prefix. Before you run the loop below, write down what you expect for each request:
for p in /exact /exact/ /exact/sub /prefix /prefix/ /prefix/sub /prefixsub; do
printf '%-14s ' "$p"; curl -s -H "Host: paths.local" http://$W:31080$p | head -c 40; echo
doneWhat happens
| Request | Result | Why |
|---|---|---|
/exact | app1 | Matches character for character |
/exact/ | 404 | One trailing slash too many, and Exact allows nothing extra |
/exact/sub | 404 | Exact never matches anything below the path |
/prefix | app2 | |
/prefix/ | app2 | |
/prefix/sub | app2 | Prefix matches whole path segments below it |
/prefixsub | 404 | Prefix compares segments split on /, not strings |
That last row is the one to remember. A Prefix of /foo matches /foo/bar, but not /foobar.
4b. Overlapping rules β which one wins?
cat > overlap.yaml <<'YAMLEOF'
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: overlap
namespace: ing-lab
spec:
ingressClassName: nginx
rules:
- host: overlap.local
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: app1
port:
number: 80
- path: /api/v2
pathType: Prefix
backend:
service:
name: app2
port:
number: 80
- path: /api/v2/health
pathType: Exact
backend:
service:
name: app1
port:
number: 80
YAMLEOF
kubectl apply -f overlap.yamlPredict, then run:
for p in /api /api/users /api/v2 /api/v2/users /api/v2/health /api/v2/health/; do
printf '%-18s ' "$p"; curl -s -H "Host: overlap.local" http://$W:31080$p | head -c 40; echo
doneWhat happens
The longest matching path wins. At equal length, Exact beats Prefix.
| Request | Answer | Matched rule |
|---|---|---|
/api, /api/users | app1 | Prefix /api |
/api/v2, /api/v2/users | app2 | Prefix /api/v2 |
/api/v2/health | app1 | Exact /api/v2/health |
/api/v2/health/ | app2 | The trailing slash makes the Exact rule miss, so it falls back to Prefix /api/v2 |
Controller-specific annotations
Everything so far is standard Ingress, and it behaves the same on any controller. Annotations are different: each one belongs to one controller. Put a task's annotations on an Ingress only when the task gives them to you.
Routed correctly, still a 404
Put a real web server behind a path:
kubectl create deploy web --image=nginx:1.29.1-alpine
kubectl expose deploy web --port=80
kubectl create ingress rewrite --class=nginx --rule="rewrite.local/web*=web:80"
curl -s -o /dev/null -w '%{http_code}\n' -H "Host: rewrite.local" http://$W:31080/web404 β and the Ingress is right. The request did reach web. But the Ingress passed the path along unchanged, and nginx has no file at /web. This 404 came from the backend.
That is exactly why Step 0 used http-echo: it has an answer for every path.
Rewrite the path before it reaches the backend:
kubectl annotate ingress rewrite nginx.ingress.kubernetes.io/rewrite-target=/
curl -s -o /dev/null -w '%{http_code}\n' -H "Host: rewrite.local" http://$W:31080/web200. When the routing is right but the answer is 404, test the Service directly before you change the Ingress. The problem is usually the path the backend receives.
Splitting traffic: a canary
ingress-nginx can send a percentage of a host's traffic to a second Ingress. You need two:
- The main Ingress, sending all traffic for
canary.localtoapp1 - A canary Ingress for the same host and path, pointing at
app2, marked with two annotations
kubectl create ingress main --class=nginx --rule="canary.local/*=app1:80"
kubectl create ingress canary --class=nginx --rule="canary.local/*=app2:80" \
--annotation=nginx.ingress.kubernetes.io/canary=true \
--annotation=nginx.ingress.kubernetes.io/canary-weight=20Normally the controller's admission webhook rejects a second Ingress for the same host and path. The canary: "true" annotation is the only thing that lets the two coexist.
Count where 100 requests land:
for i in $(seq 1 100); do curl -s -H "Host: canary.local" http://$W:31080/; done | sort | uniq -cExpect roughly 80 app1 and 20 app2. The split is random by weight, so it won't be exact.
To finish a blue-green switch, move all traffic to the canary:
kubectl annotate ingress canary nginx.ingress.kubernetes.io/canary-weight=100 --overwriteTo roll back, set the weight to 0.
Verification
kubectl get ingressclass
kubectl get ingress -n ing-lab
curl -s -H "Host: lab.example.com" http://$W:31080/app1
curl -s -H "Host: lab.example.com" http://$W:31080/app2
curl -s -o /dev/null -w '%{http_code}\n' -H "Host: paths.local" http://$W:31080/prefixsubnginxis listed as an IngressClasstwo-pathshasingressClassName: nginxand an address- The two host requests answer
app1andapp2 /prefixsubreturns404
Traps
This lab ran clean for me β my notes record nothing that went wrong, and it added no rows to my error log. So these are not mistakes I made. They are the traps the lab was built to walk you into deliberately.
| Trap | Symptom | The fix |
|---|---|---|
| No controller | Ingress created, no address, no traffic, no error | kubectl get ingressclass first |
No ingressClassName | Ignored, silently | Set the class, or check whether one is marked (default) |
Testing without a Host header | Request hits a different, host-less Ingress β or 404s | curl -H "Host: β¦" or --resolve |
Trailing slash on an Exact path | 404 | Exact means character for character |
Prefix /foo expected to match /foobar | 404 | Prefix matches segments, not strings |
| Backend Service misspelled | 503; creation succeeded | kubectl describe ingress shows <error: services β¦ not found> |
| Routed correctly, backend answers 404 | Looks like a broken Ingress | Test the Service directly; the backend lacks that path |
| Another controller's annotations | Annotation silently ignored | nginx.ingress.kubernetes.io/* is ingress-nginx; nginx.org/* is F5 NGINX |
Speed
My notes have no times for this lab, so I have no number to report. The rule I hold every lab to still applies: anything over seven minutes goes on my list of slowest tasks, to be re-run against a clock before the exam.
The fastest route is the imperative one. Know kubectl create ingress well enough not to need --help:
| You need | Write |
|---|---|
| Prefix match, any host | --rule="/path*=svc:80" |
| Exact match, one host | --rule="host.example.com/path=svc:80" |
| Everything on a host | --rule="host.example.com/*=svc:80" |
| A class | --class=nginx |
| An annotation | --annotation=key=value |
| TLS from an existing Secret | --rule="host.example.com/*=svc:80,tls=secret-name" |
Then generate YAML with $do (from part 2) whenever a task needs more than the flags can express.
The checklist:
The docs pages
- Ingress β the one to bookmark. It has a complete manifest to adapt, and a table of path types against example requests, which settles any
Exact/Prefixdoubt in seconds. - Ingress Controllers β IngressClass, and how a default class works
kubectl create ingressβ every--ruleform, with examples
Say it out loud
Before you move on, answer these without notes:
- At which layer does a Service work, and at which does an Ingress work?
- You create an Ingress and nothing happens. Give two reasons why.
- Does a
Prefixrule for/foomatch/foobar? - Two rules match a request. Which one wins?
- The routing is right, but the answer is
404. Where do you look?
Clean up
Uninstall the chart before deleting its namespace
The chart installs a cluster-wide ValidatingWebhookConfiguration. Delete the ingress-nginx namespace first, and that webhook configuration stays behind, pointing at a Service that no longer exists.
From then on, every attempt to create an Ingress fails with failed calling webhook. That includes the next lab.
kubectl config set-context --current --namespace=default
kubectl delete ns ing-lab
helm uninstall ingress-nginx -n ingress-nginx
kubectl delete ns ingress-nginx
kubectl get validatingwebhookconfiguration | grep -i ingressThe last command should print nothing.
Next: Migrate from Ingress to Gateway API Β· Back to the CKA roadmap