<?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>Forem: Noah Makau</title>
    <description>The latest articles on Forem by Noah Makau (@nkmakau).</description>
    <link>https://forem.com/nkmakau</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1916376%2F3d3874b2-ff16-4966-bcea-1cffb5a9b1aa.jpeg</url>
      <title>Forem: Noah Makau</title>
      <link>https://forem.com/nkmakau</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://forem.com/feed/nkmakau"/>
    <language>en</language>
    <item>
      <title>The Day 2 Reality of Running a Kubernetes Lab on Your Mac: Stop/Start, CKS Scenarios, and What I Learned Building It.</title>
      <dc:creator>Noah Makau</dc:creator>
      <pubDate>Fri, 22 May 2026 14:42:11 +0000</pubDate>
      <link>https://forem.com/nkmakau/the-day-2-reality-of-running-a-kubernetes-lab-on-your-mac-stopstart-cks-scenarios-and-what-i-1pdi</link>
      <guid>https://forem.com/nkmakau/the-day-2-reality-of-running-a-kubernetes-lab-on-your-mac-stopstart-cks-scenarios-and-what-i-1pdi</guid>
      <description>&lt;p&gt;Part 7 of 7 — The Mac Kubernetes Lab: A Production-Mirror Setup from Scratch.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Previously in Part 6:&lt;/strong&gt; We wired up Vault Kubernetes auth, installed Crossplane with the AWS provider, and applied LimitRanges mirroring the production configuration that resulted from a real disk-pressure incident. The EKS mirror is complete. Now we focus on Day 2 — actually using the thing sustainably.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;There’s a question I had when I first put this lab together that nobody had answered for me cleanly: Will my cluster survive a stop?&lt;/p&gt;

&lt;p&gt;You’re going to want to stop it. The whole point of a local lab is that it gets out of your way when you’re not using it. A four-VM cluster running 24/7 is fine on paper, but you’ve also got Slack, six browser tabs, and an IDE eating memory. Knowing exactly what survives a stop, and what doesn't, and what manual steps you need on the way back up is the difference between "lab I use every day" and "lab I built once and abandoned."&lt;/p&gt;

&lt;h2&gt;
  
  
  This final article is the runbook I wish I had at the start. Stop/start without losing state, CKS exam scenarios this cluster is purposely built for, and the shell setup that makes the whole thing pleasant to live with.
&lt;/h2&gt;

&lt;h2&gt;
  
  
   The full setup, recapped:
&lt;/h2&gt;

&lt;p&gt;Seven articles in, here’s what’s running:&lt;br&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.amazonaws.com%2Fuploads%2Farticles%2Fzy4r1157pr52d4auilbn.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fzy4r1157pr52d4auilbn.webp" alt="Complete dual-cluster setup — native K8s daily driver on the left, VM lab cluster on the right." width="800" height="676"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Cluster 1 — &lt;code&gt;kubectx orbstack&lt;/code&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;OrbStack-native K8s, single-node&lt;/li&gt;
&lt;li&gt;Istio with *.k8s.orb.local wildcard DNS&lt;/li&gt;
&lt;li&gt;Vault in dev mode&lt;/li&gt;
&lt;li&gt;Crossplane&lt;/li&gt;
&lt;li&gt;Always-on, idles at around 512 MB&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Cluster 2 — &lt;code&gt;kubectx lab-cluster&lt;/code&gt;
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;kubeadm Kubernetes 1.34, 3 nodes&lt;/li&gt;
&lt;li&gt;Vault PKI (3-tier hierarchy, exported intermediate CA)&lt;/li&gt;
&lt;li&gt;Istio 1.26 revision-based, MetalLB for LoadBalancer&lt;/li&gt;
&lt;li&gt;Crossplane with the AWS provider&lt;/li&gt;
&lt;li&gt;Vault Kubernetes auth&lt;/li&gt;
&lt;li&gt;Run on-demand&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  Stop/start without losing state
&lt;/h2&gt;

&lt;p&gt;The biggest question I had when I first set this up was the one I led with — will the cluster survive a stop? The answer is yes, with one exception.&lt;/p&gt;
&lt;h3&gt;
  
  
   Stopping
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 💻 Mac&lt;/span&gt;
orb stop &lt;span class="nt"&gt;-a&lt;/span&gt;      &lt;span class="c"&gt;# stop all VMs&lt;/span&gt;
orb stop k8s     &lt;span class="c"&gt;# stop the native cluster (optional — fine to leave running)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h4&gt;
  
  
  What persists:
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;All Kubernetes objects — deployments, services, configmaps, secrets, PVCs&lt;/li&gt;
&lt;li&gt;etcd data on cp01 (stored on the VM’s disk)&lt;/li&gt;
&lt;li&gt;Vault data at /opt/vault/data (file backend, persists on disk)&lt;/li&gt;
&lt;li&gt;Calico/Cilium, Istio, Crossplane, MetalLB configurations&lt;/li&gt;
&lt;li&gt;The Mac kubeconfig&lt;/li&gt;
&lt;/ul&gt;
&lt;h4&gt;
  
  
  What is released:
&lt;/h4&gt;

&lt;ul&gt;
&lt;li&gt;All CPU — drops to zero immediately&lt;/li&gt;
&lt;li&gt;All RAM — fully returned to macOS&lt;/li&gt;
&lt;li&gt;~8 GB of disk remains used.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3&gt;
  
  
  Starting back up
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 💻 Mac&lt;/span&gt;
orb start &lt;span class="nt"&gt;-a&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Then follow these steps in order:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1 — Unseal Vault.&lt;/strong&gt; This is the one manual step that can’t be automated away:&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;# 🖥️ VM: vault&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;VAULT_ADDR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'http://127.0.0.1:8200'&lt;/span&gt;
vault operator unseal &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'Unseal Key 1'&lt;/span&gt; ~/vault-init.txt | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $NF}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
vault status
&lt;span class="c"&gt;# Sealed: false ← what you want&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Vault seals itself on every shutdown by design. That’s a security feature; an unsealed Vault that survives reboots is, by definition, less secure. In production, you’d use auto-unseal with AWS KMS or Azure Key Vault. For the lab, one manual unseal command is fine.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2 — Verify the cluster:&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;# 💻 Mac&lt;/span&gt;
kubectx lab-cluster
kubectl get nodes   &lt;span class="c"&gt;# Ready within ~30 seconds&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="c"&gt;# everything restarts automatically &lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 3 — Re-export session variables:&lt;/strong&gt; This is the friction I underestimated when I started. Environment variables don’t persist across SSH sessions anything you exported in a previous session is gone:&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;# 🖥️ VM: vault (when doing PKI or auth work)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;VAULT_ADDR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'http://127.0.0.1:8200'&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;VAULT_ROOT_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'Initial Root Token'&lt;/span&gt; ~/vault-init.txt | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $NF}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 🖥️ VM: cp01 (when doing kubeadm or cert work)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CP_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt; &lt;span class="nt"&gt;-I&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 💻 Mac (when doing Istio or MetalLB work)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;VAULT_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;orb run &lt;span class="nt"&gt;-m&lt;/span&gt; vault &lt;span class="nb"&gt;hostname&lt;/span&gt; &lt;span class="nt"&gt;-I&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;INGRESS_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl get svc istio-ingress &lt;span class="nt"&gt;-n&lt;/span&gt; istio-system &lt;span class="se"&gt;\&lt;/span&gt;
  &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.loadBalancer.ingress[0].ip}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Step 4 — Regenerate /tmp files if needed&lt;/strong&gt;. /tmp on OrbStack VMs clears on reboot:&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;# 🖥️ VM: vault&lt;/span&gt;
vault &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;certificate pki_k8s/issuer/default &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/lab-ca.crt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Persistence quick reference
&lt;/h3&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.amazonaws.com%2Fuploads%2Farticles%2Foe8ilfnpk4pc0a5k7ygo.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Foe8ilfnpk4pc0a5k7ygo.webp" alt="Persistence quick reference" width="800" height="626"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Shell setup that makes it pleasant
&lt;/h2&gt;

&lt;p&gt;Put these in your &lt;code&gt;~/.zshrc&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="c"&gt;# 💻 Mac — add to ~/.zshrc&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;klab&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kubectx lab-cluster"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;korb&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kubectx orbstack"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;kns&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kubens"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;k&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kubectl"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;kgp&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kubectl get pods -A"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;kgn&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kubectl get nodes -o wide"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;orbup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"orb start -a"&lt;/span&gt;
&lt;span class="nb"&gt;alias &lt;/span&gt;&lt;span class="nv"&gt;orbdown&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"orb stop -a"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Daily flow becomes short:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;orbup               &lt;span class="c"&gt;# start everything&lt;/span&gt;
ssh vault@orb       &lt;span class="c"&gt;# unseal Vault&lt;/span&gt;
vault operator unseal &lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'Unseal Key 1'&lt;/span&gt; ~/vault-init.txt | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $NF}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;# exit vault VM&lt;/span&gt;
klab                &lt;span class="c"&gt;# switch to lab cluster&lt;/span&gt;
kgn                 &lt;span class="c"&gt;# verify nodes&lt;/span&gt;
kgp                 &lt;span class="c"&gt;# verify pods&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  CKS exam preparation:
&lt;/h2&gt;

&lt;p&gt;The VM lab cluster is purpose-built for CKS. Real kubeadm cluster, real etcd, real kubelet config files. The exam gives you a similar environment, and having practiced the same scenarios on a cluster where you control every layer makes a meaningful difference.&lt;/p&gt;

&lt;h3&gt;
  
  
  The scenarios I practice most:
&lt;/h3&gt;

&lt;h4&gt;
  
  
  Pod Security Admission:
&lt;/h4&gt;

&lt;p&gt;PSA replaced PodSecurityPolicy in Kubernetes 1.25. The CKS exam tests your ability to enforce pod security standards at the namespace level.&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;# 💻 Mac&lt;/span&gt;
kubectl create namespace restricted-ns

&lt;span class="c"&gt;# Enforce the restricted profile - blocks privilege escalation, host networking, etc.&lt;/span&gt;
kubectl label namespace restricted-ns &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/enforce&lt;span class="o"&gt;=&lt;/span&gt;restricted &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/audit&lt;span class="o"&gt;=&lt;/span&gt;restricted &lt;span class="se"&gt;\&lt;/span&gt;
  pod-security.kubernetes.io/warn&lt;span class="o"&gt;=&lt;/span&gt;restricted

&lt;span class="c"&gt;# Test - this pod should be blocked&lt;/span&gt;
kubectl run &lt;span class="nb"&gt;test&lt;/span&gt; &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;nginx &lt;span class="nt"&gt;-n&lt;/span&gt; restricted-ns
&lt;span class="c"&gt;# Error from server (Forbidden): pods "test" is forbidden: violates PodSecurity&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  RBAC
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 💻 Mac&lt;/span&gt;
 &lt;span class="c"&gt;# Create a role that can only read pods&lt;/span&gt;
kubectl create role pod-reader &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--verb&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;get,list,watch &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--resource&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pods &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; restricted-ns

&lt;span class="c"&gt;# Bind it to a service account&lt;/span&gt;
kubectl create rolebinding pod-reader-binding &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--role&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pod-reader &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--serviceaccount&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;restricted-ns:default &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; restricted-ns

&lt;span class="c"&gt;# Test with impersonation&lt;/span&gt;
kubectl auth can-i list pods &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--as&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;system:serviceaccount:restricted-ns:default &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; restricted-ns
&lt;span class="c"&gt;# yes&lt;/span&gt;

kubectl auth can-i delete pods &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--as&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;system:serviceaccount:restricted-ns:default &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-n&lt;/span&gt; restricted-ns
&lt;span class="c"&gt;# no&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Audit policy:
&lt;/h4&gt;

&lt;p&gt;The exam often asks you to configure an audit policy on the control plane. This requires editing the kube-apiserver static pod manifest directly:&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;# 🖥️ VM: cp01&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/kubernetes/audit-policy.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;
apiVersion: audit.k8s.io/v1
kind: Policy
rules:
- level: RequestResponse
  resources:
  - group: ""
    resources: ["secrets"]
- level: Metadata
  resources:
  - group: ""
    resources: ["pods"]
- level: None
  users: ["system:kube-proxy"]
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add these flags to &lt;code&gt;/etc/kubernetes/manifests/kube-apiserver.yaml&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="nt"&gt;--audit-policy-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/etc/kubernetes/audit-policy.yaml
- &lt;span class="nt"&gt;--audit-log-path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;/var/log/kubernetes/audit.log
- &lt;span class="nt"&gt;--audit-log-maxage&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;30
- &lt;span class="nt"&gt;--audit-log-maxbackup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kubelet restarts kube-apiserver automatically when the manifest changes.&lt;/p&gt;

&lt;h4&gt;
  
  
  NetworkPolicy: default-deny
&lt;/h4&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 💻 Mac&lt;/span&gt;
&lt;span class="c"&gt;# Deny all ingress and egress by default&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; - &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;
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
  namespace: default
spec:
  podSelector: {}
  policyTypes:
  - Ingress
  - Egress
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="c"&gt;# Then selectively allow what's needed&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; - &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;
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-dns
  namespace: default
spec:
  podSelector: {}
  policyTypes:
  - Egress
  egress:
  - ports:
    - port: 53
      protocol: UDP
    - port: 53
      protocol: TCP
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h4&gt;
  
  
  Short-lived admin certificates from Vault PKI
&lt;/h4&gt;

&lt;p&gt;A CKS best practice — use short-lived credentials instead of long-lived kubeconfig files:&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;# 🖥️ VM: vault&lt;/span&gt;
&lt;span class="c"&gt;# Issue a 1-hour admin certificate&lt;/span&gt;
vault write pki_k8s/issue/admin &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;common_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kubernetes-admin"&lt;/span&gt; &lt;span class="nv"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1h
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The output gives you a certificate and private key. Build a kubeconfig from them that expires in one hour. Instead of a kubeconfig with a long-lived client cert, you issue a fresh cert each session. When it expires, access is revoked automatically. In production, this is enforced at the Vault role level (&lt;code&gt;max_ttl=2h&lt;/code&gt;) you physically cannot issue a cert with a longer TTL.&lt;/p&gt;

&lt;h4&gt;
  
  
  Trivy image scanning
&lt;/h4&gt;

&lt;p&gt;CKS includes container image security. Trivy is the tool used on the exam:&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;# 🖥️ VM: cp01 — install trivy&lt;/span&gt;
curl &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | &lt;span class="nb"&gt;sudo &lt;/span&gt;sh &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; /usr/local/bin

&lt;span class="c"&gt;# Scan an image&lt;/span&gt;
trivy image nginx:latest

&lt;span class="c"&gt;# Scan for HIGH and CRITICAL only&lt;/span&gt;
trivy image &lt;span class="nt"&gt;--severity&lt;/span&gt; HIGH,CRITICAL nginx:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
   Five things I learned building this
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;OrbStack is genuinely better than Multipass, not just faster.&lt;/strong&gt; The native DNS, instant boot, and real LoadBalancer IPs remove an entire category of friction I had normalised. I didn’t realise how much time I was spending on /etc/hosts edits until I stopped having to do them.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;T*&lt;em&gt;he M1 vs M4 CNI difference is a kernel capability issue, not an OrbStack bug.&lt;/em&gt;* Once I understood that iptables NAT is restricted in unprivileged LXC containers on M1, Cilium was the obvious fix. Knowing this also makes it easier to debug similar issues in other restricted container environments; CI systems, Docker-in-Docker, anywhere Kubernetes is running inside something it doesn’t quite own.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;**Vault PKI is worth the setup cost. **You could let kubeadm generate self-signed certs and skip a whole chapter. But having a lab that uses the same certificate hierarchy as production means the mental model transfers directly. Short-lived admin certs stop being a theoretical best practice and start being how you actually work.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Session variables are the biggest day-to-day friction.&lt;/strong&gt; Anything that doesn’t persist to .bashrc gets lost between sessions. I've been burned by an empty $CP_IP, causing a "not a valid IP address" error in the kubeadm config more times than I'd like to admit. Persist in what you can.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Document as you go.&lt;/strong&gt; This whole series came out of runbook notes I was writing for myself while I built the lab. Writing each step down caught several places where the process was more manual than it needed to be, and meant I could replicate the setup on another machine in a day instead of a weekend. If you’ve built something complicated, writing it up is one of the higher-leverage things you can do, even if you never publish it.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
   The full series
