Hands-On Labs · The Capstone · Part 1 of 5

Capstone Part 1 — Cluster Foundation

This is the first of five parts that build one continuous cluster, start to finish: you'll bring it up, wire real networking into it, deploy real workloads onto it, give those workloads real storage, and finally lock the whole thing down with real RBAC. Part 1 is the ground everything else stands on — a genuine multi-node kind cluster with no default CNI, a real Calico install you watch flip every node from NotReady to Ready, and a real ingress-nginx controller that can actually hear a request from outside Docker. Nothing here is simulated: by the end of this page you will have run curl from your own terminal and watched a response travel through an Ingress, a Service, and a Pod on a cluster you built by hand, ten minutes ago.

☺ Explain it like I'm 10

Think about setting up a stage before a play, not putting on the play itself. First you build the platform — three risers bolted together, one a little sturdier because it's where the director stands. Then you run the wiring underneath the floor so a whisper on one riser reaches the microphone on another; without that wiring the risers are just wood, unable to hear each other at all. Then you cut one door in the curtain so the audience has a way in — cut zero doors and the best play in the world plays to an empty room. Only after the platform holds weight, the wiring carries sound, and the door opens do you send out a stand-in actor to say one line and confirm the audience in the back row actually heard it. That stand-in isn't the real show. It's just proof the stage is ready for one. Everything from Part 2 onward is the real show.

🦉🐦Your hosts for this part: Professor Owl and Pip the Hummingbird — Owl drew the control-plane diagram in Kubernetes Architecture that this cluster is a real instance of; Pip is the one whose whole job is making sure a request can actually travel from outside the cluster to a Pod, which is the entire second half of this page.
⚠ Where you're starting, and what you'll have when you're done

Starting: nothing — no cluster, no containers, just a laptop with Docker and a couple of CLIs installed. Leaving this page: a running 3-node kind cluster named dev; a real Calico CNI install with every node reporting Ready; an ingress-nginx controller scheduled onto the right node and actually reachable from your host machine; and firsthand proof — a real HTTP response in your terminal — that traffic can travel all the way from curl to a Pod and back. Part 2 picks up exactly here and deploys the first real workload onto this exact cluster.

What this part assumes, and what it produces

☺ Like you're 10: Just the tools on your desk — nothing built yet, nothing running yet.

You need three things installed locally: Docker (Desktop or Engine, running — kind creates every "node" as a Docker container, so nothing here works without a container runtime already up), kind itself, and kubectl. Nothing else needs to exist yet: no cloud account, no registry, no application code. Those questions don't even come up until Part 2.

Five parts, one cluster, so it's worth naming the shape once, here, before anything is built:

ThingName / valueIntroduced
The clusterkind cluster named dev — 1 control-plane, 2 workersPart 1 — this page
CNICalico, default iptables dataplane, real NetworkPolicy enforcementPart 1
Ingress controlleringress-nginx, kind-specific manifest, node labeled ingress-ready=truePart 1
Proof-of-life workloadsmoke-test Deployment/Service/Ingress — disposable, torn down at the end of this pagePart 1 (built and deleted)
The real workloadnot yet deployedPart 2
Storage class / stateful datanot yet touchedPart 4
RBAC posturewhatever kind ships by default — wide openPart 5

Keep that table in mind across all five parts: whenever a later page says "the cluster from Part 1" or "the ingress controller," this is where those names were born.

Designing the kind cluster: one control plane, two workers, no default CNI

☺ Like you're 10: Three risers, one YAML file saying how many you want and which one the director stands on.

A bare kind create cluster gives you a single all-in-one node — fine for a five-minute experiment, wrong for this capstone. Real clusters have a control plane that doesn't also run application Pods, and this one should behave the same way. It also needs to arrive with no working Pod network at all, on purpose, so the Calico install later in this page is a genuine install and not a formality on top of something that already worked. Save this as kind-config.yaml:

# kind-config.yaml — 1 control plane, 2 workers, no default CNI, ingress-ready port mappings
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: dev

networking:
  disableDefaultCNI: true          # skip kindnet — Calico goes in for real, next section
  podSubnet: "192.168.0.0/16"      # Calico's own default pool
  serviceSubnet: "10.96.0.0/16"

