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's hostPort forwards toward this node's containerPort" — the same concept as docker run -p 80:80, declared in YAML instead of on the command line.
  • kubeadmConfigPatches — a kind mechanism to inject additional kubeadm configuration (the tool kind uses underneath to initialize each node) at creation time. Here it's used to add the ingress-ready=true label to the control-plane node — a label that means nothing special to Kubernetes on its own; it's a convention ingress-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 80 and 443 are free (lsof -nP -iTCP:80 -sTCP:LISTEN and the equivalent for 443 on macOS/Linux) — if something else already uses them (a local web server, for example), kind create cluster is 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-workernot 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
  1. Understand why extraPortMappings requires recreating the cluster (it's the node's Docker container's creation-time configuration).
  2. Write the new kind-config.yaml, with extraPortMappings (ports 80/443) and kubeadmConfigPatches (the ingress-ready=true label) on the control-plane node.
  3. kind delete cluster + kind create cluster --config kind-config.yaml.
  4. Confirm the label with kubectl get nodes --show-labels.
  5. Reload the image (kind load docker-image) and reapply Module 3's six manifests.
  6. Install ingress-nginx with the official kind manifest.
  7. Discover, with kubectl get pods -n ingress-nginx -o wide, that the Pod ended up on a worker.
  8. Confirm the cause (nodeSelector with no ingress-ready) and fix it with kubectl patch.
  9. Confirm the Pod Running on the control-plane, and the Service/IngressClass created.

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

  1. kind — Ingress — the kind project's official guide on the Ingress pattern for local clusters.
  2. kind — Configuration — complete reference for extraPortMappings and kubeadmConfigPatches, this lesson's two new fields.
  3. GitHub — kubernetes/ingress-nginx: deploy/static/provider/kind — the exact manifest you applied in Step 6.
  4. Kubernetes — Assigning Pods to Nodes — official reference for nodeSelector, the field you diagnosed and fixed in Steps 7-8.
  5. Kubernetes — Update API Objects in Place Using kubectl patch — official reference for kubectl patch --type=json, used in Step 8.