&lt;/h2&gt;

&lt;p&gt;Part 1: &lt;a href="https://dev.to/nkmakau/why-i-replaced-multipass-with-orbstack-and-built-a-better-kubernetes-lab-on-my-mac-50p"&gt;Why I Replaced Multipass with OrbStack and what an M1 vs M4 Mac taught me about local Kubernetes.&lt;/a&gt;&lt;br&gt;
Part 2: &lt;a href="https://dev.to/nkmakau/one-command-one-working-kubernetes-cluster-building-my-daily-driver-lab-on-orbstack-34k5"&gt;One Command, One Working Kubernetes Cluster! Building My Daily-Driver Lab on OrbStack.&lt;/a&gt;&lt;br&gt;
Part 3: &lt;a href="https://dev.to/nkmakau/building-a-production-grade-vault-pki-for-a-local-kubeadm-cluster-without-the-shortcuts-km7"&gt;Building a Production-Grade Vault PKI for a Local kubeadm Cluster Without the Shortcuts.&lt;/a&gt;&lt;br&gt;
Part 4: &lt;a href="https://dev.to/nkmakau/same-cluster-different-mac-a-debugging-story-about-unprivileged-lxc-containers-iptables-and-why-2e8g"&gt;Same Cluster, Different Mac: A Debugging Story About Unprivileged LXC Containers, iptables, and Why Cilium Replaces kube-proxy.&lt;/a&gt;&lt;br&gt;
Part 5: &lt;a href="https://dev.to/nkmakau/how-i-practise-istio-upgrades-locally-before-touching-production-eks-2e6o"&gt;How I Practise Istio Upgrades Locally Before Touching Production EKS.&lt;/a&gt;&lt;br&gt;
Part 6: T&lt;a href="https://dev.to/nkmakau/the-disk-pressure-incident-that-taught-me-to-always-set-limitranges-and-other-lessons-from-18b8"&gt;he Disk-Pressure Incident That Taught Me to Always Set LimitRanges and Other Lessons from Mirroring EKS Locally.&lt;/a&gt;&lt;br&gt;
Part 7 (this article): The Day 2 Reality of Running a Kubernetes Lab on Your Mac: Stop/Start, CKS Scenarios, and What I Learned Building It.&lt;/p&gt;




&lt;h2&gt;
  
  
   Resources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.orbstack.dev/" rel="noopener noreferrer"&gt;OrbStack documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://orbstack.dev/pricing" rel="noopener noreferrer"&gt;OrbStack pricing&lt;/a&gt; — free for personal use, paid for commercial/team use&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.cilium.io/en/stable/gettingstarted/k8s-install-default/" rel="noopener noreferrer"&gt;Cilium installation guide&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.hashicorp.com/vault/docs/auth/kubernetes" rel="noopener noreferrer"&gt;Vault Kubernetes auth documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.crossplane.io/" rel="noopener noreferrer"&gt;Crossplane documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://istio.io/latest/docs/setup/upgrade/canary/" rel="noopener noreferrer"&gt;Istio revision-based upgrades&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/cncf/curriculum" rel="noopener noreferrer"&gt;CKS curriculum&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  ← Part 6: &lt;em&gt;&lt;a href="https://dev.to/nkmakau/the-disk-pressure-incident-that-taught-me-to-always-set-limitranges-and-other-lessons-from-18b8"&gt;The Disk-Pressure Incident That Taught Me to Always Set LimitRanges and Other Lessons from Mirroring EKS Locally.&lt;/a&gt;&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;I’m Noah Makau, a DevSecOps engineer based in Nairobi. I run a small DevOps consultancy and hold CKA, CKAD, and the AWS Solutions Architect Professional certifications, currently preparing for CKS. I write about Kubernetes, Vault, Crossplane, and the day-to-day of running platforms that actually have to stay up.&lt;/p&gt;

&lt;p&gt;Originally published at &lt;a href="https://blog.arkilasystems.com/the-day-2-reality-of-running-a-kubernetes-lab-on-your-mac-stop-start-cks-scenarios-and-what-i-learned-building-it" rel="noopener noreferrer"&gt;blog.arkilasystems.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>tutorial</category>
      <category>cks</category>
      <category>platformengineering</category>
    </item>
    <item>
      <title>The Disk-Pressure Incident That Taught Me to Always Set LimitRanges and Other Lessons from Mirroring EKS Locally.</title>
      <dc:creator>Noah Makau</dc:creator>
      <pubDate>Fri, 22 May 2026 13:26:56 +0000</pubDate>
      <link>https://forem.com/nkmakau/the-disk-pressure-incident-that-taught-me-to-always-set-limitranges-and-other-lessons-from-18b8</link>
      <guid>https://forem.com/nkmakau/the-disk-pressure-incident-that-taught-me-to-always-set-limitranges-and-other-lessons-from-18b8</guid>
      <description>&lt;p&gt;Part 6 of 7 — The Mac Kubernetes Lab: A Production-Mirror Setup from Scratch.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Previously in Part 5: We installed Istio with revision-based upgrades, MetalLB for LoadBalancer IPs, and practised traffic management with Gateways, VirtualServices, and fault injection. The cluster behaves. Now we wire up the last three pieces that turn it from “a working local cluster” into “a real mirror of our production EKS.”&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;The cluster works. Istio is running. MetalLB is handing out IPs. But it’s still missing three layers that make the production parity actually meaningful:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Vault Kubernetes auth&lt;/strong&gt; — pods authenticate to Vault with their service account tokens, the same way they do in production. No hardcoded secrets, no static credentials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crossplane&lt;/strong&gt; with the AWS provider — infrastructure compositions you can develop and test locally before they touch real AWS resources, or any other thought of OpenStack?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LimitRanges&lt;/strong&gt; — default resource requests on every namespace. This one comes from a real incident I want to talk about.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The LimitRange story is the most important of the three, so I’ll tell it properly when we get there. First, the auth layer.&lt;/p&gt;




&lt;h2&gt;
  
  
  Vault Kubernetes auth.
&lt;/h2&gt;

&lt;p&gt;Vault’s Kubernetes auth method lets pods authenticate by presenting their service account JWT. Vault validates the token against the Kubernetes API server and exchanges it for a Vault token with the appropriate policies attached.&lt;/p&gt;

&lt;p&gt;On the production EKS clusters at work, this is how microservices retrieve database credentials, API keys, and TLS certificates: no hard-coded secrets, no secret sprawl, every issuance audit-logged in Vault.&lt;/p&gt;

&lt;p&gt;Setting it up locally means I can test the full injection workflow without a VPN, and debug failures on a cluster where the stakes are zero.&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.amazonaws.com%2Fuploads%2Farticles%2F9hbm44x4jkdy2gmyscj0.webp" 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.amazonaws.com%2Fuploads%2Farticles%2F9hbm44x4jkdy2gmyscj0.webp" alt="Vault Kubernetes auth flow — pod presents service account token, Vault validates, pod receives a Vault token.&amp;lt;br&amp;gt;
" width="800" height="632"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Installing the Vault agent injector.
&lt;/h2&gt;

&lt;p&gt;We deploy just the Vault agent injector in the lab cluster. It points to the external Vault VM rather than running its own Vault server:&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;# 💻 Mac&lt;/span&gt;
kubectx lab-cluster

helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update

&lt;span class="c"&gt;# Get the vault VM IP - does not persist across sessions&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;VAULT_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;orb run &lt;span class="nt"&gt;-m&lt;/span&gt; vault &lt;span class="nb"&gt;hostname&lt;/span&gt; &lt;span class="nt"&gt;-I&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"VAULT_IP=&lt;/span&gt;&lt;span class="nv"&gt;$VAULT_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
helm &lt;span class="nb"&gt;install &lt;/span&gt;vault hashicorp/vault &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; vault &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="s2"&gt;"injector.externalVaultAddr=http://&lt;/span&gt;&lt;span class="nv"&gt;$VAULT_IP&lt;/span&gt;&lt;span class="s2"&gt;:8200"&lt;/span&gt;

kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; vault
&lt;span class="c"&gt;# vault-agent-injector-xxx   1/1   Running   0   30s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Configuring K8s auth on Vault.
&lt;/h2&gt;

&lt;p&gt;Run this on the vault VM, pointing Vault at the lab cluster’s API server:&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;# 🖥️ VM: vault&lt;/span&gt;

&lt;span class="c"&gt;# Re-export - always required, doesn't persist across sessions&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;VAULT_ADDR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'http://127.0.0.1:8200'&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;VAULT_ROOT_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'Initial Root Token'&lt;/span&gt; ~/vault-init.txt | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $NF}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;# If Vault is sealed after a reboot:&lt;/span&gt;
&lt;span class="c"&gt;# vault operator unseal $(grep 'Unseal Key 1' ~/vault-init.txt | awk '{print $NF}')&lt;/span&gt;
vault login &lt;span class="nv"&gt;$VAULT_ROOT_TOKEN&lt;/span&gt;

&lt;span class="c"&gt;# Get CP_IP from the Mac terminal: orb run -m cp01 hostname -I | awk '{print $1}'&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CP_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;cp01-ip&amp;gt;

&lt;span class="c"&gt;# Regenerate the CA cert if /tmp was cleared after reboot&lt;/span&gt;
vault &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;certificate pki_k8s/issuer/default &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/lab-ca.crt

&lt;span class="c"&gt;# Enable Kubernetes auth (safe to re-run - ignores "already enabled")&lt;/span&gt;
vault auth &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;lab-k8s kubernetes 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"already enabled"&lt;/span&gt;

&lt;span class="c"&gt;# Configure - point Vault at the lab cluster API server&lt;/span&gt;
vault write auth/lab-k8s/config &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;kubernetes_host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"https://&lt;/span&gt;&lt;span class="nv"&gt;$CP_IP&lt;/span&gt;&lt;span class="s2"&gt;:6443"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;kubernetes_ca_cert&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;@/tmp/lab-ca.crt

vault &lt;span class="nb"&gt;read &lt;/span&gt;auth/lab-k8s/config
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Testing K8s auth.
&lt;/h2&gt;

&lt;p&gt;Create a simple role and test it from a pod:&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;# 🖥️ VM: vault&lt;/span&gt;

&lt;span class="c"&gt;# Create a policy&lt;/span&gt;
vault policy write read-secrets - &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;
path "secret/data/myapp/*" {
  capabilities = ["read"]
}
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="c"&gt;# Create a K8s auth role&lt;/span&gt;
vault write auth/lab-k8s/role/myapp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;bound_service_account_names&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;myapp &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;bound_service_account_namespaces&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;default &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;policies&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;read-secrets &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1h

&lt;span class="c"&gt;# Write a test secret&lt;/span&gt;
vault secrets &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;secret kv-v2 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;vault kv put secret/myapp/config &lt;span class="nv"&gt;db_password&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"supersecret"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 💻 Mac — deploy a pod with Vault annotations&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; - &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;
apiVersion: v1
kind: ServiceAccount
metadata:
  name: myapp
  namespace: default
---
apiVersion: v1
kind: Pod
metadata:
  name: vault-test
  namespace: default
  annotations:
    vault.hashicorp.com/agent-inject: "true"
    vault.hashicorp.com/role: "myapp"
    vault.hashicorp.com/agent-inject-secret-config: "secret/data/myapp/config"
spec:
  serviceAccountName: myapp
  containers:
  - name: app
    image: busybox
    command: ["sleep", "3600"]
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="c"&gt;# Check the secret was injected&lt;/span&gt;
kubectl &lt;span class="nb"&gt;exec &lt;/span&gt;vault-test &lt;span class="nt"&gt;-c&lt;/span&gt; app &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="nb"&gt;cat&lt;/span&gt; /vault/secrets/config
&lt;span class="c"&gt;# db_password: supersecret&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that last line returns the password, the whole chain works: service account JWT → Vault validation → Vault token → secret retrieval → file injection. Every link of the chain is what a real production app does.&lt;/p&gt;




&lt;h2&gt;
  
  
  Crossplane.
&lt;/h2&gt;

&lt;p&gt;Crossplane turns a Kubernetes cluster into a universal control plane for cloud infrastructure. Instead of Terraform modules or CloudFormation stacks, you define infrastructure as Kubernetes custom resources, and Crossplane reconciles them continuously.&lt;br&gt;
I use it at work to provision AWS resources (EKS node groups, RDS, S3 buckets, IAM roles) and VMware Cloud Director resources through a custom provider. The lab version mirrors the AWS side of that.&lt;br&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.amazonaws.com%2Fuploads%2Farticles%2Fb5qrvmzlu85ryluliwyd.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fb5qrvmzlu85ryluliwyd.webp" alt="Crossplane composites — an XR triggers a Composition that creates multiple Managed Resources across cloud providers." width="800" height="610"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Installation:
&lt;/h3&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 💻 Mac&lt;/span&gt;
helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update

&lt;span class="c"&gt;# Composition Functions are enabled by default in recent versions.&lt;/span&gt;
&lt;span class="c"&gt;# The --enable-composition-functions flag was removed.&lt;/span&gt;
helm &lt;span class="nb"&gt;install &lt;/span&gt;crossplane crossplane-stable/crossplane &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; crossplane-system &lt;span class="nt"&gt;--create-namespace&lt;/span&gt;

kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; crossplane-system &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;span class="c"&gt;# NAME                                       READY   STATUS    AGE&lt;/span&gt;
&lt;span class="c"&gt;# crossplane-xxx                             1/1     Running   60s&lt;/span&gt;
&lt;span class="c"&gt;# crossplane-rbac-manager-xxx               1/1     Running   60s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;h3&gt;
  
  
  Installing the AWS provider
&lt;/h3&gt;
&lt;h1&gt;
  
  
  💻 Mac
&lt;/h1&gt;

&lt;p&gt;kubectl apply -f - &amp;lt;&amp;lt;EOF&lt;br&gt;
apiVersion: pkg.crossplane.io/v1&lt;br&gt;
kind: Provider&lt;br&gt;
metadata:&lt;br&gt;
  name: provider-aws-ec2&lt;br&gt;
spec:&lt;br&gt;
  package: xpkg.upbound.io/upbound/provider-aws-ec2:latest&lt;br&gt;
EOF&lt;/p&gt;

&lt;p&gt;kubectl get pkg&lt;/p&gt;
&lt;h1&gt;
  
  
  NAME               INSTALLED   HEALTHY   PACKAGE                              AGE
&lt;/h1&gt;
&lt;h1&gt;
  
  
  provider-aws-ec2   True        True      xpkg.upbound.io/upbound/provider-... 60s
&lt;/h1&gt;
&lt;h3&gt;
  
  
  A minimal composition:
&lt;/h3&gt;

&lt;p&gt;A bare-minimum ProviderConfig enough to verify the install is working:&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;# 💻 Mac&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; - &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;
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: Secret
    secretRef:
      namespace: crossplane-system
      name: aws-creds
      key: creds
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;h2&gt;
  
  
  &lt;em&gt;In a real setup, you create a IRSA ( IAM Role for Service Account) to authenticate and give the provider permission to create and monitor resources. For local validation, the provider installs, and the compositions can be validated structurally without ever calling AWS.&lt;/em&gt;
&lt;/h2&gt;
&lt;h2&gt;
  
  
  The LimitRange story.
&lt;/h2&gt;

&lt;p&gt;This is the one that came from a real incident at work.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We had repeated disk-pressure events in our production EKS cluster. Pods with no resource requests had crept into a few namespaces — someone deployed a YAML that omitted resources: entirely, and nobody caught it in review. The Kubernetes scheduler had no signal about their consumption, so nodes ended up overcommitted. Then ephemeral storage filled up, eviction kicked in, and a couple of unrelated pods went down with it. Total downtime measured in tens of minutes. Cause-and-effect chain that took a while to untangle.&lt;/p&gt;

&lt;p&gt;The fix is one of the most boring features in Kubernetes: LimitRanges. They set default resource requests and limits at the namespace level. Any container that doesn’t specify its own requests gets the defaults applied automatically by the admission controller. The scheduler always has a signal. Overcommit becomes a deliberate choice, not an accident.&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;# 💻 Mac&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; - &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;
apiVersion: v1
kind: LimitRange
metadata:
  name: default-limits
  namespace: default
