Module 4: Networking Ingress And Networkpolicy
4. Hands-on: installing `ingress-nginx` in `kind`
Description
This is the lesson where you hire the receptionist lesson 3 described in theory: ingress-nginx, the Kubernetes community's reference Ingress controller, really running inside andes-cargo-cluster. Before installing it, though, this lesson has to resolve something no previous module needed: ingress-nginx on kind needs the cluster to reserve, from the moment it's created, two host ports (80 and 443) toward the node where the controller is going to run — a configuration kind-config.yaml didn't have yet. That means recreating the cluster. Everything that follows really ran, including a real finding that wasn't in the plan: the official ingress-nginx manifest itself left the controller's Pod on the wrong node the first time, and this lesson documents how that gets diagnosed and fixed.
Connection to the module
This lesson finally builds the Ingress controller lesson 3 explained with nothing run. Lesson 5 declares the first real Ingress rule against this controller, already installed and healthy.
Step 1 — Why this requires recreating the cluster, not just reconfiguring it
ingress-nginx needs, to work inside kind, traffic arriving at your machine's (the host's) ports 80/443 to forward toward a specific cluster node's Docker container — kind-config.yaml's extraPortMappings field, which you already saw mentioned in Module 1 without using it yet. The problem: kind only reads extraPortMappings at the moment each node is created — there's no command to add it to a cluster that's already running, the same way you can't change the port number an already-started Docker container has mapped without recreating it.
WHY extraPortMappings CAN'T BE "ADDED LATER"
kind create cluster --config kind-config.yaml
│
▼
Docker creates the node's container
WITH the port mapping already decided
(same as "docker run -p 80:80")
│
▼
The container already exists — changing
its port mappings requires recreating it,
there's no "kubectl edit" for this
This isn't an optional step in this lesson — it's the exact technical reason you're going to recreate andes-cargo-cluster right now, with an updated kind-config.yaml.
Step 2 — The updated kind-config.yaml
# kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: andes-cargo-cluster
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
- role: worker
- role: worker
Versus Module 1's kind-config.yaml, the control-plane node gains two new fields:
extraPortMappings— two entries, one for each standard HTTP port (80,443). Each one says "my machine'shostPortforwards toward this node'scontainerPort" — the same concept asdocker run -p 80:80, declared in YAML instead of on the command line.kubeadmConfigPatches— akindmechanism to inject additionalkubeadmconfiguration (the toolkinduses underneath to initialize each node) at creation time. Here it's used to add theingress-ready=truelabel to thecontrol-planenode — a label that means nothing special to Kubernetes on its own; it's a conventioningress-nginx's own manifest recognizes, and one you're going to use yourself in Step 6 to resolve this lesson's real finding.
Notice something important: only the control-plane node gets both fields — the two workers stay exactly the same as before. That decision is intentional: extraPortMappings only makes sense on the specific node where you're going to force the controller to run; declaring it on all three nodes wouldn't add any capability, only confusion about which host port points to which container.
Step 3 — Recreate the cluster
kind delete cluster --name andes-cargo-cluster
What to expect:
Deleting cluster "andes-cargo-cluster" ...
Deleted nodes: ["andes-cargo-cluster-control-plane" "andes-cargo-cluster-worker" "andes-cargo-cluster-worker2"]
kind create cluster --config kind-config.yaml --name andes-cargo-cluster
What to expect (literal, executed — the same sequence of steps as Module 1, lesson 5, because the creation mechanism didn't change, only its configuration):
Creating cluster "andes-cargo-cluster" ...
• Ensuring node image (kindest/node:v1.36.1) 🖼 ...
✓ Ensuring node image (kindest/node:v1.36.1) 🖼
• Preparing nodes 📦 📦 📦 ...
✓ Preparing nodes 📦 📦 📦
• Writing configuration 📜 ...
✓ Writing configuration 📜
• Starting control-plane 🕹️ ...
✓ Starting control-plane 🕹️
• Installing CNI 🔌 ...
✓ Installing CNI 🔌
• Installing StorageClass 💾 ...
✓ Installing StorageClass 💾
• Joining worker nodes 🚜 ...
✓ Joining worker nodes 🚜
Set kubectl context to "kind-andes-cargo-cluster"
You can now use your cluster with:
kubectl cluster-info --context kind-andes-cargo-cluster
Before recreating the cluster on your own machine, confirm your host's ports
80and443are free (lsof -nP -iTCP:80 -sTCP:LISTENand the equivalent for443on macOS/Linux) — if something else already uses them (a local web server, for example),kind create clusteris going to fail trying to reserve them. This lesson's "Common mistakes" section goes deeper on this.
Step 4 — Confirm the ingress-ready label
kubectl get nodes --show-labels
What to expect (trimmed to the relevant columns; AGE is your variable value — notice ingress-ready=true, present only on the control-plane, exactly as you declared in kind-config.yaml):
NAME STATUS ROLES AGE LABELS
andes-cargo-cluster-control-plane Ready control-plane 31s ...,ingress-ready=true,kubernetes.io/hostname=andes-cargo-cluster-control-plane,...
andes-cargo-cluster-worker Ready <none> 16s ...,kubernetes.io/hostname=andes-cargo-cluster-worker,...
andes-cargo-cluster-worker2 Ready <none> 16s ...,kubernetes.io/hostname=andes-cargo-cluster-worker2,...
This is the first real confirmation kubeadmConfigPatches worked: a label you declared in a YAML file before the node existed, now visible as real node metadata — the same kind of label (app=, role=) you already use every day in selector/matchLabels, just this time at the node level, not the Pod level.
Step 5 — Reload the image, and rebuild Module 3's state
Recreating the cluster means you lost everything that lived inside it — the loaded image, the namespace, the Deployment, the Service, the configuration. None of that got lost permanently (your andes-cargo-status-api:latest is still in your machine's Docker, and your .yaml manifests are still on disk); you just need to reapply them, exactly as you already did in Module 1 and Module 3.
kind load docker-image andes-cargo-status-api:latest --name andes-cargo-cluster
What to expect (literal, executed — one message per node, all three get the image because none of them had it yet on this new cluster):
Image: "andes-cargo-status-api:latest" with ID "sha256:d07c069076658570594753ad62b86b160588675400625a6c1179b5f4b5022b32" not yet present on node "andes-cargo-cluster-worker", loading...
Image: "andes-cargo-status-api:latest" with ID "sha256:d07c069076658570594753ad62b86b160588675400625a6c1179b5f4b5022b32" not yet present on node "andes-cargo-cluster-control-plane", loading...
Image: "andes-cargo-status-api:latest" with ID "sha256:d07c069076658570594753ad62b86b160588675400625a6c1179b5f4b5022b32" not yet present on node "andes-cargo-cluster-worker2", loading...
Now reapply, in order, the six manifests Module 3 left — namespace.yaml, configmap.yaml, secret.yaml, deployment.yaml, service.yaml, hpa.yaml — with no content change from the ones you already know:
kubectl apply -f namespace.yaml
kubectl apply -f configmap.yaml
kubectl apply -f secret.yaml
kubectl apply -f deployment.yaml
kubectl apply -f service.yaml
kubectl apply -f hpa.yaml
kubectl rollout status deployment/andes-cargo-status-api -n andes-cargo --timeout=120s
What to expect (literal, executed):
namespace/andes-cargo created
configmap/andes-cargo-status-api-config created
secret/andes-cargo-status-api-secrets created
deployment.apps/andes-cargo-status-api created
service/status-api-service created
horizontalpodautoscaler.autoscaling/andes-cargo-status-api-hpa created
Waiting for deployment "andes-cargo-status-api" rollout to finish: 0 of 3 updated replicas are available...
Waiting for deployment "andes-cargo-status-api" rollout to finish: 1 of 3 updated replicas are available...
Waiting for deployment "andes-cargo-status-api" rollout to finish: 2 of 3 updated replicas are available...
deployment "andes-cargo-status-api" successfully rolled out
andes-cargo-cluster is back, exactly where Module 3 left it — same names, same configuration, same three replicas — just now, on top of that, with extraPortMappings and the ingress-ready label available since its creation. None of your manifests' content changed; only the kind-config.yaml that describes the cluster carrying them.
Step 6 — Install ingress-nginx, the official manifest for kind
The ingress-nginx project publishes a static, versioned manifest specific to kind clusters — designed exactly for the extraPortMappings-on-the-control-plane-node pattern you just configured:
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.15.1/deploy/static/provider/kind/deploy.yaml
What to expect (literal, executed — the URL includes the controller-v1.15.1 tag, the most recent stable version confirmed at the time this lesson was written; check the current version on the repository itself if you pick this guide back up later):
namespace/ingress-nginx created
serviceaccount/ingress-nginx created
serviceaccount/ingress-nginx-admission created
role.rbac.authorization.k8s.io/ingress-nginx created
role.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrole.rbac.authorization.k8s.io/ingress-nginx created
clusterrole.rbac.authorization.k8s.io/ingress-nginx-admission created
rolebinding.rbac.authorization.k8s.io/ingress-nginx created
rolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx created
clusterrolebinding.rbac.authorization.k8s.io/ingress-nginx-admission created
configmap/ingress-nginx-controller created
service/ingress-nginx-controller created
service/ingress-nginx-controller-admission created
deployment.apps/ingress-nginx-controller created
job.batch/ingress-nginx-admission-create created
job.batch/ingress-nginx-admission-patch created
ingressclass.networking.k8s.io/nginx created
validatingwebhookconfiguration.admissionregistration.k8s.io/ingress-nginx-admission created
A new namespace (ingress-nginx), the controller's own Deployment, two one-shot Jobs that generate ingress-nginx's internal admission-webhook certificate (a security piece of the project itself, unrelated to Module 6's Gatekeeper/Kyverno admission control in this guide — same general concept, different project), and an IngressClass named nginx — the exact value you're going to use in ingressClassName in lesson 5.
Step 7 — The real finding: the Pod landed on the wrong node
This is where this lesson stops to show something that wasn't in the plan, but really ran while writing it — and it's worth more than if everything had gone perfectly on the first try. Confirm where the controller's Pod ended up:
kubectl get pods -n ingress-nginx -o wide
What to expect (literal, executed — notice the NODE column):
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
ingress-nginx-controller-54754544b9-zh5m5 0/1 ContainerCreating 0 4s <none> andes-cargo-cluster-worker <none> <none>
The controller's Pod got scheduled onto andes-cargo-cluster-worker — not onto andes-cargo-cluster-control-plane, the only node you declared with extraPortMappings. This is a real problem: the controller is going to try reserving ports 80/443 on the node it lives on (hostPort, the same mechanism you declared in kind-config.yaml), but your kind-config.yaml's mapping only forwards traffic from your machine toward the control-plane — if the controller runs on a worker, lesson 5's curl isn't going to reach anywhere.
The cause, confirmed by checking the manifest you just applied:
kubectl get deployment ingress-nginx-controller -n ingress-nginx -o jsonpath='{.spec.template.spec.nodeSelector}'
What to expect (literal):
{"kubernetes.io/os":"linux"}
The Deployment's nodeSelector, as published by the official manifest, only requires kubernetes.io/os: linux — any Linux node in the cluster (all three, in this case) is a valid candidate for kube-scheduler. The ingress-ready=true label you declared in kind-config.yaml exists on the node, but nothing in ingress-nginx's manifest uses it yet to restrict where the Pod can be scheduled.
Step 8 — Fix it: require the ingress-ready label
kubectl patch deployment ingress-nginx-controller -n ingress-nginx \
--type='json' \
-p='[{"op":"add","path":"/spec/template/spec/nodeSelector/ingress-ready","value":"true"}]'
What to expect:
deployment.apps/ingress-nginx-controller patched
kubectl patch with --type='json' applies a surgical change — adding a single key to the existing nodeSelector — with no need to rewrite the entire Deployment manifest. By changing template.spec (the nodeSelector is part of that template), Kubernetes triggers the same RollingUpdate mechanism you already know from Module 3: it creates a new Pod with the corrected template, and only then removes the old one.
kubectl rollout status deployment/ingress-nginx-controller -n ingress-nginx --timeout=180s
What to expect (literal, executed):
Waiting for deployment "ingress-nginx-controller" rollout to finish: 0 of 1 updated replicas are available...
deployment "ingress-nginx-controller" successfully rolled out
Step 9 — Confirm: the Pod is on the correct node, and Running
kubectl get pods -n ingress-nginx -o wide
What to expect (literal, executed — Pod name and IP are your variable values; the node, this time, is the control-plane):
NAME READY STATUS RESTARTS AGE IP NODE NOMINATED NODE READINESS GATES
ingress-nginx-controller-5b9b5fd68-9pr6j 1/1 Running 0 17s 10.244.0.5 andes-cargo-cluster-control-plane <none> <none>
1/1 Running, on andes-cargo-cluster-control-plane — the only node whose extraPortMappings really forwards your machine's traffic into the cluster. Also confirm the Service and IngressClass the manifest created:
kubectl get svc -n ingress-nginx
kubectl get ingressclass
What to expect (literal — the two NodePort ports in the PORT(S) column are randomly assigned by Kubernetes, marked as variable; the rest is literal):
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
ingress-nginx-controller LoadBalancer 10.96.43.245 <pending> 80:30626/TCP,443:32283/TCP 32s
ingress-nginx-controller-admission ClusterIP 10.96.181.231 <none> 443/TCP 32s
NAME CONTROLLER PARAMETERS AGE
nginx k8s.io/ingress-nginx <none> 32s
EXTERNAL-IP: <pending> is normal and expected — on a real cloud, that field would fill in with a load balancer's IP (the same mechanism a Service type: LoadBalancer creates, Module 2); on kind, there's no cloud provider to assign that IP, so it stays pending forever. This doesn't stop the controller from working: traffic comes in through extraPortMappings (your host's port 80/443 → the Pod's hostPort), a path completely separate from EXTERNAL-IP.
Analogy: the hired receptionist, on the wrong floor of the building
Picking back up the building analogy: you hired the receptionist (ingress-nginx), but the hiring contract only said "any floor of the building works" (nodeSelector: kubernetes.io/os: linux) — and, by chance, the office-assignment system sent them to a floor with no direct connection to the street (a worker, with no extraPortMappings). The receptionist is working perfectly, but no visitor from the street can find them, because the building's main door (your host's ports 80/443) is only physically connected to the floor you prepared for that (the control-plane, with the ingress-ready label). Step 8 fixes the hiring contract to explicitly require that specific floor — not "any," but the one that really has the door connected to the street.
Common mistakes
Trying to add extraPortMappings to an already-existing cluster, without recreating it (the mistake this lesson prevents from Step 1 onward). What happens: someone looks for a command like kind edit cluster or kubectl patch node to add the port mapping without losing the cluster's current state, and doesn't find one, because it doesn't exist. How to spot it: if you're searching for how to modify an already-running cluster's extraPortMappings. How to fix it: extraPortMappings is a node's Docker container configuration, decided at docker run time (which kind create cluster does for you) — just as you can't change an already-started Docker container's mapped ports without recreating it, you can't do it here either. Recreating the cluster (this lesson's Steps 3-5) is the only path.
Not freeing the host's ports 80/443 before recreating the cluster (operational, specific to your machine). What happens: someone has a local web server (for example, a dev proxy, or Docker Desktop with its own panel exposed on those ports) running on port 80 or 443, and kind create cluster fails with an "address already in use" error trying to reserve them. How to spot it: kind create cluster's error message explicitly names the conflicting port. How to fix it: lsof -nP -iTCP:80 -sTCP:LISTEN (or the corresponding port) identifies which process has it occupied on macOS/Linux; stop that process, or change kind-config.yaml's hostPort values to different ports (for example, 8080/8443) and adjust the rest of this guide accordingly.
Assuming EXTERNAL-IP: <pending> means something went wrong (expectation). What happens: someone sees <pending> in ingress-nginx-controller's Service's EXTERNAL-IP column and assumes the controller didn't finish installing. How to spot it: if you expect to see a real IP there, like you would on an EKS cluster with a real AWS balancer behind it. How to fix it: on kind, there's no cloud provider assigning an external IP to a Service type: LoadBalancer — that field stays <pending> forever, with no effect on functionality. Traffic comes in through extraPortMappings, not through EXTERNAL-IP; confirm the controller with kubectl get pods -n ingress-nginx, not with this column.
Not noticing the manifest's default nodeSelector doesn't match ingress-ready, and not diagnosing the Pod on the wrong node (this lesson's real finding, documented so it doesn't catch you by surprise). What happens: someone installs ingress-nginx following only Step 6 of this lesson, without Steps 7-9, and wonders why curl doesn't respond anything in lesson 5. How to spot it: exactly as this lesson did it — kubectl get pods -n ingress-nginx -o wide and check the NODE column; if it isn't andes-cargo-cluster-control-plane, that's the problem. How to fix it: this lesson's Steps 7-9, in order — confirm the current nodeSelector, patch it to require ingress-ready: "true", and wait for the RollingUpdate.
Exercises
Exercise 1 — Reconstruct the whole flow from memory. Without going back to the lesson, list the steps, in order, from why the cluster had to be recreated to confirming the controller was Running on the correct node.
See solution
- Understand why
extraPortMappingsrequires recreating the cluster (it's the node's Docker container's creation-time configuration). - Write the new
kind-config.yaml, withextraPortMappings(ports80/443) andkubeadmConfigPatches(theingress-ready=truelabel) on thecontrol-planenode. kind delete cluster+kind create cluster --config kind-config.yaml.- Confirm the label with
kubectl get nodes --show-labels. - Reload the image (
kind load docker-image) and reapply Module 3's six manifests. - Install
ingress-nginxwith the officialkindmanifest. - Discover, with
kubectl get pods -n ingress-nginx -o wide, that the Pod ended up on aworker. - Confirm the cause (
nodeSelectorwith noingress-ready) and fix it withkubectl patch. - Confirm the Pod
Runningon thecontrol-plane, and theService/IngressClasscreated.
Exercise 2 — Explain why only the control-plane needs ingress-ready. A colleague asks why you didn't just add ingress-ready=true to all three nodes, to avoid any chance of the Pod landing in the wrong place. Explain, in two or three sentences, why that wouldn't be better.
See solution
A reasonable answer: "The label alone doesn't move traffic — what actually connects a node to the outside world is extraPortMappings, and that configuration only exists on the control-plane in this kind-config.yaml. If I added ingress-ready=true to all three nodes, the nodeSelector would still accept any of the three as a valid candidate, and the Pod could land on a worker again with no port mapping — this lesson's same problem, just harder to diagnose because the label would no longer give away the mistake."
Exercise 3 — Predict what would happen without Step 8. If you had skipped Step 8's kubectl patch and gone straight to declaring an Ingress (lesson 5), what would you expect to see when doing curl against your host on port 80? Justify your answer with what you know about extraPortMappings.
See solution
The curl would fail with a connection-refused or timeout error — not a 404 or 500, but a complete network failure. kind-config.yaml's extraPortMappings forwards your machine's port 80 exclusively toward the control-plane node's container; if the controller's NGINX process is listening on port 80 of a different worker, that forwarding never reaches it — it's literally like knocking on the door of a building with no connection to the real front desk, even though the front desk exists and is working on another floor.
Summary and next step
In this lesson you recreated andes-cargo-cluster with an updated kind-config.yaml (extraPortMappings + the ingress-ready label on the control-plane), rebuilt the exact state Module 3 left, installed ingress-nginx's official manifest for kind, and diagnosed and fixed a real problem: the manifest's default nodeSelector didn't require the ingress-ready label, leaving the controller's Pod on a node with no correct port mapping. ingress-nginx-controller is now 1/1 Running on andes-cargo-cluster-control-plane, with an IngressClass named nginx ready to use.
Before moving on you should be able to: explain why extraPortMappings requires recreating the cluster; reconstruct from memory why the controller's Pod landed on the wrong node the first time, and how it got fixed; and confirm, with kubectl get pods -n ingress-nginx -o wide, that the controller is healthy on the correct node.
Next lesson: hands-on, Ingress for status-api-service. With the receptionist hired and on the right floor, lesson 5 finally writes the rule book — the Ingress object — and confirms with real curl, no port-forward, that the whole path works.
Resources
- kind — Ingress — the
kindproject's official guide on theIngresspattern for local clusters. - kind — Configuration — complete reference for
extraPortMappingsandkubeadmConfigPatches, this lesson's two new fields. - GitHub — kubernetes/ingress-nginx: deploy/static/provider/kind — the exact manifest you applied in Step 6.
- Kubernetes — Assigning Pods to Nodes — official reference for
nodeSelector, the field you diagnosed and fixed in Steps 7-8. - Kubernetes — Update API Objects in Place Using kubectl patch — official reference for
kubectl patch --type=json, used in Step 8.