<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Adeoye Malumi</title>
    <description>The latest articles on DEV Community by Adeoye Malumi (@oyebobs).</description>
    <link>https://dev.to/oyebobs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1436441%2Faa047a2b-d84d-4a76-9c89-5082c6029c97.jpeg</url>
      <title>DEV Community: Adeoye Malumi</title>
      <link>https://dev.to/oyebobs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/oyebobs"/>
    <language>en</language>
    <item>
      <title>The Kubernetes Weekend That Humbled Me — Part 2: From One VM to a Real Cluster</title>
      <dc:creator>Adeoye Malumi</dc:creator>
      <pubDate>Thu, 30 Jul 2026 15:18:01 +0000</pubDate>
      <link>https://dev.to/oyebobs/the-kubernetes-weekend-that-humbled-me-part-2-from-one-vm-to-a-real-cluster-4kff</link>
      <guid>https://dev.to/oyebobs/the-kubernetes-weekend-that-humbled-me-part-2-from-one-vm-to-a-real-cluster-4kff</guid>
      <description>&lt;p&gt;Quick recap in case you're jumping in here: in Part 1 I fought Multipass, broken cloud-init, a 2.1 GB disk, and a bunch of other nonsense just to get one Ubuntu VM with a healthy Kubernetes control plane on it. That was the "get anything to boot" fight. This part is the fight I actually signed up for — giving pods a real network and turning one lonely VM into an actual cluster.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CNI Wars
&lt;/h2&gt;

&lt;p&gt;Quick context for anyone newer to this: a CNI plugin is the thing responsible for giving every pod its own IP and making sure traffic can actually get routed between pods, including pods on different nodes. Without one, &lt;code&gt;kubectl&lt;/code&gt; works, your control plane is healthy, but nothing you deploy can actually talk to anything else. CoreDNS staying pending at the end of Part 1 was a symptom of exactly this — no CNI, no pod network, no CoreDNS.&lt;/p&gt;

&lt;p&gt;First attempt was Flannel, since it's the "default" answer most tutorials give you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/flannel-io/flannel/master/Documentation/kube-flannel.yml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The flannel pod went straight into &lt;code&gt;CrashLoopBackOff&lt;/code&gt; with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;failed to load flannel 'subnet.env' file: open /run/flannel/subnet.env: no such file or directory
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That file gets written by &lt;code&gt;flanneld&lt;/code&gt; itself, &lt;em&gt;after&lt;/em&gt; it successfully reaches the API server and learns what pod CIDR the cluster is using. In my setup, flannel's own pod couldn't reach the API server at &lt;code&gt;10.96.0.1:443&lt;/code&gt; — the cluster-internal service IP for the API — so it never got far enough to write that file in the first place. Chicken and egg: the CNI needs API connectivity to bootstrap the network, but with a single-NIC NAT setup like mine, that internal routing wasn't reliably working yet.&lt;/p&gt;

&lt;p&gt;Tried Weave next, thinking a different implementation might sidestep it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://github.com/weaveworks/weave/releases/download/v2.8.1/weave-daemonset-k8s.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Images pulled fine this time, but the readiness probe failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Readiness probe failed: Get "http://127.0.0.1:6784/status": dial tcp 127.0.0.1:6784: connect: connection refused
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same root cause wearing a different outfit — Weave's own daemon also needs to reach the API server to establish its network bridge, and it was hitting the same wall Flannel did.&lt;/p&gt;

&lt;p&gt;At that point I stopped trying "real" CNIs and just wanted pods to schedule so I could keep moving. I hand-wrote a plain bridge network config that doesn't depend on talking to the API server at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; containernetworking-plugins

&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/cni/net.d
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/cni/net.d/10-bridge.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
{
  "cniVersion": "1.0.0",
  "name": "bridge",
  "type": "bridge",
  "bridge": "cni0",
  "isGateway": true,
  "ipMasq": true,
  "ipam": {
    "type": "host-local",
    "subnet": "10.244.0.0/16",
    "routes": [{ "dst": "0.0.0.0/0" }]
  }
}
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart containerd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The node flipped to &lt;code&gt;Ready&lt;/code&gt; and nginx deployed. Worth being straight with you about what this actually is, though: it's a local bridge, not an overlay network. It's fine for unblocking a single node and proving pod scheduling works, but it doesn't give you real cross-node pod routing the way Flannel or Calico would once properly configured. Good enough to keep learning on, not something I'd trust for anything beyond that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Adding a Worker Node
&lt;/h2&gt;

&lt;p&gt;Rather than repeating the whole cloud-image nightmare from Part 1, I cloned the master's disk — it already had containerd, kubeadm, kubelet, and kubectl installed, which saved me from doing that setup twice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;clonehd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"k8s-master\disk.vdi"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"k8s-worker-1\disk.vdi"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;VDI&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;createvm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8s-worker-1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--ostype&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Ubuntu_64&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--register&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;modifyvm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8s-worker-1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--cpus&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;4096&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;storagectl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8s-worker-1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SATA"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--add&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;sata&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;storageattach&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8s-worker-1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--storagectl&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"SATA"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--port&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--device&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;0&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;hdd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--medium&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"k8s-worker-1\disk.vdi"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;modifyvm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8s-worker-1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--nic1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;nat&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;modifyvm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8s-worker-1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--natpf1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ssh,tcp,,2223,,22"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But because I cloned the disk, this new VM also thinks it's still the master. Had to strip that identity out before it could join as anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;hostnamectl set-hostname k8s-worker-1

&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/k8s-master/k8s-worker-1/g'&lt;/span&gt; /etc/hosts

&lt;span class="nb"&gt;sudo &lt;/span&gt;kubeadm reset &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /etc/kubernetes/ /var/lib/kubelet/ /var/lib/dockershim/ /var/run/kubernetes/ ~/.kube/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kubeadm reset&lt;/code&gt; tears down the control-plane state and cluster certificates the clone inherited, so the node starts clean instead of trying to act as a second control plane.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Network Both VMs Actually Needed
&lt;/h2&gt;

&lt;p&gt;Here's something that trips people up: each VM's default NAT adapter can reach the internet, but NAT networking isolates VMs from each other by design — that's the whole point of NAT, it's not meant for VM-to-VM traffic. Master and worker literally couldn't see each other over NAT alone. Both needed a second network adapter, set to VirtualBox's internal network mode, purely for talking to one another:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;modifyvm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8s-master&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--nic2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;intnet&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--intnet2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"k8s-net"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;modifyvm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8s-worker-1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--nic2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;intnet&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--intnet2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"k8s-net"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then static IPs on that internal network so the addresses wouldn't shift around:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Master:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/netplan/02-internal.yaml &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
network:
  version: 2
  ethernets:
    enp0s8:
      dhcp4: no
      addresses:
        - 192.168.56.10/24
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;netplan apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Worker:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/netplan/02-internal.yaml &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
network:
  version: 2
  ethernets:
    enp0s8:
      dhcp4: no
      addresses:
        - 192.168.56.11/24
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;netplan apply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The API Server Was Listening on the Wrong Interface
&lt;/h2&gt;

&lt;p&gt;Even with both VMs able to reach each other, joining still wasn't going to work, because the API server had only ever been told to listen on &lt;code&gt;10.0.2.15&lt;/code&gt; — the NAT interface — since that was the address I originally passed to &lt;code&gt;kubeadm init&lt;/code&gt; back in Part 1. The worker needed to reach it over the internal network instead, on &lt;code&gt;192.168.56.10&lt;/code&gt;, and the API server simply wasn't listening there.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/--advertise-address=10.0.2.15/--advertise-address=192.168.56.10/'&lt;/span&gt; /etc/kubernetes/manifests/kube-apiserver.yaml

&lt;span class="nb"&gt;sleep &lt;/span&gt;30

&lt;span class="nb"&gt;sudo &lt;/span&gt;ss &lt;span class="nt"&gt;-tlnp&lt;/span&gt; | &lt;span class="nb"&gt;grep &lt;/span&gt;6443
&lt;span class="c"&gt;# LISTEN 0 4096 *:6443 *:*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Editing a file under &lt;code&gt;/etc/kubernetes/manifests/&lt;/code&gt; is enough on its own — kubelet watches that directory and automatically restarts the API server pod when it changes, no manual restart command needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Joining the Worker
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubeadm token create &lt;span class="nt"&gt;--print-join-command&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run that on the master, then on the worker with sudo:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;kubeadm &lt;span class="nb"&gt;join &lt;/span&gt;192.168.56.10:6443 &lt;span class="nt"&gt;--token&lt;/span&gt; &amp;lt;TOKEN&amp;gt; &lt;span class="nt"&gt;--discovery-token-ca-cert-hash&lt;/span&gt; sha256:&amp;lt;HASH&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Quick note on what those two values actually do, since it's easy to treat them as just noise you copy-paste: the token is a short-lived credential that authenticates the worker to the cluster, and the discovery hash lets the worker verify it's actually talking to the real API server's certificate authority and not something impersonating it. Both matter — without the hash check, a join command intercepted on the network could point your worker at the wrong cluster entirely.&lt;/p&gt;

&lt;p&gt;It joined successfully.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fixing the Node IPs
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;NAME           STATUS   ROLES           AGE   VERSION   INTERNAL-IP   OS-IMAGE
&lt;/span&gt;&lt;span class="gp"&gt;k8s-worker-1   Ready    &amp;lt;none&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;25s   v1.29.6   10.0.2.15     Ubuntu 22.04.5 LTS
&lt;span class="go"&gt;ubuntu         Ready    control-plane   4m    v1.29.6   10.0.2.15     Ubuntu 22.04.5 LTS
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both nodes reporting the exact same INTERNAL-IP was the giveaway that something was off — that's the NAT address again, not the internal network address I actually wanted the cluster using. Kubelet, left to its own defaults, picks whatever it decides is the primary interface, which on both VMs was still the original NAT adapter rather than the internal one I'd added afterward. The fix is to tell it explicitly which IP to register:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# On master&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /var/lib/kubelet/kubeadm-flags.env
&lt;span class="c"&gt;# Add: --node-ip=192.168.56.10&lt;/span&gt;

&lt;span class="c"&gt;# On worker&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;nano /var/lib/kubelet/kubeadm-flags.env
&lt;span class="c"&gt;# Add: --node-ip=192.168.56.11&lt;/span&gt;

&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl daemon-reload
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart kubelet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;NAME           STATUS   ROLES           INTERNAL-IP
ubuntu         Ready    control-plane   192.168.56.10
&lt;/span&gt;&lt;span class="gp"&gt;k8s-worker-1   Ready    &amp;lt;none&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;          &lt;/span&gt;192.168.56.11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a real two-node cluster, each one correctly identified on the network I actually built for them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proving It Actually Works
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create deployment nginx &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nginx &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3
kubectl get pods &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pods landed on both nodes, not just the control plane. That was the moment this stopped being "a VM with kubectl installed" and started being an actual cluster doing what a cluster is supposed to do.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Whole Weekend Actually Taught Me
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Multipass on Windows is a gamble.&lt;/strong&gt; Great when it works, but the moment it breaks — ghost VMs, corrupted caches, auth loops — you're debugging the tool instead of learning Kubernetes. Driving VirtualBox directly took longer to set up, but at least every failure told me something true about what was actually happening.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cloud images assume cloud-init will run. Check that it actually did.&lt;/strong&gt; Missing SSH host keys, a network interface sitting down, a disk partition way smaller than the disk itself — all three of those trace back to the same root cause: cloud-init's first-boot routine never completed. Once I understood that, every one of those errors made sense instead of feeling random.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A CNI needs API server connectivity to bootstrap itself, so check that connectivity first.&lt;/strong&gt; Both Flannel and Weave failed for the same underlying reason. If a CNI daemon can't reach &lt;code&gt;10.96.0.1:443&lt;/code&gt;, no amount of reinstalling it will help — the problem is upstream of the CNI entirely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;NAT gets you internet, not VM-to-VM communication.&lt;/strong&gt; Any time you're building a multi-node setup locally, you need a second, internal-only network specifically for the nodes to reach each other, plus static IPs so kubeadm and kubelet know exactly where things live.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Kubelet and the API server don't automatically know which interface you meant.&lt;/strong&gt; Both default to whatever they see first, which is often not the interface you actually care about. Pin &lt;code&gt;--advertise-address&lt;/code&gt; and &lt;code&gt;--node-ip&lt;/code&gt; explicitly instead of trusting the defaults.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The logs were right every time.&lt;/strong&gt; Every single failure in this whole weekend had its answer sitting in the output, if I actually read it instead of just rerunning the command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe pod &amp;lt;name&amp;gt;
journalctl &lt;span class="nt"&gt;-u&lt;/span&gt; kubelet &lt;span class="nt"&gt;-f&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;crictl logs &amp;lt;container-id&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Full Command Reference
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Windows host:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;showvminfo&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8s-master&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Select-String&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"State"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;controlvm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8s-master&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;poweroff&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;modifyvm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8s-master&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--cpus&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;6144&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;clonehd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"source.vdi"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dest.vdi"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;VDI&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;modifyhd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"disk.vdi"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--resize&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;20480&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;modifyvm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8s-master&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--nic2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;intnet&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--intnet2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"k8s-net"&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="n"&gt;ssh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;2222&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8suser&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nx"&gt;localhost&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c"&gt;# Master&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;ssh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;2223&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8suser&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nx"&gt;localhost&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="c"&gt;# Worker&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Linux VM, start to finish:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# System prep&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get upgrade &lt;span class="nt"&gt;-y&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; containerd containernetworking-plugins

&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/containerd
&lt;span class="nb"&gt;sudo &lt;/span&gt;containerd config default | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/containerd/config.toml
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/SystemdCgroup = false/SystemdCgroup = true/'&lt;/span&gt; /etc/containerd/config.toml
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart containerd

&lt;span class="nb"&gt;sudo &lt;/span&gt;swapoff &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'/ swap / s/^\(.*\)$/#\1/g'&lt;/span&gt; /etc/fstab

&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe overlay
&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe br_netfilter
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/kubernetes.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;

&lt;span class="c"&gt;# Kubernetes install&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; apt-transport-https ca-certificates curl gnupg
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://pkgs.k8s.io/core:/stable:/v1.29/deb/Release.key | &lt;span class="nb"&gt;sudo &lt;/span&gt;gpg &lt;span class="nt"&gt;--dearmor&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /etc/apt/keyrings/kubernetes-apt-keyring.gpg
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.29/deb/ /'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/kubernetes.list
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nv"&gt;kubelet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.29.6-1.1 &lt;span class="nv"&gt;kubeadm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.29.6-1.1 &lt;span class="nv"&gt;kubectl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.29.6-1.1 &lt;span class="nt"&gt;--allow-downgrades&lt;/span&gt; &lt;span class="nt"&gt;--allow-change-held-packages&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-mark hold kubelet kubeadm kubectl

&lt;span class="c"&gt;# Master init&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;kubeadm init &lt;span class="nt"&gt;--pod-network-cidr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.0.0/16 &lt;span class="nt"&gt;--apiserver-advertise-address&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.56.10 &lt;span class="nt"&gt;--node-name&lt;/span&gt; k8s-master

&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;$HOME&lt;/span&gt;/.kube
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; /etc/kubernetes/admin.conf &lt;span class="nv"&gt;$HOME&lt;/span&gt;/.kube/config
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;:&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$HOME&lt;/span&gt;/.kube/config