spec:
  limits:
  - default:
      memory: 512Mi
      cpu: 500m
    defaultRequest:
      memory: 128Mi
      cpu: 100m
    max:
      ephemeral-storage: 2Gi
    type: Container
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Apply this to every namespace that hosts workloads. In production, I now apply it as a post-provisioning step on every new namespace:&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;# 💻 Mac — apply to multiple namespaces&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;ns &lt;span class="k"&gt;in &lt;/span&gt;default vault crossplane-system istio-system&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; limitrange.yaml &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nv"&gt;$ns&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The &lt;code&gt;ephemeral-storage&lt;/code&gt; max is the part that specifically addresses the disk-pressure failure mode — it bounds how much scratch space a container can consume, which is what spirals when ephemeral storage runs unbounded.
&lt;/h2&gt;

&lt;h2&gt;
  
  
  Verifying the complete EKS mirror.
&lt;/h2&gt;

&lt;p&gt;Let’s confirm the whole stack is up:&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;# 💻 Mac&lt;/span&gt;
kubectl get nodes &lt;span class="nt"&gt;-o&lt;/span&gt; wide
&lt;span class="c"&gt;# NAME       STATUS   ROLES           VERSION&lt;/span&gt;
&lt;span class="c"&gt;# cp01       Ready    control-plane   v1.34.x&lt;/span&gt;
&lt;span class="c"&gt;# worker01   Ready    &amp;lt;none&amp;gt;          v1.34.x&lt;/span&gt;
&lt;span class="c"&gt;# worker02   Ready    &amp;lt;none&amp;gt;          v1.34.x&lt;/span&gt;

kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt;
&lt;span class="c"&gt;# Cilium/Calico, CoreDNS, istiod-1-26, MetalLB, Crossplane, Vault injector - all Running&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.amazonaws.com%2Fuploads%2Farticles%2F8bbe2e8t8avxbprnfvvo.webp" 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.amazonaws.com%2Fuploads%2Farticles%2F8bbe2e8t8avxbprnfvvo.webp" alt="Full EKS mirror stack running — all components healthy.&amp;lt;br&amp;gt;
" width="800" height="757"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Local vs production — what’s the same and what differs:
&lt;/h2&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.amazonaws.com%2Fuploads%2Farticles%2Fzp2xj0dngtx49axfhkxg.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fzp2xj0dngtx49axfhkxg.webp" alt="Local vs production — what’s the same and what differs:" width="800" height="765"&gt;&lt;/a&gt;&lt;br&gt;
The only meaningful differences are the CNI (because of OrbStack’s VM capabilities, as we covered in Part 4) and the LoadBalancer implementation. Everything else is identical in configuration. The mental model from this lab transfers directly to the production cluster, and vice versa.&lt;/p&gt;




&lt;p&gt;In the final article: How to stop and start the lab without losing state, the CKS exam scenarios this cluster was purpose-built for, and the shell aliases that make the whole thing pleasant to live with.&lt;/p&gt;

&lt;p&gt;← Part 5: &lt;a href="https://dev.to/nkmakau/how-i-practise-istio-upgrades-locally-before-touching-production-eks-2e6o"&gt;How I Practise Istio Upgrades Locally Before Touching Production EKS &lt;/a&gt;| Part 7: &lt;a href="https://dev.to/nkmakau/how-i-practise-istio-upgrades-locally-before-touching-production-eks-2e6o"&gt;The Day 2 Reality of Running a Kubernetes Lab on Your Mac: Stop/Start, CKS Scenarios, and What I Learned Building It&lt;/a&gt; →&lt;/p&gt;




&lt;p&gt;I’m Noah Makau, a DevSecOps engineer based in Nairobi. I run a small DevOps consultancy and hold CKA, CKAD, and the AWS Solutions Architect Professional certifications , currently preparing for CKS. I write about Kubernetes, Vault, Crossplane, and the day-to-day of running platforms that actually have to stay up.&lt;br&gt;
originally published at &lt;a href="https://blog.arkilasystems.com/the-disk-pressure-incident-that-taught-me-to-always-set-limitranges-and-other-lessons-from-mirroring-eks-locally" rel="noopener noreferrer"&gt;blog.arkilasystems.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>platformengineering</category>
      <category>tutorial</category>
      <category>eks</category>
    </item>
    <item>
      <title>How I Practise Istio Upgrades Locally Before Touching Production EKS.</title>
      <dc:creator>Noah Makau</dc:creator>
      <pubDate>Fri, 22 May 2026 12:44:07 +0000</pubDate>
      <link>https://forem.com/nkmakau/how-i-practise-istio-upgrades-locally-before-touching-production-eks-2e6o</link>
      <guid>https://forem.com/nkmakau/how-i-practise-istio-upgrades-locally-before-touching-production-eks-2e6o</guid>
      <description>&lt;p&gt;Part 5 of 7 — The Mac Kubernetes Lab: A Production-Mirror Setup from Scratch.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Previously in Part 4: kubeadm 1.34 is bootstrapped, the M1 vs M4 CNI problem is solved (Calico on M4, Cilium on M1), and the three-node cluster is running with certificates signed by our Vault PKI. Now we install Istio and give the cluster real LoadBalancer IPs.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I’ve done an in-place Istio upgrade in production exactly once. A misconfigured istiod update broke sidecar injection across every namespace simultaneously. That was the last time.&lt;/p&gt;

&lt;p&gt;The revision-based approach is the alternative. Run multiple versions of the Istio control plane in parallel. Install the new version alongside the old. Migrate namespaces one at a time. Verify the injection is working at each step. Then remove the old version when you’re confident. If anything misbehaves, you relabel the namespace back to the old revision. Full rollback at any point, no scrambling.&lt;br&gt;
This is how I handle Istio upgrades on the production EKS clusters at work. Building it locally first means the production upgrade procedure is something I’ve already practised on a cluster where the stakes are zero.&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.amazonaws.com%2Fuploads%2Farticles%2Fdqtn2i9f6z20ok27si44.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fdqtn2i9f6z20ok27si44.webp" alt="Revision-based Istio upgrade — two control planes running simultaneously, namespaces migrating incrementally." width="800" height="698"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Installing Istio via Helm (revision-based)
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 💻 Mac&lt;/span&gt;
kubectx lab-cluster

helm repo add istio https://istio-release.storage.googleapis.com/charts
helm repo update

&lt;span class="c"&gt;# Step 1 - Base CRDs with default revision set to 1-26&lt;/span&gt;
helm &lt;span class="nb"&gt;install &lt;/span&gt;istio-base istio/base &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; istio-system &lt;span class="nt"&gt;--create-namespace&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;defaultRevision&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1-26

&lt;span class="c"&gt;# Step 2 - istiod with revision tag&lt;/span&gt;
&lt;span class="c"&gt;# The revision label (1-26) is what namespaces reference for sidecar injection.&lt;/span&gt;
helm &lt;span class="nb"&gt;install &lt;/span&gt;istiod-1-26 istio/istiod &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; istio-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;revision&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1-26 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; global.proxy.resources.requests.cpu&lt;span class="o"&gt;=&lt;/span&gt;50m &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; global.proxy.resources.requests.memory&lt;span class="o"&gt;=&lt;/span&gt;128Mi &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; pilot.resources.requests.cpu&lt;span class="o"&gt;=&lt;/span&gt;100m &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; pilot.resources.requests.memory&lt;span class="o"&gt;=&lt;/span&gt;256Mi &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--wait&lt;/span&gt;

&lt;span class="c"&gt;# Step 3 - Ingress gateway tied to revision 1-26&lt;/span&gt;
helm &lt;span class="nb"&gt;install &lt;/span&gt;istio-ingress istio/gateway &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; istio-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;revision&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1-26 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; service.type&lt;span class="o"&gt;=&lt;/span&gt;LoadBalancer

&lt;span class="c"&gt;# Label the namespace for injection using the revision label.&lt;/span&gt;
&lt;span class="c"&gt;# Note: use istio.io/rev=1-26, NOT istio-injection=enabled.&lt;/span&gt;
&lt;span class="c"&gt;# The revision label tells the webhook which control plane to use.&lt;/span&gt;
kubectl label namespace default istio.io/rev&lt;span class="o"&gt;=&lt;/span&gt;1-26
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Verify istiod is running:&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;# 💻 Mac&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; istio-system
&lt;span class="c"&gt;# NAME                           READY   STATUS    AGE&lt;/span&gt;
&lt;span class="c"&gt;# istiod-1-26-xxx                1/1     Running   60s&lt;/span&gt;
&lt;span class="c"&gt;# istio-ingress-xxx              1/1     Running   45s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  The MetalLB problem.
&lt;/h2&gt;

&lt;p&gt;On the OrbStack-native cluster from Part 2, LoadBalancer services get real IPs automatically. On the VM cluster, there’s no cloud provider doing that work, services stay in &lt;code&gt;pending&lt;/code&gt; state indefinitely.&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;# 💻 Mac&lt;/span&gt;
kubectl get svc istio-ingress &lt;span class="nt"&gt;-n&lt;/span&gt; istio-system
&lt;span class="c"&gt;# NAME            TYPE           CLUSTER-IP   EXTERNAL-IP   PORT(S)&lt;/span&gt;
&lt;span class="c"&gt;# istio-ingress   LoadBalancer   10.x.x.x     &amp;lt;pending&amp;gt;     80:xxx&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;MetalLB solves this. It implements the Kubernetes LoadBalancer spec for bare-metal and VM environments. It watches for LoadBalancer services and assigns IPs from a pool you define.&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.amazonaws.com%2Fuploads%2Farticles%2Fktqjp8owdvx6croygd5n.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fktqjp8owdvx6croygd5n.webp" alt="MetalLB architecture — speaker pods on each node, IP pool assigned from the OrbStack subnet." width="800" height="699"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Installing MetalLB
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 💻 Mac&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/metallb/metallb/v0.14.5/config/manifests/metallb-native.yaml
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; metallb-system &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;span class="c"&gt;# Wait for the controller and speaker pods to be Running&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Configuring the IP pool.
&lt;/h3&gt;

&lt;p&gt;The IP pool must be in the same subnet as your OrbStack VMs. Get the subnet 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="c"&gt;# 💻 Mac&lt;/span&gt;
orb run &lt;span class="nt"&gt;-m&lt;/span&gt; cp01 &lt;span class="nb"&gt;hostname&lt;/span&gt; &lt;span class="nt"&gt;-I&lt;/span&gt;
&lt;span class="c"&gt;# M4: 192.168.139.200&lt;/span&gt;
&lt;span class="c"&gt;# M1 with Cilium: 192.168.139.159 10.0.0.105 fd07:...&lt;/span&gt;
&lt;span class="c"&gt;#                 ^^^^^ use this — the first 192.168.x.x IP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;M1 quirk:&lt;/strong&gt; With Cilium running, hostname -I returns multiple IPs: the OrbStack internal IP, a Cilium pod-network IP, and an IPv6 address. Always use the first 192.168.x.x IP. The others are internal to Cilium's networking and don't help you here.&lt;/em&gt;&lt;br&gt;
Configure the pool using a range that doesn’t overlap with the VM IPs:&lt;br&gt;
&lt;/p&gt;
&lt;/blockquote&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 💻 Mac&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; - &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;
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: lab-pool
  namespace: metallb-system
spec:
  addresses:
  - 192.168.139.200-192.168.139.250
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: lab-l2
  namespace: metallb-system
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch the ingress gateway get its IP:&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;# 💻 Mac&lt;/span&gt;
kubectl get svc istio-ingress &lt;span class="nt"&gt;-n&lt;/span&gt; istio-system &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;span class="c"&gt;# NAME            TYPE           EXTERNAL-IP       PORT(S)&lt;/span&gt;
&lt;span class="c"&gt;# istio-ingress   LoadBalancer   192.168.139.200   80:xxx,443:xxx&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Setting up Mac /etc/hosts.
&lt;/h3&gt;

&lt;p&gt;Unlike the native cluster, the VM cluster doesn’t have wildcard DNS. We add entries to /etc/hosts manually:&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;# 💻 Mac&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;INGRESS_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;kubectl get svc istio-ingress &lt;span class="nt"&gt;-n&lt;/span&gt; istio-system &lt;span class="se"&gt;\&lt;/span&gt;
  &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.loadBalancer.ingress[0].ip}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="nv"&gt;$INGRESS_IP&lt;/span&gt;

&lt;span class="c"&gt;# Remove any stale entry from a previous attempt&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;''&lt;/span&gt; &lt;span class="s1"&gt;'/lab.local/d'&lt;/span&gt; /etc/hosts
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$INGRESS_IP&lt;/span&gt;&lt;span class="s2"&gt;  httpbin.lab.local bookinfo.lab.local api.lab.local"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/hosts
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Gateway + VirtualService on the VM cluster.
&lt;/h2&gt;

&lt;p&gt;With MetalLB handing out real IPs, we can create Gateways and VirtualServices using *.lab.local hostnames.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bookinfo the Istio reference app.
&lt;/h3&gt;

&lt;p&gt;Bookinfo is Istio’s reference demo. It’s perfect for testing traffic management because it has multiple services with multiple versions of one of them.&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.amazonaws.com%2Fuploads%2Farticles%2Fmlm6nluj4lm9plhjzavk.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fmlm6nluj4lm9plhjzavk.webp" alt="Bookinfo application architecture — multiple services and multiple review versions for traffic management testing" width="800" height="611"&gt;&lt;/a&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;# 💻 Mac&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/istio/istio/release-1.26/samples/bookinfo/platform/kube/bookinfo.yaml

kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; - &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;
apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
  name: bookinfo-gateway
spec:
  selector:
    istio: ingress
  servers:
  - port:
      number: 80
      name: http
      protocol: HTTP
    hosts:
    - "bookinfo.lab.local"
---
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: bookinfo
spec:
  hosts:
  - "bookinfo.lab.local"
  gateways:
  - bookinfo-gateway
  http:
  - match:
    - uri:
        prefix: /productpage
    route:
    - destination:
        host: productpage
        port:
          number: 9080
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://bookinfo.lab.local/productpage&lt;/code&gt; in your Mac browser.&lt;br&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.amazonaws.com%2Fuploads%2Farticles%2Fuk1c5qk67hz2w3lw25k9.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fuk1c5qk67hz2w3lw25k9.webp" alt="Bookinfo running at bookinfo.lab.local — served through the Istio ingress gateway via MetalLB." width="800" height="713"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  Traffic splitting.
&lt;/h2&gt;

&lt;p&gt;Now we can practise the same canary deployment patterns used in production:&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;# 💻 Mac&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; - &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;
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: reviews-dr
spec:
  host: reviews
  subsets:
  - name: v1
    labels:
      version: v1
  - name: v2
    labels:
      version: v2
  - name: v3
    labels:
      version: v3
---
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: reviews-canary
spec:
  hosts:
  - reviews
  http:
  - route:
    - destination:
        host: reviews
        subset: v1
      weight: 80
    - destination:
        host: reviews
        subset: v2
      weight: 20
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Refresh the bookinfo page a few times. 80% of requests go to &lt;code&gt;reviews-v1&lt;/code&gt; (no stars), 20% to &lt;code&gt;reviews-v2&lt;/code&gt; (black stars).&lt;/p&gt;

&lt;h2&gt;
  
  
  Header-based routing.
&lt;/h2&gt;

&lt;p&gt;Route specific users to a different version based on a request header:&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;# 💻 Mac&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; - &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;
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: reviews-header
spec:
  hosts:
  - reviews
  http:
  - match:
    - headers:
        end-user:
          exact: user
    route:
    - destination:
        host: reviews
        subset: v3
  - route:
    - destination:
        host: reviews
        subset: v1
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Log in as “user” in the Bookinfo UI, and you'll always see &lt;code&gt;reviews-v3&lt;/code&gt; (red stars). Everyone else sees v1.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fault injection.
&lt;/h2&gt;

&lt;p&gt;Introduce artificial latency and errors to test resilience:&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;# 💻 Mac&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; - &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;
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: ratings-fault
spec:
  hosts:
  - ratings
  http:
  - fault:
      delay:
        percentage:
          value: 50.0
        fixedDelay: 3s
      abort:
        percentage:
          value: 10.0
        httpStatus: 500
    route:
    - destination:
        host: ratings
        port:
          number: 9080
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;50% of requests to ratings will get a 3-second delay. 10% will return 500. Useful for testing timeout and retry configurations on calling services.&lt;/p&gt;

&lt;h2&gt;
  
  
  Strict mTLS.
&lt;/h2&gt;