nodes:
  - role: control-plane
    kubeadmConfigPatches:
      - |
        kind: InitConfiguration
        nodeRegistration:
          kubeletExtraArgs:
            node-labels: "ingress-ready=true"
    extraPortMappings:              # host → this node's container, for ingress-nginx later
      - containerPort: 80
        hostPort: 80
        protocol: TCP
      - containerPort: 443
        hostPort: 443
        protocol: TCP
  - role: worker
  - role: worker

disableDefaultCNI: true is the one line doing the most work here. Without it, kind ships kindnet — a minimal built-in CNI good enough for basic Pod-to-Pod connectivity but with no NetworkPolicy enforcement at all, which would make Part 5's RBAC-and-policy work later in this capstone dishonest: you can't practice locking down what a workload can talk to on a network that can't enforce that lock in the first place. podSubnet: "192.168.0.0/16" matters too, for a duller but just-as-real reason: it has to match what the CNI manifest you apply next expects, or Pod networking silently never comes up. As the kind tool guide covers, this same kubeadmConfigPatches mechanism reaches straight into the real kubeadm config objects described in Cluster Architecture, Installation & Configuration — nothing about this file is kind-specific magic.

Bringing the cluster up — and watching every node sit at NotReady on purpose

☺ Like you're 10: The platform gets built first. It just can't hear anything yet — that's expected, not broken.

Create the cluster from that config, then check on it immediately:

$ kind create cluster --config kind-config.yaml
Creating cluster "dev" ...
 ✓ Ensuring node image (kindest/node:v1.31.0) 🖼
 ✓ Preparing nodes 📦 📦 📦
 ✓ Writing configuration 📜
 ✓ Starting control-plane 🕹️
 ✓ Installing StorageClass 💾
Set kubectl context to "kind-dev"

$ kubectl get nodes
NAME                STATUS     ROLES           AGE   VERSION
dev-control-plane   NotReady   control-plane   35s   v1.31.0
dev-worker          NotReady   <none>          24s   v1.31.0
dev-worker2         NotReady   <none>          24s   v1.31.0

Every node reads NotReady, and that is exactly correct, not an error to chase. A node's Ready condition is the kubelet reporting that its Pod network is actually usable, and right now nothing has told these three containers how to route a packet to each other at all — that's the entire point of skipping kindnet a moment ago. If you ran kubectl describe node dev-worker right now, the Ready condition's message would say almost exactly that: no CNI configuration found. The next section is the only thing that changes it.

◆ Key idea

"Ready" is not "the machine turned on." It's a specific, checkable claim: the kubelet, the container runtime, and the Pod network are all reporting healthy at once. Watching that claim go from false to true the moment Calico's agent comes up on a node — not before — is worth doing slowly, once, so the abstraction stops being a color in kubectl get nodes and starts being a fact you've personally traced to its cause.

Installing Calico: a real CNI, not the toy one

☺ Like you're 10: The wiring goes in under the floor, and only once it's live can the risers hear each other.

Apply Calico's own manifest directly — no Helm needed for this baseline install — and watch the agent Pod land on all three nodes:

$ kubectl apply -f https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/calico.yaml

$ kubectl -n kube-system get pods -l k8s-app=calico-node -w
NAME               READY   STATUS    RESTARTS   AGE
calico-node-4f8x2   1/1     Running   0          38s
calico-node-9k2pl   1/1     Running   0          38s
calico-node-w7t3q   1/1     Running   0          38s

$ kubectl get nodes
NAME                STATUS   ROLES           AGE   VERSION
dev-control-plane   Ready    control-plane   2m    v1.31.0
dev-worker          Ready    <none>          2m    v1.31.0
dev-worker2         Ready    <none>          2m    v1.31.0