&lt;span class="c"&gt;# CNI (plain bridge)&lt;/span&gt;
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/cni/net.d
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/cni/net.d/10-bridge.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
{
  "cniVersion": "1.0.0",
  "name": "bridge",
  "type": "bridge",
  "bridge": "cni0",
  "isGateway": true,
  "ipMasq": true,
  "ipam": {
    "type": "host-local",
    "subnet": "10.244.0.0/16",
    "routes": [{ "dst": "0.0.0.0/0" }]
  }
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart containerd

&lt;span class="c"&gt;# Worker join&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;kubeadm &lt;span class="nb"&gt;join &lt;/span&gt;192.168.56.10:6443 &lt;span class="nt"&gt;--token&lt;/span&gt; &amp;lt;TOKEN&amp;gt; &lt;span class="nt"&gt;--discovery-token-ca-cert-hash&lt;/span&gt; sha256:&amp;lt;HASH&amp;gt;

&lt;span class="c"&gt;# Verification&lt;/span&gt;
kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; wide
kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt;
kubectl create deployment nginx &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nginx &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;3
kubectl get pods &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole weekend. One control plane, one worker, three nginx pods scheduled across both — and about eight hours of the kind of debugging that teaches you more than a tutorial ever would.&lt;/p&gt;

</description>
      <category>learning</category>
      <category>devops</category>
      <category>cloudnative</category>
      <category>kubernetes</category>
    </item>
    <item>
      <title>The Kubernetes Weekend That Humbled Me — Part 1: Just Getting a VM to Boot</title>
      <dc:creator>Adeoye Malumi</dc:creator>
      <pubDate>Thu, 30 Jul 2026 13:31:20 +0000</pubDate>
      <link>https://dev.to/oyebobs/the-kubernetes-weekend-that-humbled-me-part-1-just-getting-a-vm-to-boot-71k</link>
      <guid>https://dev.to/oyebobs/the-kubernetes-weekend-that-humbled-me-part-1-just-getting-a-vm-to-boot-71k</guid>
      <description>&lt;h1&gt;
  
  
  I had a simple plan. Spin up a couple of VMs, run &lt;code&gt;kubeadm init&lt;/code&gt;, join a worker, deploy nginx, post about it. That plan lasted about twenty minutes before Windows and VirtualBox decided I hadn't suffered enough that week.
&lt;/h1&gt;

&lt;p&gt;This is the honest account of everything that went wrong before I even got to write a single Kubernetes command that mattered — the ghost VMs, the corrupted caches, the recovery-mode hacking, all of it. If you're trying this on a Windows laptop yourself, I want you to actually understand &lt;em&gt;why&lt;/em&gt; each thing broke, not just copy my fix and move on. Because trust me, you'll hit your own version of these and you'll need to reason your way out.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 1: Multipass, or How to Meet a Ghost
&lt;/h2&gt;

&lt;p&gt;Multipass looked like the easy way in. One command and you're supposed to have a VM:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;multipass&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;launch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8s-master&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--cpus&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;4G&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--disk&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;20G&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It hung, then eventually gave me &lt;code&gt;timed out waiting for response&lt;/code&gt;. Fine, I thought, maybe it's slow. I checked what Multipass thought existed versus what was actually sitting in VirtualBox:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;multipass&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# Name        State    IPv4    Image&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# k8s-master  Running  N/A     Ubuntu 22.04 LTS&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;vms&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# "Ubuntu 2" {b03575b5-db86-4c38-9195-d689257821d4}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See the problem? Multipass is convinced it created something called &lt;code&gt;k8s-master&lt;/code&gt;. VirtualBox, which is the thing actually doing the work underneath, has never heard that name in its life — it just has some auto-generated &lt;code&gt;"Ubuntu 2"&lt;/code&gt;. Multipass is basically a client talking to a background service that drives VirtualBox for you, and the two sides keep their own separate records of what exists. When the launch times out mid-handshake, those two records can fall out of sync, and you end up with a VM that's "running" on paper but doesn't actually correspond to anything reachable. No wonder SSH never connected — Multipass was trying to reach a machine that, as far as VirtualBox knows, doesn't exist under that name.&lt;/p&gt;

&lt;p&gt;The only clean way out was to delete both records separately, since neither tool knows how to clean up after the other:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;multipass&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;delete&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8s-master&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--purge&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;unregistervm&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"b03575b5-db86-4c38-9195-d689257821d4"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--delete&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="n"&gt;multipass&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;list&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;vms&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lesson for anyone doing this: whenever Multipass acts up, don't just trust &lt;code&gt;multipass list&lt;/code&gt;. Cross-check it against VirtualBox directly, because those two can disagree and Multipass won't tell you.&lt;/p&gt;

&lt;p&gt;Then it got worse. On the next launch attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;multipass&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;launch&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;22.04&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8s-master&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--cpus&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--memory&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;4G&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--disk&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;20G&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# launch failed: Hash of ... does not match&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# launch failed: Remote "" is unknown or unreachable.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The cached base image itself was corrupted, so I wiped it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Remove-Item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Recurse&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Force&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\ProgramData\Multipass\cache\*"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Restart-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Multipass"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Force&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that's when I fell into the real trap — an authentication loop. Multipass suddenly wanted a passphrase it had never asked me to set:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;multipass&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;authenticate&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# Please enter passphrase:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# authenticate failed: Passphrase is not set.&lt;/span&gt;&lt;span class="w"&gt;

&lt;/span&gt;&lt;span class="n"&gt;multipass&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;set&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;local.passphrase&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# set failed: The client is not authenticated&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You can see the chicken-and-egg there: it wants me authenticated to &lt;em&gt;set&lt;/em&gt; the passphrase, but it wants the passphrase to authenticate me. I tried nuking every certificate I could find on both the client and daemon side:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Stop-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Multipass"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Force&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Remove-Item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Recurse&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Force&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\ProgramData\Multipass\data"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Remove-Item&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Recurse&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Force&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;LOCALAPPDATA&lt;/span&gt;&lt;span class="s2"&gt;\Multipass"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Start-Service&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Multipass"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Still looped. At that point I stopped treating it as something I could fix and accepted it as a known Windows bug in how Multipass handles cert corruption. I abandoned Multipass entirely and went straight to driving VirtualBox myself — more manual work, but at least I could see exactly what was happening at every step instead of trusting a layer that had just proven it could quietly lie to me.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 2: Cloud Images and the ISO That Wouldn't Build
&lt;/h2&gt;

&lt;p&gt;Without Multipass holding my hand, I needed to do what it was doing under the hood: get an Ubuntu cloud image into VirtualBox and feed it configuration on first boot.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;cd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;C:\Users\dell\Downloads&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Invoke-WebRequest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Uri&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://cloud-images.ubuntu.com/jammy/current/jammy-server-cloudimg-amd64.vmdk"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-OutFile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ubuntu-22.04-cloud.vmdk"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-UseBasicParsing&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here's the thing about cloud images that nobody tells you upfront: they're not a normal installable Ubuntu. They ship stripped down and &lt;em&gt;expect&lt;/em&gt; a cloud provider (or you, faking one) to hand them a small config package on first boot — a username, an SSH key, network settings. That package needs to arrive as a specific kind of ISO, called a &lt;code&gt;cidata&lt;/code&gt; ISO, containing a &lt;code&gt;user-data&lt;/code&gt; and &lt;code&gt;meta-data&lt;/code&gt; file that &lt;code&gt;cloud-init&lt;/code&gt; reads on startup. Without it, the image boots into basically nothing: no user account, no password, nothing to log in with.&lt;/p&gt;

&lt;p&gt;So I went looking for &lt;code&gt;mkisofs&lt;/code&gt; to build that ISO:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Invoke-WebRequest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Uri&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://storage.googleapis.com/google-code-archive-downloads/v2/code.google.com/cdrtools/win32.zip"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-OutFile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cdrtools.zip"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-UseBasicParsing&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# ERROR: AccessDenied&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Dead link. Tried SourceForge instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Invoke-WebRequest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Uri&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://sourceforge.net/projects/cdrtools/files/3.01/cdrtools-3.01-win32-bin.zip/download"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-OutFile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cdrtools.zip"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-UseBasicParsing&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;Expand-Archive&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Path&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cdrtools.zip"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-DestinationPath&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\cdrtools"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# ERROR: End of Central Directory record could not be found&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That error means the file I downloaded wasn't actually a zip — SourceForge's download link redirects through an intermediate page, and &lt;code&gt;Invoke-WebRequest&lt;/code&gt; saved that redirect page instead of following it to the real file. Rather than keep fighting dead mirrors for a tool that just builds an ISO 9660 image, I wrote a small Python script to build the &lt;code&gt;cidata.iso&lt;/code&gt; myself directly. Sometimes the fastest fix isn't finding the "proper" tool, it's just building the thirty lines of code that does the one thing you actually need.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 3: I Nearly Downloaded the Full ISO
&lt;/h2&gt;

&lt;p&gt;At one point I considered giving up on cloud images completely and grabbing the full installable Ubuntu Server ISO instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;curl.exe&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-L&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ubuntu-22.04-server.iso"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://releases.ubuntu.com/jammy/ubuntu-22.04.5-live-server-amd64.iso"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;2.1 GB, and my connection at the time made that a non-starter. Stuck with the cloud image and pushed forward another way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Round 4: Breaking In Through Recovery Mode
&lt;/h2&gt;

&lt;p&gt;Even with the &lt;code&gt;cidata.iso&lt;/code&gt; attached, VirtualBox wasn't picking it up properly, so cloud-init never ran, which meant — same problem as before — no user account existed to log in with. Since I couldn't get in through the front door, I went in through the recovery console instead.&lt;/p&gt;

&lt;p&gt;In the VirtualBox window: start the VM, &lt;strong&gt;hold Shift immediately&lt;/strong&gt; as it boots (this is what gets you into the GRUB menu instead of straight into Ubuntu), then pick "Advanced options for Ubuntu" → "Recovery mode" → "root". That drops you into a root shell with the filesystem mounted read-only, so the first thing to do is make it writable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mount &lt;span class="nt"&gt;-o&lt;/span&gt; remount,rw /

adduser k8suser
&lt;span class="c"&gt;# Password: k8s123&lt;/span&gt;

usermod &lt;span class="nt"&gt;-aG&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;k8suser

&lt;span class="nb"&gt;sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/PasswordAuthentication no/PasswordAuthentication yes/'&lt;/span&gt; /etc/ssh/sshd_config.d/60-cloudimg-settings.conf

ssh-keygen &lt;span class="nt"&gt;-A&lt;/span&gt;

service ssh restart
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;ssh-keygen -A&lt;/code&gt; line matters more than it looks — cloud images ship &lt;em&gt;without&lt;/em&gt; SSH host keys generated, because normally cloud-init generates them itself on first boot as part of its regular setup routine. Since cloud-init never got to run properly on this VM, those keys were just missing, and SSH can't serve connections without them.&lt;/p&gt;

&lt;p&gt;Even after all that, SSH from Windows still failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;ssh&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;2222&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;k8suser&lt;/span&gt;&lt;span class="err"&gt;@&lt;/span&gt;&lt;span class="nx"&gt;localhost&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="c"&gt;# kex_exchange_identification: read: Connection reset&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checked the network interface and found the real problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ip addr show
&lt;span class="c"&gt;# enp0s3: &amp;lt;BROADCAST,MULTICAST&amp;gt; mtu 1500 ... state DOWN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interface was just sitting there down. Again, this is cloud-init's job normally — bring up networking, request a DHCP lease, the works. With it never having run, nothing had touched the NIC. Brought it up by hand:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;ip &lt;span class="nb"&gt;link set &lt;/span&gt;enp0s3 up
&lt;span class="nb"&gt;sudo &lt;/span&gt;dhclient enp0s3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SSH connected right after. The takeaway here: if you're working with a cloud image outside of an actual cloud, don't assume anything cloud-init normally does for you has happened. Check the interface state before you waste time debugging SSH itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Building the Master Node
&lt;/h2&gt;

&lt;p&gt;With a working, reachable VM, I could finally get to the actual Kubernetes setup. Container runtime first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; containerd
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/containerd
&lt;span class="nb"&gt;sudo &lt;/span&gt;containerd config default | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/containerd/config.toml
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'s/SystemdCgroup = false/SystemdCgroup = true/'&lt;/span&gt; /etc/containerd/config.toml
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl restart containerd
&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;containerd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;SystemdCgroup = true&lt;/code&gt; line isn't cosmetic — kubelet and containerd both need to agree on which cgroup driver manages resource limits. If they don't match, you get subtle scheduling and resource-accounting problems down the line, so it's worth setting correctly now rather than debugging it later.&lt;/p&gt;

&lt;p&gt;Swap has to go — kubelet flatly refuses to run properly with swap enabled, because its whole resource-accounting model assumes memory limits mean something real:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;swapoff &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;span class="nb"&gt;sudo sed&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="s1"&gt;'/ swap / s/^\(.*\)$/#\1/g'&lt;/span&gt; /etc/fstab
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the kernel modules and sysctl settings pods actually depend on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe overlay
&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe br_netfilter

&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/sysctl.d/kubernetes.conf &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="no"&gt;EOF&lt;/span&gt;&lt;span class="sh"&gt;
net.bridge.bridge-nf-call-iptables = 1
net.bridge.bridge-nf-call-ip6tables = 1
net.ipv4.ip_forward = 1
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;sysctl &lt;span class="nt"&gt;--system&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;overlay&lt;/code&gt; supports the container image layering. &lt;code&gt;br_netfilter&lt;/code&gt; makes sure bridged traffic between pods actually gets seen by iptables rules instead of silently bypassing them. &lt;code&gt;ip_forward&lt;/code&gt; lets the box route traffic between interfaces at all — without it, nothing about pod networking works later, no matter how good your CNI choice is.&lt;/p&gt;

&lt;p&gt;Installing Kubernetes itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; apt-transport-https ca-certificates curl gnupg

curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://pkgs.k8s.io/core:/stable:/v1.29/deb/Release.key | &lt;span class="nb"&gt;sudo &lt;/span&gt;gpg &lt;span class="nt"&gt;--dearmor&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /etc/apt/keyrings/kubernetes-apt-keyring.gpg

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] https://pkgs.k8s.io/core:/stable:/v1.29/deb/ /'&lt;/span&gt; | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/kubernetes.list

&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nv"&gt;kubelet&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.29.6-1.1 &lt;span class="nv"&gt;kubeadm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.29.6-1.1 &lt;span class="nv"&gt;kubectl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1.29.6-1.1 &lt;span class="nt"&gt;--allow-downgrades&lt;/span&gt; &lt;span class="nt"&gt;--allow-change-held-packages&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-mark hold kubelet kubeadm kubectl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That last &lt;code&gt;apt-mark hold&lt;/code&gt; is deliberate — a regular &lt;code&gt;apt upgrade&lt;/code&gt; could otherwise silently bump these to a version that doesn't match across the cluster, which is exactly the kind of thing that ruins your week later for no visible reason right now.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Disk That Was Way Too Small
&lt;/h2&gt;

&lt;p&gt;First &lt;code&gt;kubeadm init&lt;/code&gt; attempt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;kubeadm init &lt;span class="nt"&gt;--pod-network-cidr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.0.0/16 &lt;span class="nt"&gt;--apiserver-advertise-address&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.0.2.15 &lt;span class="nt"&gt;--node-name&lt;/span&gt; k8s-master
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It died with &lt;code&gt;cannot copy extracted data... failed to write (No space left on device)&lt;/code&gt;. Checked the disk and the root partition was sitting at a laughable 2.1 GB. This traces straight back to the cloud-init issue from earlier — cloud images ship with small root partitions on purpose, because normally cloud-init's &lt;code&gt;growpart&lt;/code&gt; module expands the partition to match whatever disk size the cloud provider actually gave you, as part of its first-boot routine. Since cloud-init never completed properly on this VM, that resize never happened, and I was stuck with a partition sized for a tiny default disk instead of the 20 GB I'd actually allocated.&lt;/p&gt;

&lt;p&gt;Fixing it meant leaving the guest OS entirely and working from the Windows side. VMDK format can't be resized directly, so first it had to become a VDI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;clonehd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ubuntu-22.04-cloud.vmdk"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"disk.vdi"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--format&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;VDI&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"C:\Program Files\Oracle\VirtualBox\VBoxManage.exe"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;modifyhd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"disk.vdi"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--resize&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;20480&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then, back in recovery mode, grow the partition and filesystem to actually use that new space:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; cloud-guest-utils

growpart /dev/sda 1
resize2fs /dev/sda1

&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt;
&lt;span class="c"&gt;# /dev/sda1  20G  1.7G  18G  9% /&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Moral of this one: check &lt;code&gt;df -h&lt;/code&gt; before you install anything on a cloud image locally, not after it fails halfway through.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finally, a Working Control Plane
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;kubeadm init &lt;span class="nt"&gt;--pod-network-cidr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.0.0/16 &lt;span class="nt"&gt;--apiserver-advertise-address&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10.0.2.15 &lt;span class="nt"&gt;--node-name&lt;/span&gt; k8s-master
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It went through this time. CoreDNS did throw a timeout warning during the addon phase, but that's expected at this stage — CoreDNS pods stay stuck pending until there's an actual pod network for them to run on, and I hadn't installed one yet. The control plane itself was healthy, which was the real milestone.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="nv"&gt;$HOME&lt;/span&gt;/.kube
&lt;span class="nb"&gt;sudo cp&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; /etc/kubernetes/admin.conf &lt;span class="nv"&gt;$HOME&lt;/span&gt;/.kube/config
&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;:&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt; &lt;span class="nv"&gt;$HOME&lt;/span&gt;/.kube/config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;kubectl&lt;/code&gt; was finally talking to something real.&lt;/p&gt;

&lt;p&gt;That's where I'll cut Part 1 — I had one working node and a control plane that actually responded to me, after what felt like an unreasonable amount of suffering to get there. Part 2 covers the part I actually set out to do: picking a CNI that would work, adding a second node, and getting a real multi-node cluster scheduling pods across both.&lt;/p&gt;

</description>
      <category>learning</category>
      <category>kubernetes</category>
      <category>cloudnative</category>
      <category>devops</category>
    </item>
    <item>
      <title>Debugging a Broken Ingress Setup: A Step-by-Step Walkthrough</title>
      <dc:creator>Adeoye Malumi</dc:creator>
      <pubDate>Fri, 24 Jul 2026 10:05:25 +0000</pubDate>
      <link>https://dev.to/oyebobs/debugging-a-broken-ingress-setup-a-step-by-step-walkthrough-b37</link>
      <guid>https://dev.to/oyebobs/debugging-a-broken-ingress-setup-a-step-by-step-walkthrough-b37</guid>
      <description>&lt;p&gt;This post documents a real debugging session that started with a simple symptom — &lt;code&gt;example.com&lt;/code&gt; wouldn't load — and ended up uncovering several separate, unrelated problems layered on top of each other. It's written to walk through the actual diagnostic process, not just the final fix, since the process is usually the more useful part to learn from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Starting point:&lt;/strong&gt; an Ingress setup (from a previous post) was working, then stopped working after some changes were made mid-session, including installing a different CNI (Calico) on top of the cluster's existing one. This is the story of tracking that down.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Symptom
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ curl example.com
curl: (7) Failed to connect to example.com port 80 after 21007 ms: Could not connect to server
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A connection timeout like this can mean a lot of different things: DNS pointing to the wrong place, a firewall silently dropping packets, a service that's down, or a machine that simply can't reach the target network at all. The rest of this post is the process of ruling each of those out, one at a time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Confirm What the Hostname Actually Resolves To
&lt;/h2&gt;

&lt;p&gt;Before assuming anything about networking, the first thing to check is whether &lt;code&gt;example.com&lt;/code&gt; is even resolving to the IP it's supposed to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ getent hosts example.com
10.96.101.127   example.com
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This confirmed the local &lt;code&gt;/etc/hosts&lt;/code&gt; entry was working correctly — &lt;code&gt;example.com&lt;/code&gt; resolved to &lt;code&gt;10.96.101.127&lt;/code&gt;, matching the Ingress controller's IP from earlier setup. So the problem wasn't DNS/hosts resolution. The IP itself was the next thing to check.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Test the IP Directly, Bypassing the Hostname
&lt;/h2&gt;

&lt;p&gt;If the hostname resolves correctly but the connection still fails, the next step is to test the raw IP directly — this removes DNS/hosts from the equation entirely and asks: "is this address reachable at all?"&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ curl -v http://10.96.101.127
*   Trying 10.96.101.127:80...
* connect to 10.96.101.127 port 80 from 10.0.2.15 port 57576 failed: Connection refused
* Failed to connect to 10.96.101.127 port 80 after 21003 ms: Could not connect to server
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two useful details came out of this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;Connection refused&lt;/code&gt;, not a timeout.&lt;/strong&gt; This is a meaningful difference. A timeout usually means packets are going out and nothing is answering. "Refused" happens quickly and means something &lt;em&gt;did&lt;/em&gt; respond — but actively said "no."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The source IP was &lt;code&gt;10.0.2.15&lt;/code&gt;.&lt;/strong&gt; This is VirtualBox's default address for a guest machine in NAT networking mode, which confirmed this was being tested from inside a VM.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The actual explanation:&lt;/strong&gt; &lt;code&gt;10.96.101.127&lt;/code&gt; is a Kubernetes &lt;strong&gt;ClusterIP&lt;/strong&gt;. A ClusterIP isn't a real address that exists anywhere on the network — it's a virtual IP that only works because of routing rules (&lt;code&gt;iptables&lt;/code&gt;/IPVS) that Kubernetes' &lt;code&gt;kube-proxy&lt;/code&gt; installs inside the specific machine running the cluster. No other machine — not a VM, not a laptop on the same network — can ever reach a ClusterIP directly, because that address doesn't exist anywhere outside that one machine's own routing table.&lt;/p&gt;