&lt;p&gt;Enforce mutual TLS across the mesh:&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;# 💻 Mac&lt;/span&gt;
kubectl apply &lt;span class="nt"&gt;-f&lt;/span&gt; - &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;
apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: default
  namespace: default
spec:
  mtls:
    mode: STRICT
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Practising an Istio upgrade.
&lt;/h2&gt;

&lt;p&gt;This is where the revision-based setup actually pays off. Let’s simulate upgrading from 1.26 to 1.28:&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;# 💻 Mac&lt;/span&gt;
&lt;span class="c"&gt;# Step 1 - Install the new control plane alongside the old&lt;/span&gt;
helm &lt;span class="nb"&gt;install &lt;/span&gt;istiod-1-28 istio/istiod &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--namespace&lt;/span&gt; istio-system &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;revision&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1-28 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--wait&lt;/span&gt;

&lt;span class="c"&gt;# Step 2 - Migrate a namespace to the new revision&lt;/span&gt;
kubectl label namespace default istio.io/rev&lt;span class="o"&gt;=&lt;/span&gt;1-28 &lt;span class="nt"&gt;--overwrite&lt;/span&gt;

&lt;span class="c"&gt;# Step 3 - Restart pods to pick up the new sidecar version&lt;/span&gt;
kubectl rollout restart deployment &lt;span class="nt"&gt;-n&lt;/span&gt; default

&lt;span class="c"&gt;# Step 4 - Verify new sidecars&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; default &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;'{range .items[*]}{.metadata.name}{"\t"}{.metadata.annotations.sidecar\.istio\.io/status}{"\n"}{end}'&lt;/span&gt;

&lt;span class="c"&gt;# Step 5 - Once you're satisfied, remove the old control plane&lt;/span&gt;
helm uninstall istiod-1-26 &lt;span class="nt"&gt;-n&lt;/span&gt; istio-system
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  During Steps 1–3, both &lt;code&gt;istiod-1-26&lt;/code&gt; and &lt;code&gt;istiod-1-28&lt;/code&gt; are running simultaneously. If anything goes wrong after migrating a namespace, you can roll back by re-labelling the namespace back to &lt;code&gt;1-26&lt;/code&gt;. This is exactly the procedure I use for production EKS Istio upgrades, and having practised it locally means there's no improvising on the day.
&lt;/h2&gt;

&lt;p&gt;Where we are&lt;br&gt;
The VM cluster now has:&lt;br&gt;
✅ Istio 1.26 installed with revision label (istiod-1-26)&lt;br&gt;
✅ Ingress gateway with a real LoadBalancer IP from MetalLB&lt;br&gt;
✅ /etc/hosts on the Mac routing *.lab.local to the ingress IP&lt;br&gt;
✅ Bookinfo deployed with traffic splitting, header routing, and fault injection&lt;br&gt;
✅ Strict mTLS enforced across the default namespace&lt;br&gt;
✅ Experience practising a revision-based Istio upgrade&lt;/p&gt;

&lt;p&gt;In Part 6, we complete the EKS mirror: Vault Kubernetes auth, Crossplane with the AWS provider, and a LimitRange configuration that mirrors a production gap we hit at work, the one that caused a real disk-pressure incident.&lt;/p&gt;

&lt;h2&gt;
  
  
  ← Part 4: &lt;a href="https://dev.to/nkmakau/same-cluster-different-mac-a-debugging-story-about-unprivileged-lxc-containers-iptables-and-why-2e8g"&gt;Same Cluster, Different Mac: A Debugging Story About Unprivileged LXC Containers, iptables, and Why Cilium Replaces kube-proxy&lt;/a&gt; | Part 6: &lt;a href="https://dev.to/nkmakau/..."&gt;The Disk-Pressure Incident That Taught Me to Always Set LimitRanges — and Other Lessons from Mirroring EKS Locally&lt;/a&gt; →
&lt;/h2&gt;

&lt;p&gt;I’m Noah Makau, a DevSecOps engineer based in Nairobi. I run a small DevOps consultancy and hold CKA, CKAD, and the AWS Solutions Architect Professional certifications, currently preparing for CKS. I write about Kubernetes, Vault, Crossplane, and the day-to-day of running platforms that actually have to stay up.&lt;/p&gt;

&lt;p&gt;originally published at &lt;a href="https://blog.arkilasystems.com/" rel="noopener noreferrer"&gt;blog.arkilasystems.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>platformengineering</category>
      <category>istio</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Same Cluster, Different Mac: A Debugging Story About Unprivileged LXC Containers, iptables, and Why Cilium Replaces kube-proxy.</title>
      <dc:creator>Noah Makau</dc:creator>
      <pubDate>Fri, 22 May 2026 12:02:26 +0000</pubDate>
      <link>https://forem.com/nkmakau/same-cluster-different-mac-a-debugging-story-about-unprivileged-lxc-containers-iptables-and-why-2e8g</link>
      <guid>https://forem.com/nkmakau/same-cluster-different-mac-a-debugging-story-about-unprivileged-lxc-containers-iptables-and-why-2e8g</guid>
      <description>&lt;p&gt;Part 4 of 7 — The Mac Kubernetes Lab: A Production-Mirror Setup from Scratch.&lt;/p&gt;




&lt;p&gt;I built a four-VM Kubernetes lab on my M4 Mac. Everything worked the first time. kubeadm init finished cleanly, Calico installed without complaint, and every node went Ready within a minute. The cluster behaved exactly like a small, slightly quieter version of the EKS clusters I run at work.&lt;/p&gt;

&lt;p&gt;Months later, on a whim, I tried to put the same setup on my old M1 Pro. The VMs came up. kubeadm init succeeded. I applied the Calico manifests. Nothing worked!&lt;/p&gt;

&lt;p&gt;Nodes stayed NotReady indefinitely. kube-proxy crashed in a loop. The tigera-operator pod kept logging the same line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;dial tcp 10.96.0.1:443: connect: connection refused 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s the Kubernetes API server’s ClusterIP, the address every system component uses to talk to the cluster from inside the cluster. It should always be reachable. It wasn’t.&lt;/p&gt;

&lt;p&gt;I spent the evening trying every CNI option I knew, then reading kernel source code I’d never had a reason to look at. What I found was a kernel capability difference between Apple Silicon generations that probably affects more setups than just mine. This is the walkthrough I wish I had when I started debugging.&lt;/p&gt;




&lt;h2&gt;
  
  
  The setup before things broke:
&lt;/h2&gt;

&lt;p&gt;Quick recap of where we are in the series. We’re standing up Kubernetes 1.34 via kubeadm on four OrbStack VMs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;vault&lt;/code&gt; — runs HashiCorp Vault as the cluster's certificate authority&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cp01&lt;/code&gt; — the control plane node&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;worker01&lt;/code&gt; and &lt;code&gt;worker02&lt;/code&gt; — workers
Parts 2 and 3 built the supporting pieces: the native OrbStack cluster, the Vault PKI with a Root CA and a Kubernetes Intermediate CA, and the four VMs themselves. By the time we get to &lt;code&gt;kubeadm init&lt;/code&gt;, the CA cert and key are already at &lt;code&gt;/etc/kubernetes/pki/ca.crt&lt;/code&gt; and &lt;code&gt;/etc/kubernetes/pki/ca.key&lt;/code&gt; on &lt;code&gt;cp01&lt;/code&gt;, ready for kubeadm to pick up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What I’m about to walk through happened on both my M4 and my M1. The pre-reqs and the kubeadm init are identical on both. The CNI choice is what diverges.&lt;/p&gt;




&lt;h2&gt;
  
  
  Pre-reqs on the three Kubernetes nodes.
&lt;/h2&gt;

&lt;p&gt;Run this on &lt;code&gt;cp01&lt;/code&gt;, &lt;code&gt;worker01&lt;/code&gt;, and &lt;code&gt;worker02&lt;/code&gt;. The minimal OrbStack Ubuntu image is missing a few packages, and the order matters,install &lt;code&gt;gpg&lt;/code&gt; before adding the Kubernetes apt repo, or you'll end up with a broken, unsigned repo entry that blocks every future &lt;code&gt;apt update&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="c"&gt;# 🖥️ VM: all nodes (cp01, worker01, worker02)&lt;/span&gt;

&lt;span class="c"&gt;# Disable swap&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;swapoff &lt;span class="nt"&gt;-a&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&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/d'&lt;/span&gt; /etc/fstab

&lt;span class="c"&gt;# Kernel modules&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &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; | sudo tee /etc/modules-load.d/k8s.conf
overlay
br_netfilter
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;modprobe overlay br_netfilter

&lt;span class="c"&gt;# Sysctl for bridge networking&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &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; | sudo tee /etc/sysctl.d/k8s.conf
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;# containerd&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &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
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="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;containerd

&lt;span class="c"&gt;# Clean any stale K8s repo entries before adding the new one&lt;/span&gt;
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/apt/sources.list.d/kubernetes.list
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/apt/keyrings/kubernetes-apt-keyring.gpg
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; gpg curl apt-transport-https ca-certificates
&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/apt/keyrings

&lt;span class="c"&gt;# K8s 1.34 repo&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://pkgs.k8s.io/core:/stable:/v1.34/deb/Release.key &lt;span class="se"&gt;\&lt;/span&gt;
  | &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="s2"&gt;"deb [signed-by=/etc/apt/keyrings/kubernetes-apt-keyring.gpg] &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  https://pkgs.k8s.io/core:/stable:/v1.34/deb/ /"&lt;/span&gt; &lt;span class="se"&gt;\&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 update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; kubelet kubeadm kubectl

&lt;span class="nb"&gt;sudo &lt;/span&gt;apt-mark hold kubelet kubeadm kubectl

kubectl version &lt;span class="nt"&gt;--client&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; kubeadm version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Two things worth flagging from experience. First, the Kubernetes apt repo URL pattern is &lt;code&gt;core:/stable:/v1.34/deb/&lt;/code&gt; - note the colons. Get one wrong, and you'll spend ten minutes wondering why &lt;code&gt;apt&lt;/code&gt; is returning a 404. Second, if you skip the cleanup step and your previous attempt left a broken repo entry, the &lt;code&gt;apt update&lt;/code&gt; after adding the new repo will fail silently, and apt install kubelet will install nothing useful.
&lt;/h2&gt;

&lt;h2&gt;
  
  
  &lt;code&gt;kubeadm init&lt;/code&gt; on cp01.
&lt;/h2&gt;

&lt;p&gt;Two things to know about the config:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The API version is v1beta4 in kubeadm 1.34. The old v1beta3 is deprecated and will warn. The KubeletConfiguration group also changed from kubelet.kubeadm.k8s.io to kubelet.config.k8s.io. If you're copying a kubeadm config from an older tutorial, both of those need updating.&lt;/li&gt;
&lt;li&gt;The pod CIDR depends on the CNI. Use 10.244.0.0/16 for Cilium (M1) or 192.168.0.0/16 for Calico (M4). These are each CNI's default; getting it wrong here means re-running kubeadm reset later, which is no fun.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 🖥️ VM: cp01&lt;/span&gt;

&lt;span class="c"&gt;# Set CP_IP explicitly - VM environment vars don't persist across sessions&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CP_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt; &lt;span class="nt"&gt;-I&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CP_IP=&lt;/span&gt;&lt;span class="nv"&gt;$CP_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;   &lt;span class="c"&gt;# verify - an empty value here causes "not a valid IP address" later&lt;/span&gt;

&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /tmp/kubeadm-config.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;
apiVersion: kubeadm.k8s.io/v1beta4
kind: InitConfiguration
localAPIEndpoint:
  advertiseAddress: "&lt;/span&gt;&lt;span class="nv"&gt;$CP_IP&lt;/span&gt;&lt;span class="sh"&gt;"
  bindPort: 6443
nodeRegistration:
  criSocket: unix:///run/containerd/containerd.sock
---
apiVersion: kubeadm.k8s.io/v1beta4
kind: ClusterConfiguration
kubernetesVersion: "1.34.0"
controlPlaneEndpoint: "&lt;/span&gt;&lt;span class="nv"&gt;$CP_IP&lt;/span&gt;&lt;span class="sh"&gt;:6443"
networking:
  podSubnet: "10.244.0.0/16"    # M1 + Cilium: 10.244.0.0/16 | M4 + Calico: 192.168.0.0/16
  serviceSubnet: "10.96.0.0/12"
apiServer:
  certSANs:
  - "&lt;/span&gt;&lt;span class="nv"&gt;$CP_IP&lt;/span&gt;&lt;span class="sh"&gt;"
  - "cp01"
  - "cp01.lab.local"
  - "kubernetes"
  - "127.0.0.1"
  - "10.96.0.1"
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;grep &lt;/span&gt;advertiseAddress /tmp/kubeadm-config.yaml   &lt;span class="c"&gt;# sanity check before running init&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;kubeadm init &lt;span class="nt"&gt;--config&lt;/span&gt; /tmp/kubeadm-config.yaml
&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; /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;Because the CA cert and key are already at &lt;code&gt;/etc/kubernetes/pki/&lt;/code&gt; from Part 3, kubeadm detects them and uses the Vault-managed CA instead of generating self-signed certs. You'll see:&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="o"&gt;[&lt;/span&gt;certs] Using existing ca certificate authority 
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the moment I always pause and verify. If kubeadm says “Generating ca certificate”, something went wrong with the CA distribution step in Part 3, most likely the file permissions on &lt;code&gt;ca.key&lt;/code&gt;, which has to be &lt;code&gt;0600&lt;/code&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.amazonaws.com%2Fuploads%2Farticles%2Fwvzz8vcadaxtsa324ipw.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fwvzz8vcadaxtsa324ipw.webp" alt="kubeadm init completing successfully with the Vault-managed CA." width="800" height="626"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  The CNI fork in the road!
&lt;/h2&gt;

&lt;p&gt;This is where the M1 and M4 paths split.&lt;/p&gt;

&lt;h3&gt;
  
  
  On M4 Calico, the obvious choice.
&lt;/h3&gt;

&lt;p&gt;Calico is the straightforward, well-trodden answer on M4. Full iptables support, BGP routing, NetworkPolicy enforcement. It mirrors what most production EKS clusters use (modulo the AWS VPC CNI specifics).&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;# 🖥️ VM: cp01 (M4)&lt;/span&gt;
kubectl create &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/tigera-operator.yaml
kubectl create &lt;span class="nt"&gt;-f&lt;/span&gt; https://raw.githubusercontent.com/projectcalico/calico/v3.28.0/manifests/custom-resources.yaml

&lt;span class="c"&gt;# Verify:&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-n&lt;/span&gt; calico-system &lt;span class="nt"&gt;-w&lt;/span&gt;
kubectl get nodes &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nodes flip to &lt;code&gt;Ready&lt;/code&gt; within 60–90 seconds of the Calico pods running. This is the path I expected to work everywhere.&lt;/p&gt;

&lt;h3&gt;
  
  
  On M1, Calico fails, and figuring out why takes a while!
&lt;/h3&gt;

&lt;p&gt;Here’s what I saw on the M1 when I ran the exact Calico installation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;tigera-operator pod logs:
  dial tcp 10.96.0.1:443: connect: connection refused

kube-proxy pod events:
  open /proc/sys/net/netfilter/nf_conntrack_max: permission denied
  iptables: No chain/target/match by that name.

node status:
  cp01       NotReady    control-plane
  worker01   NotReady    &amp;lt;none&amp;gt;
  worker02   NotReady    &amp;lt;none&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The kube-proxy line is the one that turned out to matter. &lt;code&gt;Permission denied&lt;/code&gt; writing to &lt;code&gt;/proc/sys/net/netfilter/nf_conntrack_max&lt;/code&gt;. That's a kernel-level write. Something about the container environment was preventing kube-proxy from touching netfilter state.&lt;/p&gt;

&lt;p&gt;After enough digging through OrbStack’s docs and the LXC documentation, the pieces fell into place. OrbStack VMs are &lt;strong&gt;unprivileged LXC containers&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;On M4, the LXC container’s capability set allows the netfilter and iptables NAT modifications that kube-proxy needs.&lt;/p&gt;

&lt;p&gt;On M1, M2, and M3, that capability set is more restricted — kube-proxy can’t write the iptables KUBE-SERVICES chain.&lt;/p&gt;