calico-node is a DaemonSet — one Pod scheduled onto every node, including the control plane, since every node needs the same local routing rules programmed regardless of what else runs there. The Calico tool guide covers the two workers inside that Pod in depth (one that programs local iptables/ipset rules, one that speaks BGP so nodes learn each other's routes), but the fact worth carrying forward from this page specifically is simpler: Ready flipped the instant calico-node reported Running on a node, node by node, not all at once — proof that the kubelet really was watching for exactly this and nothing else.

Stage A — nodes only dev-control-plane NotReady dev-worker NotReady dev-worker2 NotReady kubectl apply calico.yaml → kubectl apply ingress-nginx (kind) Stage B — CNI + ingress installed dev-control-plane · ingress-ready=true Ready calico-node + ingress-nginx controller dev-worker Ready · calico-node dev-worker2 Ready · calico-node Ready flips node by node, exactly when each node's calico-node Pod reports Running

Installing ingress-nginx: the one node that can hear the outside world

☺ Like you're 10: One door gets cut in the curtain — on the riser your laptop's port mapping actually points at.

A CNI gets Pods talking to each other. It says nothing about a request from outside Docker reaching in — that's a separate job, and it's ingress-nginx's. kind ships a manifest built specifically for this setup, which is exactly why the config in the first section labeled one node ingress-ready=true and mapped host ports 80/443 onto it:

$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.3/deploy/static/provider/kind/deploy.yaml

$ kubectl -n ingress-nginx wait --for=condition=ready pod \
    -l app.kubernetes.io/component=controller --timeout=120s
pod/ingress-nginx-controller-7d6c9f6b7-x8k2n condition met

$ kubectl -n ingress-nginx get pods -o wide
NAME                                        READY   STATUS    NODE
ingress-nginx-controller-7d6c9f6b7-x8k2n    1/1     Running   dev-control-plane

Notice the controller landed on dev-control-plane and nowhere else — not luck. That manifest carries a nodeSelector for ingress-ready=true and a toleration for the control-plane's normal taint, so it can only ever schedule onto the one node whose container actually has host ports 80 and 443 mapped out. Scheduling it onto a worker instead would leave it running perfectly well and completely unreachable, since nothing maps localhost:80 to that container. The kind tool guide and the ingress-nginx tool guide both cover this exact node-selector-plus-port-mapping pairing in more depth than this page needs to repeat.

⚠ Not the exam environment, not production

Nothing built on this page is meant to run a real workload for real users, and it isn't how CKA or CKAD present a cluster to you either — those exams hand you a pre-provisioned remote cluster through a browser terminal; you never build one yourself. That doesn't make this practice worthless — the API objects, the Ready condition, the Ingress routing rules are all the genuine upstream behavior — it just means treat kind the way its own tool guide does: fast, disposable, honest about not being either of those two things.

Proving it end to end: a disposable smoke-test workload

☺ Like you're 10: Send a stand-in out to say one line, and make sure the back row actually hears it.

Nothing so far proves the whole chain works together — a Ready node and a Running ingress controller could still fail to connect to each other for a dozen small reasons. So deploy something tiny, throwaway, and unrelated to whatever Part 2 eventually builds, purely to prove the path. Save this as smoke-test.yaml:

# smoke-test.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: smoke-test
  labels:
    app: smoke-test
spec:
  replicas: 2
  selector:
    matchLabels:
      app: smoke-test
  template:
    metadata:
      labels:
        app: smoke-test
    spec:
      containers:
        - name: nginx
          image: nginx:1.27-alpine
          ports:
            - containerPort: 80
          readinessProbe:
            httpGet:
              path: /
              port: 80
            initialDelaySeconds: 2
---
apiVersion: v1
kind: Service
metadata:
  name: smoke-test
spec:
  selector:
    app: smoke-test
  ports:
    - port: 80
      targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: smoke-test
spec:
  ingressClassName: nginx
  rules:
    - host: smoke.localtest.me
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: smoke-test
                port:
                  number: 80

smoke.localtest.me isn't a typo or a placeholder — *.localtest.me is a public DNS wildcard that always resolves to 127.0.0.1, so this Ingress host works from your terminal with no /etc/hosts edit and no local DNS setup at all. Apply it and pull the thread from the outside in:

$ kubectl apply -f smoke-test.yaml
deployment.apps/smoke-test created
service/smoke-test created
ingress.networking.k8s.io/smoke-test created

$ kubectl get pods -l app=smoke-test
NAME                          READY   STATUS    RESTARTS   AGE
smoke-test-6b9d8f6c4d-2xvqp   1/1     Running   0          14s
smoke-test-6b9d8f6c4d-mjb7t   1/1     Running   0          14s

$ curl -s http://smoke.localtest.me/ | grep -i "Welcome to nginx"
<title>Welcome to nginx!</title>

Trace that one response backward and every piece from this page is in it: curl hit port 80 on your Docker host, which extraPortMappings forwarded into the dev-control-plane container, where ingress-nginx — scheduled there because of the label — matched the Host header against the Ingress rule, resolved it to the smoke-test Service's EndpointSlice, and proxied straight to one of the two Pods, whose reply crossed the Calico-programmed Pod network on the way back out. Nothing about that path was faked.

curl smoke.localtest.me :80 on your laptop ingress-nginx on dev-control-plane matches Host header ingress-ready=true Service smoke-test ClusterIP load-balances endpoints Pod smoke-test nginx:1.27-alpine via Calico pod net every hop above is a real object you can kubectl describe
✎ Try it

With the smoke test still up, run kubectl delete pod -l app=smoke-test to kill both Pods at once, then immediately re-run the curl command from above in a loop (while true; do curl -s -o /dev/null -w "%{http_code}\n" http://smoke.localtest.me/; sleep 0.5; done). Watch it briefly return connection errors or 503s and then settle back to 200 on its own, with no command from you in between — that's the Deployment's controller and the Service's EndpointSlice reconciling in real time, the exact pattern The Kubernetes API & the Controller Pattern describes in the abstract, happening on your own cluster.

Once you've watched it work, tear the smoke test back down — it did its job, and it isn't the workload Part 2 builds on:

$ kubectl delete -f smoke-test.yaml
deployment.apps "smoke-test" deleted
service "smoke-test" deleted
ingress.networking.k8s.io "smoke-test" deleted
🐦 Pip's-eye view

"People are always surprised the Service in the middle doesn't know or care that a request came in through Ingress instead of from another Pod inside the cluster. It doesn't need to. By the time a request reaches smoke-test's ClusterIP, it looks exactly like any other request on the Pod network — same load-balancing, same EndpointSlice lookup. Ingress's whole job is getting a request from 'not on this network at all' to 'now it's just a normal Service request,' and after that handoff, I genuinely can't tell the difference. That's not a limitation. That's the layering working exactly as designed."

🎬 At the Pod Squad
🦉

Professor Owl: Three nodes, all NotReady, right on schedule. Nobody panic — that's what "no CNI yet" is supposed to look like.

👺

Gizmo the Gremlin: Or — just leave disableDefaultCNI out entirely. kindnet's already there, nodes go Ready in ten seconds, way less YAML. Why bother installing a whole separate CNI? 🤑

🐦

Pip the Hummingbird: Because kindnet can't enforce a single NetworkPolicy. Ask Timmy what Part 5 of this capstone is about.

🐢

Timmy the Turtle: RBAC and admission control. Half of it is about who can talk to what — and I can't demonstrate blocking traffic on a network that has no concept of blocking traffic in the first place. Real CNI, every time, even for practice.

👺

Gizmo: Fine, fine. Still would've been faster.

🦉

Professor Owl: Ten extra minutes now, against every later lesson on this cluster being honest. That's not close.

🐦

Pip: And the smoke test proved it wasn't just Ready in name — a real request went all the way in and a real response came all the way back out. That's the bar for "foundation," not just green checkmarks.

What "done" looks like for Part 1, and where Part 2 picks up

☺ Like you're 10: The stage holds weight, the wiring carries sound, and the door actually opens — checked, not assumed.

At the end of this part you have: a 3-node kind cluster named dev, every node Ready because a real Calico install put it there; an ingress-nginx controller scheduled onto the one node built to receive it, reachable on localhost:80; and firsthand proof — not a claim — that a request can travel from your terminal, through Ingress and a Service, to a Pod, and back. The smoke test is gone; the cluster, the CNI, and the ingress controller are not. Nothing here gets thrown away:

PartWhat it does with Part 1's foundation
2 — Workloads & ConfigDeploys the first real workload onto this exact cluster, using this exact Calico network
3 — Networking & IngressReplaces the throwaway smoke-test routing with real Services and Ingress rules for that workload
4 — Storage & Stateful AppsAdds a StorageClass and PersistentVolumeClaims on top of this same node pool
5 — Security & RBACUses Calico's real NetworkPolicy support — installed here specifically so this would be possible — plus RBAC and admission control to lock the cluster down

Milestones

☺ Like you're 10: Tick each box only once you've actually watched it happen on your own screen, not because the step "sounds right."

Work these in order — each depends on the cluster state from the one before. Progress saves in this browser.

0 / 9 milestones complete
1Confirm Docker, kind, and kubectl are installed and Docker is running
docker info, kind version, kubectl version --client — all three should return cleanly.
Done when: none of the three commands error out.
Concept: kind
2Write kind-config.yaml: 1 control plane, 2 workers, no default CNI
Exactly as shown above, including disableDefaultCNI: true and the ingress-ready=true node label.
Done when: the file exists locally and kind create cluster --config kind-config.yaml --dry-run-style review (or a careful read) shows all three node entries.
3Create the cluster and confirm all three nodes start NotReady
kind create cluster --config kind-config.yaml, then kubectl get nodes.
Done when: kubectl get nodes lists dev-control-plane, dev-worker, and dev-worker2, all reading NotReady.
4Install Calico and watch every node flip to Ready
Apply the Calico manifest exactly as shown above, then re-run kubectl get nodes.
Done when: all three calico-node Pods show Running and all three nodes show Ready.
Concept: Calico
5Install ingress-nginx's kind-specific manifest
Apply the manifest exactly as shown above, then kubectl -n ingress-nginx wait --for=condition=ready pod -l app.kubernetes.io/component=controller --timeout=120s.
Done when: the controller Pod is Running and scheduled specifically onto dev-control-plane.
Concept: ingress-nginx
6Write and apply smoke-test.yaml
The Deployment, Service, and Ingress exactly as shown above, applied with kubectl apply -f smoke-test.yaml.
Done when: kubectl get pods -l app=smoke-test shows two Pods Running and 1/1 ready.
7curl the smoke test from your own terminal
curl -s http://smoke.localtest.me/ | grep -i "Welcome to nginx".
Done when: the command prints the nginx welcome title — a real response, not a timeout or a connection refused.
8Delete a smoke-test Pod and watch the Service self-heal
Run the kubectl delete pod -l app=smoke-test plus curl-loop exercise from the "Try it" callout above.
Done when: you've personally watched a request briefly fail and then succeed again with no command from you in between.
9Tear the smoke test down and say out loud what's left running
kubectl delete -f smoke-test.yaml, then confirm: cluster dev up, all nodes Ready, Calico and ingress-nginx both still running.
Done when: you can describe that state without looking anything up — it's the exact starting point Part 2 assumes.
🐢 Timmy's checkpoint

1. Why do all three nodes report NotReady immediately after kind create cluster in this setup, and what specifically flips them to Ready? 2. What does disableDefaultCNI: true actually skip, and why bother when kindnet ships for free? 3. Name the two things in the kind config that, together, let the ingress-nginx controller Pod actually be reachable from your own terminal — and why does only one node qualify? 4. The smoke-test workload gets deleted at the end of this page. What did standing it up prove that kubectl get nodes showing all-Ready alone did not? 5. What three things does Part 2 assume already exist and already work when it picks up?

Check your answers
  1. A node's Ready condition reports whether its Pod network is actually usable, and with disableDefaultCNI: true set, nothing has configured that network yet — there's no CNI plugin installed at all. It flips to Ready, node by node, exactly when that node's calico-node Pod comes up and finishes programming its routing rules.
  2. It skips kindnet, kind's minimal built-in CNI, which gives basic Pod-to-Pod connectivity but enforces no NetworkPolicy at all. A real CNI is installed instead specifically so Part 5's RBAC-and-policy work later in this capstone has something real to enforce policy against.
  3. The ingress-ready=true node label on the control-plane node, and the extraPortMappings for host ports 80/443 mapped onto that same node's container. ingress-nginx's kind manifest carries a matching nodeSelector and a toleration for the control-plane taint, so it can only schedule onto the one node that both qualifies for the label and actually has those host ports mapped — any other node would run the controller Pod fine but leave it unreachable from outside Docker.
  4. That every piece actually talks to every other piece, not just that each piece individually reports healthy. A Ready node and a Running ingress controller could still fail to connect for a dozen small reasons — a missing label, a subnet mismatch, a misconfigured Ingress. The smoke test's successful curl response is proof the full path — Ingress, Service, Pod, and back — actually works end to end.
  5. A cluster named dev with all three nodes Ready; a working Calico CNI; and an ingress-nginx controller already reachable on localhost:80 from the control-plane node. Part 2 deploys straight onto that foundation without rebuilding any of it.

Part 1 gave you a real cluster, a real network, and a real door in from the outside — proven with your own curl, not taken on faith. Continue to Capstone Part 2 — Workloads & Config, where the first real workload lands on exactly this foundation. Or step back to Build a Cluster — Start Here to see how this capstone's five parts fit the rest of the hands-on labs, and revisit Kubernetes Architecture and Networking & the CNI for the concepts behind what you just built.