&lt;p&gt;This meant the original approach (pointing &lt;code&gt;/etc/hosts&lt;/code&gt; at the ClusterIP) was never going to work from a separate machine, regardless of any other fix.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Find a Real, Reachable Address Instead
&lt;/h2&gt;

&lt;p&gt;Since ClusterIPs don't work across machines, the next step was finding an address that &lt;em&gt;does&lt;/em&gt; — specifically, the IP of one of the actual Docker containers backing the &lt;code&gt;kind&lt;/code&gt; cluster's nodes, combined with the Ingress controller's &lt;strong&gt;NodePort&lt;/strong&gt; (a real port opened on the node itself, unlike a ClusterIP).&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ docker ps
CONTAINER ID   IMAGE                  COMMAND                  CREATED      STATUS          PORTS                       NAMES
&lt;/span&gt;&lt;span class="gp"&gt;691fe58f2715   kindest/node:v1.35.0   "/usr/local/bin/entr…"   2 days ago   Up 18 minutes   127.0.0.1:38079-&amp;gt;&lt;/span&gt;6443/tcp   cka-new-control-plane
&lt;span class="go"&gt;aae68ca53b7b   kindest/node:v1.35.0   "/usr/local/bin/entr…"   2 days ago   Up 18 minutes                               cka-new-worker2
b51bce23b84f   kindest/node:v1.35.0   "/usr/local/bin/entr…"   2 days ago   Up 18 minutes                               cka-new-worker
75634b82514e   kindest/node:v1.35.0   "/usr/local/bin/entr…"   2 days ago   Up 18 minutes                               cka-new-worker3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;None of the worker nodes had any ports published to the host — only the control-plane's Kubernetes API port was mapped. So the node's actual IP address on Docker's internal network was needed instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ docker inspect cka-new-worker | grep -A 15 '"kind"'
&lt;/span&gt;&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt;"IPAddress": "172.18.0.5",
&lt;/span&gt;&lt;span class="c"&gt;...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;A discrepancy showed up here worth noting:&lt;/strong&gt; this IP (&lt;code&gt;172.18.0.5&lt;/code&gt;) didn't match what &lt;code&gt;kubectl get nodes -o wide&lt;/code&gt; had shown earlier in the session for this same node (&lt;code&gt;172.18.0.3&lt;/code&gt;). Docker assigns container IPs dynamically, and since these containers showed &lt;code&gt;Up 18 minutes&lt;/code&gt; (meaning they had recently restarted), the IP had simply changed. This is a good general reminder that hardcoding container IPs is fragile — they aren't guaranteed to stay the same across restarts.&lt;/p&gt;

&lt;p&gt;Testing the NodePort against this IP, however, still failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ curl -v http://172.18.0.5:31003 -H "Host: example.com"
*   Trying 172.18.0.5:31003...
* connect to 172.18.0.5 port 31003 from 172.18.0.1 port 53748 failed: Connection timed out
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So even with a real, reachable-in-theory IP and the correct port, nothing answered. This meant the problem was something deeper than just "wrong IP" — worth investigating the cluster's internal health next.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Ruling Out the Obvious Culprits
&lt;/h2&gt;