&lt;p&gt;The downstream failure chain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;kubeadm init&lt;/code&gt; completes. The API server is running and reachable via the node IP.&lt;/li&gt;
&lt;li&gt;kube-proxy starts. It tries to write iptables NAT rules so that &lt;code&gt;10.96.0.1:443&lt;/code&gt; (the Kubernetes API ClusterIP) is DNAT'd to the API server's real address.&lt;/li&gt;
&lt;li&gt;The write fails. The &lt;code&gt;KUBE-SERVICES&lt;/code&gt; chain never gets populated.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;10.96.0.1&lt;/code&gt; is unreachable from inside the cluster.&lt;/li&gt;
&lt;li&gt;Every CNI plugin that needs to call the Kubernetes API via the ClusterIP fails to initialise. Calico’s tigera-operator is one of them.&lt;/li&gt;
&lt;li&gt;Nodes never go &lt;code&gt;Ready&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;So Calico isn’t the problem. kube-proxy is the problem. And kube-proxy is the problem because the container runtime isn’t giving it the capabilities it needs.&lt;/p&gt;

&lt;p&gt;This is where Cilium comes in.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why Cilium fixes this!
&lt;/h2&gt;

&lt;p&gt;Cilium is a CNI that uses eBPF programs loaded directly into the kernel instead of iptables. The eBPF programs implement service routing. What kube-proxy normally does with iptables, Cilium does with eBPF maps, and they don’t need the iptables NAT capability to function.&lt;/p&gt;

&lt;p&gt;Cilium also has a kubeProxyReplacement mode that completely replaces kube-proxy. No kube-proxy, no failing iptables writes, no broken ClusterIP routing.&lt;/p&gt;

&lt;p&gt;On a Mac where iptables NAT works fine, this is just a different way to do the same thing. On an M1, it’s the difference between a working cluster and a broken one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installing Cilium on M1.
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 🖥️ VM: cp01 (M1)&lt;/span&gt;

&lt;span class="c"&gt;# Step 1 - Remove kube-proxy entirely. Cilium will replace it.&lt;/span&gt;
kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system delete daemonset kube-proxy 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; kube-system delete configmap kube-proxy 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# Step 2 - Install Cilium CLI&lt;/span&gt;
curl &lt;span class="nt"&gt;-L&lt;/span&gt; &lt;span class="nt"&gt;--fail&lt;/span&gt; &lt;span class="nt"&gt;--remote-name-all&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://github.com/cilium/cilium-cli/releases/latest/download/cilium-linux-arm64.tar.gz
&lt;span class="nb"&gt;sudo tar &lt;/span&gt;xzvfC cilium-linux-arm64.tar.gz /usr/local/bin
&lt;span class="nb"&gt;rm &lt;/span&gt;cilium-linux-arm64.tar.gz

&lt;span class="c"&gt;# Step 3 - Install Cilium with kubeProxyReplacement enabled&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CP_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;hostname&lt;/span&gt; &lt;span class="nt"&gt;-I&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
cilium &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;kubeProxyReplacement&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;k8sServiceHost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$CP_IP&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--set&lt;/span&gt; &lt;span class="nv"&gt;k8sServicePort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;6443

&lt;span class="c"&gt;# Step 4 - Wait for Cilium to be fully up&lt;/span&gt;
cilium status &lt;span class="nt"&gt;--wait&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two flags are non-negotiable here. &lt;code&gt;kubeProxyReplacement=true&lt;/code&gt; is what tells Cilium to handle service routing itself via eBPF. &lt;code&gt;k8sServiceHost=$CP_IP&lt;/code&gt; and &lt;code&gt;k8sServicePort=6443&lt;/code&gt; give Cilium the real address of the API server so it doesn't try to reach it via the broken ClusterIP. Without those, Cilium itself can't bootstrap.&lt;/p&gt;

&lt;p&gt;The verification step that matters:&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;# 🖥️ VM: cp01&lt;/span&gt;
curl &lt;span class="nt"&gt;-k&lt;/span&gt; https://10.96.0.1:443/healthz &lt;span class="nt"&gt;--connect-timeout&lt;/span&gt; 5
&lt;span class="c"&gt;# Expected: ok&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If that &lt;code&gt;curl&lt;/code&gt; returns &lt;code&gt;ok&lt;/code&gt;, the eBPF service routing is working and the cluster is functional. CoreDNS will come up. System pods will go &lt;code&gt;Ready&lt;/code&gt;. The cluster is 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="c"&gt;# 🖥️ VM: cp01&lt;/span&gt;
kubectl get pods &lt;span class="nt"&gt;-A&lt;/span&gt; &lt;span class="nt"&gt;-w&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.amazonaws.com%2Fuploads%2Farticles%2Fdkfpv1qdgjxrh4xp3joe.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fdkfpv1qdgjxrh4xp3joe.webp" alt="All system pods Running after Cilium installation on M1." width="800" height="718"&gt;&lt;/a&gt;&lt;/p&gt;




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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 🖥️ VM: cp01 — generate the join command&lt;/span&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 on worker01 then worker02:&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;# 🖥️ VM: worker01 and worker02 (run on each)&lt;/span&gt;
&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/cni/net.d /var/lib/cni

&lt;span class="c"&gt;# Paste the join command from cp01&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;kubeadm &lt;span class="nb"&gt;join&lt;/span&gt; &amp;lt;cp01-ip&amp;gt;:6443 &lt;span class="nt"&gt;--token&lt;/span&gt; &amp;lt;token&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--discovery-token-ca-cert-hash&lt;/span&gt; sha256:&amp;lt;&lt;span class="nb"&gt;hash&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--cri-socket&lt;/span&gt; unix:///run/containerd/containerd.sock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 🖥️ VM: cp01 — watch nodes come up&lt;/span&gt;
kubectl get nodes &lt;span class="nt"&gt;-w&lt;/span&gt;
&lt;span class="c"&gt;# NAME       STATUS   ROLES           VERSION&lt;/span&gt;
&lt;span class="c"&gt;# cp01       Ready    control-plane   v1.34.x&lt;/span&gt;
&lt;span class="c"&gt;# worker01   Ready    &amp;lt;none&amp;gt;          v1.34.x&lt;/span&gt;
&lt;span class="c"&gt;# worker02   Ready    &amp;lt;none&amp;gt;          v1.34.x&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Getting the kubeconfig onto your Mac.
&lt;/h2&gt;

&lt;p&gt;The detail that catches everyone out: copy from &lt;code&gt;~/.kube/config&lt;/code&gt; on cp01, not &lt;code&gt;/etc/kubernetes/admin.conf&lt;/code&gt;. The admin.conf requires sudo, and &lt;code&gt;orb run&lt;/code&gt; injects extra output that corrupts the YAML.&lt;/p&gt;

&lt;p&gt;Also, every fresh kubeadm init rotates the certificates. You have to re-copy the kubeconfig after every init - if you skip this step, you'll get &lt;code&gt;"the server has asked for the client to provide credentials"&lt;/code&gt; and spend twenty minutes confused about why your perfectly good cluster is rejecting you.&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;# 💻 Mac&lt;/span&gt;
&lt;span class="c"&gt;# Copy from the user kubeconfig - NOT /etc/kubernetes/admin.conf&lt;/span&gt;
ssh cp01@orb &lt;span class="s2"&gt;"cat ~/.kube/config"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/lab-kubeconfig.yaml
&lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-5&lt;/span&gt; /tmp/lab-kubeconfig.yaml   &lt;span class="c"&gt;# must start with: apiVersion: v1&lt;/span&gt;

&lt;span class="c"&gt;# Remove ALL stale entries - including the user object&lt;/span&gt;
kubectl config delete-context lab-cluster 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;kubectl config delete-cluster kubernetes 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true
&lt;/span&gt;kubectl config delete-user kubernetes-admin 2&amp;gt;/dev/null &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;true&lt;/span&gt;

&lt;span class="c"&gt;# Rename and merge&lt;/span&gt;
kubectl &lt;span class="nt"&gt;--kubeconfig&lt;/span&gt; /tmp/lab-kubeconfig.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  config rename-context kubernetes-admin@kubernetes lab-cluster
&lt;span class="nv"&gt;KUBECONFIG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;~/.kube/config:/tmp/lab-kubeconfig.yaml &lt;span class="se"&gt;\&lt;/span&gt;
  kubectl config view &lt;span class="nt"&gt;--flatten&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/merged-kube.yaml
&lt;span class="nb"&gt;cp&lt;/span&gt; /tmp/merged-kube.yaml ~/.kube/config

&lt;span class="c"&gt;# Update the server IP to the cp01 VM IP&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CP_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;orb run &lt;span class="nt"&gt;-m&lt;/span&gt; cp01 &lt;span class="nb"&gt;hostname&lt;/span&gt; &lt;span class="nt"&gt;-I&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
kubectl config set-cluster kubernetes &lt;span class="nt"&gt;--server&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;https://&lt;span class="nv"&gt;$CP_IP&lt;/span&gt;:6443
kubectx lab-cluster
kubectl get nodes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;p&gt;Verifying the Vault CA is actually in use.&lt;br&gt;
This is the part that confirms the whole certificate hierarchy we built in Part 3 is working end-to-end. The cluster certs should be signed by the Vault Intermediate CA, not kubeadm’s default self-signed CA:&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;# 🖥️ VM: cp01&lt;/span&gt;
openssl x509 &lt;span class="nt"&gt;-in&lt;/span&gt; /etc/kubernetes/pki/apiserver.crt &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-issuer&lt;/span&gt;
&lt;span class="c"&gt;# issuer=CN=Lab K8s Intermediate CA&lt;/span&gt;

openssl x509 &lt;span class="nt"&gt;-in&lt;/span&gt; /etc/kubernetes/pki/ca.crt &lt;span class="nt"&gt;-noout&lt;/span&gt; &lt;span class="nt"&gt;-subject&lt;/span&gt;
&lt;span class="c"&gt;# subject=CN=Lab K8s Intermediate CA&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If those don’t match what’s in Vault, something went wrong in the CA distribution, and you’re probably running with a self-signed kubeadm CA, which works, but it’s not what we set out to build.&lt;/p&gt;




&lt;h2&gt;
  
  
  The summary I wish I had:
&lt;/h2&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.amazonaws.com%2Fuploads%2Farticles%2Fvlo3o2giprl3nb1jf4l7.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fvlo3o2giprl3nb1jf4l7.webp" alt="The summary I wish I had" width="800" height="520"&gt;&lt;/a&gt;&lt;br&gt;
The general lesson, which is worth more than the specific OrbStack/Apple Silicon story: anywhere you’re running Kubernetes inside an unprivileged Linux container — OrbStack, LXC on a server, certain Docker-in-Docker setups, some CI environments — iptables NAT may not work, and a kube-proxy-replacing CNI like Cilium is the move. I went into this thinking I had an Apple Silicon problem. What I had was a Linux container capability problem that happens to show up most loudly on Apple Silicon today.&lt;/p&gt;

&lt;p&gt;In Part 5, we install Istio with revision-based upgrades and MetalLB to give this cluster real LoadBalancer IPs, the same upgrade pattern I use on the production EKS clusters this lab is mirroring.&lt;/p&gt;

&lt;h2&gt;
  
  
  ← Part 3: &lt;a href="https://dev.to/nkmakau/building-a-production-grade-vault-pki-for-a-local-kubeadm-cluster-without-the-shortcuts-km7"&gt;Building a Production-Grade Vault PKI for a Local kubeadm Cluster Without the Shortcuts&lt;/a&gt; | Part 5: &lt;a href="https://dev.to/nkmakau/https://dev.to/nkmakau/how-i-practise-istio-upgrades-locally-before-touching-production-eks-2e6o"&gt;How I Practise Istio Upgrades Locally Before Touching Production EKS &lt;/a&gt;→
&lt;/h2&gt;

&lt;p&gt;I’m Noah Makau, a DevSecOps engineer based in Nairobi. I run a small DevOps consultancy and hold CKA, CKAD, and the AWS Solutions Architect Professional certifications, currently preparing for CKS. I write about Kubernetes, Vault, Crossplane, and the day-to-day of running platforms that actually have to stay up.&lt;/p&gt;

&lt;p&gt;originally published at &lt;a href="https://blog.arkilasystems.com/same-cluster-different-mac-a-debugging-story-about-unprivileged-lxc-containers-iptables-and-why-cilium-replaces-kube-proxy" rel="noopener noreferrer"&gt;blog.arkilasystems.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>platformengineering</category>
      <category>kubernetes</category>
      <category>tutorial</category>
      <category>cilium</category>
    </item>
    <item>
      <title>Building a Production-Grade Vault PKI for a Local kubeadm Cluster Without the Shortcuts.</title>
      <dc:creator>Noah Makau</dc:creator>
      <pubDate>Fri, 22 May 2026 10:29:24 +0000</pubDate>
      <link>https://forem.com/nkmakau/building-a-production-grade-vault-pki-for-a-local-kubeadm-cluster-without-the-shortcuts-km7</link>
      <guid>https://forem.com/nkmakau/building-a-production-grade-vault-pki-for-a-local-kubeadm-cluster-without-the-shortcuts-km7</guid>
      <description>&lt;p&gt;Part 3 of 7 — The Mac Kubernetes Lab: A Production-Mirror Setup from Scratch.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Previously in Part 2: Cluster 1- the OrbStack-native daily driver is up with Istio, Vault dev mode, and Crossplane. Now we start the harder half: a four-VM kubeadm cluster that mirrors a real production EKS setup.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;Most kubeadm tutorials skip the part that matters most for production parity. They let kubeadm generate self-signed certificates and call it done. The cluster works, but the certificate hierarchy looks nothing like what you actually run in production, where leaf certs come from an intermediate CA that’s signed by a root CA, all managed by HashiCorp Vault.&lt;/p&gt;

&lt;p&gt;This article walks through the boring-but-important version. Four OrbStack VMs, real networking, and a 3-tier Vault PKI that will sign every certificate in the Kubernetes cluster we boot up in Part 4. By the end of it, the mental model in your lab matches the mental model in production — short-lived admin certs, role-based issuance, full audit trail.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why a separate VM cluster at all?
&lt;/h2&gt;

&lt;p&gt;The OrbStack-native cluster from Part 2 is excellent for daily iteration, but it has a hard limitation: it’s single-node. You can’t test multi-node behaviours; node affinity, pod disruption budgets, rolling upgrades across nodes, or the kind of disk-pressure incidents that happen when one node misbehaves.&lt;/p&gt;

&lt;p&gt;Cluster 2 is built for that. Four VMs, a genuine kubeadm-bootstrapped cluster, and a certificate authority managed by Vault, the same PKI approach used in many enterprise Kubernetes deployments. It’s also the cluster I use for CKS exam preparation, because the exam gives you something very similar.&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.amazonaws.com%2Fuploads%2Farticles%2F30r97cd1oqq3q8x04nuj.webp" 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.amazonaws.com%2Fuploads%2Farticles%2F30r97cd1oqq3q8x04nuj.webp" alt="VM cluster topology — vault VM acts as PKI CA, cp01 is the control plane, worker01 and worker02 are worker nodes." width="800" height="650"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Creating the VMs.
&lt;/h2&gt;

&lt;p&gt;OrbStack VMs are Ubuntu Noble (24.04) on ARM64. They boot in under three seconds and share memory with the host - they only consume what they actually need.&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;# 💻 Mac&lt;/span&gt;
orb create ubuntu:noble vault
orb create ubuntu:noble cp01
orb create ubuntu:noble worker01
orb create ubuntu:noble worker02

&lt;span class="c"&gt;# Verify&lt;/span&gt;
orb list
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Getting into any VM is short:&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;# 💻 Mac&lt;/span&gt;
ssh vault@orb
ssh cp01@orb
ssh worker01@orb
ssh worker02@orb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No key management. No IP lookup. OrbStack handles SSH automatically — which alone is worth the switch for me, since I’ve spent too much of my life copy-pasting SSH commands around.&lt;/p&gt;

