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.
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.
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:
| Thing | Name / value | Introduced |
|---|---|---|
| The cluster | kind cluster named dev — 1 control-plane, 2 workers | Part 1 — this page |
| CNI | Calico, default iptables dataplane, real NetworkPolicy enforcement | Part 1 |
| Ingress controller | ingress-nginx, kind-specific manifest, node labeled ingress-ready=true | Part 1 |
| Proof-of-life workload | smoke-test Deployment/Service/Ingress — disposable, torn down at the end of this page | Part 1 (built and deleted) |
| The real workload | not yet deployed | Part 2 |
| Storage class / stateful data | not yet touched | Part 4 |
| RBAC posture | whatever kind ships by default — wide open | Part 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: workerdisableDefaultCNI: 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.0Every 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.
"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.0calico-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.
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-planeNotice 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.
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: 80smoke.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.
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"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."
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:
| Part | What it does with Part 1's foundation |
|---|---|
| 2 — Workloads & Config | Deploys the first real workload onto this exact cluster, using this exact Calico network |
| 3 — Networking & Ingress | Replaces the throwaway smoke-test routing with real Services and Ingress rules for that workload |
| 4 — Storage & Stateful Apps | Adds a StorageClass and PersistentVolumeClaims on top of this same node pool |
| 5 — Security & RBAC | Uses 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.
docker info, kind version, kubectl version --client — all three should return cleanly.kind-config.yaml: 1 control plane, 2 workers, no default CNIdisableDefaultCNI: true and the ingress-ready=true node label.kind create cluster --config kind-config.yaml --dry-run-style review (or a careful read) shows all three node entries.NotReadykind create cluster --config kind-config.yaml, then kubectl get nodes.kubectl get nodes lists dev-control-plane, dev-worker, and dev-worker2, all reading NotReady.Readykubectl get nodes.calico-node Pods show Running and all three nodes show Ready.kubectl -n ingress-nginx wait --for=condition=ready pod -l app.kubernetes.io/component=controller --timeout=120s.Running and scheduled specifically onto dev-control-plane.smoke-test.yamlkubectl apply -f smoke-test.yaml.kubectl get pods -l app=smoke-test shows two Pods Running and 1/1 ready.curl the smoke test from your own terminalcurl -s http://smoke.localtest.me/ | grep -i "Welcome to nginx".kubectl delete pod -l app=smoke-test plus curl-loop exercise from the "Try it" callout above.kubectl delete -f smoke-test.yaml, then confirm: cluster dev up, all nodes Ready, Calico and ingress-nginx both still running.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
- A node's
Readycondition reports whether its Pod network is actually usable, and withdisableDefaultCNI: trueset, nothing has configured that network yet — there's no CNI plugin installed at all. It flips toReady, node by node, exactly when that node'scalico-nodePod comes up and finishes programming its routing rules. - It skips kindnet, kind's minimal built-in CNI, which gives basic Pod-to-Pod connectivity but enforces no
NetworkPolicyat 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. - The
ingress-ready=truenode label on the control-plane node, and theextraPortMappingsfor host ports 80/443 mapped onto that same node's container. ingress-nginx's kind manifest carries a matchingnodeSelectorand 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. - That every piece actually talks to every other piece, not just that each piece individually reports healthy. A
Readynode and aRunningingress controller could still fail to connect for a dozen small reasons — a missing label, a subnet mismatch, a misconfigured Ingress. The smoke test's successfulcurlresponse is proof the full path — Ingress, Service, Pod, and back — actually works end to end. - A cluster named
devwith all three nodesReady; a working Calico CNI; and an ingress-nginx controller already reachable onlocalhost:80from 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.