&lt;p&gt;At this point, several standard checks were run to narrow down where the actual fault was.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Was the Ingress controller pod even healthy?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl get pods -n ingress-nginx -o wide
NAME                                        READY   STATUS    RESTARTS      AGE   IP           NODE
ingress-nginx-controller-7d65c586d6-kvn9s   1/1     Running   3 (20m ago)   16h   10.244.1.2   cka-new-worker2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It was &lt;code&gt;Running&lt;/code&gt;, but had restarted 3 times recently — worth remembering, but not conclusive on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Was &lt;code&gt;kube-proxy&lt;/code&gt; (the component responsible for NodePort/ClusterIP routing) healthy on every node?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl get pods -n kube-system -l k8s-app=kube-proxy -o wide
NAME               READY   STATUS    RESTARTS      AGE     NODE
kube-proxy-2mlbd   1/1     Running   4 (25m ago)   2d18h   cka-new-worker
kube-proxy-jb2rt   1/1     Running   4             2d18h   cka-new-worker2
kube-proxy-m2vq9   1/1     Running   4             2d18h   cka-new-control-plane
kube-proxy-rnlwc   1/1     Running   4             2d18h   cka-new-worker3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All &lt;code&gt;Running&lt;/code&gt;, though &lt;code&gt;cka-new-worker&lt;/code&gt; had also restarted recently.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Was there a conflict between two CNI plugins&lt;/strong&gt; (since Calico had been installed on top of the cluster's default &lt;code&gt;kindnet&lt;/code&gt; networking earlier)?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ docker exec cka-new-worker2 ls /etc/cni/net.d/
10-kindnet.conflist
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only one CNI config was present — no conflict there, at least on this node.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Did basic pod-to-pod networking inside the cluster even work at all?&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl run curl --rm -it --image=curlimages/curl -- curl http://hello-world
curl: (6) Could not resolve host: hello-world
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was the first real breakthrough. This same command had worked earlier in the setup process — now it couldn't even resolve the Service's name. This shifted the investigation toward DNS specifically.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Investigating the DNS Failure
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;First, check whether restarting &lt;code&gt;kube-proxy&lt;/code&gt; (to clear out any stale routing rules) fixes it:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl delete pods -n kube-system -l k8s-app=kube-proxy
pod "kube-proxy-2mlbd" deleted from kube-system namespace
pod "kube-proxy-jb2rt" deleted from kube-system namespace
pod "kube-proxy-m2vq9" deleted from kube-system namespace
pod "kube-proxy-rnlwc" deleted from kube-system namespace
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After waiting for the new pods to come back &lt;code&gt;Running&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl run curl --rm -it --image=curlimages/curl -- curl http://hello-world
curl: (6) Could not resolve host: hello-world
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No change. This ruled out stale &lt;code&gt;kube-proxy&lt;/code&gt; rules as the cause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check whether the cluster's internal DNS service (CoreDNS) has healthy endpoints behind it:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl get endpoints -n kube-system kube-dns
NAME       ENDPOINTS                                                   AGE
kube-dns   10.244.0.2:9153,10.244.0.3:9153,10.244.0.2:53 + 3 more...   2d18h
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looked healthy — CoreDNS pods were properly registered.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Check whether a real pod is even configured to use the right DNS server:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl exec -it hello-world-c8b6f8dbd-9t9n6 -- sh
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/resolv.conf
&lt;span class="go"&gt;search default.svc.cluster.local svc.cluster.local cluster.local
nameserver 10.96.0.10
options ndots:5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was also correct — &lt;code&gt;10.96.0.10&lt;/code&gt; is the standard internal DNS Service IP.&lt;/p&gt;

&lt;p&gt;So far: correct DNS configuration, healthy-looking CoreDNS, no stale &lt;code&gt;kube-proxy&lt;/code&gt; rules — yet DNS lookups still failed. The next step was testing whether this was actually a DNS-specific issue, or something more fundamental with networking between nodes.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Isolating DNS vs. General Networking
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Test a plain HTTP connection to a Service, bypassing DNS entirely (using the raw ClusterIP):&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# python3 -c "import urllib.request; print(urllib.request.urlopen('http://10.96.74.127').read())"
&lt;/span&gt;&lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Hello, World!&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This worked. So general Service routing (HTTP, TCP) was fine — the problem was specific to DNS.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Test raw connectivity to the DNS port itself (port 53), both against the Service IP and a CoreDNS pod's IP directly:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# python3 -c "
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_connection&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;10.96.0.10&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;53&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;connected&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;failed:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
failed: timed out
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# python3 -c "
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;
&lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;socket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_connection&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;10.244.0.2&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;53&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;connected&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;failed:&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;
failed: timed out
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both failed — even a direct connection attempt to a CoreDNS pod's own IP, with no DNS resolution or Service routing involved at all, still timed out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The key detail that explained this:&lt;/strong&gt; checking which node each pod involved was running on showed that the working test (&lt;code&gt;10.96.74.127&lt;/code&gt;, resolving to the &lt;code&gt;hello-world&lt;/code&gt; pod) happened to be a pod on the &lt;em&gt;same node&lt;/em&gt; the test was run from. The failing tests (both CoreDNS IPs) were both on the &lt;em&gt;control-plane&lt;/em&gt; node — a different node entirely. This pointed toward a &lt;strong&gt;cross-node networking problem&lt;/strong&gt;, not a DNS-specific one.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Confirming Cross-Node Networking Was Broken
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Checked whether the Ingress controller's NodePort Service had a networking policy that might explain uneven behavior across nodes:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl get svc -n ingress-nginx ingress-nginx-controller -o jsonpath='{.spec.externalTrafficPolicy}'
Local
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This turned out to be a real, separate finding: &lt;code&gt;externalTrafficPolicy: Local&lt;/code&gt; means kube-proxy will only forward NodePort traffic on a node if that node happens to be running a copy of the target pod locally — every other node will actively drop the traffic rather than forward it elsewhere. This fully explained the earlier NodePort timeout, since the Ingress controller pod only ran on &lt;code&gt;cka-new-worker2&lt;/code&gt;, and the test had hit a different node.&lt;/p&gt;

&lt;p&gt;Confirmed by checking &lt;code&gt;iptables&lt;/code&gt; on the failing node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ docker exec cka-new-worker3 iptables-save | grep -i "53\|drop" | head -30
-A KUBE-EXTERNAL-SERVICES -p tcp -m comment --comment "ingress-nginx/ingress-nginx-controller:http has no local endpoints" -m addrtype --dst-type LOCAL -m tcp --dport 31003 -j DROP
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This line confirms it directly: any NodePort traffic to port &lt;code&gt;31003&lt;/code&gt; on this node is explicitly dropped, because this node has no local copy of the Ingress controller pod.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixing the NodePort routing issue was straightforward&lt;/strong&gt; — hit the correct node directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ curl -v http://172.18.0.4:31003 -H "Host: example.com"
* Connected to 172.18.0.4 (172.18.0.4) port 31003
&amp;lt; HTTP/1.1 504 Gateway Time-out
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The connection succeeded this time — but nginx itself returned a &lt;code&gt;504 Gateway Time-out&lt;/code&gt;. This meant the request reached the Ingress controller fine, but the Ingress controller couldn't reach its own backend (the &lt;code&gt;hello-world&lt;/code&gt; Service). And, notably, the Ingress controller pod and the &lt;code&gt;hello-world&lt;/code&gt; pod were running on two &lt;em&gt;different&lt;/em&gt; nodes — another cross-node hop, timing out in exactly the same pattern as the DNS failures.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This was the real conclusion:&lt;/strong&gt; same-node pod-to-pod traffic worked consistently throughout this session. Cross-node pod-to-pod traffic (DNS lookups to CoreDNS, and now the Ingress controller reaching its backend) consistently failed. Along the way, other explanations were ruled out — no competing CNI configs, no blocking NetworkPolicy, correctly configured DNS, correct iptables NAT rules, and Calico's own control-plane pods weren't even running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl get pods -n calico-system -o wide
No resources found in calico-system namespace.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The most likely explanation: installing Calico mid-way through the cluster's life (on top of the existing &lt;code&gt;kindnet&lt;/code&gt; CNI, without removing it) had left the routes between nodes in a broken, inconsistent state — even after Calico itself was no longer running. Untangling that kind of mixed-CNI networking state by hand is generally not worth the time; the standard, reliable fix is to rebuild the cluster from scratch.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8: Rebuilding the Cluster
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kind delete cluster --name cka-new
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To make future testing easier, a &lt;code&gt;kind&lt;/code&gt; config file was created to forward the Ingress controller's expected ports straight from the container to &lt;code&gt;localhost&lt;/code&gt; on the host machine — removing the need to look up container IPs at all going forward:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cluster&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kind.x-k8s.io/v1alpha4&lt;/span&gt;
&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cka-new&lt;/span&gt;
&lt;span class="na"&gt;nodes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;control-plane&lt;/span&gt;
    &lt;span class="na"&gt;extraPortMappings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;31003&lt;/span&gt;
        &lt;span class="na"&gt;hostPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;31003&lt;/span&gt;
        &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30437&lt;/span&gt;
        &lt;span class="na"&gt;hostPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30437&lt;/span&gt;
        &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kind create cluster --name cka-new --config kind-config.yaml
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This time, Calico was intentionally left out — the default &lt;code&gt;kindnet&lt;/code&gt; CNI had been working fine before Calico was introduced mid-session, and nothing about the setup actually required Calico's features.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Reapplying the application and Ingress manifests:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl apply -f deployment.yaml -f service.yaml -f ingress.yaml
❯ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.15.1/deploy/static/provider/aws/deploy.yaml
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 9: A Slow Pod Isn't Always a Stuck Pod
&lt;/h2&gt;

&lt;p&gt;While waiting for the Ingress controller to come up, it sat in &lt;code&gt;ContainerCreating&lt;/code&gt; for several minutes, which raised the question of whether something was actually wrong:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl get pods -n ingress-nginx -w
ingress-nginx-controller-7d65c586d6-fz54r   0/1   ContainerCreating   0   2m59s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking the pod's events showed the actual cause:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl describe pod -n ingress-nginx ingress-nginx-controller-7d65c586d6-fz54r
&lt;/span&gt;&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt;Events:
  Warning  FailedMount  4m41s (x8 over 5m45s)  kubelet  MountVolume.SetUp failed for volume "webhook-cert" : secret "ingress-nginx-admission" not found
  Normal   Pulling      3m30s                  kubelet  Pulling image "registry.k8s.io/ingress-nginx/controller:v1.15.1@sha256:..."
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This looked alarming at first, but both issues were self-resolving, expected steps of the install process rather than actual problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;FailedMount&lt;/code&gt; warnings happened because two setup Jobs (&lt;code&gt;ingress-nginx-admission-create&lt;/code&gt; and &lt;code&gt;ingress-nginx-admission-patch&lt;/code&gt;) needed to finish first — they're responsible for generating the certificate secret the controller pod was waiting to mount. Until those Jobs completed, that secret simply didn't exist yet.&lt;/li&gt;
&lt;li&gt;The image pull itself was just slow — checking &lt;code&gt;kubectl get events&lt;/code&gt; showed the two setup Jobs' images had each taken over a minute to pull, which suggested a slow registry pull rather than anything broken locally.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Waiting it out confirmed this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl get pods -n ingress-nginx
ingress-nginx-controller-7d65c586d6-fz54r   1/1   Running   0   7m34s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson here:&lt;/strong&gt; not every &lt;code&gt;ContainerCreating&lt;/code&gt; or warning event means something is broken — checking the actual &lt;code&gt;Events&lt;/code&gt; section (via &lt;code&gt;kubectl describe pod&lt;/code&gt;) before assuming failure saves a lot of unnecessary troubleshooting.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 10: Fixing the Service Configuration Correctly
&lt;/h2&gt;

&lt;p&gt;With the controller running, the &lt;code&gt;externalTrafficPolicy&lt;/code&gt; and Service type still needed to be set correctly, to avoid re-hitting the earlier &lt;code&gt;Local&lt;/code&gt;-policy and &lt;code&gt;LoadBalancer&lt;/code&gt;-pending issues.&lt;/p&gt;

&lt;p&gt;The first attempt patched only &lt;code&gt;externalTrafficPolicy&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl patch svc ingress-nginx-controller -n ingress-nginx -p '{"spec":{"externalTrafficPolicy":"Cluster"}}'
❯ kubectl get svc -n ingress-nginx ingress-nginx-controller
NAME                       TYPE           CLUSTER-IP     EXTERNAL-IP   PORT(S)
&lt;/span&gt;&lt;span class="gp"&gt;ingress-nginx-controller   LoadBalancer   10.96.61.252   &amp;lt;pending&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;80:32087/TCP,443:32290/TCP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This revealed two remaining issues: the Service was still &lt;code&gt;type: LoadBalancer&lt;/code&gt; (stuck &lt;code&gt;&amp;lt;pending&amp;gt;&lt;/code&gt;, same problem as before), and the NodePorts had landed on different numbers (&lt;code&gt;32087&lt;/code&gt;/&lt;code&gt;32290&lt;/code&gt;) than what &lt;code&gt;kind-config.yaml&lt;/code&gt; was forwarding (&lt;code&gt;31003&lt;/code&gt;/&lt;code&gt;30437&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fixing both at once&lt;/strong&gt;, by explicitly setting the type to &lt;code&gt;NodePort&lt;/code&gt; and pinning the exact port numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl patch svc ingress-nginx-controller -n ingress-nginx -p '{"spec":{"type":"NodePort","ports":[{"name":"http","port":80,"targetPort":"http","protocol":"TCP","nodePort":31003},{"name":"https","port":443,"targetPort":"https","protocol":"TCP","nodePort":30437}]}}'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl get svc -n ingress-nginx ingress-nginx-controller
NAME                       TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)
&lt;/span&gt;&lt;span class="gp"&gt;ingress-nginx-controller   NodePort   10.96.61.252   &amp;lt;none&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;80:31003/TCP,443:30437/TCP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This matched the port forwarding configured in &lt;code&gt;kind-config.yaml&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 11: Confirming Everything Works
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ curl -v http://localhost:31003 -H "Host: example.com"
*   Trying [::1]:31003...
* connect to ::1 port 31003 from ::1 port 49216 failed: Connection refused
*   Trying 127.0.0.1:31003...
* Connected to localhost (127.0.0.1) port 31003
&amp;lt; HTTP/1.1 200 OK
Hello, World!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This confirmed everything was working correctly: the port forwarding in &lt;code&gt;kind-config.yaml&lt;/code&gt;, the &lt;code&gt;Cluster&lt;/code&gt; traffic policy correctly routing regardless of which node the request landed on, and the Ingress controller successfully reaching the &lt;code&gt;hello-world&lt;/code&gt; backend (no more &lt;code&gt;504 Gateway Time-out&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;::1&lt;/code&gt; (IPv6) connection attempt failing before falling back to IPv4 is expected — &lt;code&gt;kind-config.yaml&lt;/code&gt; only forwards the port over IPv4, so curl's dual-stack attempt simply skips it and retries over IPv4 instead. Not an error to fix.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Final step — update &lt;code&gt;/etc/hosts&lt;/code&gt;, this time pointing at &lt;code&gt;127.0.0.1&lt;/code&gt; instead of any container IP&lt;/strong&gt;, since port forwarding now handles the routing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;127.0.0.1   example.com
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ curl http://example.com:31003
Hello, World!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why the Port Still Has to Be Specified
&lt;/h2&gt;

&lt;p&gt;Running &lt;code&gt;curl http://example.com&lt;/code&gt; without a port defaults to port 80 — but nothing is mapped to host port 80 in this setup, only &lt;code&gt;31003&lt;/code&gt; and &lt;code&gt;30437&lt;/code&gt;. Without a port, the connection fails immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;curl: (7) Failed to connect to example.com port 80 after ...: Connection refused
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is expected: the Ingress controller is exposed via a &lt;code&gt;NodePort&lt;/code&gt; Service, and NodePorts are, by design, high-numbered ports (typically in the 30000–32767 range) — they were never going to answer on plain port 80 without an explicit mapping.&lt;/p&gt;

&lt;p&gt;To make &lt;code&gt;curl http://example.com&lt;/code&gt; work without a port number, &lt;code&gt;kind-config.yaml&lt;/code&gt; could instead map host port 80 directly to the NodePort:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;extraPortMappings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;31003&lt;/span&gt;
    &lt;span class="na"&gt;hostPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
    &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This requires a cluster rebuild to take effect, and on Linux, binding to port 80 may require elevated (&lt;code&gt;sudo&lt;/code&gt;) privileges since ports below 1024 are privileged. For local testing and practice environments, simply including the NodePort in the URL is generally the more common and simpler approach — production clusters solve the "no port number" problem differently anyway, using a real external load balancer or a bare-metal solution like MetalLB in front of the cluster.&lt;/p&gt;




</description>
      <category>learning</category>
      <category>kubernetes</category>
      <category>devops</category>
      <category>cloud</category>
    </item>
    <item>
      <title>Setting Up Ingress for a Flask App</title>
      <dc:creator>Adeoye Malumi</dc:creator>
      <pubDate>Fri, 24 Jul 2026 08:53:20 +0000</pubDate>
      <link>https://dev.to/oyebobs/setting-up-ingress-for-a-flask-app-1kj1</link>
      <guid>https://dev.to/oyebobs/setting-up-ingress-for-a-flask-app-1kj1</guid>
      <description>&lt;p&gt;Today's goal: take a simple Flask app running in Kubernetes and expose it to the outside world using &lt;strong&gt;Ingress&lt;/strong&gt;, instead of relying on &lt;code&gt;NodePort&lt;/code&gt; or &lt;code&gt;LoadBalancer&lt;/code&gt; services. This post walks through the setup, the errors I hit along the way, and how each one was resolved.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Use Ingress Instead of NodePort or LoadBalancer?
&lt;/h2&gt;

&lt;p&gt;Kubernetes gives you a few ways to expose a Service outside the cluster:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;NodePort&lt;/strong&gt; — opens a fixed port (usually in the 30000–32767 range) on every node. Works, but the port numbers are ugly and it doesn't scale well for multiple applications.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LoadBalancer&lt;/strong&gt; — provisions a cloud load balancer per Service. This has real drawbacks:

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt; — each &lt;code&gt;LoadBalancer&lt;/code&gt; Service typically means a new cloud load balancer, billed separately.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloud lock-in&lt;/strong&gt; — it depends on your cloud provider's integration (AWS, GCP, Azure, etc.).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No advanced routing&lt;/strong&gt; — no path-based or host-based routing, and no built-in SSL termination.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Ingress&lt;/strong&gt; solves this with two separate pieces:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Ingress Resource&lt;/strong&gt; — a YAML manifest where you declare routing rules (e.g., "requests for &lt;code&gt;example.com/&lt;/code&gt; should go to the &lt;code&gt;hello-world&lt;/code&gt; Service").&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ingress Controller&lt;/strong&gt; — the component that actually watches the API server for Ingress Resources and configures a real proxy (in this case, NGINX) to implement those rules.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;You write the rules; the controller enforces them. Without a running Ingress Controller, an Ingress Resource does nothing on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 0: Building and Pushing the Docker Image
&lt;/h2&gt;

&lt;p&gt;Before any of the Kubernetes manifests would work, the Flask app first had to be containerized and pushed to a registry, Kubernetes only pulls and runs images, not raw source code. This step happens before everything else, even though it isn't captured in the terminal history below.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;app.py&lt;/code&gt;&lt;/strong&gt; — a minimal Flask app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;flask&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Flask&lt;/span&gt;
&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Flask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;__name__&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@app.route&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;hello_world&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Hello, World!&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;Dockerfile&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Use the official image as a parent image&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.7-slim&lt;/span&gt;

&lt;span class="c"&gt;# Set the working directory in the container&lt;/span&gt;
&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="c"&gt;# Copy the dependencies file to the working directory&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;

&lt;span class="c"&gt;# Install any needed packages specified in requirements.txt&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="c"&gt;# Copy the content of the local src directory to the working directory&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;

&lt;span class="c"&gt;# Run app.py when the container launches&lt;/span&gt;
&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["python", "app.py"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;requirements.txt&lt;/code&gt;&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Flask==2.2.5
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This image is built and pushed to Docker Hub as &lt;code&gt;oyebobs/ingress-demo:v1&lt;/code&gt; — the same tag referenced in &lt;code&gt;deployment.yaml&lt;/code&gt; below. If this step is skipped, &lt;code&gt;kubectl&lt;/code&gt; will still accept the Deployment manifest, but the Pod will get stuck in &lt;code&gt;ImagePullBackOff&lt;/code&gt; because there's nothing to pull.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: The Kubernetes Manifests
&lt;/h2&gt;

&lt;p&gt;Three manifests, each with a distinct job.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;deployment.yaml&lt;/code&gt;&lt;/strong&gt; — defines the Pod and keeps it running:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello-world&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello-world&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;replicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello-world&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello-world&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello-world&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;oyebobs/ingress-demo:v1&lt;/span&gt;
        &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;service.yaml&lt;/code&gt;&lt;/strong&gt; — gives the Pod(s) a stable internal address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello-world&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello-world&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;ingress.yaml&lt;/code&gt;&lt;/strong&gt; — defines the routing rule for external traffic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello-world&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;nginx.ingress.kubernetes.io/rewrite-target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ingressClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
  &lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;host&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;example.com"&lt;/span&gt;
    &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;paths&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/&lt;/span&gt;
        &lt;span class="na"&gt;pathType&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Prefix&lt;/span&gt;
        &lt;span class="na"&gt;backend&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;hello-world&lt;/span&gt;
            &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;number&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ingressClassName: nginx&lt;/code&gt; field matters: in a cluster with more than one Ingress Controller installed, this tells Kubernetes which controller should handle this particular Ingress Resource.&lt;/p&gt;

&lt;p&gt;After applying the Deployment and Service, both looked healthy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k get po
NAME                          READY   STATUS    RESTARTS      AGE
curl-test                     1/1     Running   1 (44s ago)   3m43s
hello-world-c8b6f8dbd-9t9n6   1/1     Running   1             33m

❯ k get svc
NAME          TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
&lt;/span&gt;&lt;span class="gp"&gt;hello-world   ClusterIP   10.96.74.127   &amp;lt;none&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;80/TCP    26m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 2: Debugging Internal Connectivity
&lt;/h2&gt;

&lt;p&gt;A &lt;code&gt;curl-test&lt;/code&gt; Pod was already running to test connectivity to the &lt;code&gt;hello-world&lt;/code&gt; Service. The first attempt failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k exec -it curl-test -- curl 10.96.74.127
command terminated with exit code 137
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Exit code 137 means the container was OOM-killed&lt;/strong&gt; (killed for exceeding its memory limit) — this is not a networking error. Running &lt;code&gt;kubectl describe pod curl-test&lt;/code&gt; confirmed repeated restarts with &lt;code&gt;Last State: Terminated&lt;/code&gt;, &lt;code&gt;Reason: Error&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;To check whether the app itself was actually working, I shelled directly into the &lt;code&gt;hello-world&lt;/code&gt; Pod:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl exec -it hello-world-c8b6f8dbd-9t9n6 -- curl localhost:80
&lt;/span&gt;&lt;span class="gp"&gt;error: ... exec: "curl": executable file not found in $&lt;/span&gt;PATH
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;python:3.7-slim&lt;/code&gt; base image doesn't include &lt;code&gt;curl&lt;/code&gt; — "slim" images intentionally leave out extras like this. As a workaround, I used Python's built-in &lt;code&gt;urllib&lt;/code&gt; module instead:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;python3 &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import urllib.request; print(urllib.request.urlopen('http://localhost:80').read())"&lt;/span&gt;
&lt;span class="go"&gt;b'Hello, World!'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This confirmed the app itself was working correctly inside its own Pod. The issue was isolated to the &lt;code&gt;curl-test&lt;/code&gt; Pod repeatedly getting OOM-killed, not the app or the network.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; rather than continuing to debug a resource-constrained, long-running Pod, I switched to a disposable Pod using &lt;code&gt;kubectl run --rm -it&lt;/code&gt;, which creates a temporary Pod, opens a shell, and deletes the Pod automatically on exit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl run curl-test --image=curlimages/curl -it --rm -- curl -v 10.244.3.2:80
&lt;/span&gt;&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt;&amp;lt; HTTP/1.1 200 OK
&amp;lt; Server: Werkzeug/2.2.3 Python/3.7.17
&lt;/span&gt;&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt;Hello, World!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same worked against the Service's ClusterIP directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl run curl-test --image=curlimages/curl -it --rm -- curl -v 10.96.74.127:80
&amp;lt; HTTP/1.1 200 OK
Hello, World!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Takeaway:&lt;/strong&gt; internal networking and the app were fine the entire time. The failures were caused by one Pod being killed for memory usage, not by a cluster-wide issue. When only one Pod misbehaves and everything else works, the Pod itself is usually the place to look first.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Applying the Ingress — Address Stays Empty
&lt;/h2&gt;

&lt;p&gt;With internal connectivity confirmed, I applied the Ingress resource:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k apply -f ingress.yaml
ingress.networking.k8s.io/hello-world created

❯ k get ingress
NAME          CLASS   HOSTS         ADDRESS   PORTS   AGE
hello-world   nginx   example.com             80      8s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;ADDRESS&lt;/code&gt; column stayed empty. This is expected at this stage: the Ingress &lt;em&gt;Resource&lt;/em&gt; exists, but no Ingress &lt;em&gt;Controller&lt;/em&gt; is running yet to act on it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4: Installing the NGINX Ingress Controller
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.15.1/deploy/static/provider/aws/deploy.yaml
namespace/ingress-nginx created
&lt;/span&gt;&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt;deployment.apps/ingress-nginx-controller created
&lt;/span&gt;&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt;ingressclass.networking.k8s.io/nginx created
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This manifest creates a dedicated namespace, RBAC rules, the controller Deployment, and admission webhooks. The controller Pod came up successfully:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k get po -A | grep nginx
ingress-nginx   ingress-nginx-controller-7d65c586d6-kvn9s   1/1   Running   0   7m24s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its logs confirmed it detected the Ingress resource and reloaded its configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;I0723 16:19:18.803001 ... "Found valid IngressClass" ingress="default/hello-world" ingressclass="nginx"
I0723 16:19:19.839235 ... "Backend successfully reloaded"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The controller was running and had picked up the routing rule. The next question was why the Ingress still had no address.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: LoadBalancer Stuck on &lt;code&gt;&amp;lt;pending&amp;gt;&lt;/code&gt;
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k get svc -n ingress-nginx
NAME                        TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)
&lt;/span&gt;&lt;span class="gp"&gt;ingress-nginx-controller    LoadBalancer   10.96.101.127   &amp;lt;pending&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;     &lt;/span&gt;80:31003/TCP,443:30437/TCP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;EXTERNAL-IP&lt;/code&gt; stayed on &lt;code&gt;&amp;lt;pending&amp;gt;&lt;/code&gt; indefinitely. This happens because a &lt;code&gt;type: LoadBalancer&lt;/code&gt; Service depends on a &lt;strong&gt;Cloud Controller Manager&lt;/strong&gt; to provision an actual external load balancer and assign it an IP. On a self-managed &lt;code&gt;kind&lt;/code&gt;/kubeadm cluster with no cloud provider integration, there's nothing available to fulfill that request, so it never resolves.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fix:&lt;/strong&gt; change the Service type to &lt;code&gt;NodePort&lt;/code&gt;, which doesn't require any cloud integration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k edit svc ingress-nginx-controller -n ingress-nginx
service/ingress-nginx-controller edited

❯ k get svc -n ingress-nginx
NAME                        TYPE       CLUSTER-IP      EXTERNAL-IP   PORT(S)
&lt;/span&gt;&lt;span class="gp"&gt;ingress-nginx-controller    NodePort   10.96.101.127   &amp;lt;none&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;        &lt;/span&gt;80:31003/TCP,443:30437/TCP
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After deleting and re-applying &lt;code&gt;ingress.yaml&lt;/code&gt;, the &lt;code&gt;ADDRESS&lt;/code&gt; field populated:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k get ing -w
NAME          CLASS   HOSTS         ADDRESS         PORTS   AGE
hello-world   nginx   example.com   10.96.101.127   80      44s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Testing directly from the local machine, however, still failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ curl 10.96.101.127
curl: (7) Failed to connect to 10.96.101.127 port 80 after 21024 ms: Could not connect to server
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 6: Investigating the CNI (Calico Installation)
&lt;/h2&gt;

&lt;p&gt;While troubleshooting the connection timeout, I also installed &lt;strong&gt;Calico&lt;/strong&gt; as the cluster's CNI (Container Network Interface) plugin, replacing the default &lt;code&gt;kindnet&lt;/code&gt; networking that &lt;code&gt;kind&lt;/code&gt; clusters ship with. Calico is a common choice in real-world clusters because of its more detailed network policy support and easier debugging tools.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.30.3/manifests/tigera-operator.yaml
namespace/tigera-operator created
&lt;/span&gt;&lt;span class="c"&gt;...
&lt;/span&gt;&lt;span class="go"&gt;deployment.apps/tigera-operator created

❯ kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.30.3/manifests/custom-resources.yaml
resource mapping not found for name: "default" ... no matches for kind "Installation" in version "operator.tigera.io/v1"
ensure CRDs are installed first
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This error occurs when the Calico custom resources are applied before the &lt;code&gt;tigera-operator&lt;/code&gt; has finished installing its Custom Resource Definitions (CRDs) — the CRDs need to exist before Kubernetes can accept objects of that kind. &lt;strong&gt;Fix:&lt;/strong&gt; wait for the operator Pod to reach &lt;code&gt;Running&lt;/code&gt;, then re-apply:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl get pods -n tigera-operator -w
tigera-operator-75c7c596d9-tsmbz   1/1   Running   0   2m4s

❯ kubectl create -f https://raw.githubusercontent.com/projectcalico/calico/v3.30.3/manifests/custom-resources.yaml
installation.operator.tigera.io/default created
apiserver.operator.tigera.io/default created
&lt;/span&gt;&lt;span class="c"&gt;...
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calico's components came up correctly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k get po -A -n calico-system | grep calico
calico-apiserver   calico-apiserver-64cf87458d-qrwnb   1/1   Running   0   3m58s
calico-apiserver   calico-apiserver-64cf87458d-tqw44   1/1   Running   0   3m58s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Even with Calico running, curling the Ingress IP from the local machine still timed out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ curl 10.96.101.127
curl: (7) Failed to connect to 10.96.101.127 port 80 after 21007 ms: Could not connect to server
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Important clarification:&lt;/strong&gt; &lt;code&gt;10.96.101.127&lt;/code&gt; is a &lt;strong&gt;ClusterIP&lt;/strong&gt;, which is only routable &lt;em&gt;inside&lt;/em&gt; the cluster's network — it is not reachable from an external machine like a laptop, regardless of which CNI is installed. Swapping the CNI plugin did not (and could not) resolve this particular timeout, since the address itself is not meant to be reachable externally. The correct way to test was to send traffic from inside the cluster, or through the exposed NodePort.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7: Testing From Inside the Cluster
&lt;/h2&gt;

&lt;p&gt;To test correctly, I ran a temporary Pod inside the cluster and issued requests from there, which reflects how real traffic would actually reach the Service:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ kubectl run curl --rm -it --image=curlimages/curl -- sh
&lt;/span&gt;&lt;span class="gp"&gt;~ $&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl http://hello-world
&lt;span class="go"&gt;Hello, World!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This confirmed internal DNS-based service discovery was working. Next, hitting the Ingress Service's ClusterIP directly by IP, without specifying a hostname:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;~ $ curl http://10.96.101.127
&lt;span class="nt"&gt;&amp;lt;html&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;head&amp;gt;&amp;lt;title&amp;gt;&lt;/span&gt;404 Not Found&lt;span class="nt"&gt;&amp;lt;/title&amp;gt;&amp;lt;/head&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;body&amp;gt;&amp;lt;center&amp;gt;&amp;lt;h1&amp;gt;&lt;/span&gt;404 Not Found&lt;span class="nt"&gt;&amp;lt;/h1&amp;gt;&amp;lt;/center&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;hr&amp;gt;&amp;lt;center&amp;gt;&lt;/span&gt;nginx&lt;span class="nt"&gt;&amp;lt;/center&amp;gt;&amp;lt;/body&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;/html&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This 404 is expected behavior, not an error. The Ingress rule only matches requests where the &lt;code&gt;Host&lt;/code&gt; header is &lt;code&gt;example.com&lt;/code&gt;. Curling the raw IP sends no &lt;code&gt;Host&lt;/code&gt; header, so NGINX has no matching rule and returns a 404.&lt;/p&gt;

&lt;p&gt;To simulate a request with the correct hostname, I used curl's &lt;code&gt;--resolve&lt;/code&gt; flag to manually map &lt;code&gt;example.com&lt;/code&gt; to the Ingress IP. The first attempt used the wrong syntax:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;~ $&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl example.com &lt;span class="nt"&gt;--resolve&lt;/span&gt; example.com:80 http://10.96.101.127
&lt;span class="go"&gt;curl: (49) Could not parse CURLOPT_RESOLVE entry 'example.com:80'
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--resolve&lt;/code&gt; expects the format &lt;code&gt;host:port:ip&lt;/code&gt;, not a separate URL. Corrected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;~ $&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl example.com &lt;span class="nt"&gt;--resolve&lt;/span&gt; example.com:80:10.96.101.127
&lt;span class="go"&gt;Hello, World!

&lt;/span&gt;&lt;span class="gp"&gt;~ $&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;curl http://example.com
&lt;span class="go"&gt;Hello, World!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This confirmed host-based routing was working as configured: the same Ingress IP serves different responses depending on the requested hostname — the core capability that distinguishes Ingress from a plain NodePort or LoadBalancer setup.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 8: Mapping example.com in /etc/hosts
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;--resolve&lt;/code&gt; is convenient for one-off curl requests, but it only applies to that single command — it doesn't help if you want to open &lt;code&gt;http://example.com&lt;/code&gt; in a browser or run multiple tools against the same hostname. For that, the standard approach is to edit &lt;code&gt;/etc/hosts&lt;/code&gt; directly, which tells the local machine to resolve &lt;code&gt;example.com&lt;/code&gt; to the Ingress IP for every request, not just one curl call.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ sudo nano /etc/hosts
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the file, a line was added mapping the hostname &lt;code&gt;example.com&lt;/code&gt; to the Ingress controller's IP address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="m"&gt;10&lt;/span&gt;.&lt;span class="m"&gt;96&lt;/span&gt;.&lt;span class="m"&gt;101&lt;/span&gt;.&lt;span class="m"&gt;127&lt;/span&gt;   &lt;span class="n"&gt;example&lt;/span&gt;.&lt;span class="n"&gt;com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After saving the file, any tool on the machine — curl, a browser, etc. — resolves &lt;code&gt;example.com&lt;/code&gt; to that IP without needing &lt;code&gt;--resolve&lt;/code&gt; or any other flag:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ curl http://example.com
Hello, World!
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This simulates what a real DNS record would do in production: pointing a domain name at the Ingress controller's IP. In production, you'd create an actual DNS &lt;code&gt;A&lt;/code&gt; record for &lt;code&gt;example.com&lt;/code&gt; pointing at the Ingress controller's external IP; &lt;code&gt;/etc/hosts&lt;/code&gt; is the local-machine equivalent for testing without needing a real, registered domain.&lt;/p&gt;

&lt;p&gt;One thing worth noting: this entry only affects DNS resolution on the local machine — it doesn't change how anyone else resolves the domain. It's also worth removing once testing is done, so it doesn't cause confusion later if the Ingress IP changes.&lt;/p&gt;

</description>
      <category>learning</category>
      <category>kubernetes</category>
      <category>cloudnative</category>
      <category>devops</category>
    </item>
    <item>
      <title>Understanding PersistentVolumes (PV) and PersistentVolumeClaims (PVC)</title>
      <dc:creator>Adeoye Malumi</dc:creator>
      <pubDate>Thu, 16 Jul 2026 16:23:52 +0000</pubDate>
      <link>https://dev.to/oyebobs/understanding-persistentvolumes-pv-and-persistentvolumeclaims-pvc-b9g</link>
      <guid>https://dev.to/oyebobs/understanding-persistentvolumes-pv-and-persistentvolumeclaims-pvc-b9g</guid>
      <description>&lt;p&gt;Today I learned one of the most confusing Kubernetes concepts for beginners: &lt;strong&gt;PersistentVolumes (PV)&lt;/strong&gt; and &lt;strong&gt;PersistentVolumeClaims (PVC)&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;At first, my PVC refused to bind and stayed in the &lt;code&gt;Pending&lt;/code&gt; state. After some troubleshooting, I finally got everything working. And honestly? Breaking it first helped me understand it much better.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why do we even need PVs?
&lt;/h2&gt;

&lt;p&gt;Imagine you're living in a rented apartment.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Pod&lt;/strong&gt; = You.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Container filesystem&lt;/strong&gt; = Your temporary room.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PersistentVolume (PV)&lt;/strong&gt; = A storage unit that exists independently of you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PersistentVolumeClaim (PVC)&lt;/strong&gt; = The rental agreement saying, "I'd like to use a storage unit."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your Pod dies, everything inside the container normally disappears.&lt;/p&gt;

&lt;p&gt;A PV lets your data survive even when the Pod is deleted.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Relationship
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pod
 │
 ▼
PersistentVolumeClaim (PVC)
 │
 ▼
PersistentVolume (PV)
 │
 ▼
Actual storage (hostPath, NFS, cloud disk, etc.)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Pod never talks directly to the PV.&lt;/p&gt;

&lt;p&gt;Instead it says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Hey PVC, can I borrow some storage?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The PVC then finds a suitable PV.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Create a PersistentVolume
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PersistentVolume&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pv-demo&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;capacity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;512Mi&lt;/span&gt;
  &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteMany&lt;/span&gt;
  &lt;span class="na"&gt;hostPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/data/config"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This creates &lt;strong&gt;512Mi&lt;/strong&gt; of storage backed by a directory on the node.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Create a PersistentVolumeClaim
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PersistentVolumeClaim&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pvc-demo&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;storageClassName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;
  &lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteOnce&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;storage&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;256Mi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice that the claim only requests &lt;strong&gt;256Mi&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Kubernetes looks for a PV that satisfies the request and binds them together.&lt;/p&gt;

&lt;h2&gt;
  
  
  My First Roadblock
&lt;/h2&gt;

&lt;p&gt;After creating the PVC:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; pvc.yaml
kubectl get pvc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I saw:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;STATUS
Pending
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why Was My PVC Stuck in &lt;code&gt;Pending&lt;/code&gt;?
&lt;/h2&gt;

&lt;p&gt;This was the part that confused me the most.&lt;/p&gt;

&lt;p&gt;I had already created my PersistentVolume (PV), so I expected my PersistentVolumeClaim (PVC) to bind to it immediately. Instead, every time I checked, the PVC was still in the &lt;code&gt;Pending&lt;/code&gt; state.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pvc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME       STATUS
pvc-demo   Pending
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To investigate, I ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe pvc pvc-demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Events section showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FailedBinding
no persistent volumes available for this claim and no storage class is set
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first, I thought Kubernetes couldn't see my PersistentVolume at all. That wasn't actually the problem.&lt;/p&gt;

&lt;p&gt;The issue was that my PVC and PV didn't agree on the access mode.&lt;/p&gt;

&lt;p&gt;My PVC requested:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteOnce&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;while my PV was configured with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;accessModes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;ReadWriteMany&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because of this difference, Kubernetes couldn't match the claim to the volume, so the PVC stayed in the &lt;code&gt;Pending&lt;/code&gt; state.&lt;/p&gt;

&lt;p&gt;After deleting the old resources, correcting the configuration, and recreating both the PV and PVC, Kubernetes successfully matched them and their status changed to &lt;strong&gt;Bound&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The biggest lesson I learned is that whenever a PVC is stuck in &lt;code&gt;Pending&lt;/code&gt;, the first command to run should be:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe pvc pvc-demo
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;strong&gt;Events&lt;/strong&gt; section usually contains the clue you need to figure out why the claim couldn't be bound.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Mount the PVC inside a Pod
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pv-container&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:latest&lt;/span&gt;
      &lt;span class="na"&gt;volumeMounts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pv-storage&lt;/span&gt;
          &lt;span class="na"&gt;mountPath&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/var/app/config&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http-server&lt;/span&gt;
  &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pv-storage&lt;/span&gt;
      &lt;span class="na"&gt;persistentVolumeClaim&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;claimName&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pvc-demo&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Think of this as plugging an external hard drive into your laptop.&lt;/p&gt;

&lt;p&gt;The Pod can now read and write files through the PVC.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — Test It
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; app &lt;span class="nt"&gt;--&lt;/span&gt; bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cd&lt;/span&gt; /var/app/config

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"This is a test"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; test.html

&lt;span class="nb"&gt;cat &lt;/span&gt;test.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;This is a test
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Success! &lt;/p&gt;

&lt;p&gt;I accidentally tried:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl test.html
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;which failed because &lt;code&gt;curl&lt;/code&gt; fetches URLs, not local files.&lt;/p&gt;

&lt;p&gt;Lesson learned. &lt;/p&gt;

&lt;h2&gt;
  
  
  The Big Idea
&lt;/h2&gt;

&lt;p&gt;Many beginners confuse PVs and PVCs.&lt;/p&gt;

&lt;p&gt;Remember this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PersistentVolume (PV)&lt;/strong&gt; = The actual storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PersistentVolumeClaim (PVC)&lt;/strong&gt; = The request for storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pod&lt;/strong&gt; = The application that uses the claim.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A simple analogy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🍕 PV = The pizza.&lt;/li&gt;
&lt;li&gt;🧾 PVC = The order receipt.&lt;/li&gt;
&lt;li&gt;😋 Pod = The hungry person.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You don't grab the pizza straight from the oven—you present your receipt first!&lt;/p&gt;

&lt;h2&gt;
  
  
  Commands I Used
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; pv.yaml
kubectl get pv

kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; pvc.yaml
kubectl get pvc
kubectl describe pvc pvc-demo

kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; pod.yaml
kubectl get pods

kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; app &lt;span class="nt"&gt;--&lt;/span&gt; bash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Persistent storage survives Pod restarts.&lt;/li&gt;
&lt;li&gt;Pods use PVCs, not PVs, directly.&lt;/li&gt;
&lt;li&gt;A PVC must successfully bind before a Pod can use it.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl describe&lt;/code&gt; is one of the best debugging tools when something is stuck.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>learning</category>
      <category>kubernetes</category>
      <category>cloudnative</category>
      <category>devops</category>
    </item>
    <item>
      <title>Kubernetes Network Policies Explained (and How I Actually Made Them Work)</title>
      <dc:creator>Adeoye Malumi</dc:creator>
      <pubDate>Thu, 09 Jul 2026 16:50:22 +0000</pubDate>
      <link>https://dev.to/oyebobs/kubernetes-network-policies-explained-and-how-i-actually-made-them-work-4bpd</link>
      <guid>https://dev.to/oyebobs/kubernetes-network-policies-explained-and-how-i-actually-made-them-work-4bpd</guid>
      <description>&lt;p&gt;If you've ever wondered, "How do I stop my pods from randomly talking to everything in my cluster?" – this is the post for you.&lt;/p&gt;

&lt;p&gt;Today, I dove into &lt;strong&gt;Kubernetes Network Policies&lt;/strong&gt; – the way you control who can talk to whom inside your cluster. I started with &lt;code&gt;weavenet&lt;/code&gt;, then switched to &lt;strong&gt;Calico&lt;/strong&gt;, and I'm excited to walk you through how it all came together.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Even Is a Network Policy?
&lt;/h2&gt;

&lt;p&gt;A &lt;strong&gt;NetworkPolicy&lt;/strong&gt; in Kubernetes is like a firewall rule for your pods. It lets you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Control &lt;strong&gt;inbound traffic&lt;/strong&gt; (who can talk to a pod)&lt;/li&gt;
&lt;li&gt;Control &lt;strong&gt;outbound traffic&lt;/strong&gt; (where a pod can talk to)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You can create a &lt;strong&gt;deny-all&lt;/strong&gt; policy that blocks all incoming traffic to a pod.&lt;/li&gt;
&lt;li&gt;Or an &lt;strong&gt;allow-only&lt;/strong&gt; policy that says: "Only the &lt;code&gt;backend&lt;/code&gt; pods can talk to the &lt;code&gt;mysql&lt;/code&gt; pod on port 3306."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without network policies, by default, &lt;strong&gt;everything can talk to everything&lt;/strong&gt; in your cluster. That's fine for demos, terrible for real security.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why I Needed a Different CNI
&lt;/h2&gt;

&lt;p&gt;Kubernetes itself doesn't implement network policies. It just defines them. The actual work is done by your &lt;strong&gt;CNI plugin&lt;/strong&gt; (Container Network Interface).&lt;/p&gt;

&lt;p&gt;I started with &lt;strong&gt;weavenet&lt;/strong&gt;, which supports basic network policies. But as I dug deeper, I realized I wanted something more powerful and production-friendly. That's when I switched to &lt;strong&gt;Calico&lt;/strong&gt;, which is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Fast&lt;/li&gt;
&lt;li&gt;Scalable&lt;/li&gt;
&lt;li&gt;Great for complex network policies&lt;/li&gt;
&lt;li&gt;Works well with &lt;code&gt;kind&lt;/code&gt; clusters (as long as you configure it correctly)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're just experimenting, weavenet is fine. If you want to level up your cluster networking, Calico is a great next step.&lt;/p&gt;




&lt;h2&gt;
  
  
  My Test Cluster: A Kind Setup with Calico
&lt;/h2&gt;

&lt;p&gt;To test network policies, I created a simple &lt;code&gt;kind&lt;/code&gt; cluster with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1 control-plane node&lt;/li&gt;
&lt;li&gt;2 worker nodes&lt;/li&gt;
&lt;li&gt;Disabled default CNI (&lt;code&gt;disableDefaultCNI: true&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;Custom pod subnet: &lt;code&gt;192.168.0.0/16&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  kind Cluster YAML
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Cluster&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;kind.x-k8s.io/v1alpha4&lt;/span&gt;
&lt;span class="na"&gt;nodes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;control-plane&lt;/span&gt;
    &lt;span class="na"&gt;extraPortMappings&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30001&lt;/span&gt;
        &lt;span class="na"&gt;hostPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;30001&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;worker&lt;/span&gt;
&lt;span class="na"&gt;networking&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;disableDefaultCNI&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;podSubnet&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;192.168.0.0/16"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Creating the Cluster
&lt;/h3&gt;

&lt;p&gt;With the YAML saved as &lt;code&gt;kind-config.yaml&lt;/code&gt;, I created the cluster like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kind create cluster &lt;span class="nt"&gt;--name&lt;/span&gt; day26 &lt;span class="nt"&gt;--config&lt;/span&gt; kind-config.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I checked the nodes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here's the part that was a bit confusing at first:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Until I installed a CNI (like Calico), the worker nodes stayed in a &lt;code&gt;NotReady&lt;/code&gt; state.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;That's because Kubernetes needs a CNI to:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Assign IPs to pods&lt;/li&gt;
&lt;li&gt;Set up networking between nodes&lt;/li&gt;
&lt;li&gt;Enable traffic to flow across the cluster&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without a CNI, the control-plane node can be &lt;code&gt;Ready&lt;/code&gt;, but the worker nodes will remain &lt;code&gt;NotReady&lt;/code&gt; until networking is configured.&lt;/p&gt;

&lt;p&gt;Only after installing Calico did all nodes show as &lt;code&gt;Ready&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get nodes
&lt;span class="c"&gt;# NAME           STATUS   ROLES           AGE   VERSION&lt;/span&gt;
&lt;span class="c"&gt;# day26-control-plane   Ready    control-plane   ...&lt;/span&gt;
&lt;span class="c"&gt;# day26-worker          Ready    worker          ...&lt;/span&gt;
&lt;span class="c"&gt;# day26-worker2         Ready    worker          ...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once Calico was running, my cluster was ready for real network policy experiments.&lt;/p&gt;




&lt;h2&gt;
  
  
  The App I Built: Frontend → Backend → MySQL
&lt;/h2&gt;

&lt;p&gt;To make network policies meaningful, I created a tiny 3-tier app:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;frontend&lt;/code&gt; pod + service&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;backend&lt;/code&gt; pod + service&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mysql&lt;/code&gt; pod + service (&lt;code&gt;db&lt;/code&gt; service)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Pods &amp;amp; Services
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
          &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
          &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
&lt;span class="err"&gt;***&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;frontend&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;span class="err"&gt;***&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
          &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
          &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
&lt;span class="err"&gt;***&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;span class="err"&gt;***&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
      &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3306&lt;/span&gt;
      &lt;span class="na"&gt;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3306&lt;/span&gt;
&lt;span class="err"&gt;***&lt;/span&gt;
&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql&lt;/span&gt;
      &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql:latest&lt;/span&gt;
      &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MYSQL_USER"&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mysql"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MYSQL_PASSWORD"&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mysql"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MYSQL_DATABASE"&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;testdb"&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MYSQL_ROOT_PASSWORD"&lt;/span&gt;
          &lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;verysecure"&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http&lt;/span&gt;
          &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3306&lt;/span&gt;
          &lt;span class="na"&gt;protocol&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;TCP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point, &lt;strong&gt;everything could talk to everything&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;frontend&lt;/code&gt; could reach &lt;code&gt;backend&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;backend&lt;/code&gt; could reach &lt;code&gt;mysql&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;frontend&lt;/code&gt; could also reach &lt;code&gt;mysql&lt;/code&gt; directly (which we probably don't want)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now it was time to lock it down.&lt;/p&gt;




&lt;h2&gt;
  
  
  My First Real Network Policy: Only Backend → MySQL
&lt;/h2&gt;

&lt;p&gt;The goal:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;mysql&lt;/code&gt; should &lt;strong&gt;only&lt;/strong&gt; accept traffic from pods with label &lt;code&gt;role: backend&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;On port &lt;strong&gt;3306&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;No one else (including &lt;code&gt;frontend&lt;/code&gt;) should touch the database&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  NetworkPolicy Manifest
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;networking.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;NetworkPolicy&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;db-test&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;mysql&lt;/span&gt;
  &lt;span class="na"&gt;policyTypes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;Ingress&lt;/span&gt;
  &lt;span class="na"&gt;ingress&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;from&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;podSelector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
              &lt;span class="na"&gt;role&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;backend&lt;/span&gt;
      &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;3306&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;podSelector&lt;/code&gt;: targets the &lt;code&gt;mysql&lt;/code&gt; pod (&lt;code&gt;name: mysql&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;policyTypes: [Ingress]&lt;/code&gt;: we're controlling inbound traffic&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ingress.from.podSelector&lt;/code&gt;: only pods with &lt;code&gt;role: backend&lt;/code&gt; are allowed&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ports&lt;/code&gt;: only port 3306&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;After applying this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;backend&lt;/code&gt; → &lt;code&gt;mysql&lt;/code&gt;  works&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;frontend&lt;/code&gt; → &lt;code&gt;mysql&lt;/code&gt;  blocked&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;mysql&lt;/code&gt; → anywhere (outbound) still allowed (we didn't restrict Egress)&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  How I Tested It
&lt;/h2&gt;

&lt;p&gt;I used a simple approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Run a temporary pod with &lt;code&gt;curl&lt;/code&gt; or &lt;code&gt;mysql&lt;/code&gt; client.&lt;/li&gt;
&lt;li&gt;Try to reach services from different pods.&lt;/li&gt;
&lt;li&gt;Observe which connections succeed or fail.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Example:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# From frontend pod – should fail&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; frontend &lt;span class="nt"&gt;--&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://db:3306

&lt;span class="c"&gt;# From backend pod – should succeed&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; backend &lt;span class="nt"&gt;--&lt;/span&gt; curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://db:3306
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Seeing the first one fail and the second one succeed was the "aha!" moment: &lt;strong&gt;the policy is actually working&lt;/strong&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wrapping Up
&lt;/h2&gt;

&lt;p&gt;Network Policies are one of those things that feel abstract until you actually apply them and see connections blocked in real time. Once that happens, you suddenly understand:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why security matters&lt;/li&gt;
&lt;li&gt;Why "everything can talk to everything" is not a strategy&lt;/li&gt;
&lt;li&gt;How Kubernetes gives you granular control over your cluster's traffic&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>learning</category>
      <category>kubernetes</category>
      <category>cloudnative</category>
      <category>devops</category>
    </item>
    <item>
      <title>Understanding Kubernetes Service Accounts (The Identity Every Pod Uses)</title>
      <dc:creator>Adeoye Malumi</dc:creator>
      <pubDate>Tue, 07 Jul 2026 15:58:47 +0000</pubDate>
      <link>https://dev.to/oyebobs/understanding-kubernetes-service-accounts-the-identity-every-pod-uses-4lcd</link>
      <guid>https://dev.to/oyebobs/understanding-kubernetes-service-accounts-the-identity-every-pod-uses-4lcd</guid>
      <description>&lt;p&gt;Most of us learn about Kubernetes users before we learn about Service Accounts. That's actually where the confusion starts Service Accounts end up looking like "just another kind of user," and you file them away without really getting what makes them different.&lt;/p&gt;

&lt;p&gt;They're not just another user. They solve a completely different problem.&lt;/p&gt;

&lt;p&gt;Today I learned that human users authenticate &lt;em&gt;from outside&lt;/em&gt; the cluster — you, sitting at your laptop, running &lt;code&gt;kubectl&lt;/code&gt; with your certificate or your login. But Pods run &lt;em&gt;inside&lt;/em&gt; the cluster, and they often need to talk to the Kubernetes API too — to list other pods, read a ConfigMap, watch for changes, whatever. A Pod can't exactly type in a username and password. It needs its own identity.&lt;/p&gt;

&lt;p&gt;That identity is called a &lt;strong&gt;Service Account&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  User vs. Service Account
&lt;/h2&gt;

&lt;p&gt;Before touching a single command, it's worth seeing the two side by side:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;User&lt;/th&gt;
&lt;th&gt;Service Account&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Used by humans&lt;/td&gt;
&lt;td&gt;Used by Pods&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External to Kubernetes&lt;/td&gt;
&lt;td&gt;Managed by Kubernetes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Usually authenticated using certificates&lt;/td&gt;
&lt;td&gt;Authenticated using tokens&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;You create it outside the cluster&lt;/td&gt;
&lt;td&gt;Kubernetes creates and manages it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Keep that table in your head — everything below is really just this table playing out in the terminal.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — Checking Existing Service Accounts
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k get sa
NAME      AGE
default   38d
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Notice I didn't create this. It was already there, 38 days old — as old as the namespace itself.&lt;/p&gt;

&lt;p&gt;That's the first thing to internalize: &lt;strong&gt;every namespace automatically gets a Service Account called &lt;code&gt;default&lt;/code&gt;.&lt;/strong&gt; You don't ask for it. If you create a Pod and never say which Service Account it should use, Kubernetes quietly attaches this one for you. Keep that in mind — it comes back to bite (in a good way) later in this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — Creating My Own Service Account
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k create sa build-sa
serviceaccount/build-sa created

❯ k describe sa build-sa
Name:                build-sa
Namespace:           default
&lt;/span&gt;&lt;span class="gp"&gt;Labels:              &amp;lt;none&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;Annotations:         &amp;lt;none&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;Image pull secrets:  &amp;lt;none&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;Events:              &amp;lt;none&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple enough — I now have a second identity, &lt;code&gt;build-sa&lt;/code&gt;, sitting in the &lt;code&gt;default&lt;/code&gt; namespace, ready to be attached to Pods later.&lt;/p&gt;

&lt;p&gt;But here's the question that trips a lot of beginners up: &lt;strong&gt;why doesn't it have a token?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;If you learned Kubernetes a few years ago, or you're reading an older tutorial, you'll expect &lt;code&gt;describe sa&lt;/code&gt; to show a &lt;code&gt;Tokens:&lt;/code&gt; field pointing at an auto-generated Secret. Not anymore. Newer versions of Kubernetes stopped automatically minting long-lived Secret-based tokens for every Service Account, mainly for security reasons — a token that never expires and just sits around as a Secret is a standing risk if it leaks. Instead, Kubernetes now prefers short-lived, automatically-rotated tokens issued on demand.&lt;/p&gt;

&lt;p&gt;Since I specifically wanted a durable token I could inspect and use for &lt;code&gt;--as&lt;/code&gt; testing, I had to create it myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — Creating a ServiceAccount Token Secret
&lt;/h2&gt;

&lt;p&gt;I wrote a &lt;code&gt;secret.yaml&lt;/code&gt; that, at minimum, needs this annotation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;annotations&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;kubernetes.io/service-account.name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;build-sa&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This one line is doing all the work. It tells the Kubernetes controller: &lt;em&gt;"This Secret isn't just a random Opaque blob — it's a token for the &lt;code&gt;build-sa&lt;/code&gt; Service Account."&lt;/em&gt; Because of that annotation, a built-in controller notices the Secret, generates a signed token for &lt;code&gt;build-sa&lt;/code&gt;, and populates the Secret with it automatically.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k apply -f secret.yaml
secret/build-robot-secret created

❯ k get secret
NAME                 TYPE                                  DATA   AGE
backend-user         Opaque                                1      5d3h
build-robot-secret   kubernetes.io/service-account-token   3      14s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;See the &lt;code&gt;TYPE&lt;/code&gt; column — &lt;code&gt;kubernetes.io/service-account-token&lt;/code&gt;, not &lt;code&gt;Opaque&lt;/code&gt;. That type is exactly what tells Kubernetes "treat this Secret specially and fill it with SA credentials."&lt;/p&gt;

&lt;h3&gt;
  
  
  Inspecting the Secret
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k describe secret build-robot-secret
Name:         build-robot-secret
Namespace:    default
&lt;/span&gt;&lt;span class="gp"&gt;Labels:       &amp;lt;none&amp;gt;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="go"&gt;Annotations:  kubernetes.io/service-account.name: build-sa
              kubernetes.io/service-account.uid: d065d358-58fa-44a6-9656-8263e0fa05eb

Type:  kubernetes.io/service-account-token

Data
====
ca.crt:     1107 bytes
namespace:  7 bytes
token:      eyJhbGciOiJSUzI1NiIs... (truncated)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three fields, each with a job:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ca.crt&lt;/code&gt;&lt;/strong&gt; — the cluster's certificate authority. Whoever holds this Secret can verify they're really talking to the real API server, not an impersonator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;namespace&lt;/code&gt;&lt;/strong&gt; — just the namespace this identity belongs to (&lt;code&gt;default&lt;/code&gt;), so any client using this Secret knows where it "lives."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;token&lt;/code&gt;&lt;/strong&gt; — the important one. This is a &lt;strong&gt;JWT (JSON Web Token)&lt;/strong&gt;, signed by the cluster. This is what a Pod, script, or client hands to the API server to say "I am &lt;code&gt;build-sa&lt;/code&gt;, and here's proof." I've truncated the token above — treat these things like passwords, not like log output, even in a lab cluster.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you decode a Service Account JWT (there's nothing secret about the &lt;em&gt;structure&lt;/em&gt;, just don't paste your actual token online), you'll find claims like the issuer, the namespace, and a &lt;code&gt;sub&lt;/code&gt; field that looks like &lt;code&gt;system:serviceaccount:default:build-sa&lt;/code&gt;. That string is the Service Account's real, canonical identity inside Kubernetes — remember it, it matters in a minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Permissions
&lt;/h2&gt;

&lt;p&gt;Now the interesting part. I wanted to see what &lt;code&gt;build-sa&lt;/code&gt; could actually do, without creating a Pod yet:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k get pods --as build-sa
Error from server (Forbidden): pods is forbidden: User "build-sa" cannot list resource "pods" in API group "" in the namespace "default"
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things to unpack here.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;First — what does &lt;code&gt;--as&lt;/code&gt; actually do?&lt;/strong&gt; It's easy to assume you're "logging in" as &lt;code&gt;build-sa&lt;/code&gt;. You're not. &lt;code&gt;--as&lt;/code&gt; is &lt;strong&gt;impersonation&lt;/strong&gt;. You're still using &lt;em&gt;your own&lt;/em&gt; admin credentials to talk to the API server, but you're telling it: "Evaluate this request as if it came from &lt;code&gt;build-sa&lt;/code&gt; instead of me." The API server checks whether &lt;em&gt;your&lt;/em&gt; account is allowed to impersonate &lt;code&gt;build-sa&lt;/code&gt; (admin usually is), and if so, it re-runs the authorization check as that identity. It's a testing and debugging superpower — you get to try on someone else's permissions without actually needing their credentials.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Second — why Forbidden?&lt;/strong&gt; This is the single most important lesson in this whole exercise: &lt;strong&gt;the Service Account existed. The token existed. Authentication succeeded. Authorization failed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Those are two separate gates in Kubernetes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authentication&lt;/strong&gt; — "Who are you?" &lt;code&gt;build-sa&lt;/code&gt; proved this fine, via impersonation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorization&lt;/strong&gt; — "What are you allowed to do?" Nothing had granted &lt;code&gt;build-sa&lt;/code&gt; any permissions at all, so RBAC said no.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A lot of beginners collapse these into one concept. They're not the same thing, and separating them in your head will save you hours of confused debugging later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating the Role
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k create role build-role \
--verb=list,get,watch \
--resource=pod
role.rbac.authorization.k8s.io/build-role created
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking down the flags:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;role build-role&lt;/code&gt; — I'm creating a &lt;strong&gt;Role&lt;/strong&gt;, which is a namespaced set of permissions (as opposed to a &lt;strong&gt;ClusterRole&lt;/strong&gt;, which applies cluster-wide).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--verb=list,get,watch&lt;/code&gt; — the specific actions this Role allows. Not &lt;code&gt;create&lt;/code&gt;, not &lt;code&gt;delete&lt;/code&gt; — just read-style operations.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--resource=pod&lt;/code&gt; — the Kubernetes resource type these verbs apply to.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On its own, a Role does nothing. It's a permission &lt;em&gt;template&lt;/em&gt; sitting unused until something binds an identity to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating the RoleBinding
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k create rolebinding rb \
--role=build-role \
--user=build-sa
rolebinding.rbac.authorization.k8s.io/rb created
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the glue: it binds the &lt;code&gt;build-role&lt;/code&gt; permissions to the identity &lt;code&gt;build-sa&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;One honest caveat worth flagging, since I want this post to be technically accurate and not just "it worked so it's fine": I used &lt;code&gt;--user=build-sa&lt;/code&gt; here, and it worked in this exercise because I was testing purely through impersonation with &lt;code&gt;--as build-sa&lt;/code&gt;, and &lt;code&gt;--as&lt;/code&gt; treats whatever string you give it as a plain username.&lt;/p&gt;

&lt;p&gt;But that's not actually how a real Service Account identifies itself to the cluster. Its canonical identity is &lt;code&gt;system:serviceaccount:&amp;lt;namespace&amp;gt;:&amp;lt;name&amp;gt;&lt;/code&gt; — in my case, &lt;code&gt;system:serviceaccount:default:build-sa&lt;/code&gt;. In production, the standard, correct way to bind a RoleBinding to a Service Account is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;--serviceaccount=default:build-sa
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If I'd actually mounted this token inside a real client and tried to use it (rather than testing through admin impersonation), a binding made with &lt;code&gt;--user=build-sa&lt;/code&gt; would &lt;strong&gt;not&lt;/strong&gt; match that Service Account's real identity, and the request would still be Forbidden. So: my command worked for this specific impersonation test, but if you're setting this up for a real workload, use &lt;code&gt;--serviceaccount=&amp;lt;namespace&amp;gt;:&amp;lt;name&amp;gt;&lt;/code&gt; instead. Good to know the difference exists before it costs you a debugging session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing Again
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k get pods --as build-sa
No resources found in default namespace.
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No error this time — just an empty result, because there genuinely were no pods yet. Authentication &lt;em&gt;and&lt;/em&gt; authorization both passed. That's the fix landing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating a Deployment
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k create deploy test --image=nginx
deployment.apps/test created
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth pointing out deliberately: I created this Deployment as &lt;em&gt;myself&lt;/em&gt; (admin), not as &lt;code&gt;build-sa&lt;/code&gt;. &lt;code&gt;build-sa&lt;/code&gt; only has &lt;code&gt;list&lt;/code&gt;, &lt;code&gt;get&lt;/code&gt;, and &lt;code&gt;watch&lt;/code&gt; on pods — nowhere near enough permission to create a Deployment. If I'd tried &lt;code&gt;k create deploy test --image=nginx --as build-sa&lt;/code&gt;, it would have been Forbidden too.&lt;/p&gt;

&lt;h2&gt;
  
  
  Watching the Pod
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k get po -w --as build-sa
NAME                    READY   STATUS    RESTARTS   AGE
test-56848fd9dc-fhdjv   1/1     Running   0          89s
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;-w&lt;/code&gt; flag means &lt;strong&gt;watch&lt;/strong&gt; — instead of printing the pod state once and exiting, &lt;code&gt;kubectl&lt;/code&gt; keeps the connection open and streams updates as the pod's status changes. Combined with &lt;code&gt;--as build-sa&lt;/code&gt;, this also confirms &lt;code&gt;build-sa&lt;/code&gt;'s &lt;code&gt;watch&lt;/code&gt; verb from the Role is working, not just &lt;code&gt;list&lt;/code&gt; and &lt;code&gt;get&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going Inside the Pod
&lt;/h2&gt;

&lt;p&gt;This is where it got genuinely interesting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k exec -it test-56848fd9dc-fhdjv bash
error: exec [POD] [COMMAND] is not supported anymore. Use exec [POD] -- [COMMAND] instead
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I hit this error a few times before it clicked. Older &lt;code&gt;kubectl&lt;/code&gt; versions let you run &lt;code&gt;kubectl exec pod bash&lt;/code&gt; and it would figure out where your flags ended and the container's command began. Modern &lt;code&gt;kubectl&lt;/code&gt; refuses to guess — it wants an explicit &lt;code&gt;--&lt;/code&gt; to separate "arguments meant for kubectl" from "the command to run inside the container." So:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="go"&gt;❯ k exec -it test-56848fd9dc-fhdjv -- bash
&lt;/span&gt;&lt;span class="gp"&gt;root@test-56848fd9dc-fhdjv:/#&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That extra &lt;code&gt;--&lt;/code&gt; is the whole fix. Everything after it belongs to the container, not to &lt;code&gt;kubectl&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Most Interesting Discovery
&lt;/h2&gt;

&lt;p&gt;Once inside, I went looking for exactly the kind of thing this Pod would need to talk to the API server — and there it was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;root@test-56848fd9dc-fhdjv:/var/run/secrets/kubernetes.io/serviceaccount#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; &lt;span class="nt"&gt;-lrt&lt;/span&gt;
&lt;span class="go"&gt;total 0
&lt;/span&gt;&lt;span class="gp"&gt;lrwxrwxrwx 1 root root 12 Jul  7 14:38 token -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;..data/token
&lt;span class="gp"&gt;lrwxrwxrwx 1 root root 16 Jul  7 14:38 namespace -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;..data/namespace
&lt;span class="gp"&gt;lrwxrwxrwx 1 root root 13 Jul  7 14:38 ca.crt -&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;..data/ca.crt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same three files as the Secret I inspected earlier — &lt;code&gt;token&lt;/code&gt;, &lt;code&gt;namespace&lt;/code&gt;, &lt;code&gt;ca.crt&lt;/code&gt; — automatically mounted at &lt;code&gt;/var/run/secrets/kubernetes.io/serviceaccount&lt;/code&gt;, with &lt;strong&gt;no configuration from me at all.&lt;/strong&gt; Kubernetes does this for every Pod by default, via a projected volume, so that any process running inside the container can immediately authenticate to the API server if it needs to (this is how things like controllers and operators, running as Pods themselves, talk back to Kubernetes).&lt;/p&gt;

&lt;p&gt;Now the obvious beginner question, and the one I asked myself immediately: &lt;strong&gt;"Wait — I created &lt;code&gt;build-sa&lt;/code&gt;. Why does the token in here say &lt;code&gt;default&lt;/code&gt;?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Sure enough, decoding what's inside shows a &lt;code&gt;sub&lt;/code&gt; claim of &lt;code&gt;system:serviceaccount:default:default&lt;/code&gt; — the &lt;code&gt;default&lt;/code&gt; Service Account, not &lt;code&gt;build-sa&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The answer is almost anticlimactic once you see it: my Deployment spec never included &lt;code&gt;serviceAccountName: build-sa&lt;/code&gt;. I never told Kubernetes which Service Account this Pod should use — so it fell back to the one thing every namespace already has, the one from Step 1. &lt;strong&gt;The &lt;code&gt;default&lt;/code&gt; Service Account.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This is, genuinely, the most valuable lesson from the whole exercise: creating a Service Account doesn't automatically attach it to anything. You have to explicitly wire it into a Pod (or Deployment, or Job, etc.) with &lt;code&gt;serviceAccountName&lt;/code&gt; — or Kubernetes will silently keep using &lt;code&gt;default&lt;/code&gt; on your behalf, and you won't get an error telling you. You'll just quietly have the wrong permissions, or, worse in production, the &lt;em&gt;default&lt;/em&gt; Service Account might have more access than you meant to grant that workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Service Accounts are identities for Pods — not humans.&lt;/li&gt;
&lt;li&gt;Every namespace automatically gets a &lt;code&gt;default&lt;/code&gt; Service Account.&lt;/li&gt;
&lt;li&gt;Service Accounts authenticate using tokens (JWTs), not certificates.&lt;/li&gt;
&lt;li&gt;Newer Kubernetes versions don't auto-create long-lived token Secrets anymore — you create them explicitly when you need one.&lt;/li&gt;
&lt;li&gt;RBAC still governs what a Service Account is allowed to do, exactly like it does for users.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;--as&lt;/code&gt; impersonates another identity for testing — it doesn't log you in as them.&lt;/li&gt;
&lt;li&gt;Authentication and authorization are two separate checks: proving who you are is not the same as being allowed to do something.&lt;/li&gt;
&lt;li&gt;Bind RoleBindings to Service Accounts with &lt;code&gt;--serviceaccount=&amp;lt;namespace&amp;gt;:&amp;lt;name&amp;gt;&lt;/code&gt; in real setups, not &lt;code&gt;--user=&amp;lt;name&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Pods automatically get their Service Account's credentials mounted at &lt;code&gt;/var/run/secrets/kubernetes.io/serviceaccount&lt;/code&gt; — no extra config needed.&lt;/li&gt;
&lt;li&gt;If you don't set &lt;code&gt;serviceAccountName&lt;/code&gt; on a Pod, Kubernetes silently uses &lt;code&gt;default&lt;/code&gt;. Always check.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>learning</category>
      <category>cloudcomputing</category>
    </item>
    <item>
      <title>Understanding Kubernetes ClusterRoles and ClusterRoleBindings</title>
      <dc:creator>Adeoye Malumi</dc:creator>
      <pubDate>Tue, 07 Jul 2026 13:13:42 +0000</pubDate>
      <link>https://dev.to/oyebobs/understanding-kubernetes-clusterroles-and-clusterrolebindings-1k64</link>
      <guid>https://dev.to/oyebobs/understanding-kubernetes-clusterroles-and-clusterrolebindings-1k64</guid>
      <description>&lt;p&gt;Yesterday I learned about Roles and RoleBindings. Today I explored ClusterRoles and ClusterRoleBindings for cluster-scoped resources like Nodes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a Role Wasn't Enough
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl auth can-i get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Warning: resource 'nodes' is not namespace scoped
no
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nodes are cluster-scoped resources, so a namespace Role cannot grant access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Creating a ClusterRole
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create clusterrole node-reader &lt;span class="nt"&gt;--verb&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;get,list,watch &lt;span class="nt"&gt;--resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get clusterrole | &lt;span class="nb"&gt;grep &lt;/span&gt;node-reader
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Creating a ClusterRoleBinding
&lt;/h2&gt;

&lt;p&gt;I first made a mistake:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create clusterrolebinding reader-binding &lt;span class="nt"&gt;--clusterrole&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;cluster-reader &lt;span class="nt"&gt;--user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;adeoye
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl auth can-i get nodes &lt;span class="nt"&gt;--as&lt;/span&gt; adeoye
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;no - RBAC: clusterrole.rbac.authorization.k8s.io "cluster-reader" not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The binding referenced a ClusterRole that didn't exist.&lt;/p&gt;

&lt;p&gt;I fixed it by deleting and recreating the binding:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete clusterrolebinding reader-binding
kubectl create clusterrolebinding reader-binding &lt;span class="nt"&gt;--clusterrole&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;node-reader &lt;span class="nt"&gt;--user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;adeoye
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl auth can-i get nodes &lt;span class="nt"&gt;--as&lt;/span&gt; adeoye
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Testing as adeoye
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl config use-context adeoye
kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Listing nodes worked.&lt;/p&gt;

&lt;p&gt;Describing a node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl describe node cka-cluster3-worker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;showed most information, but RBAC blocked access to &lt;strong&gt;leases&lt;/strong&gt; and &lt;strong&gt;pods&lt;/strong&gt; because the ClusterRole only granted access to &lt;strong&gt;nodes&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Trying to delete a node:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete node cka-cluster3-worker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;returned &lt;strong&gt;Forbidden&lt;/strong&gt; because the ClusterRole only included &lt;code&gt;get&lt;/code&gt;, &lt;code&gt;list&lt;/code&gt;, and &lt;code&gt;watch&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Roles are namespace-scoped.&lt;/li&gt;
&lt;li&gt;ClusterRoles are cluster-scoped.&lt;/li&gt;
&lt;li&gt;ClusterRoleBindings grant cluster-wide permissions.&lt;/li&gt;
&lt;li&gt;RBAC permissions are resource-specific.&lt;/li&gt;
&lt;li&gt;Kubernetes follows the principle of least privilege.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>learning</category>
      <category>devops</category>
      <category>kubernetes</category>
      <category>cloudcomputing</category>
    </item>
    <item>
      <title>Understanding Kubernetes RBAC: Roles, RoleBindings, and and Client Certificates</title>
      <dc:creator>Adeoye Malumi</dc:creator>
      <pubDate>Mon, 06 Jul 2026 14:14:46 +0000</pubDate>
      <link>https://dev.to/oyebobs/understanding-kubernetes-rbac-roles-rolebindings-and-and-client-certificates-d97</link>
      <guid>https://dev.to/oyebobs/understanding-kubernetes-rbac-roles-rolebindings-and-and-client-certificates-d97</guid>
      <description>&lt;p&gt;One thing I discovered today is that before Kubernetes can decide &lt;strong&gt;what you're allowed to do&lt;/strong&gt;, it first needs to know &lt;strong&gt;who you are&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That means there are two separate steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authentication&lt;/strong&gt; – Proving your identity using a client certificate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Authorization&lt;/strong&gt; – Deciding what you're allowed to do using RBAC.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In this lab, I created my own Kubernetes user named &lt;strong&gt;adeoye&lt;/strong&gt;, authenticated the user with a certificate, and then used RBAC to control what that user could access.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1 – Generate a Private Key
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl genrsa &lt;span class="nt"&gt;-out&lt;/span&gt; adeoye.key 2048
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This generates a 2048-bit RSA private key.&lt;/p&gt;

&lt;p&gt;Think of this as your digital identity. It should never be shared.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2 – Generate a Certificate Signing Request (CSR)
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl req &lt;span class="nt"&gt;-new&lt;/span&gt; &lt;span class="nt"&gt;-key&lt;/span&gt; adeoye.key &lt;span class="nt"&gt;-out&lt;/span&gt; adeoye.csr &lt;span class="nt"&gt;-subj&lt;/span&gt; &lt;span class="s2"&gt;"/CN=adeoye"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Certificate Signing Request asks Kubernetes to issue a client certificate.&lt;/p&gt;

&lt;p&gt;The important part is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/CN=adeoye
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Common Name (CN) becomes the Kubernetes username.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3 – Base64 Encode the CSR
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;adeoye.csr | &lt;span class="nb"&gt;base64&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'\n'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Kubernetes expects the CSR to be embedded inside a YAML manifest as Base64-encoded text. The &lt;code&gt;tr -d '\n'&lt;/code&gt; removes line breaks so it becomes a single continuous string.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 4 – Submit the CSR
&lt;/h2&gt;

&lt;p&gt;After inserting the encoded CSR into &lt;code&gt;csr.yaml&lt;/code&gt;, I submitted it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; csr.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Initially, the request was in the &lt;strong&gt;Pending&lt;/strong&gt; state so I had to approve the request.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5 – Approve the Certificate
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl certificate approve adeoye
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Approving the request tells Kubernetes to trust the user and issue a signed client certificate.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6 – Extract the Signed Certificate
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get csr adeoye &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;jsonpath&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'{.status.certificate}'&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; adeoye.crt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now I had:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;adeoye.key&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;adeoye.crt&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The key proves my identity, while the certificate proves Kubernetes trusts that identity.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 7 – Configure kubectl
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl config set-credentials adeoye &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--client-key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;adeoye.key &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--client-certificate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;adeoye.crt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I created a context.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl config set-context adeoye &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--cluster&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;kind-cka-cluster3 &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--user&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;adeoye
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A mistake I made:&lt;/strong&gt; I initially typed &lt;code&gt;lind-cka-cluster3&lt;/code&gt; instead of &lt;code&gt;kind-cka-cluster3&lt;/code&gt;, which caused API errors until I corrected the cluster name.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Finally, I switched to my new user.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl config use-context adeoye
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At this point I was authenticated, but I still didn't have permissions to perform actions.&lt;/p&gt;




&lt;h2&gt;
  
  
  RBAC Time!
&lt;/h2&gt;

&lt;p&gt;Authentication tells Kubernetes &lt;strong&gt;who I am&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;RBAC tells Kubernetes &lt;strong&gt;what I'm allowed to do&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating the Role
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;role.yaml&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Role&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pod-reader&lt;/span&gt;

&lt;span class="na"&gt;rules&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;apiGroups&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pods"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
  &lt;span class="na"&gt;verbs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;get&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;watch&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;list&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This Role only allows reading Pods.&lt;/p&gt;

&lt;p&gt;Apply it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; role.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Creating the RoleBinding
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;binding.yaml&lt;/code&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;RoleBinding&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;read-pods&lt;/span&gt;
  &lt;span class="na"&gt;namespace&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;default&lt;/span&gt;

&lt;span class="na"&gt;subjects&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;User&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;adeoye&lt;/span&gt;
  &lt;span class="na"&gt;apiGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io&lt;/span&gt;

&lt;span class="na"&gt;roleRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Role&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pod-reader&lt;/span&gt;
  &lt;span class="na"&gt;apiGroup&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;rbac.authorization.k8s.io&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This binds the &lt;strong&gt;adeoye&lt;/strong&gt; user to the &lt;strong&gt;pod-reader&lt;/strong&gt; Role.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; binding.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h3&gt;
  
  
  Testing the Permissions
&lt;/h3&gt;

&lt;p&gt;Verify permission:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl auth can-i get pods
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;List Pods:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get pods
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No resources found in default namespace.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That means the command succeeded, but there were simply no Pods.&lt;/p&gt;

&lt;p&gt;Next, I tried listing Deployments.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get deploy
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error from server (Forbidden): deployments.apps is forbidden
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why Could I Read Pods but Not Deployments?
&lt;/h2&gt;

&lt;p&gt;The answer is in the Role.&lt;/p&gt;

&lt;p&gt;It only grants access to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;pods&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does &lt;strong&gt;not&lt;/strong&gt; include Deployments.&lt;/p&gt;

&lt;p&gt;Deployments belong to the &lt;strong&gt;apps&lt;/strong&gt; API group, while Pods belong to the core API group.&lt;/p&gt;

&lt;p&gt;Since my RoleBinding connects &lt;strong&gt;adeoye&lt;/strong&gt; to the &lt;strong&gt;pod-reader&lt;/strong&gt; Role, Kubernetes only allows the permissions defined there.&lt;/p&gt;

&lt;p&gt;No Deployment permissions were granted, so Kubernetes correctly denied access.&lt;/p&gt;

&lt;p&gt;This is the Principle of Least Privilege in action.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Kubernetes first authenticates users using client certificates.&lt;/li&gt;
&lt;li&gt;The certificate's Common Name becomes the Kubernetes username.&lt;/li&gt;
&lt;li&gt;A Role defines permissions.&lt;/li&gt;
&lt;li&gt;A RoleBinding assigns those permissions to users.&lt;/li&gt;
&lt;li&gt;Authentication and authorization are separate processes.&lt;/li&gt;
&lt;li&gt;RBAC only grants explicitly defined permissions.&lt;/li&gt;
&lt;li&gt;Pods and Deployments are different resources, so access to one doesn't imply access to the other.&lt;/li&gt;
&lt;/ul&gt;




&lt;h3&gt;
  
  
  Final Thoughts
&lt;/h3&gt;

&lt;p&gt;Today's lab tied together authentication and authorization. Creating my own client certificate, configuring a new kubectl context, and then restricting that user with RBAC made the concepts much easier to understand.&lt;/p&gt;

&lt;p&gt;Security in Kubernetes isn't about giving users full access,but about giving them exactly the permissions they need, nothing more.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>cloud</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Day 20&amp;21/40 - Manage TLS Certificates In a Kubernetes Cluster</title>
      <dc:creator>Adeoye Malumi</dc:creator>
      <pubDate>Fri, 03 Jul 2026 11:57:11 +0000</pubDate>
      <link>https://dev.to/oyebobs/day-202140-manage-tls-certificates-in-a-kubernetes-cluster-27k0</link>
      <guid>https://dev.to/oyebobs/day-202140-manage-tls-certificates-in-a-kubernetes-cluster-27k0</guid>
      <description>&lt;p&gt;Today's challenge was about understanding how Kubernetes uses TLS certificates to secure communication between its components, and getting hands-on by onboarding a "new user" into the cluster using a Certificate Signing Request (CSR).&lt;/p&gt;

&lt;p&gt;If you've ever wondered how &lt;code&gt;kubectl&lt;/code&gt; actually proves to the API server that you're allowed to run commands, this is the exercise that answers it.&lt;/p&gt;

&lt;p&gt;A quick rule of thumb before diving in: &lt;strong&gt;if a filename or extension has the word "key" in it, it's a private key. Otherwise, it's a public certificate.&lt;/strong&gt; Keep that in mind as you read through the commands below.&lt;/p&gt;




&lt;h3&gt;
  
  
  Step 1: Generate a private key and a CSR
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl genrsa &lt;span class="nt"&gt;-out&lt;/span&gt; adeoye.key 2048
openssl req &lt;span class="nt"&gt;-new&lt;/span&gt; &lt;span class="nt"&gt;-key&lt;/span&gt; adeoye.key &lt;span class="nt"&gt;-out&lt;/span&gt; adeoye.csr &lt;span class="nt"&gt;-subj&lt;/span&gt; &lt;span class="s2"&gt;"/CN=adeoye"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;genrsa&lt;/code&gt; creates the private key, and &lt;code&gt;req -new&lt;/code&gt; uses that key to generate a Certificate Signing Request. The &lt;code&gt;-subj "/CN=adeoye"&lt;/code&gt; flag sets the "Common Name" on the certificate — this becomes the username Kubernetes will recognize once the cert is issued.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; I typed &lt;code&gt;adam.key&lt;/code&gt; in my &lt;code&gt;req&lt;/code&gt; command before actually creating a file with that name, and got:&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Could not open file or uri for loading private key from adam.key: No such file or directory
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Lesson learned — the key file has to exist &lt;em&gt;before&lt;/em&gt; you reference it when generating the CSR. Double-check your filenames match across commands.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 2: Base64-encode the CSR
&lt;/h3&gt;

&lt;p&gt;The CSR object in Kubernetes expects the request in base64 form inside a YAML manifest. This is where things got interesting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;adeoye.csr | &lt;span class="nb"&gt;base64 tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"/"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's wrong — it treats &lt;code&gt;tr&lt;/code&gt; as a filename argument for &lt;code&gt;base64&lt;/code&gt;, not a separate piped command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;base64&lt;/span&gt;: extra operand &lt;span class="s1"&gt;'/'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The correct version needs a pipe (&lt;code&gt;|&lt;/code&gt;) before &lt;code&gt;tr&lt;/code&gt;, and the actual newline character (&lt;code&gt;\n&lt;/code&gt;), not the literal string &lt;code&gt;"/n"&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;adeoye.csr | &lt;span class="nb"&gt;base64&lt;/span&gt; | &lt;span class="nb"&gt;tr&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This gives you a single-line base64 blob with no line breaks — exactly what the CSR YAML needs.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Create the CSR manifest and apply it
&lt;/h3&gt;

&lt;p&gt;Create a &lt;code&gt;csr.yaml&lt;/code&gt; file with the base64-encoded request embedded in it, following the structure from the &lt;a href="https://kubernetes.io/docs/reference/access-authn-authz/certificate-signing-requests/#create-certificatessigningrequest" rel="noopener noreferrer"&gt;Kubernetes docs&lt;/a&gt;, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; csr.yaml
kubectl get csr
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME     AGE   SIGNERNAME                            REQUESTOR          REQUESTEDDURATION   CONDITION
adeoye   25s   kubernetes.io/kube-apiserver-client   kubernetes-admin   24h                 Pending
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CSR shows up as &lt;code&gt;Pending&lt;/code&gt; — Kubernetes has received the request but it hasn't been approved yet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Approve the CSR
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl certificate approve adeoye
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;certificatesigningrequest.certificates.k8s.io/adeoye approved
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking again with &lt;code&gt;kubectl get csr&lt;/code&gt; now shows &lt;code&gt;Approved,Issued&lt;/code&gt; — the cluster has signed the certificate.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; &lt;code&gt;kubectl approve certificate adeoye&lt;/code&gt; doesn't work — the order of the words matters:&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: unknown command "approve" for "kubectl"
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;It's &lt;code&gt;kubectl certificate approve &amp;lt;name&amp;gt;&lt;/code&gt;, not &lt;code&gt;kubectl approve certificate &amp;lt;name&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Step 5: Retrieve and decode the issued certificate
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get csr adeoye &lt;span class="nt"&gt;-o&lt;/span&gt; yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; issuecert.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The issued certificate lives (base64-encoded) inside that YAML file under &lt;code&gt;status.certificate&lt;/code&gt;. To read the actual PEM certificate, decode it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;base64-blob&amp;gt;"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This prints out the human-readable certificate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-----BEGIN CERTIFICATE REQUEST-----
MIICVjCCAT4CAQAwETEPMA0GA1UEAww...
-----END CERTIFICATE REQUEST-----
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; Pasting a long base64 string directly into the terminal can trigger "bracketed paste mode," which shows up as weird escape codes and leaves your shell hanging on a &lt;code&gt;dquote&amp;gt;&lt;/code&gt; prompt. If that happens, press &lt;code&gt;Ctrl+C&lt;/code&gt; to cancel and try again — wrapping the string in quotes (&lt;code&gt;echo "..."&lt;/code&gt;) helps avoid it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;To save the decoded certificate directly to a file instead of just printing it, redirect the output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;base64-blob&amp;gt;"&lt;/span&gt; | &lt;span class="nb"&gt;base64&lt;/span&gt; &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; learner.crt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Gotcha:&lt;/strong&gt; Writing &lt;code&gt;-o learner.crt&lt;/code&gt; instead of &lt;code&gt;&amp;gt; learner.crt&lt;/code&gt; doesn't work here — &lt;code&gt;-o&lt;/code&gt; isn't a flag for &lt;code&gt;base64&lt;/code&gt;, and the shell will try to run it as its own command:&lt;/p&gt;


&lt;pre class="highlight plaintext"&gt;&lt;code&gt;zsh: command not found: -o
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;Use the &lt;code&gt;&amp;gt;&lt;/code&gt; redirect operator to send output to a file.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Repeating the process for a second user
&lt;/h3&gt;

&lt;p&gt;To make sure the workflow actually sank in, I repeated the whole thing for a second user, &lt;code&gt;learner&lt;/code&gt;, this time being more careful with the pipe syntax:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl genrsa &lt;span class="nt"&gt;-out&lt;/span&gt; learner.key 2048
openssl req &lt;span class="nt"&gt;-new&lt;/span&gt; &lt;span class="nt"&gt;-key&lt;/span&gt; learner.key &lt;span class="nt"&gt;-out&lt;/span&gt; learner.csr &lt;span class="nt"&gt;-subj&lt;/span&gt; &lt;span class="s2"&gt;"/CN=learner"&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; learner.yaml
kubectl certificate approve learner
kubectl get csr learner &lt;span class="nt"&gt;-o&lt;/span&gt; yaml &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; issuecert-learner.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME      AGE     SIGNERNAME                            REQUESTOR          REQUESTEDDURATION   CONDITION
learner   4m37s   kubernetes.io/kube-apiserver-client   kubernetes-admin   7d                  Approved,Issued
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the certificate was decoded and saved to &lt;code&gt;learner.crt&lt;/code&gt;, it would be ready to plug into a kubeconfig for that user, giving them client-cert-based access to the cluster.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key commands from today
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Generate a private key:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl genrsa &lt;span class="nt"&gt;-out&lt;/span&gt; adeoye.key 2048
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Generate a CSR:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl req &lt;span class="nt"&gt;-new&lt;/span&gt; &lt;span class="nt"&gt;-key&lt;/span&gt; adeoye.key &lt;span class="nt"&gt;-out&lt;/span&gt; adeoye.csr &lt;span class="nt"&gt;-subj&lt;/span&gt; &lt;span class="s2"&gt;"/CN=adeoye"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Approve a CSR:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl certificate approve &amp;lt;certificate-signing-request-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Deny a CSR:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl certificate deny &amp;lt;certificate-signing-request-name&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Reference:&lt;/strong&gt; &lt;a href="https://kubernetes.io/docs/reference/access-authn-authz/certificate-signing-requests/#create-certificatessigningrequest" rel="noopener noreferrer"&gt;Kubernetes docs — Create CertificateSigningRequest&lt;/a&gt;&lt;/p&gt;




&lt;h3&gt;
  
  
  What I took away from today
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A CSR workflow has four clean stages: generate a key → generate a CSR → submit it to the API server as a &lt;code&gt;CertificateSigningRequest&lt;/code&gt; object → get it approved and retrieve the signed cert.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;base64 | tr -d "\n"&lt;/code&gt; (with the pipe, and a real newline character) is the correct way to get a clean, single-line encoded string for a YAML manifest.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;kubectl certificate approve&lt;/code&gt; — command, then subcommand, then name — not the other way around.&lt;/li&gt;
&lt;li&gt;Almost all my mistakes today were syntax slips (missing pipes, wrong flags, mismatched filenames), not conceptual ones. That's usually a good sign you understand &lt;em&gt;what&lt;/em&gt; you're doing, even when the terminal disagrees with you for a minute.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>tls</category>
      <category>cka</category>
    </item>
    <item>
      <title>Day 19/40 - Kubernetes ConfigMaps and Secrets</title>
      <dc:creator>Adeoye Malumi</dc:creator>
      <pubDate>Thu, 02 Jul 2026 11:22:28 +0000</pubDate>
      <link>https://dev.to/oyebobs/day-1940-kubernetes-configmaps-and-secrets-59ef</link>
      <guid>https://dev.to/oyebobs/day-1940-kubernetes-configmaps-and-secrets-59ef</guid>
      <description>&lt;p&gt;Today's mission: stop hardcoding values into pod manifests and learn how to inject them properly using &lt;strong&gt;ConfigMaps&lt;/strong&gt; and &lt;strong&gt;Secrets&lt;/strong&gt;. I also managed to break a few things along the way, which honestly taught me more than if everything had just worked first try. Let's get into it.&lt;/p&gt;




&lt;h2&gt;
  
  
  What is a ConfigMap?
&lt;/h2&gt;

&lt;p&gt;As your manifest grows, it becomes harder to manage a pile of environment variables directly inside it. A ConfigMap lets you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Pull those key-value pairs &lt;strong&gt;out of the manifest&lt;/strong&gt; and store them as their own Kubernetes object&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inject&lt;/strong&gt; that ConfigMap into a pod as environment variables (or as files, but that's for another day)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reuse&lt;/strong&gt; the same ConfigMap across multiple pods, so you're not repeating yourself everywhere&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Basically, it's a clean way to separate configuration from your application definition.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating a ConfigMap
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create cm app-cm &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;firstname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;adeoye &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;lastname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;malumi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here, &lt;code&gt;firstname&lt;/code&gt; and &lt;code&gt;lastname&lt;/code&gt; are the keys, and &lt;code&gt;adeoye&lt;/code&gt; / &lt;code&gt;malumi&lt;/code&gt; are the values.&lt;/p&gt;

&lt;h3&gt;
  
  
  My first "gotcha" of the day
&lt;/h3&gt;

&lt;p&gt;I actually tried to split this command across two lines like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create cm app-cm &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;firstname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;adeoye &lt;span class="se"&gt;\&lt;/span&gt;
&lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;lastname&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;malumi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First attempt gave me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: exactly one NAME is required, got 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Turns out I had a trailing space &lt;em&gt;after&lt;/em&gt; the backslash on the first line. In bash, the &lt;code&gt;\&lt;/code&gt; has to be the very last character on the line for line continuation to work. A space after it breaks the continuation, and the shell just treats the next line as a separate (garbled) argument. When I finally got the syntax right, &lt;code&gt;kubectl describe cm app-cm&lt;/code&gt; showed the value for &lt;code&gt;firstname&lt;/code&gt; as &lt;code&gt;adeoye--from-literal=lastname=malumi&lt;/code&gt; — my second flag had literally been glued onto the first value as text!&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson learned:&lt;/strong&gt; either keep the command on one line, or make sure there's truly nothing (not even a space) after your &lt;code&gt;\&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Once fixed, &lt;code&gt;describe&lt;/code&gt; showed exactly what I wanted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="s"&gt;Data&lt;/span&gt;
&lt;span class="s"&gt;====&lt;/span&gt;
&lt;span class="na"&gt;firstname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="s"&gt;----&lt;/span&gt;
&lt;span class="s"&gt;adeoye&lt;/span&gt;

&lt;span class="na"&gt;lastname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="s"&gt;----&lt;/span&gt;
&lt;span class="s"&gt;malumi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Injecting a ConfigMap into a Pod
&lt;/h2&gt;

&lt;p&gt;Here's the pod manifest I used to consume the ConfigMap as environment variables:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Pod&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp-pod&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;app.kubernetes.io/name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;MyApp&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;myapp-container&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;busybox:1.28&lt;/span&gt;
    &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;FIRSTNAME&lt;/span&gt;
      &lt;span class="na"&gt;valueFrom&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;configMapKeyRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;app-cm&lt;/span&gt;
          &lt;span class="na"&gt;key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;firstname&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;sh'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;-c'&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;echo&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;The&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;app&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;is&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;running!&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;&amp;amp;&amp;amp;&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;sleep&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;3600'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;valueFrom.configMapKeyRef&lt;/code&gt; block tells Kubernetes: "don't hardcode this value — go fetch it from the &lt;code&gt;app-cm&lt;/code&gt; ConfigMap, using the &lt;code&gt;firstname&lt;/code&gt; key."&lt;/p&gt;




&lt;h2&gt;
  
  
  Gotcha #2: Pods are mostly immutable
&lt;/h2&gt;

&lt;p&gt;After creating my ConfigMap with both &lt;code&gt;firstname&lt;/code&gt; and &lt;code&gt;lastname&lt;/code&gt;, I went back and added a &lt;code&gt;LASTNAME&lt;/code&gt; env var to my already-running pod's manifest, then ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; pod.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And got slapped with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The Pod "myapp-pod" is invalid: spec: Forbidden: pod updates may not change fields other than 
`spec.containers[*].image`,`spec.initContainers[*].image`,`spec.activeDeadlineSeconds`,
`spec.tolerations` (only additions to existing tolerations),
`spec.terminationGracePeriodSeconds` (allow it to be set to 1 if it was previously negative)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This was a great reminder: &lt;strong&gt;a bare Pod's spec is mostly frozen once it's created.&lt;/strong&gt; Kubernetes only lets you patch a small allowlist of fields in-place — image, a couple of grace period settings, and tolerations. Environment variables are &lt;em&gt;not&lt;/em&gt; on that list.&lt;/p&gt;

&lt;p&gt;Two ways around this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1 — Delete and recreate:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl delete pod myapp-pod
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; pod.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Option 2 — Force replace in one shot:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl replace &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; pod.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I went with &lt;code&gt;replace --force&lt;/code&gt;, and it worked perfectly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;❯ kubectl replace &lt;span class="nt"&gt;--force&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; pod.yaml
pod &lt;span class="s2"&gt;"myapp-pod"&lt;/span&gt; deleted from default namespace
pod/myapp-pod replaced
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verified inside the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;❯ kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-it&lt;/span&gt; myapp-pod &lt;span class="nt"&gt;--&lt;/span&gt; sh
/ &lt;span class="c"&gt;# echo $FIRSTNAME&lt;/span&gt;
adeoye
/ &lt;span class="c"&gt;# echo $LASTNAME&lt;/span&gt;
malumi
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Side note:&lt;/strong&gt; this is actually one of the reasons Deployments exist and are preferred over bare Pods in real-world use. A Deployment lets you update the pod template (image, env vars, etc.) and &lt;code&gt;kubectl apply&lt;/code&gt; will handle the rolling replacement of pods for you — no manual delete/recreate dance required.&lt;/p&gt;




&lt;h2&gt;
  
  
  Secrets: same idea, but for sensitive data
&lt;/h2&gt;

&lt;p&gt;Secrets work almost identically to ConfigMaps, except they're meant for sensitive values like passwords, tokens, or usernames you don't want sitting in plain text in your manifests.&lt;/p&gt;

&lt;p&gt;I followed the official Kubernetes docs on &lt;a href="https://kubernetes.io/docs/tasks/inject-data-application/distribute-credentials-secure/#define-container-environment-variables-using-secret-data" rel="noopener noreferrer"&gt;defining container environment variables using Secret data&lt;/a&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Creating a Secret
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create secret generic backend-user &lt;span class="nt"&gt;--from-literal&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;backend-username&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'adeoye-admin'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Using the sample pod from the docs
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl create &lt;span class="nt"&gt;-f&lt;/span&gt; https://k8s.io/examples/pods/inject/pod-single-secret-env-variable.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pod injects the secret as an environment variable called &lt;code&gt;SECRET_USERNAME&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  One more small hiccup
&lt;/h3&gt;

&lt;p&gt;Right after creating the pod, I tried to exec into it immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;❯ kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; env-single-secret &lt;span class="nt"&gt;--&lt;/span&gt; /bin/sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo $SECRET_USERNAME'&lt;/span&gt;
error: Internal error occurred: unable to upgrade connection: container not found &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"envars-test-container"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Checking &lt;code&gt;kubectl get po&lt;/code&gt; showed the pod was still &lt;code&gt;ContainerCreating&lt;/code&gt;. Classic case of being too impatient — I just needed to wait a few seconds for the container to actually spin up. Once it hit &lt;code&gt;Running&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;❯ kubectl &lt;span class="nb"&gt;exec&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; env-single-secret &lt;span class="nt"&gt;--&lt;/span&gt; /bin/sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'echo $SECRET_USERNAME'&lt;/span&gt;
adeoye-admin
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And there it is — the secret successfully injected as an env var.&lt;/p&gt;




&lt;h2&gt;
  
  
  Key Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;ConfigMaps&lt;/strong&gt; decouple non-sensitive configuration from your pod manifests, and can be reused across multiple pods.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets&lt;/strong&gt; work the same way but are the right home for sensitive values.&lt;/li&gt;
&lt;li&gt;Both can be injected into a pod's containers via &lt;code&gt;env.valueFrom.configMapKeyRef&lt;/code&gt; or &lt;code&gt;secretKeyRef&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Watch your shell syntax — a stray space after a line-continuation &lt;code&gt;\&lt;/code&gt; will silently mangle your command.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pods are mostly immutable&lt;/strong&gt; once created. Only image, tolerations, and a couple of grace-period fields can be patched live. Everything else means delete + recreate, or &lt;code&gt;kubectl replace --force&lt;/code&gt;. This is a big part of why Deployments exist.&lt;/li&gt;
&lt;li&gt;Give newly created pods a moment to leave &lt;code&gt;ContainerCreating&lt;/code&gt; before you &lt;code&gt;exec&lt;/code&gt; into them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's Day 19 done — small errors, but each one taught me something I'll remember longer than if it had just worked on the first try.&lt;/p&gt;




</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>40daysofkubernetes</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Day 17/40 — Kubernetes Autoscaling: HPA vs VPA Explained With Hands-On Practice</title>
      <dc:creator>Adeoye Malumi</dc:creator>
      <pubDate>Wed, 01 Jul 2026 12:09:21 +0000</pubDate>
      <link>https://dev.to/oyebobs/day-1740-kubernetes-autoscaling-hpa-vs-vpa-explained-with-hands-on-practice-2in1</link>
      <guid>https://dev.to/oyebobs/day-1740-kubernetes-autoscaling-hpa-vs-vpa-explained-with-hands-on-practice-2in1</guid>
      <description>&lt;p&gt;If you've ever wondered how Kubernetes knows when to spin up more pods or give a pod more memory, that's autoscaling — and it's one of those concepts that sounds intimidating until you actually do it yourself. Day 17 of the &lt;strong&gt;#40DaysOfKubernetes&lt;/strong&gt; challenge is where it clicked for me.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is Autoscaling in Kubernetes?
&lt;/h2&gt;

&lt;p&gt;At its core, autoscaling means Kubernetes adjusts resources automatically based on demand. You don't manually intervene every time traffic spikes. There are two main types:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;HPA (Horizontal Pod Autoscaler)&lt;/strong&gt; — adds or removes &lt;em&gt;pods&lt;/em&gt; based on CPU/memory usage&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VPA (Vertical Pod Autoscaler)&lt;/strong&gt; — adjusts the &lt;em&gt;resources&lt;/em&gt; (CPU/memory) of existing pods&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Think of HPA as hiring more staff when the shop gets busy. VPA is more like giving one staff member more tools to handle the workload alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Did — Setting Up HPA
&lt;/h2&gt;

&lt;p&gt;First I deployed the sample &lt;code&gt;php-apache&lt;/code&gt; app with defined CPU requests and limits:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php-apache&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;matchLabels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php-apache&lt;/span&gt;
  &lt;span class="na"&gt;template&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php-apache&lt;/span&gt;
    &lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;containers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php-apache&lt;/span&gt;
        &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;registry.k8s.io/hpa-example&lt;/span&gt;
        &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;containerPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
        &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;500m&lt;/span&gt;
          &lt;span class="na"&gt;requests&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
            &lt;span class="na"&gt;cpu&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;200m&lt;/span&gt;

&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;v1&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Service&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php-apache&lt;/span&gt;
  &lt;span class="na"&gt;labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php-apache&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;port&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
  &lt;span class="na"&gt;selector&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php-apache&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffcryk4ykh0pg2e6g4vuw.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffcryk4ykh0pg2e6g4vuw.png" alt="Applying the yaml file" width="593" height="54"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3n9jt6ugn5ifu1s3jak3.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3n9jt6ugn5ifu1s3jak3.png" alt="The pod is up and running" width="669" height="79"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The key part is setting &lt;code&gt;resources.requests.cpu&lt;/code&gt; — HPA needs this to calculate utilization. Without it, the autoscaler has nothing to measure against.&lt;/p&gt;

&lt;p&gt;Then I created the HPA object targeting &lt;strong&gt;50% average CPU utilization&lt;/strong&gt;, with a minimum of 1 pod and maximum of 10. This is the declarative method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;autoscaling/v2&lt;/span&gt;
&lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;HorizontalPodAutoscaler&lt;/span&gt;
&lt;span class="na"&gt;metadata&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php-apache&lt;/span&gt;
&lt;span class="na"&gt;spec&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scaleTargetRef&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;apiVersion&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;apps/v1&lt;/span&gt;
    &lt;span class="na"&gt;kind&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Deployment&lt;/span&gt;
    &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;php-apache&lt;/span&gt;
  &lt;span class="na"&gt;minReplicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt;
  &lt;span class="na"&gt;maxReplicas&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;10&lt;/span&gt;
  &lt;span class="na"&gt;metrics&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Resource&lt;/span&gt;
    &lt;span class="na"&gt;resource&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;cpu&lt;/span&gt;
      &lt;span class="na"&gt;target&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;type&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Utilization&lt;/span&gt;
        &lt;span class="na"&gt;averageUtilization&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;50&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;While this is the imperative method:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl autoscale deployment php-apache &lt;span class="nt"&gt;--cpu-percent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;50 &lt;span class="nt"&gt;--min&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;--max&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcp8kogxyhe1c8dt87oul.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fcp8kogxyhe1c8dt87oul.png" alt="autoscale applied" width="800" height="41"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftmj616n2d70tpempd998.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ftmj616n2d70tpempd998.png" alt="Autoscale complete" width="768" height="50"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Generating Load to Watch It Scale
&lt;/h2&gt;

&lt;p&gt;This is the fun part. I ran a load generator in a separate pod — basically a loop hammering the apache service with requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl run &lt;span class="nt"&gt;-i&lt;/span&gt; &lt;span class="nt"&gt;--tty&lt;/span&gt; load-generator &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;busybox:1.28 &lt;span class="nt"&gt;--restart&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Never &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  /bin/sh &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"while sleep 0.01; do wget -q -O- http://php-apache; done"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then watched the HPA respond in real time:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl get hpa php-apache &lt;span class="nt"&gt;--watch&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fubjwvgn5z5ggr2zrm573.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fubjwvgn5z5ggr2zrm573.png" alt=" " width="800" height="133"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Watching the replica count climb from 1 to several pods as CPU utilization crossed 50% made the whole concept land in a way that reading documentation never does.&lt;/p&gt;

&lt;h2&gt;
  
  
  HPA vs VPA — When Do You Use Which?
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;HPA&lt;/th&gt;
&lt;th&gt;VPA&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Scales&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Number of pods&lt;/td&gt;
&lt;td&gt;Pod resource limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Best for&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Stateless apps with variable traffic&lt;/td&gt;
&lt;td&gt;Apps where sizing is hard to predict upfront&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Works with&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;CPU, memory, custom metrics&lt;/td&gt;
&lt;td&gt;CPU and memory&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;In practice, most production workloads use HPA. VPA is useful during early deployment when you're still figuring out the right resource requests for an app.&lt;/p&gt;

&lt;h2&gt;
  
  
  Key Takeaway
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;Don't skip setting &lt;code&gt;resources.requests&lt;/code&gt; in your deployment spec. HPA is blind without it. That one line is what connects your workload to the autoscaler.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>cka</category>
    </item>
  </channel>
</rss>