&lt;h2&gt;
  
  
  Record the IPs:
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 💻 Mac&lt;/span&gt;
&lt;span class="k"&gt;for &lt;/span&gt;vm &lt;span class="k"&gt;in &lt;/span&gt;vault cp01 worker01 worker02&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$vm&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;orb run &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="nv"&gt;$vm&lt;/span&gt; &lt;span class="nb"&gt;hostname&lt;/span&gt; &lt;span class="nt"&gt;-I&lt;/span&gt; | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $1}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vault: 192.168.139.100
cp01: 192.168.139.101
worker01: 192.168.139.102
worker02: 192.168.139.103
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Set these on your Mac and persist them on each VM:&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;# 💻 Mac&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;VAULT_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.139.100
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CP_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.139.101
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;W1_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.139.102
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;W2_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;192.168.139.103
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 🖥️ VM: vault / cp01 / worker01 / worker02 (run on each)&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc &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;
export VAULT_IP=&amp;lt;vault-ip&amp;gt;
export CP_IP=&amp;lt;cp01-ip&amp;gt;
export W1_IP=&amp;lt;worker01-ip&amp;gt;
export W2_IP=&amp;lt;worker02-ip&amp;gt;
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Why persist to .bashrc?&lt;/strong&gt; OrbStack VM shell sessions don't share environment variables. Every time you SSH into a VM, you start with a clean environment. Persisting the IPs to .bashrc means they're always available without re-exporting. This is the single biggest day-to-day friction in the entire lab — I'll come back to it in Part 7.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  /etc/hosts on all VMs:
&lt;/h2&gt;

&lt;p&gt;Each VM needs to resolve the others by hostname:&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;# 🖥️ VM: vault / cp01 / worker01 / worker02 (run on each)&lt;/span&gt;
&lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="nt"&gt;-a&lt;/span&gt; /etc/hosts &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;
&lt;/span&gt;&lt;span class="nv"&gt;$VAULT_IP&lt;/span&gt;&lt;span class="sh"&gt;    vault vault.lab.local
&lt;/span&gt;&lt;span class="nv"&gt;$CP_IP&lt;/span&gt;&lt;span class="sh"&gt;       cp01 cp01.lab.local kubernetes
&lt;/span&gt;&lt;span class="nv"&gt;$W1_IP&lt;/span&gt;&lt;span class="sh"&gt;       worker01 worker01.lab.local
&lt;/span&gt;&lt;span class="nv"&gt;$W2_IP&lt;/span&gt;&lt;span class="sh"&gt;       worker02 worker02.lab.local
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Why Vault PKI, not kubeadm’s defaults?
&lt;/h2&gt;

&lt;p&gt;This is the part most local-cluster tutorials skip. They use whatever self-signed certificates kubeadm generates automatically. That works, but it has nothing in common with how certificates are managed in a real enterprise cluster.&lt;/p&gt;

&lt;p&gt;In production, I use Vault PKI because it gives me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A proper certificate hierarchy — Root CA → Intermediate CA → leaf certificates.&lt;/li&gt;
&lt;li&gt;Short-lived credentials — admin certificates with one- or two-hour TTLs instead of long-lived kubeconfig credentials.&lt;/li&gt;
&lt;li&gt;Auditability — every certificate issuance is logged in Vault.&lt;/li&gt;
&lt;li&gt;Role-based issuance — separate roles for kube-apiserver, kubelet, etcd, and admin, each with its own constraints.
Setting it up locally means the mental model from the lab matches production. It also means when something goes wrong with cert rotation in production, you’ve already debugged it once on your laptop, where the stakes are zero.&lt;/li&gt;
&lt;/ul&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.amazonaws.com%2Fuploads%2Farticles%2F0ehpekik7zqtilq7zk66.webp" 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.amazonaws.com%2Fuploads%2Farticles%2F0ehpekik7zqtilq7zk66.webp" alt="3-tier PKI hierarchy — Root CA signs the Intermediate CA, which issues all cluster certificates.&amp;lt;br&amp;gt;
" width="800" height="610"&gt;&lt;/a&gt;&lt;/p&gt;


&lt;h2&gt;
  
  
  Installing Vault on the vault VM.
&lt;/h2&gt;

&lt;p&gt;The minimal OrbStack Ubuntu image doesn’t include &lt;code&gt;gpg&lt;/code&gt; by default. This trips people up. Adding the HashiCorp repo before installing &lt;code&gt;gpg&lt;/code&gt; leaves a broken, unsigned repo entry that blocks every future &lt;code&gt;apt update&lt;/code&gt; call. Always install &lt;code&gt;gpg&lt;/code&gt; first and clean up any stale entries from a previous 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="c"&gt;# 🖥️ VM: vault&lt;/span&gt;

&lt;span class="c"&gt;# Remove any stale repo entry from a previous attempt&lt;/span&gt;
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /etc/apt/sources.list.d/hashicorp.list

&lt;span class="c"&gt;# Install prerequisites first&lt;/span&gt;
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; gpg curl apt-transport-https ca-certificates jq

&lt;span class="c"&gt;# Import HashiCorp GPG key&lt;/span&gt;
curl &lt;span class="nt"&gt;-fsSL&lt;/span&gt; https://apt.releases.hashicorp.com/gpg | &lt;span class="se"&gt;\&lt;/span&gt;
  &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; /usr/share/keyrings/hashicorp-archive-keyring.gpg

&lt;span class="c"&gt;# Add repo with signed-by reference&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [arch=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;dpkg &lt;span class="nt"&gt;--print-architecture&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] &lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
  https://apt.releases.hashicorp.com &lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;lsb_release &lt;span class="nt"&gt;-cs&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt; main"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/apt/sources.list.d/hashicorp.list

&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; vault
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Configure and start Vault:
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 🖥️ VM: vault&lt;/span&gt;

&lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /opt/vault/data

&lt;span class="nb"&gt;sudo chown&lt;/span&gt; &lt;span class="nt"&gt;-R&lt;/span&gt; vault:vault /opt/vault/data

&lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/vault.d/vault.hcl &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;
storage "file" {
  path = "/opt/vault/data"
}
listener "tcp" {
  address     = "0.0.0.0:8200"
  tls_disable = true
}
ui       = true
api_addr = "http://vault.lab.local:8200"
&lt;/span&gt;&lt;span class="no"&gt;EOF

&lt;/span&gt;&lt;span class="nb"&gt;sudo chown &lt;/span&gt;vault:vault /etc/vault.d/vault.hcl

&lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl &lt;span class="nb"&gt;enable &lt;/span&gt;vault &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;systemctl start vault

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;VAULT_ADDR&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'http://127.0.0.1:8200'&lt;/span&gt;

vault status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Initialize and unseal:
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 🖥️ VM: vault&lt;/span&gt;

&lt;span class="c"&gt;# Save to home dir - writing to /root gives "permission denied"&lt;/span&gt;
vault operator init &lt;span class="nt"&gt;-key-shares&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="nt"&gt;-key-threshold&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1 &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; ~/vault-init.txt

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;VAULT_UNSEAL_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'Unseal Key 1'&lt;/span&gt; ~/vault-init.txt | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $NF}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;VAULT_ROOT_TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'Initial Root Token'&lt;/span&gt; ~/vault-init.txt | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $NF}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;

vault operator unseal &lt;span class="nv"&gt;$VAULT_UNSEAL_KEY&lt;/span&gt;
vault login &lt;span class="nv"&gt;$VAULT_ROOT_TOKEN&lt;/span&gt;

&lt;span class="c"&gt;# Persist so these survive session restarts&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"export VAULT_ADDR='http://127.0.0.1:8200'"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"export VAULT_TOKEN=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'Initial Root Token'&lt;/span&gt; ~/vault-init.txt | &lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'{print $NF}'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&amp;gt;&lt;/span&gt; ~/.bashrc

&lt;span class="nb"&gt;source&lt;/span&gt; ~/.bashrc
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Save ~/vault-init.txt carefully&lt;/strong&gt;. With a single key share, this file contains everything needed to unseal and access Vault. In production, use multiple key shares and distribute them. For a lab, one share is fine.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Building the 3-tier PKI:
&lt;/h2&gt;

&lt;h3&gt;
  
  
   Root CA:
&lt;/h3&gt;

&lt;p&gt;The Root CA signs nothing directly except the Intermediate CA. In a real production setup, the root key would be kept offline. Here we keep it in the Vault, but never use it for anything else after the initial intermediate signing.&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;# 🖥️ VM: vault&lt;/span&gt;

vault secrets &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pki pki
vault secrets tune &lt;span class="nt"&gt;-max-lease-ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;87600h pki   &lt;span class="c"&gt;# 10 years&lt;/span&gt;

vault write &lt;span class="nt"&gt;-field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;certificate pki/root/generate/internal &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;common_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Lab Root CA"&lt;/span&gt; &lt;span class="nv"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;87600h &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/root-ca.crt

vault write pki/config/urls &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;issuing_certificates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://vault.lab.local:8200/v1/pki/ca"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;crl_distribution_points&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://vault.lab.local:8200/v1/pki/crl"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  K8s Intermediate CA: the part most guides get wrong:
&lt;/h3&gt;

&lt;p&gt;Here is where most guides go wrong. The default intermediate CA type in Vault is &lt;code&gt;internal&lt;/code&gt;; the private key is generated inside Vault and never leaves. That's great for security, but kubeadm needs the CA private key on disk at &lt;code&gt;/etc/kubernetes/pki/ca.key&lt;/code&gt; to sign cluster certificates.&lt;br&gt;
&lt;strong&gt;You must use the exported type&lt;/strong&gt;. The private key is returned only once, at generation time, so save the full JSON response before extracting anything from it. If you lose this output, you can't recover the key from Vault later. I learned this the hard way.&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;# 🖥️ VM: vault&lt;/span&gt;

vault secrets &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;-path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pki_k8s pki
vault secrets tune &lt;span class="nt"&gt;-max-lease-ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;43800h pki_k8s   &lt;span class="c"&gt;# 5 years&lt;/span&gt;

&lt;span class="c"&gt;# CRITICAL: use 'exported' not 'internal'&lt;/span&gt;
&lt;span class="c"&gt;# Save the FULL JSON - private key is only returned at generation time&lt;/span&gt;
vault write &lt;span class="nt"&gt;-format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json pki_k8s/intermediate/generate/exported &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;common_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"Lab K8s Intermediate CA"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | &lt;span class="nb"&gt;tee&lt;/span&gt; /tmp/intermediate-full.json &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.data.csr'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/k8s-intermediate.csr

&lt;span class="c"&gt;# Extract and save the private key immediately&lt;/span&gt;
jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.data.private_key'&lt;/span&gt; /tmp/intermediate-full.json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/ca.key

&lt;span class="c"&gt;# Verify - must show -----BEGIN RSA PRIVATE KEY-----&lt;/span&gt;
&lt;span class="nb"&gt;cat&lt;/span&gt; /tmp/ca.key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Sign the intermediate with the Root CA and import it back:&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;# 🖥️ VM: vault&lt;/span&gt;

vault write &lt;span class="nt"&gt;-format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json pki/root/sign-intermediate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;csr&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;@/tmp/k8s-intermediate.csr &lt;span class="nv"&gt;format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;pem_bundle &lt;span class="nv"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;43800h &lt;span class="se"&gt;\&lt;/span&gt;
  | jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.data.certificate'&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/k8s-intermediate-signed.pem

vault write pki_k8s/intermediate/set-signed &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;certificate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;@/tmp/k8s-intermediate-signed.pem

&lt;span class="c"&gt;# Configure AIA URLs - resolves the "authority information access" warning&lt;/span&gt;
vault write pki_k8s/config/urls &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;issuing_certificates&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://vault.lab.local:8200/v1/pki_k8s/ca"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;crl_distribution_points&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://vault.lab.local:8200/v1/pki_k8s/crl"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Roles:
&lt;/h3&gt;

&lt;p&gt;Roles define what certificates each component of the cluster can request:&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;# 🖥️ VM: vault&lt;/span&gt;

&lt;span class="c"&gt;# API server - SANs for all DNS names and IPs the apiserver answers on&lt;/span&gt;
vault write pki_k8s/roles/kube-apiserver &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;allowed_domains&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kubernetes,kubernetes.default,kubernetes.default.svc,kubernetes.default.svc.cluster.local,cp01,cp01.lab.local"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;allow_bare_domains&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true &lt;/span&gt;&lt;span class="nv"&gt;allow_subdomains&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true &lt;/span&gt;&lt;span class="nv"&gt;allow_ip_sans&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true &lt;/span&gt;&lt;span class="nv"&gt;max_ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8760h

&lt;span class="c"&gt;# Kubelet - nodes in the system:nodes group&lt;/span&gt;
vault write pki_k8s/roles/kubelet &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;allowed_domains&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"system:node"&lt;/span&gt; &lt;span class="nv"&gt;allow_bare_domains&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;organization&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"system:nodes"&lt;/span&gt; &lt;span class="nv"&gt;max_ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8760h

&lt;span class="c"&gt;# Admin - short-lived, 2 hour maximum (CKS best practice).&lt;/span&gt;
&lt;span class="c"&gt;# This forces regular credential rotation and prevents long-lived kubeconfig files.&lt;/span&gt;
vault write pki_k8s/roles/admin &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;allowed_domains&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kubernetes-admin"&lt;/span&gt; &lt;span class="nv"&gt;allow_bare_domains&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;organization&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"system:masters"&lt;/span&gt; &lt;span class="nv"&gt;server_flag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false &lt;/span&gt;&lt;span class="nv"&gt;client_flag&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true &lt;/span&gt;&lt;span class="nv"&gt;max_ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2h

&lt;span class="c"&gt;# etcd&lt;/span&gt;
vault write pki_k8s/roles/etcd &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;allow_any_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true &lt;/span&gt;&lt;span class="nv"&gt;max_ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8760h

&lt;span class="c"&gt;# Policy for cluster bootstrap&lt;/span&gt;
vault policy write k8s-pki - &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;
path "pki_k8s/issue/*" { capabilities = ["create","update"] }
path "pki_k8s/sign/*"  { capabilities = ["create","update"] }
path "pki_k8s/ca"      { capabilities = ["read"] }
path "pki_k8s/ca/pem"  { capabilities = ["read"] }
path "pki_k8s/crl"     { capabilities = ["read"] }
&lt;/span&gt;&lt;span class="no"&gt;EOF
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Distributing the CA cert and key to cp01
&lt;/h2&gt;

&lt;p&gt;kubeadm looks for &lt;code&gt;/etc/kubernetes/pki/ca.crt&lt;/code&gt; and &lt;code&gt;/etc/kubernetes/pki/ca.key&lt;/code&gt; before init. If both files are present, it uses them as the cluster CA instead of generating new self-signed certificates, which is exactly what we want.&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;# 🖥️ VM: vault — issue an apiserver cert to extract the CA cert&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CP_IP&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&amp;lt;cp01-ip&amp;gt;   &lt;span class="c"&gt;# set explicitly — does not persist across sessions&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"CP_IP=&lt;/span&gt;&lt;span class="nv"&gt;$CP_IP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

vault write &lt;span class="nt"&gt;-format&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;json pki_k8s/issue/kube-apiserver &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;common_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kubernetes"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;alt_names&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"kubernetes,kubernetes.default,kubernetes.default.svc,kubernetes.default.svc.cluster.local,cp01"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nv"&gt;ip_sans&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$CP_IP&lt;/span&gt;&lt;span class="s2"&gt;,127.0.0.1,10.96.0.1"&lt;/span&gt; &lt;span class="nv"&gt;ttl&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;8760h &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/apiserver-cert.json
jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.data.issuing_ca'&lt;/span&gt; /tmp/apiserver-cert.json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/ca.crt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 💻 Mac — pull both files and push them to cp01&lt;/span&gt;
orb run &lt;span class="nt"&gt;-m&lt;/span&gt; vault &lt;span class="nb"&gt;cat&lt;/span&gt; /tmp/ca.crt &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/lab-ca.crt
orb run &lt;span class="nt"&gt;-m&lt;/span&gt; vault &lt;span class="nb"&gt;cat&lt;/span&gt; /tmp/ca.key &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/lab-ca.key

orb run &lt;span class="nt"&gt;-m&lt;/span&gt; cp01 &lt;span class="nb"&gt;sudo mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; /etc/kubernetes/pki
&lt;span class="nb"&gt;cat&lt;/span&gt; /tmp/lab-ca.crt | orb run &lt;span class="nt"&gt;-m&lt;/span&gt; cp01 &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/kubernetes/pki/ca.crt
&lt;span class="nb"&gt;cat&lt;/span&gt; /tmp/lab-ca.key | orb run &lt;span class="nt"&gt;-m&lt;/span&gt; cp01 &lt;span class="nb"&gt;sudo tee&lt;/span&gt; /etc/kubernetes/pki/ca.key

&lt;span class="c"&gt;# kubeadm requires strict permissions on ca.key&lt;/span&gt;
orb run &lt;span class="nt"&gt;-m&lt;/span&gt; cp01 &lt;span class="nb"&gt;sudo chmod &lt;/span&gt;600 /etc/kubernetes/pki/ca.key
orb run &lt;span class="nt"&gt;-m&lt;/span&gt; cp01 &lt;span class="nb"&gt;sudo ls&lt;/span&gt; &lt;span class="nt"&gt;-la&lt;/span&gt; /etc/kubernetes/pki/
&lt;span class="c"&gt;# -rw-r--r-- ca.crt&lt;/span&gt;
&lt;span class="c"&gt;# -rw------- ca.key&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;&lt;strong&gt;Why orb run -m vault cat instead of scp or orb shell?&lt;/strong&gt; When you need file content on the Mac, orb run pipes stdout directly. orb shell opens an interactive session and SCP requires setting up keys. For simple file reads, orb run -m  cat  &amp;gt; local-file is the cleanest approach.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  A note on /tmp
&lt;/h2&gt;

&lt;p&gt;/tmp on OrbStack VMs does not persist across reboots. The CA cert and key you just extracted will be gone the next time you restart the vault VM. That's fine — you can always regenerate them from Vault:&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;# 🖥️ VM: vault — regenerate CA cert after reboot&lt;/span&gt;
vault &lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;certificate pki_k8s/issuer/default &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/lab-ca.crt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The private key &lt;code&gt;(/tmp/ca.key)&lt;/code&gt; was saved from the &lt;code&gt;exported&lt;/code&gt; generation step. If you need it again, re-export from &lt;code&gt;~/intermediate-full.json&lt;/code&gt;, which is in your home directory and does persist:&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;# 🖥️ VM: vault&lt;/span&gt;
jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.data.private_key'&lt;/span&gt; ~/intermediate-full.json &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/ca.key
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Where we are
&lt;/h2&gt;

&lt;p&gt;At this point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;✅ Four OrbStack VMs created and networked&lt;/li&gt;
&lt;li&gt;✅ /etc/hosts configured on all VMs for hostname resolution&lt;/li&gt;
&lt;li&gt;✅ Vault installed, initialised, and unsealed on the vault VM&lt;/li&gt;
&lt;li&gt;✅ 3-tier PKI: Root CA → K8s Intermediate CA (exported type)&lt;/li&gt;
&lt;li&gt;✅ Roles for kube-apiserver, kubelet, etcd, and short-lived admin&lt;/li&gt;
&lt;li&gt;✅ ca.crt and ca.key placed on cp01 ready for kubeadm
In Part 4, we run kubeadm init and hit the most interesting problem in this entire setup — why Calico works on M4 but quietly fails on M1, and how Cilium's eBPF dataplane solves it.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;← Part 2: &lt;a href="https://dev.to/nkmakau/one-command-one-working-kubernetes-cluster-building-my-daily-driver-lab-on-orbstack-34k5"&gt;One Command, One Working Kubernetes Cluster! Building My Daily-Driver Lab on OrbStack&lt;/a&gt; | Part 4: &lt;a href="https://dev.to/nkmakau/same-cluster-different-mac-a-debugging-story-about-unprivileged-lxc-containers-iptables-and-why-2e8g"&gt;Same Cluster, Different Mac: A Debugging Story About Unprivileged LXC Containers, iptables, and Why Cilium Replaces kube-proxy&lt;/a&gt; →&lt;/p&gt;




&lt;p&gt;I’m Noah Makau, a DevSecOps engineer based in Nairobi. I run a small DevOps consultancy and hold CKA, CKAD, and the AWS Solutions Architect Professional certifications, currently preparing for CKS. I write about Kubernetes, Vault, Crossplane, and the day-to-day of running platforms that actually have to stay up.&lt;/p&gt;

&lt;p&gt;originally published at &lt;a href="https://blog.arkilasystems.com/building-a-production-grade-vault-pki-for-a-local-kubeadm-cluster-without-the-shortcuts" rel="noopener noreferrer"&gt;blog.arkilasystems.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>platformengineering</category>
      <category>kubernetes</category>
      <category>tutorial</category>
      <category>cks</category>
    </item>
    <item>
      <title>One Command, One Working Kubernetes Cluster! Building My Daily-Driver Lab on OrbStack.</title>
      <dc:creator>Noah Makau</dc:creator>
      <pubDate>Fri, 22 May 2026 09:41:26 +0000</pubDate>
      <link>https://forem.com/nkmakau/one-command-one-working-kubernetes-cluster-building-my-daily-driver-lab-on-orbstack-34k5</link>
      <guid>https://forem.com/nkmakau/one-command-one-working-kubernetes-cluster-building-my-daily-driver-lab-on-orbstack-34k5</guid>
      <description>&lt;p&gt;Part 2 of 7 — The Mac Kubernetes Lab: A Production-Mirror Setup from Scratch.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;Previously in Part 1: I walked through why I replaced Multipass with OrbStack, the dual-cluster architecture I settled on, and a preview of the M1 vs M4 CNI problem that’s coming in Part 4.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;p&gt;The cluster I am going to set up in this article is the one I spend most of my working day inside. It’s a single-node Kubernetes cluster, always on, idles at around 512 MB of memory, has real LoadBalancer IPs and wildcard DNS out of the box. No MetalLB, &lt;code&gt;/etc/hosts&lt;/code&gt; editing,&lt;code&gt;kubectl port-forward muscle&lt;/code&gt; memory. By the time this article is done, it will also have Istio, Vault, and Crossplane running. Total elapsed time, the first time you do it: about ten minutes.&lt;/p&gt;

&lt;p&gt;If you’ve ever built a local Kubernetes cluster and then spent the next twenty minutes wiring up MetalLB and editing /etc/hosts so you can actually reach a service from a browser, this is going to feel almost suspicious.&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.amazonaws.com%2Fuploads%2Farticles%2Fyihd3wudmhxo2di30pb5.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fyihd3wudmhxo2di30pb5.webp" alt="OrbStack native K8s up &amp;amp; running — one command, instant cluster.&amp;lt;br&amp;gt;
Starting the cluster" width="798" height="244"&gt;&lt;/a&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="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;💻 Mac
&lt;span class="go"&gt;orb start k8s

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Confirm cluster is udr
&lt;span class="go"&gt;kubectl get nodes
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;NAME       STATUS   ROLES                  AGE   VERSION
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;orbstack   Ready    control-plane,master   30s   v1.33.x
&lt;span class="go"&gt;
kubectl config current-context
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;orbstack
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That’s all! No kubeadm, no CNI configuration, no certificate management. The cluster is up and reachable in under thirty seconds the first time, and instantly on every subsequent start.&lt;/p&gt;

&lt;h2&gt;
  
  
  What makes OrbStack’s networking special?
&lt;/h2&gt;

&lt;p&gt;This is where OrbStack genuinely earns its keep. On a typical local cluster; kind, minikube, kubeadm, LoadBalancer services stay in &lt;code&gt;pending&lt;/code&gt; state until you install MetalLB on OrbStack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LoadBalancer services get a real, reachable IP automatically.&lt;/li&gt;
&lt;li&gt;*.k8s.orb.local wildcard DNS resolves from your Mac browser, with no /etc/hosts entry.&lt;/li&gt;
&lt;li&gt;cluster.local DNS also resolves from your Mac.&lt;/li&gt;
&lt;li&gt;Every service type is reachable without kubectl port-forward.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;⚠️ The wildcard DNS only resolves on your Mac. Other devices on your network won’t see *.k8s.orb.local. If you need a service reachable from another machine, that's what Cluster 2 (Parts 3–6) is for.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Installing Istio via Helm.
&lt;/h2&gt;

&lt;p&gt;I use Helm rather than istioctl for two reasons.&lt;br&gt;
First, it's how I manage Istio on the production EKS clusters at work, so the muscle memory transfers.&lt;br&gt;
Second, Helm gives fine-grained control over resource requests, which matters on a laptop.&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.amazonaws.com%2Fuploads%2Farticles%2F1e3t8pr3okzq60u5bjdu.webp" 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.amazonaws.com%2Fuploads%2Farticles%2F1e3t8pr3okzq60u5bjdu.webp" alt="Istio components — istiod control plane, ingress gateway, and sidecar proxies" width="800" height="523"&gt;&lt;/a&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="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;💻 Mac
&lt;span class="go"&gt;kubectx orbstack

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Add helm charts
&lt;span class="go"&gt;helm repo add istio https://istio-release.storage.googleapis.com/charts
helm repo update

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Step 1 - Base CRDs
&lt;span class="go"&gt;helm install istio-base istio/base \
  --namespace istio-system --create-namespace \
  --set defaultRevision=default

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Step 2 - Control plane
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;The PILOT_ENABLE_WORKLOAD_ENTRY_AUTOREGISTRATION flag is required on OrbStack
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;to prevent DNS resolution conflicts with the host network.
&lt;span class="go"&gt;helm install istiod istio/istiod \
  --namespace istio-system \
  --set pilot.env.PILOT_ENABLE_WORKLOAD_ENTRY_AUTOREGISTRATION=true \
  --set global.proxy.resources.requests.cpu=10m \
  --set global.proxy.resources.requests.memory=64Mi \
  --wait

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Step 3 - Ingress gateway
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;OrbStack assigns a real LoadBalancer IP automatically - no MetalLB needed.
&lt;span class="go"&gt;helm install istio-ingress istio/gateway \
  --namespace istio-ingress --create-namespace \
  --set service.type=LoadBalancer

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Verify the gateway got an EXTERNAL-IP
&lt;span class="go"&gt;kubectl get svc -n istio-ingress
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;NAME            TYPE           CLUSTER-IP   EXTERNAL-IP   PORT&lt;span class="o"&gt;(&lt;/span&gt;S&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;istio-ingress   LoadBalancer   10.x.x.x     198.19.x.x    80:xxx/TCP
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Enable sidecar injection on the default namespace
&lt;span class="go"&gt;kubectl label namespace default istio-injection=enabled
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Gateways and Virtual Services on the native cluster.
&lt;/h2&gt;

&lt;p&gt;This is the moment OrbStack feels like cheating. You create a Gateway pointing at &lt;code&gt;*.k8s.orb.local&lt;/code&gt;, and it just works from your Mac browser. No IP lookups. No &lt;code&gt;/etc/hosts&lt;/code&gt;, no &lt;code&gt;127.0.0.1:8080&lt;/code&gt; proxying.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the traffic actually flows.
&lt;/h2&gt;

&lt;p&gt;The Gateway resource binds to the istio-ingress LoadBalancer service, OrbStack intercepts traffic to the *.k8s.orb.localwildcard domain at the Mac level and routes it to that LoadBalancer IP. The Virtual Service then routes inside the cluster to the right service.&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.amazonaws.com%2Fuploads%2Farticles%2Fqsvlr4bq5nyx4std6dtl.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fqsvlr4bq5nyx4std6dtl.webp" alt="Traffic flow from Mac browser through OrbStack wildcard DNS to a pod" width="800" height="638"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Example 1: Basic routing with httpbin:
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# 💻 Mac&lt;/span&gt;
&lt;span class="s"&gt;kubectl apply -f - &amp;lt;&amp;lt;EOF&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;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;httpbin&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;httpbin&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;httpbin&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;httpbin&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;kennethreitz/httpbin:latest&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="nn"&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;httpbin&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;httpbin&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;targetPort&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
&lt;span class="nn"&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;networking.istio.io/v1beta1&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;Gateway&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;httpbin-gateway&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;istio&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;servers&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="na"&gt;number&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&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;HTTP&lt;/span&gt;
    &lt;span class="na"&gt;hosts&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;httpbin.k8s.orb.local"&lt;/span&gt;
&lt;span class="nn"&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;networking.istio.io/v1beta1&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;VirtualService&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;httpbin&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;hosts&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;httpbin.k8s.orb.local"&lt;/span&gt;
  &lt;span class="na"&gt;gateways&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;httpbin-gateway&lt;/span&gt;
  &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;destination&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="s"&gt;httpbin&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;span class="s"&gt;EOF&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Open &lt;code&gt;http://httpbin.k8s.orb.local&lt;/code&gt; in your Mac browser. It works immediately.&lt;br&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.amazonaws.com%2Fuploads%2Farticles%2Fpimxbbaopvybekpdb3wx.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fpimxbbaopvybekpdb3wx.webp" alt="httpbin screenshot" width="800" height="487"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  Example 2: Traffic splitting (canary):
&lt;/h3&gt;

&lt;p&gt;The same pattern used in production canary deployments:&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="c1"&gt;# 💻 Mac&lt;/span&gt;
&lt;span class="s"&gt;kubectl apply -f - &amp;lt;&amp;lt;EOF&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;networking.istio.io/v1beta1&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;VirtualService&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-split&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;hosts&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;myapp.k8s.orb.local"&lt;/span&gt;
  &lt;span class="na"&gt;gateways&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;myapp-gateway&lt;/span&gt;
  &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;destination&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="s"&gt;myapp-v1&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;span class="na"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;80&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;destination&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="s"&gt;myapp-v2&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;span class="na"&gt;weight&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;20&lt;/span&gt;
&lt;span class="s"&gt;EOF&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Example 3: Path-based routing:
&lt;/h3&gt;

&lt;p&gt;Route /v1 and /v2 to different backend services:&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="c1"&gt;# 💻 Mac&lt;/span&gt;
&lt;span class="s"&gt;kubectl apply -f - &amp;lt;&amp;lt;EOF&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;networking.istio.io/v1beta1&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;VirtualService&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;path-routing&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;hosts&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;api.k8s.orb.local"&lt;/span&gt;
  &lt;span class="na"&gt;gateways&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;api-gateway&lt;/span&gt;
  &lt;span class="na"&gt;http&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;prefix&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;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;destination&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="s"&gt;api-v1-svc&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;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;match&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;prefix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;/v2&lt;/span&gt;
    &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;destination&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="s"&gt;api-v2-svc&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;span class="s"&gt;EOF&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  One OrbStack gotcha: don’t enable httpsRedirect!
&lt;/h2&gt;

&lt;p&gt;Do not use httpsRedirect: true in a Gateway on the native cluster. OrbStack intercepts LoadBalancer traffic in a way that causes an infinite 301 redirect loop when TLS redirect is enabled.&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="c1"&gt;# ❌ This breaks on OrbStack native K8s&lt;/span&gt;
&lt;span class="na"&gt;servers&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;tls&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;httpsRedirect&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;

&lt;span class="c1"&gt;# ✅ Use plain HTTP on the native cluster&lt;/span&gt;
&lt;span class="na"&gt;servers&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="na"&gt;number&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;HTTP&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For TLS testing, use Cluster 2 (the VM cluster, coming in Part 3), where you have full control over the network stack.&lt;/p&gt;




&lt;h2&gt;
  
  
  Installing the rest of the daily stack:
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Vault in dev mode.
&lt;/h3&gt;

&lt;p&gt;Dev mode trades durability for speed. No unsealing, no persistence concerns, instant startup. It’s the right call for the daily-driver cluster where I’m testing AppRole workflows, PKI policies, or Kubernetes auth configurations, and the value is in iteration speed, not data preservation.&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;💻 Mac
&lt;span class="go"&gt;helm repo add hashicorp https://helm.releases.hashicorp.com
helm repo update

helm install vault hashicorp/vault \
  --namespace vault --create-namespace \
  --set "server.dev.enabled=true"

kubectl get pods -n vault
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vault-0   1/1   Running   0   30s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;For production-grade Vault with HA Raft storage, use Cluster 2 (Part 3). Dev mode here is intentional — it trades durability for speed.&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  Crossplane:
&lt;/h3&gt;

&lt;p&gt;Crossplane turns the Kubernetes cluster into a universal control plane for cloud infrastructure. I use it heavily with the AWS provider at work.&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;💻 Mac
&lt;span class="go"&gt;helm repo add crossplane-stable https://charts.crossplane.io/stable
helm repo update

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Note: &lt;span class="nt"&gt;--enable-composition-functions&lt;/span&gt; was removed &lt;span class="k"&gt;in &lt;/span&gt;newer versions.
&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Composition Functions are enabled by default now.
&lt;span class="go"&gt;helm install crossplane crossplane-stable/crossplane \
  --namespace crossplane-system --create-namespace

kubectl get pods -n crossplane-system
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Stop/start without losing your work.
&lt;/h2&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;💻 Mac
&lt;span class="go"&gt;
&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Stop - releases all CPU and RAM
&lt;span class="go"&gt;orb stop k8s

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Start - full state persists &lt;span class="o"&gt;(&lt;/span&gt;deployments, services, secrets, configmaps&lt;span class="o"&gt;)&lt;/span&gt;
&lt;span class="go"&gt;orb start k8s

&lt;/span&gt;&lt;span class="gp"&gt;#&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;Verify
&lt;span class="go"&gt;kubectx orbstack
kubectl get nodes
kubectl get pods -A
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The native cluster state persists across stop/start. Vault dev mode data is lost on restart by design, but everything else, Crossplane installations, Istio configuration, and your workload deployments come back exactly as provisioned.&lt;/p&gt;




&lt;h2&gt;
  
  
  Quick reference for this cluster:
&lt;/h2&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.amazonaws.com%2Fuploads%2Farticles%2Fiwylp0g2oztnehyb8ozk.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fiwylp0g2oztnehyb8ozk.webp" alt="Quick reference for this cluster." width="800" height="410"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  What’s next.
&lt;/h2&gt;

&lt;p&gt;The daily-driver cluster is the easy half of this lab. The hard half is the one that mirrors production, a real multi-node kubeadm cluster running on four VMs, with HashiCorp Vault as its certificate authority.&lt;/p&gt;

&lt;p&gt;That’s where Part 3 picks up: creating the VMs, wiring their networking, and standing up a 3-tier Vault PKI that will sign every certificate in the cluster.&lt;/p&gt;

&lt;p&gt;← Part 1: &lt;a href="https://dev.to/nkmakau/why-i-replaced-multipass-with-orbstack-and-built-a-better-kubernetes-lab-on-my-mac-50p"&gt;Why I Replaced Multipass with OrbStack..&lt;/a&gt; | Part 3: &lt;a href="https://dev.to/nkmakau/building-a-production-grade-vault-pki-for-a-local-kubeadm-cluster-without-the-shortcuts-km7"&gt;Building a Production-Grade Vault PKI for a Local kubeadm Cluster Without the Shortcuts&lt;/a&gt; →&lt;/p&gt;

&lt;p&gt;I’m Noah Makau, a DevSecOps engineer based in Nairobi. I run a small DevOps consultancy and hold CKA, CKAD, and AWS Solutions Architect Professional certifications, currently preparing for CKS. I write about Kubernetes, Vault, Crossplane, and the day-to-day of running platforms that actually have to stay up.&lt;/p&gt;

&lt;p&gt;Originally published at &lt;a href="https://blog.arkilasystems.com/one-command-one-working-kubernetes-cluster-building-my-daily-driver-lab-on-orbstack" rel="noopener noreferrer"&gt;blog.arkilasystems.com&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>platformengineering</category>
      <category>kubernetes</category>
      <category>devops</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why I Replaced Multipass with OrbStack and what an M1 vs M4 Mac taught me about local Kubernetes.</title>
      <dc:creator>Noah Makau</dc:creator>
      <pubDate>Fri, 15 May 2026 21:00:00 +0000</pubDate>
      <link>https://forem.com/nkmakau/why-i-replaced-multipass-with-orbstack-and-built-a-better-kubernetes-lab-on-my-mac-50p</link>
      <guid>https://forem.com/nkmakau/why-i-replaced-multipass-with-orbstack-and-built-a-better-kubernetes-lab-on-my-mac-50p</guid>
      <description>&lt;p&gt;&lt;em&gt;Part 1 of 7 — "The Mac Kubernetes Lab: A Production-Mirror Setup from Scratch"&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Here’s something nobody warned me about running Kubernetes on Apple Silicon: the same lab setup will work on one chip and fail silently on another. I learned this twice.&lt;/p&gt;

&lt;p&gt;The first time was when my Multipass-based cluster broke after I moved from an M1 Pro to an M4. Everything I’d put together over months — four VMs, a kubeadm cluster, the lot — stopped working in a way the error messages couldn’t quite explain. The QEMU drivers Multipass relies on weren’t compatible with the M4’s hardware, and there was no quick fix. The setup was gone.&lt;/p&gt;

&lt;p&gt;The second time was months later. I’d long since replaced Multipass with OrbStack on the M4, and it had been working beautifully. Out of curiosity, I tried to put the same OrbStack cluster on my old M1. The VMs came up fine. kubeadm finished. Calico installed. And then everything sat in NotReady for an entire evening while I read kernel docs to figure out why iptables NAT was silently misbehaving inside an unprivileged LXC container on one chip but not the other.&lt;/p&gt;

&lt;p&gt;What I ended up with is a Kubernetes lab that runs on either chip, mirrors a real EKS production cluster, and taught me more about Apple’s Virtualization Framework and Linux container capabilities than I ever wanted to know.&lt;/p&gt;

&lt;p&gt;This is part one of a seven-part series walking through that setup from scratch. By the end, you’ll have a dual-cluster Kubernetes environment on your Mac — one fast daily-driver cluster for iteration, one full VM-based cluster that mirrors production EKS, complete with Vault PKI, Istio, and Crossplane. We’ll start by replacing Multipass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Multipass setup that worked, then didn’t.
&lt;/h2&gt;

&lt;p&gt;For most of 202,4 my local Kubernetes setup was on Multipass. Four Ubuntu VMs on my M1 Pro, a kubeadm cluster, and a working developer loop. It wasn’t fast: Multipass VMs took 30 to 60 seconds to boot, and pre-allocated memory, whether you used it or not. It worked, and it was familiar. Then I upgraded to an M4, and it stopped.&lt;/p&gt;

&lt;p&gt;Multipass on macOS uses QEMU under the hood. The QEMU driver bundled with the version of Multipass I had didn’t play nicely with the M4’s silicon. VMs failed to launch, with errors that didn’t map cleanly to actionable issues. I spent a weekend on the Multipass GitHub issues, looking for someone with the same problem and a fix. That weekend is when I tried OrbStack instead, and I never went back.&lt;/p&gt;




&lt;h2&gt;
  
  
  What OrbStack is, and why the switch wasn’t just about boot time.
&lt;/h2&gt;

&lt;p&gt;OrbStack is a macOS-native tool for running Linux VMs and Docker containers. The thing that matters for our purposes is that it’s built specifically for Apple Silicon, using Apple’s Virtualization Framework, written in Swift, Go, Rust, and C — not a port of something originally designed for x86.&lt;/p&gt;

&lt;p&gt;The numbers that made me commit:&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.amazonaws.com%2Fuploads%2Farticles%2Fincgwqrshxzby6azeqr7.webp" 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.amazonaws.com%2Fuploads%2Farticles%2Fincgwqrshxzby6azeqr7.webp" alt="The numbers that made me commit" width="800" height="536"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The boot time alone is a different category of experience. Spinning up a four-VM cluster used to be a “go make coffee” event. With OrbStack, the whole cluster is up in under 15 seconds.&lt;/p&gt;

&lt;p&gt;But the part I underestimated was the networking. OrbStack shiwith a built-in Kubernetes cluster: one command and you have a working cluster with real LoadBalancer IPs and wildcard DNS out of the box. No MetalLB, and/etc/hosts editing. You apply a Service of type LoadBalancer, and you can hit it from your Mac browser at something.k8s.orb.local. The first time it just worked, I genuinely thought I had misconfigured it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;💡 Pricing note:&lt;/strong&gt; OrbStack is free for personal use, which covers everything in this series. If you’re rolling it out across a team or using it commercially at work, check orbstack.dev/pricing. The grey area worth knowing: a home lab on your own Mac is free. A work laptop doing your day job is commercial use.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Architecture: Two Clusters, One Tool
&lt;/h2&gt;

&lt;p&gt;After a few weeks with OrbStack, I settled on a dual-cluster setup. Two clusters, two purposes.&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.amazonaws.com%2Fuploads%2Farticles%2F471n7in3vinjohat7kox.webp" 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.amazonaws.com%2Fuploads%2Farticles%2F471n7in3vinjohat7kox.webp" alt="Dual-cluster architecture — OrbStack native K8s (daily driver) + VM kubeadm cluster (EKS mirror)" width="800" height="511"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Cluster 1 — OrbStack Native K8s (Daily Driver)
&lt;/h3&gt;

&lt;p&gt;The built-in cluster handles fast iteration work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Crossplane compositions and provider development&lt;/li&gt;
&lt;li&gt;HashiCorp Vault AppRole workflows&lt;/li&gt;
&lt;li&gt;Helm chart testing&lt;/li&gt;
&lt;li&gt;Istio Gateway and VirtualService experimentation — though I break this constantly, which is fine&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Switch to it with &lt;code&gt;kubectx orbstack&lt;/code&gt;. Services are reachable at &lt;code&gt;*.k8s.orb.local&lt;/code&gt; from your browser immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cluster 2 — VM kubeadm Cluster (EKS Mirror + CKS Lab)
&lt;/h3&gt;

&lt;p&gt;Four OrbStack Linux VMs running a real kubeadm-bootstrapped cluster:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;K8s 1.34 — matching our upcoming EKS upgrade target at Arkila Systems&lt;/li&gt;
&lt;li&gt;Vault PKI as the cluster Certificate Authority&lt;/li&gt;
&lt;li&gt;Istio with revision-based upgrades, identical to our EKS approach&lt;/li&gt;
&lt;li&gt;Crossplane with AWS provider&lt;/li&gt;
&lt;li&gt;Multi-node topology (control plane + 2 workers) mirroring production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is also my CKS exam preparation environment — Pod Security Admission, audit policies, NetworkPolicy, short-lived admin certificates via Vault.&lt;/p&gt;




&lt;h2&gt;
  
  
  Apple Silicon Compatibility — M1 vs M4
&lt;/h2&gt;

&lt;p&gt;This is the part I didn’t see coming until I tried it.&lt;/p&gt;

&lt;p&gt;OrbStack works flawlessly on M4. I built the entire dual-cluster setup, including the kubeadm VM cluster, and every component came up on the first try. Months later, on a whim, I tried to replicate the same setup on my M1 Pro. The VMs booted, kubeadm initialised, and I installed Calico.&lt;/p&gt;

&lt;p&gt;Nothing worked!!&lt;/p&gt;

&lt;p&gt;Nodes stayed NotReady. kube-proxy crashed in a loop. The tigera-operator pod logged dial tcp 10.96.0.1:443: connect: connection refused. The Calico pods looked healthy from the outside, but were completely non-functional from the inside.&lt;/p&gt;

&lt;p&gt;The root cause turned out to be a kernel capability difference:&lt;/p&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;M4 Mac&lt;/th&gt;
&lt;th&gt;M1 / M2 / M3 Mac&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OrbStack VM type&lt;/td&gt;
&lt;td&gt;Unprivileged LXC&lt;/td&gt;
&lt;td&gt;Unprivileged LXC&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;iptables NAT&lt;/td&gt;
&lt;td&gt;✅ Works&lt;/td&gt;
&lt;td&gt;❌ Restricted&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recommended CNI&lt;/td&gt;
&lt;td&gt;Calico&lt;/td&gt;
&lt;td&gt;Cilium (eBPF)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kube-proxy&lt;/td&gt;
&lt;td&gt;Standard&lt;/td&gt;
&lt;td&gt;Replaced by Cilium&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;OrbStack VMs run as unprivileged LXC containers on both chips. On M4, the iptables NAT table is fully writable from inside the container. On M1, M2, and M3, it isn’t! Kube-proxy can’t write the KUBE-SERVICES chain, which means ClusterIP services aren't reachable, which means any CNI plugin that tries to call the Kubernetes API server through the ClusterIP 10.96.0.1 fails to start. Calico is one of those plugins. So is half the ecosystem! &lt;/p&gt;

&lt;p&gt;The fix is Cilium, which uses eBPF-based service routing instead of iptables and completely replaces kube-proxy. I’ll cover the full debugging walk-through and the Cilium install in Part 4, because it’s the most technically interesting thing in this series — and probably useful to anyone running unprivileged Linux containers in restricted environments, not just OrbStack on Apple Silicon.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You'll Need
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Apple Silicon Mac (M1, M2, M3, or M4)&lt;/li&gt;
&lt;li&gt;Homebrew installed&lt;/li&gt;
&lt;li&gt;At least 16 GB RAM — 8 GB will technically work but you'll feel it when all four VMs are running&lt;/li&gt;
&lt;li&gt;About 20 GB free disk space
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# 💻 Mac — check your chip&lt;/span&gt;
system_profiler SPHardwareDataType | &lt;span class="nb"&gt;grep &lt;/span&gt;Chip

&lt;span class="c"&gt;# Check available disk&lt;/span&gt;
&lt;span class="nb"&gt;df&lt;/span&gt; &lt;span class="nt"&gt;-h&lt;/span&gt; ~
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;If you’re on an Intel Mac, this series isn’t for you — none of it depends on x86, but the whole point is the Apple Silicon experience, and I haven’t tested any of it on Intel.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The Multipass → OrbStack migration, if you’re coming from Multipass.
&lt;/h2&gt;

&lt;p&gt;The command mapping in short:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Multipass&lt;/th&gt;
&lt;th&gt;OrbStack&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;multipass launch ubuntu&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;orb create ubuntu&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;multipass shell &amp;lt;name&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ssh &amp;lt;name&amp;gt;@orb&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;multipass exec &amp;lt;name&amp;gt; -- cmd&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;orb run -m &amp;lt;name&amp;gt; cmd&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;multipass list&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;orb list&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;multipass delete &amp;lt;name&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;orb delete &amp;lt;name&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;multipass stop &amp;lt;name&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;orb stop &amp;lt;name&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;multipass stop --all&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;orb stop -a&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Install OrbStack and clean out Multipass:&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;# 💻 Mac&lt;/span&gt;
brew &lt;span class="nb"&gt;install &lt;/span&gt;orbstack
open &lt;span class="nt"&gt;-a&lt;/span&gt; OrbStack   &lt;span class="c"&gt;# required once for first-time GUI setup&lt;/span&gt;

&lt;span class="c"&gt;# Remove Multipass&lt;/span&gt;
brew uninstall multipass
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/root/Library/Application&lt;span class="se"&gt;\ &lt;/span&gt;Support/multipassd
&lt;span class="nb"&gt;sudo rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; ~/Library/Application&lt;span class="se"&gt;\ &lt;/span&gt;Support/multipass
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;OrbStack auto-installs &lt;code&gt;orb&lt;/code&gt;, &lt;code&gt;docker&lt;/code&gt;, and &lt;code&gt;kubectl&lt;/code&gt; on your PATH. If you already have any of those installed via Homebrew, you may want to check which version comes first in your shell.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's Coming in This Series
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Part 1 (this article):&lt;/strong&gt; Why I Replaced Multipass with OrbStack and what an M1 vs M4 Mac taught me about local Kubernetes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 2:&lt;/strong&gt; Cluster 1 — Native K8s daily driver with Istio, Vault, Crossplane&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 3:&lt;/strong&gt; Cluster 2 — VM creation, networking, and Vault PKI bootstrap&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 4:&lt;/strong&gt; kubeadm 1.34 — M1 vs M4 CNI deep dive (Calico vs Cilium)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 5:&lt;/strong&gt; Istio revision-based upgrades and MetalLB on the VM cluster&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 6:&lt;/strong&gt; Vault K8s auth and Crossplane — mirroring your EKS stack&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Part 7:&lt;/strong&gt; Day 2 operations, CKS lab scenarios, and making it all stick&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Part 2: &lt;a href="https://dev.to/nkmakau/cluster-1-your-daily-kubernetes-driver-in-one-command"&gt;One Command, One Working Kubernetes Cluster! Building My Daily-Driver Lab on OrbStack&lt;/a&gt; →&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;I’m Noah Makau, a DevSecOps engineer based in Nairobi. I run a small DevOps consultancy and hold CKA, CKAD, and AWS Solutions Architect Professional certifications, currently preparing for CKS. I write about Kubernetes, Vault, Crossplane, and the day-to-day of running platforms that actually have to stay up.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Originally publisher at &lt;a href="https://blog.arkilasystems.com/why-i-replaced-multipass-with-orbstack" rel="noopener noreferrer"&gt;blog.arkilasystems.com&lt;/a&gt;&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>devops</category>
      <category>tutorial</category>
      <category>platformengieering</category>
    </item>
  </channel>
</rss>
