<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Kubernetes - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Kubernetes - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Thu, 01 Oct 2026 06:02:52 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/kubernetes/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Diagnose and Fix AI Inference Latency on Kubernetes ]]>
                </title>
                <description>
                    <![CDATA[ It's Thursday, around quarter past two. Your team shipped an internal assistant two weeks ago. The demo went well enough that someone in finance asked whether it could read contracts. Word spread. Tod ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-diagnose-and-fix-ai-inference-latency-on-kubernetes/</link>
                <guid isPermaLink="false">6abca2f9f944d026311c7f7b</guid>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ infrastructure ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Artificial Intelligence ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Gursimar Singh ]]>
                </dc:creator>
                <pubDate>Wed, 30 Sep 2026 05:49:45 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4ab24802-80a3-43bb-a432-28f18d1f777e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>It's Thursday, around quarter past two. Your team shipped an internal assistant two weeks ago. The demo went well enough that someone in finance asked whether it could read contracts. Word spread. Today, for the first time, everyone is using it at once.</p>
<p>The support channel has the same complaint arriving in six different tones. Is it down? Mine is just spinning. It worked this morning. Somebody has posted a screenshot of a loading indicator with no caption, which you suspect they're enjoying.</p>
<p>So you open the dashboard.</p>
<p>Nodes ready. Pods running. CPU at 20%. No restarts, nothing in CrashLoopBackOff, no alert fired. By every signal Kubernetes gives you, the system is healthy.</p>
<p>It is not healthy. Somebody is eleven seconds into waiting for the first word of an answer, and they're about to go back to doing it the old way, and they're not coming back.</p>
<p>That is the failure this piece is about. Not the outage. The quiet one, where everything is technically up and the product is still unusable.</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ul>
<li><p><a href="#heading-your-first-instinct-is-wrong">Your First Instinct is Wrong</a></p>
</li>
<li><p><a href="#heading-you-are-not-the-only-one">You Are Not the Only One</a></p>
</li>
<li><p><a href="#heading-the-thing-nobody-told-you-when-you-deployed-it">The Thing Nobody Told You When You Deployed It</a></p>
</li>
<li><p><a href="#heading-where-the-eleven-seconds-went">Where the Eleven Seconds Went</a></p>
<ul>
<li><p><a href="#heading-stage-one-routing-your-load-balancer-is-guessing">Stage One, Routing: Your Load Balancer is Guessing</a></p>
</li>
<li><p><a href="#heading-stage-two-the-queue-nothing-is-coming-to-help">Stage Two, The Queue: Nothing is Coming to Help</a></p>
</li>
<li><p><a href="#heading-stage-three-prefill-the-gpus-are-there-and-useless">Stage Three, Prefill: The GPUs Are There, and Useless</a></p>
</li>
<li><p><a href="#heading-stage-four-decode-your-ingress-is-cutting-people-off">Stage Four, Decode: Your Ingress is Cutting People Off</a></p>
</li>
<li><p><a href="#heading-and-underneath-all-four-bursty-traffic-and-nobodys-name-on-the-problem">And Underneath All Four: Bursty Traffic and Nobody's Name on the Problem</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-meanwhile-the-platform-did-not-stand-still">Meanwhile, the Platform Did Not Stand Still</a></p>
</li>
<li><p><a href="#heading-how-this-got-here-in-the-first-place">How This Got Here in the First Place</a></p>
</li>
<li><p><a href="#heading-what-to-do-once-the-fire-is-out">What To Do Once the Fire is Out</a></p>
<ul>
<li><p><a href="#heading-capacity">Capacity</a></p>
</li>
<li><p><a href="#heading-performance">Performance</a></p>
</li>
<li><p><a href="#heading-placement">Placement</a></p>
</li>
<li><p><a href="#heading-resilience">Resilience</a></p>
</li>
<li><p><a href="#heading-ownership">Ownership</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-four-things-your-monitoring-still-isnt-telling-you">Four Things Your Monitoring Still Isn't Telling You</a></p>
</li>
<li><p><a href="#heading-the-thing-that-is-still-unsettled">The Thing That is Still Unsettled</a></p>
</li>
<li><p><a href="#heading-monday-morning">Monday Morning</a></p>
</li>
<li><p><a href="#heading-back-to-thursday">Back to Thursday</a></p>
</li>
</ul>
<h2 id="heading-your-first-instinct-is-wrong">Your First Instinct is Wrong</h2>
<p>The instinct at that moment is to suspect the model. Wrong version, bad quantization, context too long, or that someone changed the system prompt.</p>
<p>It is almost never the model.</p>
<p>Walk one request end to end and you find four stages. It gets routed to a replica. It waits in a queue. The model reads the prompt. The model streams an answer. Only the last two involve the model doing anything you are paying for. The first two are queueing and routing, which is to say infrastructure.</p>
<p>When people say their model is slow, the model is usually fine. The wait is somewhere else.</p>
<h2 id="heading-you-are-not-the-only-one">You Are Not the Only One</h2>
<p>It's worth knowing before you go hunting that this is the most common complaint in the field, not an unlucky configuration on your part.</p>
<p>A <a href="https://www.akamai.com/lp/the-state-of-ai-inference">practitioner survey</a> of 200 people running AI in production found that nearly half, 49.5%, named latency at peak load as their single hardest scaling problem. Not accuracy. Not hallucination. Not cost per token. Latency, at the exact moment people are trying to use the thing.</p>
<p>Two more figures from the same research are worth holding alongside it. 59.5% said running inference closer to users or decision points is critical or very important. 45.5% still serve from a single cloud region. People know proximity matters and have not managed to act on it, which tells you something about what multi-region GPU capacity costs to stand up.</p>
<p>The same research mentions organizations mandating sub-250ms response times alongside 99.9% availability. Read that carefully, because taken literally it is impossible. No meaningful LLM response completes in a quarter of a second. It has to mean time to first token (how long before the first word of the answer appears), and that is the right thing to hold yourself to anyway. Once tokens are flowing at a readable pace, people stop counting. The silence before the first one is what loses them.</p>
<h2 id="heading-the-thing-nobody-told-you-when-you-deployed-it">The Thing Nobody Told You When You Deployed It</h2>
<p>Here's the part that explains everything else.</p>
<p>A normal web request is short, small, and costs roughly what the last one cost. Kubernetes scheduling assumes that. Service load balancing assumes it. The Horizontal Pod Autoscaler assumes it. Your ingress controller's default timeout assumes it. Nobody wrote the assumption down, because for fifteen years it was simply true.</p>
<p>An LLM request breaks it in three places.</p>
<p>It runs in two phases with completely different profiles. Prefill: the model reads the entire prompt in one pass. Compute-bound, bursty, and this is what decides how long your user stares at nothing. Then decode: one token at a time, each needing the accumulated state of every token before it. That state is the KV cache. It lives in GPU memory next to the weights, and it grows as the answer grows.</p>
<p>So requests are not short, they can run for a minute. They do not cost the same, since a prompt with a document pasted into it can cost fifty times what a one-liner costs. And the genuinely scarce resource is GPU memory, which does not appear on a single dashboard you inherited.</p>
<p>That is why your monitoring said everything was fine. It was measuring the wrong machine.</p>
<h2 id="heading-where-the-eleven-seconds-went">Where the Eleven Seconds Went</h2>
<p>Follow the request through the four stages and the failure modes fall out in order. These six are the ones practitioners name most often, and they map neatly onto the path.</p>
<h3 id="heading-stage-one-routing-your-load-balancer-is-guessing">Stage One, Routing: Your Load Balancer is Guessing</h3>
<p>Round-robin stops being fair the moment request costs diverge. Three heavy prompts land on one pod while its neighbour handles one-liners. The loaded pod's queue grows, its tail latency climbs, and the fleet average still looks completely reasonable, which is why nobody noticed before today.</p>
<p>Long-lived connections make it worse. With HTTP/2 or keepalive, a client opens one connection and sends everything down it, and a Kubernetes Service balances per connection rather than per request. All that traffic pins to a single backend and stays there.</p>
<p>Then there is the cost most teams have never considered. If a replica already holds the prefix of this prompt in its KV cache, routing there skips real prefill work. Since most production prompts share a long system preamble, that is not an edge case, it is most of your traffic. Round-robin throws the benefit away at random, every time.</p>
<h3 id="heading-stage-two-the-queue-nothing-is-coming-to-help">Stage Two, The Queue: Nothing is Coming to Help</h3>
<p>Your autoscaler could add capacity. It will be late, and the reason is unglamorous: a new replica has to pull a multi-gigabyte serving image, fetch 20GB to 70GB of weights over the network, and load them into GPU memory. Minutes, not seconds, and that assumes a node with a free card already exists. If it does not, add provisioning time, and add whether your provider has stock in that zone today.</p>
<p>There is a second problem stacked on the first. The signal is usually wrong. CPU tells you nothing here, because the CPU is idle while the GPU works. GPU utilization is barely better: it reports that a kernel was executing during the sample window, and a server handling one request and a server handling sixty both read 100%. It cannot tell you whether anyone is waiting.</p>
<p>Queue depth can. vLLM already exposes running and waiting request counts as Prometheus metrics. Scale on those:</p>
<pre><code class="language-yaml">apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
  name: llm-server
spec:
  scaleTargetRef:
    name: llm-server
  minReplicaCount: 2
  maxReplicaCount: 8
  cooldownPeriod: 600
  triggers:
    - type: prometheus
      metadata:
        serverAddress: http://prometheus.monitoring:9090
        query: sum(vllm:num_requests_waiting{app="llm-server"})
        threshold: "5"
</code></pre>
<p>Check the metric names against your vLLM version. Several have been renamed across releases, and the failure is silent.</p>
<p>Two values there are deliberate. The minimum of 2, because a cold start means you cannot sit at zero and expect to serve. The ten minute cooldown, because scaling down eagerly means paying that cold start again shortly afterwards.</p>
<p>Which leads to the uncomfortable conclusion about autoscaling inference. Reactive scaling is permanently late by however long your cold start is, and no trigger fixes that. The fix is to keep more spare capacity than feels comfortable. Run extra replicas at all times, scale up earlier than you think you need to, and scale down more slowly. This also means scale to zero isn't a good fit for user-facing inference. Save it for batch jobs, where nobody is sitting there waiting for a response.</p>
<h3 id="heading-stage-three-prefill-the-gpus-are-there-and-useless">Stage Three, Prefill: The GPUs Are There, and Useless</h3>
<p>Kubernetes hands out CPU in millicores and GPUs whole. A pod asks for one card and gets the entire card, whether it needs 10% of it or all of it.</p>
<p>The waste is the obvious problem. Fragmentation is the one that ruins a Thursday. A 70B model in 16-bit needs roughly 140GB of weights, so a replica needs several GPUs on one node. You can have six free GPUs spread two-two-one-one across four nodes, and the replica sits in Pending indefinitely. Capacity on paper, none of it usable, and no alert, because nothing is broken.</p>
<p>Sharing a card gives three options, each with a catch you should know before choosing. Time-slicing is easy to enable and gives no memory isolation, so one greedy pod can take down its neighbour. Fine for development, not for anything customer-facing. MIG gives real hardware isolation but fixed slice geometry, which means predicting your workload mix before you have one. Dynamic Resource Allocation is the structurally correct answer and brings accelerator requests properly into the core Kubernetes API.</p>
<p>None of these are on by default. Each needs a deliberate decision from somebody who understands the workload, who is usually not in the room when the cluster gets built.</p>
<h3 id="heading-stage-four-decode-your-ingress-is-cutting-people-off">Stage Four, Decode: Your Ingress is Cutting People Off</h3>
<p>Many ingress controllers ship with a 60 second read timeout and response buffering enabled.</p>
<p>The timeout terminates long generations mid-sentence. Users report that as a crash, and you cannot reproduce it, because your test prompt is short. The buffering holds tokens and releases them in clumps, which destroys the feel of streaming while the model is behaving perfectly.</p>
<p>Two config lines, written for a workload that no longer exists.</p>
<h3 id="heading-and-underneath-all-four-bursty-traffic-and-nobodys-name-on-the-problem">And Underneath All four: Bursty Traffic and Nobody's Name on the Problem</h3>
<p>Internal AI tools don't get steady traffic. They get sudden spikes: when everyone starts work at 9am, in the hour after a company all-hands meeting, or when someone shares the link in a busy Slack channel and says it's actually quite good. A fixed replica count handles exactly one of those. Most teams set it once during a quiet week and never revisit it, so they get both waste and collapse on the same day.</p>
<p>Then the sixth failure mode, which is not technical at all, and which is the reason the other five survive for quarters.</p>
<p>Platform engineering owns the cluster. The cluster is green. From where they sit, the job is done and done well. Application developers can see latency is bad and cannot see why, because every cause sits in scheduling, scaling and routing that they neither control nor observe. MLOps owns the model artifact and almost nothing that determines how it performs at quarter past two on a Thursday.</p>
<p>Three teams, all telling the truth, and the problem living in the gap between their on-call rotas, where no alert is configured and no dashboard points.</p>
<h2 id="heading-meanwhile-the-platform-did-not-stand-still">Meanwhile, the Platform Did Not Stand Still</h2>
<p>The encouraging part of this story is that the gaps above are known, and the fixes have been shipping.</p>
<p>Dynamic Resource Allocation brings accelerator hardware requests into the core Kubernetes API, so the scheduler can reason about the device rather than counting opaque units. Kueue adds job queueing and multi-tenant GPU quota alongside CPU and memory, which is how you stop a research job starving the endpoint your users depend on. Gateway API Inference Extension and llm-d add model-aware routing: request criticality, dynamic balancing from live model metrics, and awareness of cache state. That last one is the direct answer to the guessing load balancer in stage one.</p>
<p>Published benchmarks give a sense of scale: Kueue reducing multi-stage workload makespan by up to 15%, dynamic accelerator slicing cutting mean job completion time by 36%, and Gateway API plus llm-d improving tail time to first token by up to 90% under heavy load.</p>
<p>Treat those as headroom rather than forecast. Up to 90% is measured on a configuration chosen to demonstrate the improvement, and your gain depends entirely on how poor your baseline is. If you already run least-outstanding-requests balancing with a warm cache, expect far less. If you are on stock round-robin behind a default ingress, possibly something dramatic, because that baseline is genuinely bad.</p>
<p>The direction is what matters. The biggest published gain is in tail time to first token, which is precisely what half the surveyed practitioners named as their worst problem. Somebody built the fix for the thing people were complaining about.</p>
<h2 id="heading-how-this-got-here-in-the-first-place">How This Got Here in the First Place</h2>
<p>Worth a short detour, because it explains why the tooling arrived late.</p>
<p>When generative AI landed in enterprise planning documents, the confident position was that Kubernetes could not hold it. Container orchestration was built for small fungible units of CPU and memory, cheap restarts, interchangeable replicas. AI needed scheduled specialized silicon carrying enormous state that takes minutes to place. A purpose-built platform was coming.</p>
<p>It never arrived. AI went into the microservices stack and Kubernetes absorbed it.</p>
<p>The <a href="https://www.cncf.io/reports/the-cncf-annual-cloud-native-survey/">survey data</a> explains why. More than half of enterprises do not train models at all, and only 7% deploy a model on any given day. Meanwhile 82% of container users run Kubernetes in production, and 66% of organizations hosting generative AI already serve it there. The industry spent years designing for the workload almost nobody has, while everybody else quietly downloaded weights and put them behind an endpoint.</p>
<p>What actually converged was not a place to run AI. It was a standard way to run it, with the location left negotiable. Which is exactly why your problems today are placement, routing and capacity rather than anything model-shaped.</p>
<h2 id="heading-what-to-do-once-the-fire-is-out">What To Do Once the Fire is Out</h2>
<p>The path from working to reliable comes down to five things: capacity, performance, placement, resilience and ownership.</p>
<h3 id="heading-capacity">Capacity</h3>
<p>Requests per second is meaningless when one request is 50 tokens and the next is 8,000. Plan in tokens per second and track prompt and completion separately, because they stress different parts of the system. Prompt tokens are a compute burst during prefill. Completion tokens are a sustained drip during decode that holds GPU memory for the life of the response.</p>
<p>Load test with your worst realistic prompt at your worst realistic concurrency, and plan from that number. A benchmark built on short prompts gives a reassuring figure that evaporates in week one.</p>
<p>On supply, treat GPUs as something you reserve ahead of time rather than request when needed. Keep a committed baseline and burst on top. Find out your provider's quota and regional stock position before the evening you need it, and use Kueue so one team cannot quietly consume the pool.</p>
<h3 id="heading-performance">Performance</h3>
<p>Two numbers matter most. Time to first token (TTFT) is how long a user waits between sending a prompt and seeing the first word of the answer. It decides whether people trust the product. Inter-token latency is the gap between each word after that, and it decides whether the answer feels smooth to read.</p>
<p>Track both at p95 and p99, not the average. These are percentiles. p95 is the time that 95% of requests come in under, so it shows what your slowest 1 in 20 users experience. p99 does the same for the slowest 1 in 100. An average can look healthy while those users wait far too long, and they're the ones who give up.</p>
<p>Next, tune the serving layer. This is the software that loads the model and handles requests, such as vLLM. It's where you'll get the biggest improvements for the least effort and cost. Continuous batching keeps the GPU busy without making early requests wait for a batch to fill. Quantization roughly halves memory footprint where the quality tradeoff is acceptable, and freed memory becomes concurrent requests. Set maximum context length to what your product actually needs rather than what the model supports. If the model handles 128K and your longest genuine prompt is 6K, you are reserving KV cache for a scenario that never occurs. One config line, and it can substantially raise effective concurrency.</p>
<h3 id="heading-placement">Placement</h3>
<p>Taint GPU nodes so general workloads cannot land on them and only inference pods with matching tolerations schedule there. A logging sidecar should never be the reason a model replica cannot place.</p>
<p>Keep weights close. Node-local NVMe or a zone-local cache turns a 40GB network pull into a fast local read, which feeds directly into cold start, autoscaling lag, and the peak-hour latency you spent Thursday afternoon on. This is the highest-leverage unglamorous fix on the list.</p>
<p>Respect topology. Cards connected by NVLink behave very differently from cards that merely share a PCIe bus, and for a sharded model that interconnect sits on the critical path of every token generated.</p>
<p>Then be honest about geography. If your users are in London and your GPUs are in Virginia, every token crosses an ocean, and a well-tuned model behind a long network path is still a slow product.</p>
<h3 id="heading-resilience">Resilience</h3>
<p>Readiness should pass only when the model is loaded and genuinely able to serve. Use a startup probe with a generous failure threshold, otherwise liveness kills the pod partway through loading weights and you get a restart loop that presents as a mystery and costs an hour.</p>
<p>Set a PodDisruptionBudget so a routine node upgrade cannot remove half your replicas at once.</p>
<p>Raise the termination grace period past the 30 second default and add a preStop hook. A generation can easily run longer than that, so otherwise every rollout severs in-flight streams and your users experience deployments as random failure.</p>
<p>Shed load deliberately. Past a queue depth threshold, return a fast busy-try-again rather than accepting a request that will hang for two minutes. This feels wrong to engineers and is right for users. People forgive a quick honest retry. Nobody forgives a frozen screen.</p>
<p>Have somewhere to fall back to: a second region, a smaller model covering the common cases, or an external API at the edge of disaster. Given that 45.5% of practitioners run from a single region, this is the most commonly skipped item on the list, which makes it the most likely candidate for your next incident.</p>
<h3 id="heading-ownership">Ownership</h3>
<p>Agree on the numbers and write them down: time to first token, inter-token latency, error rate, queue time. Put them on one dashboard showing cluster and model metrics side by side, and make sure both teams open the same link rather than maintaining separate versions of reality.</p>
<p>Split responsibility explicitly. Platform owns the ability to hit the target: capacity, scheduling, routing, scaling, placement. Application owns how the model is configured and called: context length, batching parameters, prompt size, retry behaviour. When the number slips, both show up, and the conversation starts from the same graph instead of two that disagree.</p>
<h2 id="heading-four-things-your-monitoring-still-isnt-telling-you">Four Things Your Monitoring Still Isn't Telling You</h2>
<p>Latency, traffic, errors and saturation are still necessary and no longer sufficient. Four more belong on the board.</p>
<p>Token consumption rate, split prompt and completion, because that is your real unit of capacity. Cost per request by tenant and query type, because we run eight GPUs is not an answer to what a feature costs per user. Output quality, tracking hallucination rate and guardrail drift, because latency work that degrades quality is not a win. Model attribution, recording which checkpoint and which prompt version produced a response, because when quality moves you need to know what changed.</p>
<p>That last one connects to a shift worth noticing. Model registries have become peers to container registries, and prompts and agent configurations are increasingly versioned as GitOps artifacts in the same delivery pipelines as everything else.</p>
<p>The prompt is code now. It changes production behaviour, it can break things, and it should go through review, versioning and rollback like anything else that does. Plenty of teams are still editing prompts in a web console and wondering why quality shifted on a Tuesday.</p>
<h2 id="heading-the-thing-that-is-still-unsettled">The Thing That is Still Unsettled</h2>
<p>The substrate has converged, and the speed of it is the evidence. A Kubernetes AI conformance programme launched in late 2025 with 18 platforms. Within roughly four months it had grown to 31 and extended to agentic workloads, covering the major hyperscalers, the enterprise and private cloud distributions, GPU neoclouds and edge networks. Companies that agree on almost nothing agreed on this.</p>
<p>The layer above has not converged at all. AI gateways, evaluation platforms, agent control planes, agent observability. All advancing faster in commercial products than in any standard body, and all sitting exactly where the interesting business logic is heading.</p>
<p>The risk is worth naming plainly. You can be perfectly portable at the Kubernetes layer and completely locked in at the layer where you actually build. Your pods will migrate between clouds beautifully while your agent definitions, eval suites and gateway policies stay exactly where they are, because there is nowhere standard to move them to.</p>
<p>That is not a reason to avoid those tools. They solve real problems today. It is a reason to know which of your components has an exit and which does not, and to make that a decision you wrote down rather than one you discover during a renewal negotiation.</p>
<h2 id="heading-monday-morning">Monday Morning</h2>
<p>Start with one measurement. Time a cold start end to end, from pod created to first token served, and write the number down. That figure is your autoscaling floor, and your minimum replica count falls directly out of it.</p>
<p>Put time to first token and queue depth on your main dashboard at p99. Replace CPU-based autoscaling with a queue-depth trigger and minimums that respect the cold start. Check your ingress read timeout and buffering against a real long streaming response rather than a test prompt. Fix the termination grace period so deployments stop cutting people off.</p>
<p>Then, over the next few weeks: taint your GPU nodes, move weights to node-local or zone-local storage, set maximum context length to what you actually use, and find out whether you are still on round-robin. You probably are.</p>
<p>And before the quarter ends, decide who owns the end-to-end latency number, and say it out loud in a room with the other team present.</p>
<h2 id="heading-back-to-thursday">Back to Thursday</h2>
<p>The dashboard was never lying. It was answering a different question.</p>
<p>It was telling you whether the containers were alive, which they were. It could not tell you that requests were queueing behind a badly routed batch, that no new replica was coming for four minutes, that six GPUs were free and unusable, or that the ingress was buffering tokens it should have been streaming. Nothing in the standard toolkit is pointed at any of that.</p>
<p>Getting a model to answer on Kubernetes takes an afternoon. Getting it to answer well when everyone logs in at once is a different discipline, and it looks far more like ordinary infrastructure engineering than the current conversation suggests. Scheduling, routing, capacity, ownership. We have been solving those since long before any of this carried the AI label. The skills are already in the building. They need pointing at the right metric.</p>
<p>A green cluster is where the work starts. What counts is what the person typing the prompt sees.</p>
<p>I hope you’ve enjoyed this and learned something new. I’m always open to suggestions and discussions on <a href="https://www.linkedin.com/in/gursimarsm">LinkedIn</a>. Hit me up with direct messages.</p>
<p>If you’ve enjoyed my writing and want to keep me motivated, consider leaving stars on <a href="https://github.com/gursimarsm">GitHub</a> and endorsing me for relevant skills on <a href="https://www.linkedin.com/in/gursimarsm">LinkedIn</a>.</p>
<p>Till the next one, happy exploring!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Kubernetes Networking Explained: From ClusterIP to Cilium Service Mesh ]]>
                </title>
                <description>
                    <![CDATA[ Here's something that most Kubernetes tutorials won't tell you: most engineers can run kubectl expose. Fewer than 10% understand what happens when they do. I've debugged Kubernetes networking issues a ]]>
                </description>
                <link>https://www.freecodecamp.org/news/kubernetes-networking-explained-from-clusterip-to-cilium-service-mesh/</link>
                <guid isPermaLink="false">6a88812be09a3c3682f4fe95</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ networking ]]>
                    </category>
                
                    <category>
                        <![CDATA[ debugging ]]>
                    </category>
                
                    <category>
                        <![CDATA[ containers ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ayobami Adejumo ]]>
                </dc:creator>
                <pubDate>Fri, 21 Aug 2026 16:47:39 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/425eabfd-bd40-4c0d-b6c3-4f1f7bd53caa.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Here's something that most Kubernetes tutorials won't tell you: most engineers can run <code>kubectl expose</code>. Fewer than 10% understand what happens when they do.</p>
<p>I've debugged Kubernetes networking issues at more than 10 companies. The same knowledge gaps appear every time. Engineers don't understand how ClusterIP works under the hood. They don't understand why Pods in different namespaces can talk to each other by default. And they don't understand what a CNI plugin actually does at the kernel level.</p>
<p>This tutorial is the fix. You'll learn how Kubernetes networking works from the bottom up: how Pod IPs are assigned and why they work across nodes, how kube-proxy implements ClusterIP using iptables rules, how Ingress controllers route external traffic through a single load balancer, how Network Policies enforce micro-segmentation for SOC2 compliance, and how Cilium uses eBPF to replace all of this with a faster, more observable, and more secure alternative.</p>
<p>By the end of this guide, you'll be able to debug "why can't my pod talk to that service?", implement default-deny Network Policies that satisfy SOC2 CC6.1, and choose the right CNI for your cluster with confidence.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-learn">What You'll Learn</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-part-1-pod-ips-and-the-container-network-model">Part 1: Pod IPs and the Container Network Model</a></p>
</li>
<li><p><a href="#heading-part-2-services-clusterip-nodeport-and-loadbalancer">Part 2: Services — ClusterIP, NodePort, and LoadBalancer</a></p>
</li>
<li><p><a href="#heading-part-3-ingress-external-traffic-routing">Part 3: Ingress — External Traffic Routing</a></p>
</li>
<li><p><a href="#heading-part-4-network-policies-micro-segmentation">Part 4: Network Policies — Micro-Segmentation</a></p>
</li>
<li><p><a href="#heading-part-5-cni-comparison-cilium-vs-calico-vs-aws-vpc-cni">Part 5: CNI Comparison — Cilium vs Calico vs AWS VPC CNI</a></p>
</li>
<li><p><a href="#heading-part-6-service-mesh-cilium-vs-istio-vs-linkerd">Part 6: Service Mesh — Cilium vs Istio vs Linkerd</a></p>
</li>
<li><p><a href="#heading-best-practices-for-kubernetes-networking">Best Practices Summary</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-what-youll-learn">What You'll Learn</h2>
<ul>
<li><p>Pod IPs, the container network model, and how the CNI assigns addresses</p>
</li>
<li><p>How kube-proxy implements ClusterIP with iptables and why eBPF is faster</p>
</li>
<li><p>Ingress controllers: routing all external traffic through a single load balancer</p>
</li>
<li><p>Network Policies: default-deny and per-service allow rules for zero-trust networking</p>
</li>
<li><p>CNI comparison: Cilium vs Calico vs AWS VPC CNI and when to use each</p>
</li>
<li><p>Service mesh: Cilium vs Istio vs Linkerd for mTLS and observability</p>
</li>
</ul>
<p>Let's dive in.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before following along, you should have:</p>
<p><strong>Knowledge:</strong></p>
<ul>
<li><p>Basic Kubernetes familiarity: you can deploy a Pod and create a Service</p>
</li>
<li><p>Basic Linux networking concepts: you know what an IP address and a port are</p>
</li>
<li><p>A general understanding of what a load balancer does</p>
</li>
</ul>
<p><strong>Tools and access:</strong></p>
<ul>
<li><p>A running Kubernetes cluster (EKS, GKE, or a local cluster via <a href="https://kind.sigs.k8s.io/">kind</a>)</p>
</li>
<li><p><code>kubectl</code> configured and pointing at your cluster</p>
</li>
<li><p><code>helm</code> 3 installed (for Cilium installation in Part 4)</p>
</li>
<li><p>For Part 4 onwards: Cilium installed on your cluster (<code>helm install cilium cilium/cilium</code>)</p>
</li>
</ul>
<p>A note on CNI: Parts 1–3 apply to any Kubernetes cluster regardless of CNI. Parts 4–6 use Cilium-specific resources (<code>CiliumNetworkPolicy</code>, Hubble). If you're on a different CNI, the concepts are identical and only the YAML syntax differs.</p>
<h2 id="heading-part-1-pod-ips-and-the-container-network-model">Part 1: Pod IPs and the Container Network Model</h2>
<h3 id="heading-11-why-every-pod-gets-its-own-ip">1.1 Why Every Pod Gets Its Own IP</h3>
<p>The Kubernetes networking model has one foundational rule: every Pod gets its own unique IP address, and every Pod can communicate with every other Pod using those IPs – without Network Address Translation (NAT).</p>
<p>This is different from how Docker works by default, where containers share the host network or use port mapping. In Kubernetes, there's no port mapping between pods. Pod A at IP <code>10.244.1.2</code> can directly reach Pod B at <code>10.244.2.3</code> across a different node, and the source IP is preserved.</p>
<p>Verify this for your cluster:</p>
<pre><code class="language-bash"># List all pods across all namespaces with their IP addresses and node placement
kubectl get pods -o wide --all-namespaces
</code></pre>
<p>Expected output:</p>
<pre><code class="language-text">NAMESPACE     NAME                                READY   STATUS    IP            NODE
production    payment-api-5d6b8d8c4f-abc12        1/1     Running   10.244.1.2    node-1
production    user-api-5d6b8d8c4f-def34           1/1     Running   10.244.2.3    node-2
production    redis-master-0                      1/1     Running   10.244.1.4    node-1
</code></pre>
<p>Each pod has a unique IP. The payment-api on node-1 and the user-api on node-2 can reach each other directly at those IPs. Notice that the IPs come from the <code>10.244.0.0/16</code> CIDR: this is the Pod network, separate from the node network.</p>
<h3 id="heading-12-what-the-cni-plugin-actually-does">1.2 What the CNI Plugin Actually Does</h3>
<p>The Container Network Interface (CNI) is the plugin responsible for making the Kubernetes networking model work. When a new Pod is scheduled on a node, the Kubernetes kubelet calls the CNI plugin, which performs four operations:</p>
<ol>
<li><p>Creates a new network namespace for the Pod: an isolated networking environment</p>
</li>
<li><p>Creates a virtual Ethernet pair (<code>veth</code>): one end inside the Pod's namespace, one end on the node</p>
</li>
<li><p>Assigns an IP address from the cluster's Pod CIDR to the Pod's end of the veth pair</p>
</li>
<li><p>Adds routing rules so the node knows how to reach every Pod IP in the cluster</p>
</li>
</ol>
<p>Without the CNI, pods would have no network connectivity. With it, the flat Pod network model becomes reality.</p>
<p>Check which CNI plugin is installed on your cluster:</p>
<pre><code class="language-bash"># List the CNI binaries installed on a node
ls /opt/cni/bin/
</code></pre>
<p>Here are some common CNI plugins and when to use each:</p>
<table>
<thead>
<tr>
<th>CNI</th>
<th>Default on?</th>
<th>Primary Use Case</th>
</tr>
</thead>
<tbody><tr>
<td>AWS VPC CNI</td>
<td>Yes (EKS)</td>
<td>Pods get real VPC IPs. Best for AWS-native integration</td>
</tr>
<tr>
<td>Calico</td>
<td>No</td>
<td>Advanced network policies with BGP routing</td>
</tr>
<tr>
<td>Cilium</td>
<td>No</td>
<td>eBPF-based networking, Layer 7 policies, service mesh, SOC2 evidence</td>
</tr>
</tbody></table>
<h3 id="heading-13-verifying-pod-to-pod-communication">1.3 Verifying Pod-to-Pod Communication</h3>
<p>The most fundamental networking test: exec into one Pod and ping another by IP.</p>
<pre><code class="language-bash"># Step 1: Get the IP of a target pod
TARGET_IP=$(kubectl get pod redis-master-0 -o jsonpath='{.status.podIP}')
echo "Target IP: $TARGET_IP"

# Step 2: Exec into another pod and ping the target
kubectl exec -it payment-api-5d6b8d8c4f-abc12 -- ping -c 3 $TARGET_IP
</code></pre>
<p>Expected output:</p>
<pre><code class="language-text">PING 10.244.1.4 (10.244.1.4): 56 data bytes
64 bytes from 10.244.1.4: icmp_seq=0 ttl=62 time=0.8ms
64 bytes from 10.244.1.4: icmp_seq=1 ttl=62 time=0.7ms
64 bytes from 10.244.1.4: icmp_seq=2 ttl=62 time=0.9ms
</code></pre>
<p>If this succeeds, the CNI is working correctly. If it fails, check whether a Network Policy is blocking ICMP traffic (Part 4 covers this).</p>
<p>The one rule to remember: every Pod gets an IP. Pods can communicate directly using those IPs. The CNI plugin makes both of these things true.</p>
<h2 id="heading-part-2-services-clusterip-nodeport-and-loadbalancer">Part 2: Services — ClusterIP, NodePort, and LoadBalancer</h2>
<h3 id="heading-21-the-problem-pod-ips-are-not-stable">2.1 The Problem: Pod IPs Are Not Stable</h3>
<p>Pod IPs change every time a Pod restarts. If you deploy a new version of your payment API, the old Pods are deleted and new Pods are created with new IPs. Any service that was configured to call the old IPs now has dead references.</p>
<p>Here's the incorrect approach: hardcoding a Pod IP.</p>
<pre><code class="language-yaml"># Bad: Direct Pod IP in application configuration
# This IP will stop working the next time the database Pod restarts
apiVersion: v1
kind: Pod
metadata:
  name: payment-api
spec:
  containers:
  - name: api
    env:
    - name: DATABASE_HOST
      value: "10.244.1.4"  # Pod IP — will change on next restart
</code></pre>
<p>This is fragile in development and catastrophic in production. A routine Pod restart – from a node drain, an OOM kill, or a deployment rollout – will break any application that hardcoded the old IP.</p>
<h3 id="heading-22-how-services-solve-the-stability-problem">2.2 How Services Solve the Stability Problem</h3>
<p>A Kubernetes Service provides two things that Pod IPs can't: a stable IP address (the ClusterIP) that never changes as long as the Service exists, and a stable DNS name that other Pods can use regardless of the IP.</p>
<p>When you create a Service, Kubernetes assigns it a virtual ClusterIP from the service CIDR (for example, <code>10.100.0.0/16</code>), creates a DNS record in CoreDNS as <code>&lt;service-name&gt;.&lt;namespace&gt;.svc.cluster.local</code>, and configures kube-proxy on every node to add iptables rules that load-balance traffic from the ClusterIP to the healthy Pod IPs behind it.</p>
<p>Here's the correct implementation: a ClusterIP Service.</p>
<pre><code class="language-yaml"># Good: ClusterIP Service provides a stable IP and DNS name
# redis.production.svc.cluster.local always resolves to 10.100.0.1
# regardless of which Redis pods are running behind it
apiVersion: v1
kind: Service
metadata:
  name: redis
  namespace: production
spec:
  selector:
    app: redis
    role: master   # Only pods with these labels receive traffic
  ports:
  - port: 6379        # Port the Service listens on
    targetPort: 6379  # Port the Pod actually runs on
  type: ClusterIP     # Default: accessible only inside the cluster
</code></pre>
<p>How kube-proxy implements the load balancing using iptables: when a Service is created, kube-proxy adds iptables rules to every node in the cluster. These rules intercept traffic destined for the ClusterIP and redirect it to one of the healthy Pod IPs. Run this on a node to see the rules in action:</p>
<pre><code class="language-bash"># View the iptables rules kube-proxy created for the redis Service
# Each KUBE-SEP entry represents one Pod endpoint
sudo iptables -t nat -L KUBE-SERVICES | grep redis
</code></pre>
<p>Expected output:</p>
<pre><code class="language-text">Chain KUBE-SVC-REDIS (1 references)
target          prot  source    destination
KUBE-SEP-AAA    all   anywhere  anywhere    /* production/redis */ statistic mode random probability 0.50
KUBE-SEP-BBB    all   anywhere  anywhere    /* production/redis */
</code></pre>
<p>Traffic to the Redis ClusterIP is distributed 50/50 between the two Pod endpoints via these iptables rules. When a Pod restarts and gets a new IP, kube-proxy updates the rules automatically.</p>
<h3 id="heading-23-when-to-use-each-service-type">2.3 When to Use Each Service Type</h3>
<table>
<thead>
<tr>
<th>Type</th>
<th>DNS Name</th>
<th>Accessible From</th>
<th>Use Case</th>
</tr>
</thead>
<tbody><tr>
<td>ClusterIP</td>
<td><code>redis.production.svc.cluster.local</code></td>
<td>Inside the cluster only</td>
<td>Databases, caches, internal APIs</td>
</tr>
<tr>
<td>NodePort</td>
<td><code>&lt;node-ip&gt;:30000–32767</code></td>
<td>Node IP + port</td>
<td>Local development, debugging</td>
</tr>
<tr>
<td>LoadBalancer</td>
<td>AWS ELB DNS name</td>
<td>Internet (via cloud load balancer)</td>
<td>External APIs, web applications</td>
</tr>
</tbody></table>
<p>Verify a Service is routing traffic correctly:</p>
<pre><code class="language-bash"># Describe a Service to see its endpoints (the actual Pod IPs behind it)
kubectl describe service redis -n production
</code></pre>
<p>Expected output:</p>
<pre><code class="language-text">Name:              redis
Namespace:         production
Type:              ClusterIP
IP:                10.100.0.1
Port:              6379/TCP
TargetPort:        6379/TCP
Endpoints:         10.244.1.4:6379,10.244.2.5:6379
Session Affinity:  None
</code></pre>
<p>If <code>Endpoints</code> shows <code>&lt;none&gt;</code>, the Service selector doesn't match any running Pods. This is the most common cause of "connection refused" errors in Kubernetes.</p>
<p>The one rule to remember is that pods should always connect to Service DNS names, never to Pod IPs. The Service handles stability, load balancing, and health checking automatically.</p>
<h2 id="heading-part-3-ingress-external-traffic-routing">Part 3: Ingress — External Traffic Routing</h2>
<h3 id="heading-31-the-problem-a-loadbalancer-service-per-microservice-is-expensive">3.1 The Problem: A LoadBalancer Service Per Microservice Is Expensive</h3>
<p>Each <code>LoadBalancer</code> Service creates a dedicated cloud load balancer. On AWS, each Application Load Balancer costs approximately \(0.008/LCU-hour plus \)0.0225/hour base charge. That's roughly $16–27/month per load balancer.</p>
<p>At 20 microservices, that's $320–$540/month in load balancer charges alone, plus $0.008/LCU for each request processed.</p>
<p>Here's the incorrect approach with one LoadBalancer per microservice:</p>
<pre><code class="language-yaml"># Bad: This creates a new AWS ALB every time it is applied
# 20 microservices = 20 ALBs = $300-500/month before any traffic charges
apiVersion: v1
kind: Service
metadata:
  name: payment-api
spec:
  type: LoadBalancer   # Creates a dedicated ALB
  ports:
  - port: 80
    targetPort: 8080
</code></pre>
<h3 id="heading-32-how-an-ingress-controller-solves-this">3.2 How an Ingress Controller Solves This</h3>
<p>An Ingress controller is a Pod running inside your cluster that watches for <code>Ingress</code> resources and programs a single external load balancer to route traffic to multiple Services based on the hostname and URL path.</p>
<p>The AWS Load Balancer Controller, for example, creates one ALB for all your Ingress resources and programs its listener rules to route <code>api.company.com/payments</code> to the payment Service and <code>api.company.com/users</code> to the user Service, all through the same load balancer.</p>
<p>Here's the correct implementation: one Ingress for all services.</p>
<pre><code class="language-yaml"># Good: One Ingress resource routes all external traffic
# One ALB is created total, regardless of how many services are listed
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: shared-ingress
  namespace: production
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
    alb.ingress.kubernetes.io/ssl-redirect: "443"
spec:
  rules:
  - host: api.company.com
    http:
      paths:
      - path: /payments
        pathType: Prefix
        backend:
          service:
            name: payment-service
            port:
              number: 8080
      - path: /users
        pathType: Prefix
        backend:
          service:
            name: user-service
            port:
              number: 8080
  - host: dashboard.company.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: dashboard-service
            port:
              number: 3000
  tls:
  - hosts:
    - api.company.com
    - dashboard.company.com
    secretName: tls-wildcard-cert
</code></pre>
<p>Verify the Ingress is provisioned and the ALB DNS name is assigned:</p>
<pre><code class="language-bash"># Watch until the ADDRESS column shows the ALB DNS name (typically 2-3 minutes)
kubectl get ingress shared-ingress -n production -w
</code></pre>
<p>Expected output:</p>
<pre><code class="language-text">NAME             CLASS   HOSTS                                    ADDRESS                                              PORTS
shared-ingress   alb     api.company.com,dashboard.company.com   k8s-prod-sharedin-abc123.us-east-1.elb.amazonaws.com   80, 443
</code></pre>
<p>The cost difference:</p>
<table>
<thead>
<tr>
<th>Approach</th>
<th>Load balancers</th>
<th>Monthly cost</th>
</tr>
</thead>
<tbody><tr>
<td>LoadBalancer Service per microservice (20 services)</td>
<td>20 ALBs</td>
<td>~$400/month</td>
</tr>
<tr>
<td>Single Ingress controller</td>
<td>1 ALB</td>
<td>~$27/month</td>
</tr>
</tbody></table>
<p>The one rule to remember: one Ingress controller with path-based routing serves all your services through a single load balancer. The per-service LoadBalancer approach is for early prototyping only.</p>
<h2 id="heading-part-4-network-policies-micro-segmentation">Part 4: Network Policies — Micro-Segmentation</h2>
<h3 id="heading-41-the-default-every-pod-can-talk-to-every-other-pod">4.1 The Default: Every Pod Can Talk to Every Other Pod</h3>
<p>Out of the box, Kubernetes applies no network restrictions between Pods. A frontend Pod can make direct API calls to a database Pod. An analytics service can query the payment database. A compromised Pod can scan every other Pod in the cluster.</p>
<p>This isn't secure. For SOC2 CC6.1 (logical access controls), HIPAA, and most enterprise security frameworks, you need to be able to prove that network traffic is restricted to what's necessary.</p>
<p>Verify that unrestricted traffic is currently possible:</p>
<pre><code class="language-bash"># Without Network Policies, this call from the frontend to the payment DB will succeed
# It should not be allowed in a secure cluster
kubectl exec -it frontend-pod -n production -- \
  curl http://payment-postgres.production.svc.cluster.local:5432
</code></pre>
<p>If this succeeds on your cluster, you have no network segmentation.</p>
<h3 id="heading-42-the-solution-default-deny-with-cilium-network-policies">4.2 The Solution: Default-Deny with Cilium Network Policies</h3>
<p>The correct approach is default-deny: block all traffic between Pods first, then explicitly allow only the specific communication paths that your application requires.</p>
<h4 id="heading-step-1-apply-the-default-deny-policy">Step 1 — Apply the default-deny policy:</h4>
<pre><code class="language-yaml"># This policy applies to all pods in the namespace (empty endpointSelector matches all)
# It blocks all ingress and egress traffic by default
# Warning: apply this and all pod-to-pod communication immediately stops
# Have your allow rules ready before applying this in production
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: default-deny-all
  namespace: production
spec:
  description: "Block all inter-pod traffic by default — zero-trust baseline"
  endpointSelector: {}  # Matches all pods in this namespace
  ingress:
  - {}                  # Empty ingress rule = deny all inbound
  egress:
  - {}                  # Empty egress rule = deny all outbound
</code></pre>
<p>Applying the default-deny policy will break all pod-to-pod communication in the namespace immediately. Apply your allow rules (below) in the same <code>kubectl apply</code> command, or apply allow rules first.</p>
<h4 id="heading-step-2-add-namespace-level-isolation">Step 2 — Add namespace-level isolation:</h4>
<pre><code class="language-yaml"># Allow pods to communicate within the same namespace
# Block cross-namespace traffic by default
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: allow-same-namespace
  namespace: production
spec:
  endpointSelector: {}
  ingress:
  - fromEndpoints:
    - matchLabels:
        io.kubernetes.pod.namespace: production
  egress:
  - toEndpoints:
    - matchLabels:
        io.kubernetes.pod.namespace: production
</code></pre>
<h4 id="heading-step-3-add-per-service-allow-rules">Step 3 — Add per-service allow rules:</h4>
<pre><code class="language-yaml"># Grant the payment service only the specific network access it needs
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: payment-service-network-policy
  namespace: production
spec:
  endpointSelector:
    matchLabels:
      app: payment-service
  egress:
  # Allow: payment-service → postgres on port 5432
  - toEndpoints:
    - matchLabels:
        app: postgres-db
    toPorts:
    - ports:
      - port: "5432"
        protocol: TCP
  # Allow: payment-service → Stripe API externally
  - toFQDNs:
    - matchName: "api.stripe.com"
    toPorts:
    - ports:
      - port: "443"
        protocol: TCP
</code></pre>
<h3 id="heading-43-using-hubble-to-verify-policies-and-collect-soc2-evidence">4.3 Using Hubble to Verify Policies and Collect SOC2 Evidence</h3>
<p>Cilium includes Hubble, a network observability tool that shows you exactly which flows are being allowed and which are being dropped by your Network Policies. Hubble is your SOC2 evidence that network segmentation is operating correctly.</p>
<pre><code class="language-bash"># Install the Hubble CLI
export HUBBLE_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/hubble/master/stable.txt)
curl -L --remote-name-all https://github.com/cilium/hubble/releases/download/$HUBBLE_VERSION/hubble-linux-amd64.tar.gz
tar xzvf hubble-linux-amd64.tar.gz
sudo mv hubble /usr/local/bin/

# Port-forward to the Hubble relay
kubectl port-forward -n kube-system svc/hubble-relay 4245:80 &amp;

# Show all flows in the production namespace from the last hour
hubble observe --namespace production --since 1h

# Show only dropped flows — proves policies are blocking unauthorised traffic
hubble observe --namespace production --verdict DROPPED --since 1h
</code></pre>
<p>Example Hubble output showing a blocked connection attempt:</p>
<pre><code class="language-text">Apr 19 03:17:41.234   DROPPED   TCP   10.244.1.5:52341 → 10.244.1.4:5432   policy-deny
Apr 19 03:17:41.235   ALLOWED   TCP   10.244.1.2:43211 → 10.244.1.4:5432   allow-same-namespace
</code></pre>
<p>The first line shows an unauthorized connection attempt blocked. The second shows a legitimate connection allowed. Export this log daily to your SOC2 evidence bucket.</p>
<p>The one rule to remember: default-deny is the zero-trust baseline. Then add explicit allow rules for every required communication path. Hubble gives you the evidence that it's working.</p>
<h2 id="heading-part-5-cni-comparison-cilium-vs-calico-vs-aws-vpc-cni">Part 5: CNI Comparison — Cilium vs Calico vs AWS VPC CNI</h2>
<p>Choosing the right CNI is a decision that's difficult to reverse. Migrating between CNIs requires draining and replacing every node in the cluster. Make the decision once, for the right reasons.</p>
<p>Here's a real comparison across the capabilities that matter for production EKS clusters:</p>
<table>
<thead>
<tr>
<th>Capability</th>
<th>AWS VPC CNI</th>
<th>Calico</th>
<th>Cilium</th>
</tr>
</thead>
<tbody><tr>
<td>Pod IPs from VPC CIDR</td>
<td>✅ Yes</td>
<td>❌ No (overlay network)</td>
<td>❌ No (overlay network)</td>
</tr>
<tr>
<td>Basic network policies</td>
<td>✅ Yes (Kubernetes standard)</td>
<td>✅ Yes</td>
<td>✅ Yes</td>
</tr>
<tr>
<td>Layer 7 policies (HTTP path, gRPC method)</td>
<td>❌ No</td>
<td>❌ No</td>
<td>✅ Yes</td>
</tr>
<tr>
<td>eBPF dataplane</td>
<td>❌ No</td>
<td>❌ No</td>
<td>✅ Yes</td>
</tr>
<tr>
<td>Hubble flow observability</td>
<td>❌ No</td>
<td>❌ No</td>
<td>✅ Yes</td>
</tr>
<tr>
<td>Service mesh (mTLS without sidecar)</td>
<td>❌ No</td>
<td>❌ No</td>
<td>✅ Yes</td>
</tr>
<tr>
<td>SOC2 network evidence built in</td>
<td>❌ No</td>
<td>❌ No</td>
<td>✅ Yes (Hubble)</td>
</tr>
<tr>
<td>Performance overhead</td>
<td>Low</td>
<td>Medium</td>
<td>Very Low (eBPF bypasses iptables)</td>
</tr>
<tr>
<td>AWS-native integration</td>
<td>✅ Best</td>
<td>Medium</td>
<td>Medium</td>
</tr>
</tbody></table>
<p>The recommendation matrix:</p>
<table>
<thead>
<tr>
<th>Your Situation</th>
<th>Recommended CNI</th>
</tr>
</thead>
<tbody><tr>
<td>Simple EKS cluster, AWS-native tooling, no advanced policies</td>
<td>AWS VPC CNI</td>
</tr>
<tr>
<td>Need network policies but no Layer 7 or observability</td>
<td>Calico</td>
</tr>
<tr>
<td>Need SOC2 compliance with pod-level isolation evidence</td>
<td>Cilium</td>
</tr>
<tr>
<td>Need service mesh without sidecar proxy overhead</td>
<td>Cilium</td>
</tr>
<tr>
<td>Need Layer 7 network policies (allow GET /health, deny POST /admin)</td>
<td>Cilium</td>
</tr>
</tbody></table>
<p>The one rule to remember: for SOC2 compliance and zero-trust networking on EKS, Cilium is the right choice. It provides pod-level isolation, Layer 7 policies, and Hubble flow logs that serve as audit evidence. These are capabilities no other CNI provides together.</p>
<h2 id="heading-part-6-service-mesh-cilium-vs-istio-vs-linkerd">Part 6: Service Mesh — Cilium vs Istio vs Linkerd</h2>
<h3 id="heading-61-what-a-service-mesh-provides">6.1 What a Service Mesh Provides</h3>
<p>A service mesh adds three capabilities to your cluster's networking that Kubernetes doesn't provide natively.</p>
<p>mTLS (mutual TLS) encrypts communication between every pair of services and verifies both sides' identities. Without mTLS, traffic between your payment service and your database travels in plaintext inside the cluster.</p>
<p>Traffic observability tracks request rates, latency percentiles, and error rates for every service-to-service call, giving you a real-time performance map of your application.</p>
<p>Traffic management controls how traffic flows: retries on failure, timeouts, circuit breaking when a downstream service is degraded, and traffic splitting for canary deployments.</p>
<h3 id="heading-62-the-sidecar-problem">6.2 The Sidecar Problem</h3>
<p>Traditional service meshes (like Istio and Linkerd) inject a sidecar proxy container into every Pod. This sidecar intercepts all network traffic and applies the mesh policies. The problem is resource overhead: Istio's Envoy sidecar adds approximately 128MB of memory and 5–10% latency overhead per Pod.</p>
<p>On a cluster with 200 Pods, Istio sidecars add 25.6GB of memory overhead and measurable latency to every service call.</p>
<p>Cilium solves this differently. It implements the service mesh at the kernel level using eBPF without any sidecar at all.</p>
<h3 id="heading-63-the-full-comparison">6.3 The Full Comparison</h3>
<table>
<thead>
<tr>
<th>Capability</th>
<th>Cilium</th>
<th>Istio</th>
<th>Linkerd</th>
</tr>
</thead>
<tbody><tr>
<td>Sidecar required</td>
<td>❌ No (eBPF kernel)</td>
<td>✅ Yes (Envoy, ~128MB/pod)</td>
<td>✅ Yes (Rust proxy, ~10MB/pod)</td>
</tr>
<tr>
<td>Memory overhead per pod</td>
<td>0 MB</td>
<td>~128 MB</td>
<td>~10 MB</td>
</tr>
<tr>
<td>Latency overhead</td>
<td>&lt;1%</td>
<td>5–10%</td>
<td>2–3%</td>
</tr>
<tr>
<td>mTLS</td>
<td>✅ Yes</td>
<td>✅ Yes</td>
<td>✅ Yes</td>
</tr>
<tr>
<td>Traffic management (canary, circuit breaking)</td>
<td>Limited</td>
<td>✅ Full</td>
<td>✅ Full</td>
</tr>
<tr>
<td>Built-in flow observability (Hubble)</td>
<td>✅ Yes</td>
<td>❌ Requires Kiali</td>
<td>❌ Requires Buoyant Cloud</td>
</tr>
<tr>
<td>SOC2 evidence natively</td>
<td>✅ Yes</td>
<td>❌ Additional tooling</td>
<td>❌ Additional tooling</td>
</tr>
<tr>
<td>Setup complexity</td>
<td>Low</td>
<td>High</td>
<td>Medium</td>
</tr>
</tbody></table>
<p>The recommendation matrix:</p>
<table>
<thead>
<tr>
<th>Your Situation</th>
<th>Recommended Service Mesh</th>
</tr>
</thead>
<tbody><tr>
<td>Need mTLS and SOC2 evidence with minimal resource overhead</td>
<td>Cilium</td>
</tr>
<tr>
<td>Need advanced traffic management: canary, circuit breaking, weighted routing</td>
<td>Istio</td>
</tr>
<tr>
<td>Need lightweight mTLS without Istio's operational complexity</td>
<td>Linkerd</td>
</tr>
<tr>
<td>Running a cluster with hundreds of pods where sidecar overhead is a budget concern</td>
<td>Cilium</td>
</tr>
</tbody></table>
<p>Enable Cilium's service mesh mode (no sidecars required):</p>
<pre><code class="language-bash"># Upgrade your Cilium installation to enable service mesh features
helm upgrade cilium cilium/cilium \
  --namespace kube-system \
  --reuse-values \
  --set envoy.enabled=true \
  --set ingressController.enabled=true

# Verify the service mesh is active
cilium status | grep "Service Mesh"
</code></pre>
<p>The one rule to remember: Cilium gives you mTLS and SOC2 evidence with zero sidecar overhead. For teams that need advanced traffic management or complex canary release patterns, Istio provides more control at the cost of higher operational complexity.</p>
<h2 id="heading-best-practices-for-kubernetes-networking">Best Practices for Kubernetes Networking</h2>
<p>✅ <strong>Do:</strong> Use Services, not Pod IPs. Pod IPs change on every restart. Service DNS names never change.</p>
<p>✅ <strong>Do:</strong> Use a single Ingress controller with path-based routing. One ALB serves all your services and saves $300–$400/month versus per-service LoadBalancer.</p>
<p>✅ <strong>Do:</strong> Implement default-deny Network Policies with Cilium. This is the technical control required by SOC2 CC6.1.</p>
<p>✅ <strong>Do:</strong> Use Hubble flow logs as SOC2 evidence. Export daily dropped-flow logs to your evidence bucket.</p>
<p>✅ <strong>Do:</strong> Enable mTLS with Cilium for encrypted service-to-service communication. No sidecar required.</p>
<p>✅ <strong>Do:</strong> Use topology-aware routing to keep traffic within the same Availability Zone and reduce cross-AZ data transfer costs.</p>
<p>❌ <strong>Don't:</strong> Create a LoadBalancer Service for every microservice. Use Ingress for external routing.</p>
<p>❌ <strong>Don't:</strong> Rely on Security Groups alone for pod-level isolation. Security Groups work at the node level. Any pod on a node shares the node's security group. Network Policies work at the pod level.</p>
<p>❌ <strong>Don't:</strong> Assume the default "allow all" pod networking is secure. Apply default-deny before your first enterprise customer asks for your network segmentation diagram.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p><a href="https://docs.cilium.io/"><strong>Cilium Documentation</strong></a>: Official Cilium installation guide, CiliumNetworkPolicy reference, and Hubble observability documentation</p>
</li>
<li><p><a href="https://docs.cilium.io/en/stable/network/servicemesh/"><strong>Cilium Service Mesh Guide</strong></a>: How to enable mTLS and Layer 7 policies without sidecars</p>
</li>
<li><p><a href="https://kubernetes.io/docs/concepts/services-networking/network-policies/"><strong>Kubernetes Network Policy Documentation</strong></a>: The standard Kubernetes NetworkPolicy API reference</p>
</li>
<li><p><a href="https://kubernetes-sigs.github.io/aws-load-balancer-controller/"><strong>AWS Load Balancer Controller</strong></a>: Official documentation for the Ingress controller that provisions AWS ALBs from Kubernetes Ingress resources</p>
</li>
<li><p><a href="https://github.com/cilium/hubble/releases"><strong>Hubble CLI Installation</strong></a>: Install the Hubble CLI for observing Cilium network flows</p>
</li>
<li><p><a href="https://kubernetes.io/docs/reference/networking/virtual-ips/"><strong>kube-proxy iptables mode</strong></a>: Kubernetes documentation explaining how kube-proxy implements Service routing using iptables</p>
</li>
<li><p><a href="https://github.com/containernetworking/cni"><strong>Kubernetes CNI Plugin Specification</strong></a>: The CNI interface specification that all CNI plugins implement</p>
</li>
<li><p><a href="https://github.com/aws/amazon-vpc-cni-k8s"><strong>AWS VPC CNI Plugin GitHub</strong></a>: Source code and documentation for the default EKS networking plugin</p>
</li>
<li><p><a href="https://github.com/aayostem/platform-toolkit"><strong>Companion Repository</strong></a>: CiliumNetworkPolicy manifests and Hubble evidence export scripts from this guide</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ What Happens During a Production Deployment? A Behind-the-Scenes Guide ]]>
                </title>
                <description>
                    <![CDATA[ You push your code. A few minutes later, it is live for real users. Between those two moments runs a long chain of machinery: builds, artefacts, migrations, health checks, traffic shifts. Every produc ]]>
                </description>
                <link>https://www.freecodecamp.org/news/what-happens-during-a-production-deployment/</link>
                <guid isPermaLink="false">6a70dada6358084ff948ee0f</guid>
                
                    <category>
                        <![CDATA[ deployment ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Web Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Mon, 03 Aug 2026 18:15:54 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/43567dce-ecbe-412e-ab40-2ef6e07dde0e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You push your code. A few minutes later, it is live for real users.</p>
<p>Between those two moments runs a long chain of machinery: builds, artefacts, migrations, health checks, traffic shifts. Every production engineer depends on that chain, and many teams still build and operate it themselves.</p>
<p>Deployment infrastructure has quietly become operational overhead. It started as a technical necessity, something every team had to assemble because nothing else existed.</p>
<p>Today it is a second system your engineers maintain alongside the product, consuming on-call rotations, sprint capacity, and 2 a.m. attention that could go somewhere better.</p>
<p>In this article, we'll walk through each stage of a real production deployment: the build, the artefact it produces, database migrations, health checks, rolling updates, and rollbacks. Along the way, we'll look at why <a href="https://www.freecodecamp.org/news/my-team-s-experience-moving-from-aws-to-a-paas/">platform-as-a-service (PaaS)</a> tools handle most of these steps for you, and what it costs a team to keep handling them itself.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-build-turning-code-into-something-that-can-run">The Build: Turning Code into Something That Can Run</a></p>
</li>
<li><p><a href="#heading-the-artefact-one-version-frozen-in-time">The Artefact: One Version, Frozen in Time</a></p>
</li>
<li><p><a href="#heading-database-migrations-the-riskiest-step">Database Migrations: The Riskiest Step</a></p>
</li>
<li><p><a href="#heading-health-checks-proving-the-new-version-is-alive">Health Checks: Proving the New Version Is Alive</a></p>
</li>
<li><p><a href="#heading-rolling-updates-replacing-the-planes-engine-mid-flight">Rolling Updates: Replacing the Plane's Engine Mid-Flight</a></p>
</li>
<li><p><a href="#heading-rollbacks-the-escape-hatch">Rollbacks: The Escape Hatch</a></p>
</li>
<li><p><a href="#heading-when-you-dont-need-a-paas">When You Don't Need a PaaS</a></p>
</li>
<li><p><a href="#heading-should-you-still-be-running-this-yourself">Should You Still Be Running This Yourself?</a></p>
</li>
</ul>
<h2 id="heading-the-build-turning-code-into-something-that-can-run"><strong>The Build: Turning Code into Something That Can Run</strong></h2>
<p>A deployment does not ship your source code as-is. It ships the result of a build. The build stage takes your code and turns it into something a server can run.</p>
<p>What this looks like depends on your stack. A Java or Go project gets compiled into a binary. A JavaScript front end gets bundled and minified. A Python app gets its dependencies resolved and pinned. In most modern setups, all of this gets packed into a <a href="https://www.freecodecamp.org/news/an-introduction-to-docker-and-containers-for-beginners/">container image</a>, which is a frozen snapshot of your app plus everything it needs to run.</p>
<p>The build stage also runs your tests. Unit tests, linting, and security scans all happen here. If any of them fail, the deployment stops before it can touch production. This is the cheapest place to catch a bug. A failed build costs you a few minutes. A failed deployment can cost you customers.</p>
<img src="https://cdn.hashnode.com/uploads/covers/66c6d8f04fa7fe6a6e337edd/9a8b5d93-802c-4898-bd07-91a90041f93a.svg" alt="stages of code deployment" style="display: block;" width="680" height="520" loading="lazy">

<p>Teams that run their own pipelines spend real effort here. They maintain build servers, cache dependencies, and debug flaky test runners.</p>
<p>None of that work ships a feature. It is pure upkeep, and it never ends. A PaaS bakes this whole stage into the platform. You push code, and the platform detects your language, builds it the same way every time, and fails fast when something is wrong. The build still happens. Your engineers just stop paying for it in hours.</p>
<h2 id="heading-the-artefact-one-version-frozen-in-time"><strong>The Artefact: One Version, Frozen in Time</strong></h2>
<p>The output of a build is called an artefact. It might be a container image, a compiled binary, or a zipped bundle. Whatever the format, the artefact has one job: to be exact. It represents one precise version of your app, frozen at one point in time.</p>
<p>This matters more than it sounds. The artefact that passed your tests must be the exact same one that reaches production. If you rebuild between testing and shipping, you risk shipping something slightly different. A dependency may have updated. A build flag may have changed. "It worked in staging" often means "we built it twice and got two different results."</p>
<p>Good pipelines build once and promote the same artefact through every stage. Artefacts get versioned and stored in a registry, so any version can be pulled and run again later. That stored history is also what makes rollbacks possible, which we will get to soon.</p>
<p>On a PaaS, artefact handling is standard practice by default. Every deploy produces a numbered release. The platform stores it, tracks it, and can restore it. You do not have to design a registry strategy, write promotion scripts, or assign an engineer to own them. The discipline is built in.</p>
<h2 id="heading-database-migrations-the-riskiest-step"><strong>Database Migrations: The Riskiest Step</strong></h2>
<p>Before new code goes live, the database often has to change with it. Maybe the new version needs a new column or a new table. These changes are called migrations, and they are the most dangerous part of most deployments.</p>
<p>Why? Code is easy to replace. Data is not. If you deploy a bad code version, you can swap it out. If a migration corrupts or drops data, there may be no clean way back. Migrations also create a tricky window of time. For a few minutes, old code and new code may run against the same database at once. Both versions have to work with the schema during that window.</p>
<p>The safe pattern is to make migrations backwards-compatible. Add the new column first, deploy code that can handle both shapes, then clean up the old column in a later release. It takes more steps, but each step is safe on its own.</p>
<p>A PaaS cannot write your migrations for you. No tool can know what your data means. But a good platform gives migrations a defined place in the release process, runs them in order, and logs exactly what ran and when. That structure prevents the classic failure where someone runs a migration by hand and forgets to tell the team.</p>
<h2 id="heading-health-checks-proving-the-new-version-is-alive"><strong>Health Checks: Proving the New Version Is Alive</strong></h2>
<p>Once the new version starts, the platform does not just trust it. It checks. A health check is a small endpoint in your app, often just a route that returns "OK." The platform calls it over and over. If the app answers, it is considered healthy. If it does not, the platform assumes something is wrong.</p>
<p>There are usually two kinds of checks. A readiness check asks, "Are you ready to receive traffic?" A liveness check asks, "Are you still working, or should I restart you?" The difference matters. An app can be alive but not ready, such as when it is still warming up a cache.</p>
<p>Health checks are the gatekeepers of a deployment. No traffic reaches a new version until it proves it can handle requests. Without them, you would be routing real users to an app that might still be crashing on startup.</p>
<p>Every serious PaaS runs health checks automatically. You define the endpoint, and the platform handles the polling, the timeouts, and the decisions. Teams that build this themselves tune all of those settings by hand, and they usually learn the right values through painful trial and error. That tuition is paid in engineering time, on a problem the industry solved years ago.</p>
<h2 id="heading-rolling-updates-replacing-the-planes-engine-mid-flight"><strong>Rolling Updates: Replacing the Plane's Engine Mid-Flight</strong></h2>
<p>Here is the hard part. Your old version is serving live traffic right now. You need to replace it without dropping a single request. The most common answer is a <a href="https://kubernetes.io/docs/tutorials/kubernetes-basics/update/update-intro/">rolling update</a>.</p>
<p>It works like this. Say you have four copies of your app running. The platform starts one copy of the new version and waits for its health checks to pass. Then it shifts a slice of traffic to it and shuts down one old copy. It repeats this, one copy at a time, until only the new version remains. Users never notice, because at every moment there are enough healthy copies to serve everyone.</p>
<p>Some teams use variations of this idea. A blue-green deployment runs the full new version beside the old one, then flips all traffic at once. A canary release sends a tiny share of users to the new version first, watching for errors before going wider.</p>
<p>Doing this by hand means writing orchestration logic, managing load balancer rules, and handling every edge case where a step fails halfway. That is months of engineering effort to build and a permanent tax to maintain, all for behavior a PaaS ships as the default. On a platform, you get zero-downtime releases out of the box, not as a project your team has to staff.</p>
<h2 id="heading-rollbacks-the-escape-hatch"><strong>Rollbacks: The Escape Hatch</strong></h2>
<p>Sometimes the new version passes every check and still breaks something real. An error rate climbs. A page loads blank. Now speed matters more than anything, and the fastest fix is rarely a new patch. It is a rollback: redeploying the previous artefact that you already know works.</p>
<p>This is why frozen, versioned artefacts are so important. A rollback is only fast if the old version is stored, tested, and ready to run. Teams that rebuild from an old commit under pressure are gambling at the worst possible time.</p>
<p>On most PaaS platforms, a rollback is one command or one click. The platform keeps your release history and can restore any previous version in seconds. That single feature has saved more on-call engineers' nights than perhaps any other.</p>
<h2 id="heading-when-you-dont-need-a-paas">When You Don't Need a PaaS</h2>
<p>The case for handing deployment to a platform is strong, but it isn't universal. There are teams for whom owning the pipeline is not overhead; it is a deliberate and justified engineering decision.</p>
<h3 id="heading-when-compliance-demands-it">When Compliance Demands It</h3>
<p>Regulated industries like finance, healthcare, government, often operate under requirements that a standard PaaS cannot satisfy out of the box. Data residency rules may dictate exactly which physical infrastructure your builds touch. Audit requirements may demand a level of provenance and access logging that a managed platform doesn't expose.</p>
<p>Security controls may need to extend into the build environment itself, not just the runtime. In these contexts, the cost of owning the pipeline is real, but it is the cost of operating in that industry.</p>
<h3 id="heading-when-deployment-is-your-product">When Deployment is Your Product</h3>
<p>If your company sells deployment infrastructure, a CI/CD platform, a release orchestration tool, an internal developer platform, then your pipeline is not overhead at all. It is the product.</p>
<p>The engineers maintaining it are doing product work, not distraction work. The same applies to platform engineering teams at large organizations whose explicit charter is to build and own the deployment layer for dozens of other internal teams. In both cases, the question of "why are we running this ourselves" has an obvious answer: because this is what we do.</p>
<h3 id="heading-when-your-infrastructure-is-genuinely-unusual">When Your Infrastructure is Genuinely Unusual</h3>
<p>Most PaaS platforms are optimized for stateless web services and standard container workloads. If your system falls outside that envelope, GPU clusters, real-time systems with strict latency requirements, hybrid on-premise and cloud deployments, hardware-in-the-loop testing, a general-purpose platform may simply not fit.</p>
<p>Shoehorning an unusual workload into a PaaS often produces more friction than building narrow, purpose-built deployment tooling around the specific constraints you actually have.</p>
<p>The common thread across all three cases is specificity. The teams that are right to own their pipelines can usually state clearly why a platform doesn't fit. If the answer is "we've always done it this way" or "we like having control," that's worth questioning. If the answer is "our compliance requirements mandate X" or "we sell this," that's a reason.</p>
<h2 id="heading-should-you-still-be-running-this-yourself">Should You Still Be Running This Yourself?</h2>
<p>A PaaS does not make any of these steps disappear. The build still runs. Artefacts still get stored. Migrations still execute, health checks still poll, and traffic still shifts one copy at a time. Abstracting these mechanics does not eliminate them. It standardizes them, and pushes their maintenance onto a team whose entire product is deployment.</p>
<p>That is the question every product team should now ask plainly: why are we still building and operating this machinery ourselves? A decade ago, a custom pipeline was unavoidable. Today it is a choice, and for most teams it is the wrong one. Every hour spent debugging a flaky build agent, tuning a health check timeout, or patching orchestration scripts is an hour taken from the product your customers actually pay for. The pipeline does not differentiate you. It cannot. Your competitors' deploys work the same way yours do.</p>
<p>Know how the chain works, because on-call at 2 a.m. demands it. But knowing how it works is not a reason to own it. "We built our own deployment system" is not a badge of honor anymore. It is an admission that your team maintains a second product with no customers. Unless deployment infrastructure is your business, hand the machinery to a platform, and put your engineers back on the work only they can do.</p>
<p>Hope you enjoyed this article. You can <a href="https://linkedin.com/in/manishmshiva">connect with me on LinkedIn</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Kubernetes Operators: A Handbook for Devs ]]>
                </title>
                <description>
                    <![CDATA[ Kubernetes ships with controllers that manage a fixed set of built-in resources: Deployments, Services, Nodes, and so on. An operator extends the same pattern to resources Kubernetes doesn't know abou ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-kubernetes-operators-a-handbook-for-devs/</link>
                <guid isPermaLink="false">6a6a09f608b0619a2131a1cb</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Go Language ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloud native ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Karan Pratap Singh ]]>
                </dc:creator>
                <pubDate>Wed, 29 Jul 2026 14:11:02 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/21c81312-eb74-40f3-823a-3831945a3f58.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Kubernetes ships with controllers that manage a fixed set of built-in resources: Deployments, Services, Nodes, and so on.</p>
<p>An operator extends the same pattern to resources Kubernetes doesn't know about natively, letting you manage custom, often external, systems the same declarative way you manage everything else in the cluster.</p>
<p>This guide is divided into four parts: what an operator actually is, the anatomy of one, building one from scratch, and preparing it for production.</p>
<h3 id="heading-table-of-contents">Table of Contents</h3>
<ul>
<li><p><a href="#heading-part-1-introduction">Part 1: Introduction</a></p>
<ul>
<li><p><a href="#heading-what-is-an-operator">What is an Operator?</a></p>
</li>
<li><p><a href="#heading-operator-vs-controller-vs-crd">Operator vs Controller vs CRD</a></p>
</li>
<li><p><a href="#heading-why-not-just-a-helm-chart-a-cronjob-or-a-script">Why Not Just a Helm Chart, a CronJob, or a Script?</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-2-anatomy-of-an-operator">Part 2: Anatomy of an Operator</a></p>
<ul>
<li><p><a href="#heading-custom-resource">Custom Resource</a></p>
</li>
<li><p><a href="#heading-watching-for-change">Watching for Change</a></p>
</li>
<li><p><a href="#heading-manager">Manager</a></p>
</li>
<li><p><a href="#heading-reconciliation-loop">Reconciliation Loop</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-3-building-the-operator">Part 3: Building the Operator</a></p>
<ul>
<li><p><a href="#heading-setup">Setup</a></p>
</li>
<li><p><a href="#heading-mock-provider">Mock Provider</a></p>
</li>
<li><p><a href="#heading-defining-the-virtualmachine-crd">Defining the VirtualMachine CRD</a></p>
</li>
<li><p><a href="#heading-reconciler">Reconciler</a></p>
</li>
<li><p><a href="#heading-failure-handling-amp-retries">Failure Handling &amp; Retries</a></p>
</li>
<li><p><a href="#heading-finalizer">Finalizer</a></p>
</li>
<li><p><a href="#heading-predicate">Predicate</a></p>
</li>
<li><p><a href="#heading-owned-resources">Owned Resources</a></p>
</li>
<li><p><a href="#heading-cross-resource-reconciliation">Cross-Resource Reconciliation</a></p>
</li>
<li><p><a href="#heading-rbac">RBAC</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-4-production-amp-deployment">Part 4: Production &amp; Deployment</a></p>
<ul>
<li><p><a href="#heading-packaging-amp-deployment">Packaging &amp; Deployment</a></p>
</li>
<li><p><a href="#heading-performance-amp-resilience">Performance &amp; Resilience</a></p>
</li>
<li><p><a href="#heading-security">Security</a></p>
</li>
<li><p><a href="#heading-observability">Observability</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-next-steps">Next steps</a></p>
</li>
</ul>
<h2 id="heading-part-1-introduction">Part 1: Introduction</h2>
<h3 id="heading-what-is-an-operator">What is an Operator?</h3>
<p>Kubernetes works by comparing the state we describe against actual state. A controller acts to close the gap, whether that's the Deployment controller replacing a pod we killed or scaling down the ones we no longer want.</p>
<p>This loop of observe, compare, and act is called <strong>reconciliation</strong>. It means looking up a resource's desired and actual state, deciding what to do next, and recomputing that decision fresh on every run regardless of what changed.</p>
<p>That's what makes the loop resilient: it never has to trust that it saw every event, only that it gets called again.</p>
<img src="https://raw.githubusercontent.com/karanpratapsingh/portfolio/refs/heads/master/public/static/blogs/kubernetes-operators/reconcile-loop.png" alt="reconciliation loop illustration" style="display: block;" width="680" height="400" loading="lazy">

<p>An operator applies that exact same loop to a resource Kubernetes doesn't understand natively. We define a Custom Resource, describe our domain's desired state in it, and write a controller that knows how to reconcile that domain.</p>
<p>That's the whole concept. Everything else in this guide (informers, workqueues, finalizers, status conditions) exists to make that one loop reliable for a resource type Kubernetes only knows about because we defined it.</p>
<h3 id="heading-operator-vs-controller-vs-crd">Operator vs Controller vs CRD</h3>
<p>These three terms are often used interchangeably, but they describe three different layers of the same system.</p>
<ul>
<li><p><strong>CRD (CustomResourceDefinition)</strong>: a schema we register with the Kubernetes API server to teach it about a new resource type. On its own, a CRD does nothing. It only gives the API server a shape to store, validate, and serve.</p>
</li>
<li><p><strong>Controller</strong>: any piece of software running a reconciliation loop against a resource type, from the built-in Deployment controller to a custom controller reconciling a <code>PostgresCluster</code>.</p>
</li>
<li><p><strong>Operator</strong>: a controller, or a small set of controllers, that targets a custom resource and encodes enough domain-specific knowledge to manage its full lifecycle without a human: provisioning, upgrades, failure recovery, and so on.</p>
</li>
</ul>
<p>Every operator is a controller, but not every controller is an operator. A CRD without a controller behind it is just a schema that nothing acts on.</p>
<h3 id="heading-why-not-just-a-helm-chart-a-cronjob-or-a-script">Why Not Just a Helm Chart, a CronJob, or a Script?</h3>
<p>A <a href="https://helm.sh/docs/topics/charts/"><strong>Helm chart</strong></a> renders a set of values into YAML and applies it once. It has no way to keep watching afterward. if a resource it created is deleted or drifts, Helm has no idea until we run <code>helm upgrade</code> again by hand.</p>
<p>A <strong>CronJob</strong> gives us a loop back, at the cost of granularity, staleness up to one interval, no state carried between runs, and no way for one CronJob to react to a status change another one made.</p>
<p>And a <strong>one-off script</strong> only acts when triggered, manually or by a CI pipeline, and does nothing about drift in between runs. It's also rarely written with retries and idempotency as first-class concerns.</p>
<h4 id="heading-why-an-operator-wins-here">Why an operator wins here:</h4>
<p>An operator is event-driven and continuous. The API server notifies it the instant a custom resource is created, updated, or deleted, and it keeps reconciling for that resource's entire lifetime, not just at apply time. That matters most for state that takes time to converge, can fail partway through, and can drift after it's first created.</p>
<p>This comes at a cost, though. An operator is a long-running process with its own RBAC (Role-Based Access Control), failure modes, and observability surface. This is more to build and operate than a chart or a script.</p>
<p>If the problem really is rendering some YAML once, a Helm chart is the right tool. An operator earns its keep when the problem is keeping something continuously correct, which is what the rest of this guide builds toward.</p>
<h2 id="heading-part-2-anatomy-of-an-operator">Part 2: Anatomy of an Operator</h2>
<p>Next, we'll look at the pieces that make that loop actually work: the Custom Resource itself, the machinery that notices when something changed, and the manager that runs it all, before going into the reconciliation loop in detail.</p>
<h3 id="heading-custom-resource">Custom Resource</h3>
<p>Before a controller can reconcile anything, the API server needs to know the shape of what it's storing. Registering a CRD teaches it that shape.</p>
<p>Every Custom Resource carries the same identity fields every Kubernetes object already has (<code>kind</code>, <code>name</code>, <code>namespace</code>, <code>labels</code>, and so on), plus two fields that are entirely ours to define: a spec and a status. That split isn't a style choice. It maps directly to the reconciliation loop.</p>
<ul>
<li><p><strong>Spec</strong> is desired state. Whoever creates or edits the resource writes it, and the controller only ever reads it.</p>
</li>
<li><p><strong>Status</strong> is observed state. It's written only by the controller, to record what it found and what it did.</p>
</li>
</ul>
<p>A client that writes to status directly is working around the controller instead of through it, which is why status is usually served as its own subresource with separate permissions.</p>
<p>The last piece is registration. The API server and any client talking to it need a shared, agreed-upon way to encode and decode our type, so we register it once against a scheme before anything can use it. Without that, our type is just a definition nobody can serve. With it, the API server can store and serve it exactly the way it serves Pods or Deployments.</p>
<p>We'll see exactly what that registration looks like when we build one for real in Part 3.</p>
<h3 id="heading-watching-for-change">Watching for Change</h3>
<p>A reconciler doesn't poll the API server in a loop asking "did anything change yet?" Three pieces work together to avoid that.</p>
<p>An <strong>informer</strong> opens a long-lived watch against the API server and keeps a local, in-memory cache of every object of a given type, updating it as add, update, and delete events arrive.</p>
<p>A <strong>lister</strong> reads from that cache instead of the API server, so a reconciler checking "does this Resource already exist?" costs a local map lookup, not a network call.</p>
<p>A <strong>workqueue</strong> sits between the informer and the reconciler. When the informer sees a change, it doesn't call the reconciler directly. Instead, it enqueues a key, namespace, and name, not the object itself. Workers pull keys off the queue and reconcile them, and the queue deduplicates and rate-limits on our behalf, so ten rapid updates to the same object collapse into one pending item instead of ten redundant reconciles.</p>
<img src="https://raw.githubusercontent.com/karanpratapsingh/portfolio/refs/heads/master/public/static/blogs/kubernetes-operators/informer-pipeline.png" alt="informer, workqueue, and reconciler pipeline" style="display: block;" width="880" height="420" loading="lazy">

<p>This is also why a reconciler receives a key and not an object. By the time a worker picks the key off the queue, the object may have changed again, so the reconciler always looks up the current state itself rather than trusting whatever triggered it.</p>
<h3 id="heading-manager">Manager</h3>
<p>The manager is the process that owns all of this: the shared cache the informers populate, the client the reconciler uses to read and write objects, and the health, readiness, and metrics endpoints the rest of the cluster uses to know the controller is alive. Every reconciler we register runs inside one manager.</p>
<p>If we run more than one replica of the same controller for availability, we don't want both replicas reconciling the same object at once and racing each other.</p>
<p>The manager coordinates this through <strong>leader election</strong>. Replicas compete for a lease, exactly one holds it and actively reconciles, and the rest sit idle until the leader stops renewing it.</p>
<p>We'll come back to this in practice in Part 4. For now it's enough to know the manager is what makes it possible.</p>
<h3 id="heading-reconciliation-loop">Reconciliation Loop</h3>
<p>This is the part that matters most. Once we understand this loop well, most of what an operator does is a variation on it.</p>
<p>A reconciler's entry point is called with just a namespace and a name, nothing else. No spec, status, or diff. The reconciler has to fetch the object itself, compare its spec against what it can observe of the actual state, and decide what to do. That constraint is deliberate, and it's the reason for everything below.</p>
<h4 id="heading-idempotency">Idempotency</h4>
<p>Because the reconciler only ever gets a key, and because it can be called any number of times for the same object (in a row, out of order, or after a long gap), it has to produce the same end result no matter how many times it runs. A reconciler that blindly calls create every time it runs breaks the moment it runs twice, since the second call fails against an object that already exists.</p>
<p>The fix is to always check current state before acting: create only if missing, update only if different, and delete only if it shouldn't exist.</p>
<h4 id="heading-event-driven-reconciliation">Event-driven reconciliation</h4>
<p>A reconcile is triggered by a watch event on the resource being reconciled, and by convention also on anything it owns or otherwise depends on. On top of that, most controllers set a periodic resync so the loop also runs on a schedule even with no watch event at all, which matters once state can drift for reasons a watch would never catch.</p>
<h4 id="heading-requeues">Requeues</h4>
<p>Sometimes a single pass through reconcile can't finish the job, becuase the work it's waiting on is still in progress elsewhere. A reconciler can ask to be called again after a delay without treating this as a failure. This is how it polls something that takes time to converge, rather than blocking inside a single call.</p>
<h4 id="heading-error-handling">Error handling</h4>
<p>Returning an error does something similar: it requeues, but with exponential backoff instead of a fixed delay. So a persistently failing reconcile doesn't hammer whatever it's failing against.</p>
<p>It's worth distinguishing errors that are worth retrying (like a timeout or lock conflict) from ones that aren't (like a spec that will never be valid, which should be surfaced as a status condition instead of retried forever).</p>
<h4 id="heading-drift-correction">Drift correction</h4>
<p>Put all of the above together and the loop is self-healing by construction. Because reconcile recomputes the full diff every time rather than reacting to what specifically changed, it doesn't matter whether the drift came from someone running <code>kubectl edit</code>, another controller, or the underlying system the resource represents changing state on its own. The next reconcile, whether triggered by a watch event or a resync, sees the same gap either way and closes it the same way.</p>
<h2 id="heading-part-3-building-the-operator">Part 3: Building the Operator</h2>
<p>Everything so far has been building toward this. We now know what an operator is, how the terms around it relate, and what pieces a reconciliation loop is made of.</p>
<p>Now we'll put all of it to use and build <strong>VMOperator</strong>. It's an operator that manages a <code>VirtualMachine</code> Custom Resource backed by a mock cloud provider, a small HTTP service we'll also write that stands in for a real one.</p>
<pre><code class="language-yaml">apiVersion: compute.example.com/v1
kind: VirtualMachine
spec:
  image: ubuntu-22.04
  cpu: 2
  memory: 4Gi
status:
  phase: Running
  id: vm-123
</code></pre>
<p>Say we want to represent a virtual machine in a Kubernetes-native way, <code>kubectl apply</code> a YAML file, and get a VM – all without touching a cloud console or a separate CLI.</p>
<p>That's the motivation behind VMOperator: something that lives entirely outside Kubernetes becomes just another object the cluster's own tooling (<code>kubectl</code>, RBAC, GitOps pipelines) already knows how to work with.</p>
<img src="https://raw.githubusercontent.com/karanpratapsingh/portfolio/refs/heads/master/public/static/blogs/kubernetes-operators/vmoperator-architecture.png" alt="VMOperator architecture" style="display: block;" width="1000" height="520" loading="lazy">

<h3 id="heading-setup">Setup</h3>
<p>We'll need a local cluster, and <a href="https://kind.sigs.k8s.io/">kind</a> is the easiest way to get one:</p>
<pre><code class="language-bash">kind create cluster --name vmoperator
</code></pre>
<p>Beyond that, we'll be using <a href="https://go.dev/doc/install">Go</a> in this part for the operator, <code>kubectl</code> pointed at the new cluster, and Python with Flask for the mock provider below.</p>
<pre><code class="language-bash">pip install flask
</code></pre>
<h3 id="heading-mock-provider">Mock Provider</h3>
<p>Before we write controller code, we need something for it to control. The mock provider is a small HTTP service with three endpoints:</p>
<ul>
<li><p><code>POST /vms</code> to create one</p>
</li>
<li><p><code>GET /vms/{id}</code> to check on it</p>
</li>
<li><p><code>DELETE /vms/{id}</code> to remove it</p>
</li>
</ul>
<p>backed by nothing more than a dict in memory.</p>
<p>Every VM it creates starts in <code>Provisioning</code> and flips to <code>Running</code> a few seconds later on its own, which is enough to force our reconciler to actually poll instead of assuming success.</p>
<p>We're writing this one in Python rather than Go. It has nothing to do with the operator's code, as this is only for mock purposes.</p>
<pre><code class="language-python">import random
import string
import threading
import time

from flask import Flask, jsonify, request

app = Flask(__name__)
vms = {}  # in-memory store, keyed by VM id

def provision(vm):
    time.sleep(5)  # simulate provisioning taking time
    vm["phase"] = "Running"

@app.post("/vms")
def create_vm():
    body = request.get_json()
    vm_id = "vm-" + "".join(random.choices(string.digits, k=6))
    vm = {"id": vm_id, "image": body["image"], "phase": "Provisioning"}
    vms[vm_id] = vm

    threading.Thread(target=provision, args=(vm,), daemon=True).start()  # flips to Running in the background

    return jsonify(vm)

@app.get("/vms/&lt;vm_id&gt;")
def get_vm(vm_id):
    vm = vms.get(vm_id)
    if vm is None:
        return "", 404
    return jsonify(vm)

@app.delete("/vms/&lt;vm_id&gt;")
def delete_vm(vm_id):
    vms.pop(vm_id, None)
    return "", 204

if __name__ == "__main__":
    app.run(port=8080, threaded=True)
</code></pre>
<p>We'll run this as its own process, alongside the cluster, listening on the port the operator will be configured to call. Nothing about it knows Kubernetes exists, which is the point: it's standing in for a real cloud API.</p>
<h3 id="heading-defining-the-virtualmachine-crd">Defining the VirtualMachine CRD</h3>
<p>With something to control, we can define what we're controlling. The <code>VirtualMachine</code> type follows exactly the spec and status split from Part 2:</p>
<pre><code class="language-go">type VirtualMachineSpec struct {
	Image  string `json:"image"`
	CPU    int    `json:"cpu"`
	Memory string `json:"memory"`
}

type VirtualMachineStatus struct {
	ID    string `json:"id,omitempty"`    // provider-assigned id, empty until first provisioned
	Phase string `json:"phase,omitempty"` // mirrors the provider's lifecycle phase
}

type VirtualMachine struct {
	metav1.TypeMeta   `json:",inline"`
	metav1.ObjectMeta `json:"metadata,omitempty"`

	Spec   VirtualMachineSpec   `json:"spec,omitempty"`
	Status VirtualMachineStatus `json:"status,omitempty"`
}

type VirtualMachineList struct {
	metav1.TypeMeta `json:",inline"`
	metav1.ListMeta `json:"metadata,omitempty"`
	Items           []VirtualMachine `json:"items"`
}
</code></pre>
<p><code>TypeMeta</code> carries <code>kind</code> and <code>apiVersion</code>, the same two fields on every Kubernetes object, built-in or custom, that say what this thing is.</p>
<p><code>ListMeta</code> is its counterpart for list types, <code>resourceVersion</code> and <code>continue</code> for pagination (instead of <code>name</code>/<code>namespace</code>). This is why <code>VirtualMachineList</code> embeds <code>ListMeta</code> next to its <code>TypeMeta</code> while <code>VirtualMachine</code> itself embeds <code>ObjectMeta</code>.</p>
<p>Every type we register needs to satisfy <code>runtime.Object</code>, which means implementing <code>DeepCopyObject</code>. This is normally generated for us, but since we're doing this by hand, here's what that generated code actually looks like for <code>VirtualMachine</code>. The rest follow the same mechanical pattern:</p>
<pre><code class="language-go">func (in *VirtualMachine) DeepCopyObject() runtime.Object {
	out := VirtualMachine{
		TypeMeta:   in.TypeMeta,
		ObjectMeta: *in.ObjectMeta.DeepCopy(), // ObjectMeta already knows how to copy itself
		Spec:       in.Spec,                   // no pointers or slices in Spec, a plain copy is safe
		Status:     in.Status,
	}
	return &amp;out
}
</code></pre>
<p>And the CRD manifest that teaches the API server about it:</p>
<pre><code class="language-yaml">apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: virtualmachines.compute.example.com
spec:
  group: compute.example.com
  scope: Namespaced
  names:
    kind: VirtualMachine
    listKind: VirtualMachineList
    plural: virtualmachines
    singular: virtualmachine
    shortNames: [vm] # lets us type `kubectl get vm` instead of the full plural
  versions:
    - name: v1
      served: true
      storage: true
      subresources:
        status: {} # splits status into its own subresource, see Part 2
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              required: [image, cpu, memory]
              properties:
                image: { type: string }
                cpu: { type: integer }
                memory: { type: string }
            status:
              type: object
              properties:
                phase: { type: string }
                id: { type: string }
</code></pre>
<p>The <code>subresources.status</code> line matters. It's what makes status a separate subresource with its own update path. This is exactly the boundary we talked about in Part 2 between what a client can write and what only the controller can.</p>
<p>The <code>names</code> block is also what <code>kubectl</code> resolves against, <code>kubectl get virtualmachines</code> works because <code>plural</code> says so. <code>shortNames</code> is why <code>kubectl get vm</code> works too, the same way <code>kubectl get po</code> works for Pods.</p>
<h3 id="heading-reconciler">Reconciler</h3>
<p>The reconciler's job is small on paper, look at a <code>VirtualMachine</code>, and make sure a matching VM exists in the provider and its status reflects reality. We wrap the provider's HTTP API behind a small client so the reconciler itself stays readable:</p>
<pre><code class="language-go">func (r *VirtualMachineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var vm computev1.VirtualMachine
	if err := r.Get(ctx, req.NamespacedName, &amp;vm); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err) // object was deleted, nothing left to do
	}

	if vm.Status.ID == "" {
		// no VM yet, this is the first time we've seen this object
		created, err := r.Provider.Create(ctx, vm.Spec.Image)
		if err != nil {
			return ctrl.Result{}, err
		}

		vm.Status.ID = created.ID
		vm.Status.Phase = created.Phase
		if err := r.Status().Update(ctx, &amp;vm); err != nil {
			return ctrl.Result{}, err
		}

		return ctrl.Result{RequeueAfter: 2 * time.Second}, nil // check back shortly instead of blocking here
	}

	// VM already exists, poll the provider for whatever it knows right now
	current, err := r.Provider.Get(ctx, vm.Status.ID)
	if err != nil {
		return ctrl.Result{}, err
	}

	vm.Status.Phase = current.Phase
	if err := r.Status().Update(ctx, &amp;vm); err != nil {
		return ctrl.Result{}, err
	}

	if current.Phase != "Running" {
		return ctrl.Result{RequeueAfter: 2 * time.Second}, nil // still provisioning, keep polling
	}

	return ctrl.Result{}, nil
}
</code></pre>
<p>Two things are worth calling out. First, this is only reachable at all because we've registered a watch on <code>VirtualMachine</code>. The API server tells us the moment one is created or edited, which is what triggers the first call.</p>
<p>Second, every branch ends by writing to <code>vm.Status</code>, mapping whatever the provider told us onto the resource. Kubernetes never talks to the provider directly. The only way anyone finds out a VM is running is because our reconciler wrote it into status.</p>
<h3 id="heading-failure-handling-amp-retries">Failure Handling &amp; Retries</h3>
<p>Notice the reconciler above never retries anything itself. When <code>r.Provider.Create</code> or <code>r.Provider.Get</code> fails (like because of a network blip or the mock provider not being up yet), it just returns the error. That's deliberate. Returning an error is how we ask controller-runtime to requeue with exponential backoff on our behalf. This means we don't need to hand-roll a retry loop, and a persistently unreachable provider doesn't get flooded with retries.</p>
<p>The one thing worth being careful about is treating every failure the same way. A timeout talking to the provider is worth retrying. A <code>VirtualMachine</code> whose <code>spec.image</code> the provider will never accept is not. Retrying that forever just produces a busy loop that never succeeds.</p>
<p>We'll leave surfacing that distinction through status conditions to the exercises. The reconciler above only has one failure mode to worry about, since the mock provider never rejects a request outright.</p>
<h3 id="heading-finalizer">Finalizer</h3>
<p>If we delete a <code>VirtualMachine</code> right now, Kubernetes removes the object and we're left with an orphaned VM the provider still thinks is running. A finalizer closes that gap: it's a string on the object that tells Kubernetes "don't actually delete this until I say so."</p>
<pre><code class="language-go">const vmFinalizer = "compute.example.com/vm-cleanup"

func (r *VirtualMachineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
	var vm computev1.VirtualMachine
	if err := r.Get(ctx, req.NamespacedName, &amp;vm); err != nil {
		return ctrl.Result{}, client.IgnoreNotFound(err)
	}

	if !vm.DeletionTimestamp.IsZero() {
		// being deleted, deprovision through the provider before letting it go
		if controllerutil.ContainsFinalizer(&amp;vm, vmFinalizer) {
			if vm.Status.ID != "" {
				if err := r.Provider.Delete(ctx, vm.Status.ID); err != nil {
					return ctrl.Result{}, err
				}
			}
			controllerutil.RemoveFinalizer(&amp;vm, vmFinalizer) // safe to let the delete proceed now
			return ctrl.Result{}, r.Update(ctx, &amp;vm)
		}
		return ctrl.Result{}, nil
	}

	if !controllerutil.ContainsFinalizer(&amp;vm, vmFinalizer) {
		controllerutil.AddFinalizer(&amp;vm, vmFinalizer) // register before we ever provision anything
		if err := r.Update(ctx, &amp;vm); err != nil {
			return ctrl.Result{}, err
		}
	}

	// ... provisioning logic from before
	return ctrl.Result{}, nil
}
</code></pre>
<p>A <code>kubectl delete</code> on a <code>VirtualMachine</code> with our finalizer present doesn't remove it. Rather, it sets <code>deletionTimestamp</code> and waits.</p>
<p>Our reconciler sees that on the next call, deprovisions the VM through the provider, and only then removes the finalizer. At this point Kubernetes finally deletes the object. If there's no finalizer, there's no guarantee that cleanup ever runs.</p>
<h3 id="heading-predicate">Predicate</h3>
<p>There's a subtle bug already sitting in the reconciler above. Every time it calls <code>r.Status().Update</code>, that write is itself a change to the object. This triggers our own watch, which calls reconcile again.</p>
<p>Left alone, this doesn't spin forever, since we're recomputing the same status until it settles. But it's still wasted work reconciling in response to writes we made ourselves.</p>
<p>A predicate filters those events that actually enqueue a reconcile, before our code ever runs:</p>
<pre><code class="language-go">func (r *VirtualMachineReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&amp;computev1.VirtualMachine{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). // drop status only events
		Complete(r)
}
</code></pre>
<p><code>generation</code> only increments when <code>spec</code> changes. Status updates don't touch it. <code>GenerationChangedPredicate</code> uses that to drop events where nothing but status moved, so our own writes stop retriggering us. Then we're back to reconciling only when something meaningful changed, or when we explicitly ask to be requeued.</p>
<h3 id="heading-owned-resources">Owned Resources</h3>
<p>A <code>VirtualMachine</code> being <code>Running</code> somewhere isn't very useful on its own, so let's make the operator also create a <code>Secret</code> holding the VM's connection details in-cluster:</p>
<pre><code class="language-go">func (r *VirtualMachineReconciler) reconcileConnectionSecret(ctx context.Context, vm *computev1.VirtualMachine) error {
	secret := &amp;corev1.Secret{
		ObjectMeta: metav1.ObjectMeta{
			Name:      vm.Name + "-connection",
			Namespace: vm.Namespace,
		},
		StringData: map[string]string{"id": vm.Status.ID},
	}

	if err := controllerutil.SetControllerReference(vm, secret, r.Scheme); err != nil {
		return err // ties the Secret's lifecycle to this VirtualMachine
	}

	return r.Patch(ctx, secret, client.Apply, client.ForceOwnership, client.FieldOwner("vmoperator")) // create or update, either way
}
</code></pre>
<p><code>SetControllerReference</code> is what makes this an <strong>owned resource</strong>, it stamps an owner reference onto the <code>Secret</code> pointing back at the <code>VirtualMachine</code>.</p>
<p>Two things fall out of that for free. Deleting the <code>VirtualMachine</code> now cascades, Kubernetes garbage collects the <code>Secret</code> automatically, and there's no finalizer needed since it's an in-cluster object, not an external one.</p>
<p>And if we add <code>Owns(&amp;corev1.Secret{})</code> alongside <code>For(&amp;computev1.VirtualMachine{})</code> in <code>SetupWithManager</code>, an edit or deletion of the <code>Secret</code> itself re-triggers reconciliation of its owning <code>VirtualMachine</code>. So if someone deletes it by hand, we notice and recreate it.</p>
<p>The same pattern, <code>SetControllerReference</code> call, and <code>Owns()</code> registration creates a second owned resource: a <code>Service</code> fronting the VM in-cluster. That's two different resource kinds owned by one <code>VirtualMachine</code>, which is all <strong>multiple owned resources</strong> means in practice. There's nothing more to it than calling the same pattern twice for different types.</p>
<h3 id="heading-cross-resource-reconciliation">Cross-Resource Reconciliation</h3>
<p>Every <code>VirtualMachine</code> so far talks to one hardcoded provider endpoint. Real deployments need that to be configurable, and it's rarely a one-off: fifty <code>VirtualMachine</code>s in the same AWS account share the same endpoint and credentials, and a hundred more might live in Azure instead.</p>
<p>We could put an <code>endpoint</code> field directly on <code>VirtualMachineSpec</code>, but rotating a credential or fixing a typo would then mean editing every <code>VirtualMachine</code> that uses it, one at a time. Pulling that into its own object lets many <code>VirtualMachine</code>s reference it by name instead, so a single edit propagates to all of them.</p>
<p>Now let's add a second, small CRD:</p>
<pre><code class="language-go">type ProviderConfigSpec struct {
	Endpoint string `json:"endpoint"`
}
</code></pre>
<p>And a <code>providerRef</code> field on <code>VirtualMachineSpec</code> pointing at one by name. The interesting part isn't the new type. It's what happens when a <code>ProviderConfig</code> changes.</p>
<p>A <code>VirtualMachine</code> doesn't watch <code>ProviderConfig</code> directly, and there's no owner reference between them, so a plain <code>Owns()</code> won't do it. Instead, we watch the type and map each event onto every <code>VirtualMachine</code> that references it:</p>
<img src="https://raw.githubusercontent.com/karanpratapsingh/portfolio/refs/heads/master/public/static/blogs/kubernetes-operators/cross-resource-fanout.png" alt="cross-resource reconciliation fan-out" style="display: block;" width="820" height="380" loading="lazy">

<pre><code class="language-go">func (r *VirtualMachineReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&amp;computev1.VirtualMachine{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
		Owns(&amp;corev1.Secret{}).
		Owns(&amp;corev1.Service{}).
		Watches(
			&amp;computev1.ProviderConfig{}, // not owned, so Owns() won't catch its changes
			handler.EnqueueRequestsFromMapFunc(r.findVirtualMachinesForProviderConfig),
		).
		Complete(r)
}

func (r *VirtualMachineReconciler) findVirtualMachinesForProviderConfig(ctx context.Context, obj client.Object) []reconcile.Request {
	var vms computev1.VirtualMachineList
	if err := r.List(ctx, &amp;vms, client.InNamespace(obj.GetNamespace())); err != nil {
		return nil
	}

	var requests []reconcile.Request
	for _, vm := range vms.Items {
		if vm.Spec.ProviderRef == obj.GetName() { // only re-enqueue VMs that actually reference this config
			requests = append(requests, reconcile.Request{NamespacedName: client.ObjectKeyFromObject(&amp;vm)})
		}
	}
	return requests
}
</code></pre>
<p>This is <strong>cross-resource reconciliation</strong>: one resource's change causing a different resource type entirely to reconcile, connected only by a field value rather than ownership.</p>
<p>It's also where the theme of this whole project comes back around. <code>ProviderConfig</code> is what would hold real credentials and a real endpoint for AWS, Azure, or GCP in a production version of this operator. The mock provider is standing in for exactly that boundary.</p>
<h3 id="heading-rbac">RBAC</h3>
<p>None of the above works without permission to act on it. The manifest just has to list what we actually touch: <code>VirtualMachine</code> and <code>ProviderConfig</code> objects, the <code>VirtualMachine</code> status subresource separately, and the <code>Secret</code>/<code>Service</code> objects we create:</p>
<pre><code class="language-yaml">apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: vmoperator-manager-role
rules:
  - apiGroups: ['compute.example.com']
    resources: ['virtualmachines', 'providerconfigs']
    verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete']
  - apiGroups: ['compute.example.com']
    resources: ['virtualmachines/status'] # separate rule, it's a separate subresource
    verbs: ['get', 'update', 'patch']
  - apiGroups: ['']
    resources: ['secrets', 'services']
    verbs: ['get', 'list', 'watch', 'create', 'update', 'patch', 'delete']
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: vmoperator-manager-rolebinding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: vmoperator-manager-role
subjects:
  - kind: ServiceAccount
    name: vmoperator-controller-manager
    namespace: vmoperator-system
</code></pre>
<p><strong>Note:</strong> VMOperator manages a resource entirely outside the cluster through a hand-rolled HTTP client, but it's not a novel pattern. <a href="https://www.crossplane.io/">Crossplane</a>, <a href="https://aws-controllers-k8s.github.io/community/">AWS Controllers for Kubernetes</a>, <a href="https://cluster-api.sigs.k8s.io/">Cluster API</a>, and cert-manager all reconcile external or non-Kubernetes state through CRDs the same way. These resources are worth reading once this pattern feels familiar.</p>
<p><strong>Another note:</strong> we're keeping VMOperator's scope narrow on purpose. Resizing a running VM, stopping and restarting one, taking snapshots, and supporting more than one real provider behind <code>ProviderConfig</code> are all natural extensions of what's here, and a reasonable next step once the core loop feels solid.</p>
<h2 id="heading-part-4-production-amp-deployment">Part 4: Production &amp; Deployment</h2>
<p>Now that VMOperator works, let's see how to package and deploy it and improve it for production.</p>
<h3 id="heading-packaging-amp-deployment">Packaging &amp; Deployment</h3>
<p>Everything so far has run as a binary on our own machine. <code>go run</code> against whatever cluster <code>kubectl</code> happens to be pointed at.</p>
<p>A <code>Deployment</code> needs an image instead, so the operator gets a multi-stage <code>Dockerfile</code>: one stage to compile it, and a second, much smaller image to actually run it:</p>
<pre><code class="language-dockerfile">FROM golang:1.26 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /vmoperator ./cmd/manager

FROM gcr.io/distroless/static-debian12
COPY --from=build /vmoperator /vmoperator
USER 65532:65532 # nonroot, matches the security context on the Deployment below
ENTRYPOINT ["/vmoperator"]
</code></pre>
<p>The build stage has the full Go toolchain and every source file, none of which need to ship. The final image only has the compiled binary, which covers most of what a container security context later in this part would otherwise have to ask for. There's no shell to get a foothold in even before <code>runAsNonRoot</code> is set.</p>
<pre><code class="language-bash">docker build -t registry.example.com/vmoperator:v0.1.0 .
docker push registry.example.com/vmoperator:v0.1.0
</code></pre>
<p>That image is what the <code>Deployment</code> manifest under <code>config/manager/</code> actually references. With it pushed somewhere the cluster can pull from, the rest of the manifests can go on: the CRDs, RBAC, the operator's <code>Deployment</code>, and a <code>Deployment</code> and <code>Service</code> for the mock provider. So it's no longer something we run as a side process on our own machine either:</p>
<pre><code class="language-bash">kubectl apply -f config/crd/
kubectl apply -f config/rbac/
kubectl apply -f config/manager/
</code></pre>
<p>Installing the CRDs before anything else matters. Otherwise the operator's <code>Deployment</code> will crash-loop if it starts and immediately (it tries to watch a resource type the API server has never heard of).</p>
<p>Schema changes are the part that hand-written manifests make us feel directly. Adding a field to <code>VirtualMachineSpec</code> is harmless, but existing objects just don't have it set. Renaming or restructuring one isn't: every stored <code>VirtualMachine</code> was serialized against the old shape.</p>
<p>The CRD's <code>versions</code> list is built for exactly this, as more than one version can be <code>served</code> at once. One is marked <code>storage</code> to say which shape objects are actually persisted as, and a conversion webhook translates between them when a client asks for a version that isn't the stored one.</p>
<p>We don't need this for VMOperator today, since <code>v1</code> is the only version that's ever existed. But it's why the <code>versions</code> field was a list and not a single value from the very first manifest we wrote.</p>
<p>None of the above replaces a person running <code>kubectl apply</code> by hand forever. A CI pipeline that builds the operator's image, pushes it, and applies the manifests on merge to main is the natural next step. This is ordinary CI/CD, nothing operator-specific about it once the manifests themselves are in Git.</p>
<h3 id="heading-performance-amp-resilience">Performance &amp; Resilience</h3>
<p>By default, a controller only processes one reconcile at a time. That's fine while we're the only ones testing it, but with hundreds of <code>VirtualMachine</code> objects it means that most of them sit in the workqueue waiting their turn even though nothing about reconciling one blocks reconciling another.</p>
<p><code>MaxConcurrentReconciles</code> raises that:</p>
<pre><code class="language-go">func (r *VirtualMachineReconciler) SetupWithManager(mgr ctrl.Manager) error {
	return ctrl.NewControllerManagedBy(mgr).
		For(&amp;computev1.VirtualMachine{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})).
		Owns(&amp;corev1.Secret{}).
		Owns(&amp;corev1.Service{}).
		Watches(&amp;computev1.ProviderConfig{}, handler.EnqueueRequestsFromMapFunc(r.findVirtualMachinesForProviderConfig)).
		WithOptions(controller.Options{MaxConcurrentReconciles: 5}). // five VMs in flight instead of one
		Complete(r)
}
</code></pre>
<p>Caching only helps one side of this reconciler. Reading <code>vm</code> back from <code>r.Get</code> is already fast and local, as informers keep that in memory. But <code>r.Provider.Get</code> is a real HTTP round trip every single time, and there's no cache in front of it.</p>
<p>That asymmetry is worth sitting with, because it's the same one from Part 2: in-cluster reads are cheap because Kubernetes built the caching layer for us, and external reads are exactly as expensive as whatever's on the other end of the wire. We could add a short-lived cache in front of the provider client, but it comes with a real cost: a cached <code>Running</code> for a VM that just failed is a lie our status will repeat until the cache expires.</p>
<p>The provider not having a cache in front of it also means nothing is stopping us from hammering it. A burst of reconciles, say every <code>VirtualMachine</code> getting touched at once after a cluster restart, turns into a burst of HTTP calls with no coordination between them. Wrapping the client in a rate limiter caps that independently of whatever backoff the workqueue is already doing on failures:</p>
<pre><code class="language-go">type Client struct {
	baseURL string
	http    *http.Client
	limiter *rate.Limiter // shared across every reconcile using this client
}

func (c *Client) Create(ctx context.Context, image string) (*VM, error) {
	if err := c.limiter.Wait(ctx); err != nil {
		return nil, err
	}
	// ... existing HTTP call
}
</code></pre>
<p>Leader election is the other half of running more than one replica safely. We turned this down to a concept in Part 2, but in practice it's two fields on the manager:</p>
<pre><code class="language-go">mgr, err := ctrl.NewManager(cfg, ctrl.Options{
	LeaderElection:   true,
	LeaderElectionID: "vmoperator-leader",
})
</code></pre>
<p>With this set, every replica starts up, but only the one holding the lease actually reconciles. The rest sit ready to take over the moment it doesn't renew in time.</p>
<p>Concurrency also surfaces a race we glossed over in Part 3. Say the reconciler calls <code>r.Provider.Create</code>, the provider creates the VM and returns its id, and then the process crashes before <code>r.Status().Update</code> ever runs. <code>vm.Status.ID</code> is still empty, so the next reconcile sees an object with no VM yet and calls <code>Create</code> again. Now the provider has two VMs for one <code>VirtualMachine</code>.</p>
<p>Nothing about <code>MaxConcurrentReconciles</code> or leader election prevents this. It's a gap in the create step itself, and it only shows up once something can fail between the external call and the write that records it.</p>
<p>Closing it for real means the provider needs to accept an idempotency key, generated once and stored on the object before the first <code>Create</code> call, so a retried create recognizes that it already happened instead of making a second VM.</p>
<h3 id="heading-security">Security</h3>
<p>The <code>ClusterRole</code> from Part 3 works, but it's broader than it needs to be. It grants every verb on <code>secrets</code> and <code>services</code> cluster-wide, when the operator only ever touches the ones it owns.</p>
<p>A tighter version scopes to a single namespace with <code>Role</code>/<code>RoleBinding</code> instead of <code>ClusterRole</code>/<code>ClusterRoleBinding</code> wherever VMOperator is only expected to run in one, and it drops verbs we never call. We never <code>list</code> or <code>watch</code> arbitrary <code>Secret</code>s outside our own, only the ones we create. This is also the RBAC the manifests applied in the previous section were referring to.</p>
<p>Credentials are the other gap. <code>ProviderConfig</code> currently holds a plaintext endpoint, and a real provider needs an API key alongside it, which has no business sitting in a CRD spec anyone with read access to the object can see. It belongs in a <code>Secret</code>, referenced by name instead of embedded:</p>
<pre><code class="language-go">type ProviderConfigSpec struct {
	Endpoint  string                      `json:"endpoint"`
	SecretRef corev1.LocalObjectReference `json:"secretRef"` // Secret holding the provider's API key
}
</code></pre>
<p>The reconciler resolves <code>SecretRef</code> at the point it builds the provider client, reads the key out of the <code>Secret</code>'s data, and never logs it or writes it back to anything with wider read access, including the <code>VirtualMachine</code>'s own status.</p>
<p>The last piece is the operator's own pod. A container security context that runs as a non-root user sets a read-only root filesystem, drops Linux capabilities it doesn't need, and shrinks what's possible if the binary itself is ever compromised. This is standard practice for any workload, not something specific to operators.</p>
<p>If we'd added an admission webhook anywhere in this guide, its certificates would belong here too. We didn't need one for VMOperator, so we'll leave that as a pointer rather than something to configure.</p>
<h3 id="heading-observability">Observability</h3>
<p>The manager exposes a Prometheus endpoint without us writing anything for it. Workqueue depth, reconcile duration, and reconcile error counts are already there per controller.</p>
<p>What isn't there automatically is anything about the provider, so we add a metric the same way any Go service would:</p>
<pre><code class="language-go">var providerCallDuration = prometheus.NewHistogramVec(
	prometheus.HistogramOpts{
		Name: "vmoperator_provider_call_duration_seconds",
		Help: "Duration of calls to the VM provider, by operation",
	},
	[]string{"operation"},
)

func init() {
	metrics.Registry.MustRegister(providerCallDuration) // shares the manager's existing /metrics endpoint
}
</code></pre>
<p>Wrapping each provider call with a timer around this turns "is the provider slow" from a question we'd have to guess at into one we can graph.</p>
<p>Logging benefits from the same instinct. <code>log.FromContext(ctx)</code> inside <code>Reconcile</code> already carries the <code>VirtualMachine</code>'s name and namespace on every line if we set that up once in <code>SetupWithManager</code>. Adding <code>vm.Status.ID</code> to that logger right after it's set means every subsequent log line for that reconcile also carries the provider's own identifier for the VM. That one field is what makes it possible to grep a mock provider log and an operator log for the same request and find both sides of the same failure.</p>
<h2 id="heading-next-steps">Next Steps</h2>
<p>In this guide, you learned what a Kubernetes operator is, how to build one from scratch, and how to prepare it for production. You also learned about finalizers, predicates, owned resources, and cross-resource reconciliation along the way.</p>
<p>None of this is specific to managing VMs. The next operator, whatever it manages, is the same shape.</p>
<p>You can also review the resources below to keep learning:</p>
<ul>
<li><p><a href="https://kubernetes.io/docs/concepts/extend-kubernetes/api-extension/custom-resources/">K8s custom resources</a></p>
</li>
<li><p><a href="https://github.com/kubernetes/client-go">client-go</a></p>
</li>
<li><p><a href="https://github.com/kubernetes-sigs/controller-runtime">controller-runtime</a></p>
</li>
<li><p><a href="https://docs.docker.com/build/">Docker docs</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Kubernetes Networking Without Kubernetes: Do What the CNI Does By Hand ]]>
                </title>
                <description>
                    <![CDATA[ In this article, you'll build an accurate mental model of what a Container Network Interface (CNI) actually does. Not by reading YAML, but by doing every single step it does by hand with raw Linux ker ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-kubernetes-networking-without-kubernetes-do-what-the-cni-does-by-hand/</link>
                <guid isPermaLink="false">6a614f114250f422f9a7be1d</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ networking ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cni ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shubham Katara ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 23:15:29 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8fc3683f-15c8-45ff-8424-bb1b427f68e2.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this article, you'll build an accurate mental model of what a Container Network Interface (CNI) actually does. Not by reading YAML, but by doing every single step it does by hand with raw Linux kernel primitives.</p>
<p>The Container Network Interface (CNI) is one of the great black boxes of Kubernetes. Most people who run clusters every day have never once looked inside it. They know the <em>name</em> of their CNI ("we run Calico," "we're on Cilium") the way you know the brand of the alternator in your car: as a label, not as a thing you actually understand.</p>
<p>It lives at the very bottom of the stack, beneath the kubelet, beneath your pods, quietly moving every single packet. And precisely because it never fails loudly on a good day, almost nobody learns what it does.</p>
<p>We won't run <code>helm install cilium</code>. We won't apply a single manifest. Instead, we'll wire up pod networking from scratch, feel exactly where it breaks the moment traffic tries to leave a physical machine, and fix it manually.</p>
<p>By the end, you'll understand it in your bones, not just in theory, why tools like Cilium exist and what they're really solving under the hood.</p>
<p><strong>Who this is for:</strong></p>
<ul>
<li><p>Developers, platform engineers, and SREs who use Kubernetes every day but quietly treat pod-to-pod networking as magic.</p>
</li>
<li><p>Anyone who has ever watched a pod flip to <code>Running</code> and assumed the network "just works" and wants to know what's actually happening.</p>
</li>
</ul>
<p><strong>What you'll build with your own hands:</strong></p>
<ul>
<li><p>Two isolated network namespaces wired together with a virtual cable (<code>veth</code> pair).</p>
</li>
<li><p>A three-namespace virtual switch using a Linux bridge, the same trick legacy CNIs use on a single node.</p>
</li>
<li><p>A deliberately broken two-node setup where a packet gets dropped on the floor, plus the manual fix that makes it work.</p>
</li>
<li><p>A clear picture of the three jobs every CNI does, and why cloud providers force advanced CNIs like Cilium to use overlays and eBPF.</p>
</li>
</ul>
<p><strong>Note:</strong> Every command here needs a Linux host and <code>root</code>. Run this in a throwaway VM or lab environment, not on anything you care about. The whole point is to make a mess and learn from it.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-illusion-kubernetes-routes-zero-packets">The Illusion: Kubernetes Routes Zero Packets</a></p>
</li>
<li><p><a href="#heading-the-foundation-virtual-ethernet-veth-pairs">The Foundation: Virtual Ethernet (veth) Pairs</a></p>
</li>
<li><p><a href="#heading-how-to-scale-locally-with-a-linux-bridge">How to Scale Locally with a Linux Bridge</a></p>
</li>
<li><p><a href="#heading-the-multi-node-boundary-problem">The Multi-Node Boundary Problem</a></p>
</li>
<li><p><a href="#heading-how-to-fix-it-manually-with-direct-routing">How to Fix It Manually with Direct Routing</a></p>
</li>
<li><p><a href="#heading-so-what-is-a-cni-really">So What Is a CNI, Really?</a></p>
</li>
<li><p><a href="#heading-the-cloud-catch-and-why-cilium-changes-the-game">The Cloud Catch and Why Cilium Changes the Game</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before following along, you'll need:</p>
<ul>
<li><p>Two Linux VMs on the same network for the multi-node section so you can watch traffic cross a real machine boundary.</p>
</li>
<li><p>The <code>ip</code> command from the <code>iproute2</code> package (already installed on virtually every modern distro).</p>
</li>
<li><p>A basic comfort with IP addresses, subnets, and the word "gateway." You don't need to be a network engineer.</p>
</li>
<li><p><strong>No Kubernetes.</strong> That's not a typo. We're going underneath Kubernetes on purpose.</p>
</li>
</ul>
<h2 id="heading-the-illusion-kubernetes-routes-zero-packets">The Illusion: Kubernetes Routes Zero Packets</h2>
<p>Here's the uncomfortable truth most people never confront: <strong>Kubernetes can't route a single network packet.</strong></p>
<p>Not one. Kubernetes is a orchestrator. It schedules pods, watches their health, and updates state in etcd. But when it comes to actually moving a packet from one container to another, it has zero built-in capability. None.</p>
<p>So how do your pods talk to each other? They rely completely on an external agent to wire up the virtual network plumbing on every node. That agent is the <strong>Container Network Interface (CNI)</strong>. What the CNI does under the hood quietly, is what we would do ourselves to feel the pain and then the solution a CNI provides.</p>
<p>Here's the proof that it's load-bearing. Spin up a brand-new cluster with <code>kubeadm</code> and look at your nodes:</p>
<pre><code class="language-bash">$ kubectl get nodes
NAME       STATUS     ROLES                  AGE   VERSION   INTERNAL-IP     EXTERNAL-IP   OS-IMAGE             KERNEL-VERSION                        CONTAINER-RUNTIME
no-cni     NotReady   control-plane,master   52s   v1.35.0   192.168.117.2   &lt;none&gt;        Ubuntu 24.04.3 LTS   7.0.11-orbstack-00360-gc9bc4d96ac70   containerd://2.1.6
worker-1   NotReady   &lt;none&gt;                 46s   v1.35.0   192.168.117.3   &lt;none&gt;        Ubuntu 24.04.3 LTS   7.0.11-orbstack-00360-gc9bc4d96ac70   containerd://2.1.6
worker-2   NotReady   &lt;none&gt;                 40s   v1.35.0   192.168.117.4   &lt;none&gt;        Ubuntu 24.04.3 LTS   7.0.11-orbstack-00360-gc9bc4d96ac70   containerd://2.1.6
</code></pre>
<p><code>NotReady</code>. Every node. The control plane is healthy, etcd is up, the scheduler is alive, and the cluster still flatly refuses to be <code>Ready</code>. If you describe the node, it tells you precisely what's missing:</p>
<pre><code class="language-plaintext">Conditions:
  Type             Status  LastHeartbeatTime                 LastTransitionTime                Reason                       Message
  ----             ------  -----------------                 ------------------                ------                       -------
  Ready            False   Sat, 18 Jul 2026 10:25:51 +0200   Sat, 18 Jul 2026 10:25:20 +0200   KubeletNotReady              container runtime network not ready: NetworkReady=false reason:NetworkPluginNotReady message:Network plugin returns error: cni plugin not initialized
</code></pre>
<p>Read that again: <strong>your cluster is not</strong> <code>Ready</code> <strong>until you install a CNI.</strong> Not "mostly ready." Not "ready except for networking." <code>NotReady</code>, full stop. Until an external plugin shows up and takes responsibility for the packets Kubernetes itself refuses to touch.</p>
<p>A cluster without a CNI is a telephone exchange with no lines plugged in: every operator is present and ready, but not a single call is able to connect.</p>
<p>So what do most people do at this exact moment? They copy one line from a getting-started page:</p>
<pre><code class="language-bash">kubectl apply -f https://.../calico.yaml
</code></pre>
<p>They watch the nodes flip to <code>Ready</code>, and they move on. That's the entire relationship most engineers have with the single component that makes their cluster work. They wing it. It works, so they never ask what "it" is.</p>
<p>This matters because "the network just works" is a dangerous story to tell yourself. The moment something breaks (a pod can't reach a service, cross-node traffic vanishes, a cloud migration mysteriously blackholes packets), you're standing in front of a system you never actually understood. So let's understand it. From the bottom up.</p>
<h2 id="heading-the-foundation-virtual-ethernet-veth-pairs">The Foundation: Virtual Ethernet (veth) Pairs</h2>
<p>To understand container networking, you first have to understand how Linux isolates it.</p>
<p>When a container (or a Kubernetes pod) is created, the kernel wraps it in an isolated <strong>Network Namespace</strong> (<code>netns</code>). Think of a fresh network namespace as an island with no bridges to the mainland. By default it's completely blind to the outside world: no interfaces, no IP addresses, and no routing tables. It can't talk to anything, and nothing can talk to it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63d79ac66a29d3450a1f08f7/9acf8795-df14-49e3-ad68-900308ae51a0.png" alt="A new network namespace is an island: disconnected from everything." style="display: block;" width="1536" height="1024" loading="lazy">

<p>So how do we get off the island? With a kernel primitive called a <strong>Virtual Ethernet (</strong><code>veth</code><strong>) pair</strong>.</p>
<p>A <code>veth</code> pair is a virtual network cable. Whatever packet enters one end immediately pops out the other end, even if the two ends live in different namespaces. Plug one end into the island and the other end into the mainland, and suddenly you have a connection.</p>
<p>Let's wire two isolated namespaces, <code>red</code> and <code>blue</code>, directly together.</p>
<pre><code class="language-bash"># Step 1: Create the isolated network namespaces
sudo ip netns add red
sudo ip netns add blue

# Step 2: Create the virtual ethernet cable (veth pair)
sudo ip link add veth-red type veth peer name veth-blue

# Step 3: Move each end of the cable into its namespace
sudo ip link set veth-red netns red
sudo ip link set veth-blue netns blue

# Step 4: Assign IP addresses and bring the interfaces UP
sudo ip netns exec red ip addr add 10.0.0.1/24 dev veth-red
sudo ip netns exec red ip link set veth-red up

sudo ip netns exec blue ip addr add 10.0.0.2/24 dev veth-blue
sudo ip netns exec blue ip link set veth-blue up
</code></pre>
<p>Now test the connection by pinging <code>blue</code> from inside <code>red</code>:</p>
<pre><code class="language-bash">sudo ip netns exec red ping -c 2 10.0.0.2
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/63d79ac66a29d3450a1f08f7/33c0c06b-60a2-4ad5-b2ec-2d6acf08b922.png" alt="A new network namespace is an island: now connected with mainland using veth pairs." style="display: block;" width="1536" height="1024" loading="lazy">

<p>In the end, it would look something like this:</p>
<ul>
<li><p>Isolation broken safely: The container transitions from an unreachable, isolated namespace (no IP, no routing) to an addressable endpoint (10.1.1.2) linked directly to the host network.</p>
</li>
<li><p>Bi-directional traffic flow: Packets originating inside the container can reach external public IP networks, and incoming response packets from the internet can traverse back through the host's eth0 interface (192.168.1.10) directly into the container's veth pairs.</p>
</li>
<li><p>Zero-latency in-kernel bridging: The veth pair (veth-island &lt;--&gt; veth-mainland) acts as a direct virtual pipe, allowing instant packet transit between distinct Linux network namespaces (netns) without requiring external physical hardware .</p>
</li>
</ul>
<p><strong>The verdict:</strong> the ping succeeds. You just manually wired two isolated environments together with nothing but a virtual cable.</p>
<p>But here's the problem with this approach: it scales horribly. It does not scale well because a <code>veth</code> pair is strictly point to point.</p>
<p>Following is the number of pairs need to be configured for the number of containers:</p>
<ul>
<li><p>2 containers: 1 pair</p>
</li>
<li><p>3 containers: 3 pairs</p>
</li>
<li><p>4 containers: 6 cables</p>
</li>
</ul>
<p>For ten, you'd need 45 cables to connect every pair.</p>
<ul>
<li><p>Explodes in cable count: The number of veth pairs grow quadratically as containers increase</p>
</li>
<li><p>Complex to manage: Too many interfaces, routes and rules to configure and maintain.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/63d79ac66a29d3450a1f08f7/c20651dd-8932-46bb-8e1b-b99846f56854.png" alt="Image illustrating the problem with single veth pairs at scale" style="display: block;" width="1536" height="1024" loading="lazy">

<p>This is the same reason data centers don't run a physical cable between every pair of servers. You need a switch.</p>
<h2 id="heading-how-to-scale-locally-with-a-linux-bridge">How to Scale Locally with a Linux Bridge</h2>
<p>When you need to connect more than two interfaces on a single host, you stop running cables between everything and plug everything into a central hub instead. In the Linux kernel, that hub is a <strong>Linux Bridge</strong>. It's a software Layer 2 virtual switch (you'll often see it named <code>br0</code> or <code>cni0</code>).</p>
<p>A bridge does exactly what a physical switch does: it learns MAC addresses and forwards frames across every connected interface in the same broadcast domain.</p>
<p>The pattern changes slightly. Instead of connecting namespaces directly to each other, you attach one end of a <code>veth</code> pair to the namespace, and plug the <em>other</em> end into the host's bridge.</p>
<img src="https://cdn.hashnode.com/uploads/covers/63d79ac66a29d3450a1f08f7/c70ac344-0c3b-46ef-8e6f-f81bc52224df.png" alt="Image showing how veth pairs connect islands to mainland via Linux bridge" style="display: block;" width="1536" height="1024" loading="lazy">

<p>Let's tear down the old setup and build a three-namespace switch: <code>red</code>, <code>blue</code>, and <code>green</code>. They'll all share one broadcast domain.</p>
<pre><code class="language-bash"># Clean up any previous configuration
sudo ip netns del red 2&gt;/dev/null || true
sudo ip netns del blue 2&gt;/dev/null || true
sudo ip netns del green 2&gt;/dev/null || true
sudo ip link del br0 2&gt;/dev/null || true

# Step 1: Create the host switch (bridge) and bring it up
sudo ip link add br0 type bridge
sudo ip link set br0 up

# Step 2: Wire namespace 1 (red) into the bridge
sudo ip netns add red
sudo ip link add veth-red type veth peer name veth-red-host
sudo ip link set veth-red netns red
sudo ip link set veth-red-host master br0
sudo ip link set veth-red-host up
sudo ip netns exec red ip addr add 10.0.0.1/24 dev veth-red
sudo ip netns exec red ip link set veth-red up

# Step 3: Wire namespace 2 (blue) into the bridge
sudo ip netns add blue
sudo ip link add veth-blue type veth peer name veth-blue-host
sudo ip link set veth-blue netns blue
sudo ip link set veth-blue-host master br0
sudo ip link set veth-blue-host up
sudo ip netns exec blue ip addr add 10.0.0.2/24 dev veth-blue
sudo ip netns exec blue ip link set veth-blue up

# Step 4: Wire namespace 3 (green) into the bridge
sudo ip netns add green
sudo ip link add veth-green type veth peer name veth-green-host
sudo ip link set veth-green netns green
sudo ip link set veth-green-host master br0
sudo ip link set veth-green-host up
sudo ip netns exec green ip addr add 10.0.0.3/24 dev veth-green
sudo ip netns exec green ip link set veth-green up
</code></pre>
<p>Because all three namespaces are connected to the shared <code>br0</code> device, they can communicate freely with each other across the virtual network switch. But how do they actually "find" each other on the network? This is where ARP comes in.</p>
<p><strong>ARP</strong> stands for Address Resolution Protocol. It's a fundamental part of local networking. When one computer (or namespace, in our case) wants to talk to another using an IP address, it needs to discover the other computer's hardware address (called a MAC address) to actually send packets on the network.</p>
<p>ARP is the system that allows this to happen — it sends out a broadcast asking "Who has IP address X? Please tell me your MAC address," and the right system answers back.</p>
<p>Thanks to ARP, all the namespaces plugged into <code>br0</code> can learn each other's MAC addresses automatically and send packets directly within their shared network segment. Let's prove it by pinging every pair, both ways:</p>
<pre><code class="language-bash"># red reaches blue and green
sudo ip netns exec red ping -c 1 10.0.0.2
sudo ip netns exec red ping -c 1 10.0.0.3

# blue reaches red and green
sudo ip netns exec blue ping -c 1 10.0.0.1
sudo ip netns exec blue ping -c 1 10.0.0.3

# green reaches red and blue
sudo ip netns exec green ping -c 1 10.0.0.1
sudo ip netns exec green ping -c 1 10.0.0.2
</code></pre>
<p>All six pings succeed. And notice there isn't a single routing rule involved anywhere. Every namespace lives in the same <code>10.0.0.0/24</code> subnet on the same Layer 2 switch, so the kernel resolves the whole mesh with plain ARP.</p>
<p>This is <em>exactly</em> how legacy single-node CNIs (the old <code>kubenet</code>) operate. On one machine, it's clean and simple.</p>
<p>But Kubernetes is a distributed system designed to scale across thousands of physical machines. So here's the question that breaks everything: what happens when our namespaces need to leave the host?</p>
<h2 id="heading-the-multi-node-boundary-problem">The Multi-Node Boundary Problem</h2>
<p>Everything so far has lived on one machine. Kubernetes doesn't. So let's do the honest thing: stand up two real VMs and watch the single-node trick fall apart. Don't take my word for it: build this and watch the packet die.</p>
<p>Here's the setup:</p>
<ul>
<li><p><strong>VM 1</strong> (host IP <code>10.1.44.216</code>): home to the <code>red</code> namespace, pod subnet <code>10.0.1.0/24</code>.</p>
</li>
<li><p><strong>VM 2</strong> (host IP <code>10.1.44.178</code>): home to the <code>blue</code> namespace, pod subnet <code>10.0.2.0/24</code>.</p>
</li>
</ul>
<p>Two things to notice before we start. First, each node gets its <strong>own</strong> pod subnet (<code>10.0.1.0/24</code> on VM 1, <code>10.0.2.0/24</code> on VM 2) because if both nodes handed out <code>10.0.0.x</code> addresses, you'd get IP collisions the instant two pods landed on the same number.</p>
<p>Second, because the subnets now differ, each namespace needs a <strong>gateway</strong> to route through, and that gateway is its own host's bridge.</p>
<p><strong>Note:</strong> this is the <a href="http://cleanup-multinode.sh">cleanup-multinode.sh</a> script that should only be used in case you make any errors while setting up the cross node routes and veth pairs.</p>
<pre><code class="language-shell">#!/usr/bin/env bash
#
# cleanup-multinode.sh
# Tears down the manual multi-node CNI lab (bridge + namespaces + veth
# pairs + cross-node static routes) from "Build a Mental Model for
# Kubernetes CNI by Doing It Manually."
#
# Safe to run on BOTH VMs. Every step is idempotent: anything that was
# never created on this host is skipped instead of erroring out, so
# re-running it is harmless.
#
# Usage:  sudo ./cleanup-multinode.sh
#
set -u

if [[ $EUID -ne 0 ]]; then
  echo "This script needs root. Run:  sudo $0" &gt;&amp;2
  exit 1
fi

echo "==&gt; Deleting network namespaces (this also destroys their veth pairs)..."
ip netns del red  2&gt;/dev/null &amp;&amp; echo "    - removed netns 'red'"  || true
ip netns del blue 2&gt;/dev/null &amp;&amp; echo "    - removed netns 'blue'" || true

echo "==&gt; Removing any orphaned host-side veth interfaces..."
ip link del veth-red-host  2&gt;/dev/null &amp;&amp; echo "    - removed veth-red-host"  || true
ip link del veth-blue-host 2&gt;/dev/null &amp;&amp; echo "    - removed veth-blue-host" || true

echo "==&gt; Deleting the bridge..."
ip link del br0 2&gt;/dev/null &amp;&amp; echo "    - removed bridge 'br0'" || true

echo "==&gt; Removing cross-node static routes..."
ip route del 10.0.1.0/24 2&gt;/dev/null &amp;&amp; echo "    - removed route to 10.0.1.0/24" || true
ip route del 10.0.2.0/24 2&gt;/dev/null &amp;&amp; echo "    - removed route to 10.0.2.0/24" || true

echo "==&gt; Disabling IP forwarding (non-persistent; resets on reboot anyway)..."
sysctl -w net.ipv4.ip_forward=0 &gt;/dev/null

# --- Optional: undo the 'Common Gotchas' tweaks, ONLY if you applied them ---
# On a throwaway lab VM, leaving FORWARD at ACCEPT or rp_filter at 0 is
# usually harmless, so these are opt-in. Uncomment whatever you changed.
# sysctl -w net.ipv4.conf.all.rp_filter=1 &gt;/dev/null
# iptables -P FORWARD DROP

echo
echo "==&gt; Teardown complete. Verifying nothing is left behind:"
echo "--- namespaces (expect: no red/blue) ---"
out=$(ip netns list);                          echo "${out:-  (none)}"
echo "--- bridges (expect: no br0) ---"
out=$(ip -br link show type bridge 2&gt;/dev/null); echo "${out:-  (none)}"
echo "--- lab routes (expect: none) ---"
out=$(ip route | grep -E '10\.0\.[12]\.0/24'); echo "${out:-  (none)}"
</code></pre>
<p><strong>On VM 1 (</strong><code>10.1.44.216</code><strong>)</strong>, build the bridge, wire up <code>red</code>, and turn the host into a router:</p>
<pre><code class="language-bash"># Make the host a router so it can transit packets that aren't its own
sudo sysctl -w net.ipv4.ip_forward=1

# Build the bridge and give it a gateway IP for VM 1's pod subnet
sudo ip link add br0 type bridge
sudo ip addr add 10.0.1.254/24 dev br0
sudo ip link set br0 up

# Wire the red namespace into the bridge
sudo ip netns add red
sudo ip link add veth-red type veth peer name veth-red-host
sudo ip link set veth-red netns red
sudo ip link set veth-red-host master br0
sudo ip link set veth-red-host up
sudo ip netns exec red ip addr add 10.0.1.1/24 dev veth-red
sudo ip netns exec red ip link set veth-red up

# Point the namespace's default route at its bridge gateway
sudo ip netns exec red ip route add default via 10.0.1.254
</code></pre>
<p><strong>On VM 2 (</strong><code>10.1.44.178</code><strong>)</strong>, do the mirror image for <code>blue</code>:</p>
<pre><code class="language-bash">sudo sysctl -w net.ipv4.ip_forward=1

sudo ip link add br0 type bridge
sudo ip addr add 10.0.2.254/24 dev br0
sudo ip link set br0 up

sudo ip netns add blue
sudo ip link add veth-blue type veth peer name veth-blue-host
sudo ip link set veth-blue netns blue
sudo ip link set veth-blue-host master br0
sudo ip link set veth-blue-host up
sudo ip netns exec blue ip addr add 10.0.2.1/24 dev veth-blue
sudo ip netns exec blue ip link set veth-blue up

sudo ip netns exec blue ip route add default via 10.0.2.254
</code></pre>
<p>Both hosts are routers now. Both namespaces are wired up. Ping <code>blue</code> on VM 2 from <code>red</code> on VM 1:</p>
<pre><code class="language-bash"># On VM 1
sudo ip netns exec red ping -c 3 10.0.2.1
</code></pre>
<pre><code class="language-plaintext">PING 10.0.2.1 (10.0.2.1) 56(84) bytes of data.

--- 10.0.2.1 ping statistics ---
3 packets transmitted, 0 received, 100% packet loss, time 2043ms
</code></pre>
<p><strong>100% packet loss.</strong> The packet is dropped on the floor, exactly as promised, but now you've seen it with your own eyes.</p>
<p>Here's the part worth proving to yourself: the packet really does leave VM 1. It just never arrives at VM 2. Run <code>tcpdump</code> on both boxes and ping again:</p>
<pre><code class="language-bash"># Detect your physical NIC once (enp1s0, ens3, eth0, ...)
NIC=$(ip route get 1.1.1.1 | grep -oP 'dev \K\S+')

# On VM 1: the echo requests march out the door
sudo tcpdump -ni "$NIC" icmp
IP 10.0.1.1 &gt; 10.0.2.1: ICMP echo request, id 5, seq 1, length 64
IP 10.0.1.1 &gt; 10.0.2.1: ICMP echo request, id 5, seq 2, length 64

# On VM 2: dead silence. Nothing ever shows up.
sudo tcpdump -ni "$NIC" icmp
(no output)
</code></pre>
<p>So where does it die? Follow the life and death of that packet:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63d79ac66a29d3450a1f08f7/c2cea829-08c0-4b1d-a449-376791f9d070.png" alt="Death of the packet cross nodes" style="display: block;" width="1714" height="918" loading="lazy">

<p>To keep it even simpler, the flow is as follows:</p>
<pre><code class="language-bash">Red Pod (10.0.1.1)
      │
      ▼
eth0
      │
      ▼
veth-red
      │
      ▼
br0 (10.0.1.254)
      │
      ▼
VM 1 Routing Table
(No route for 10.0.2.0/24)
      │
      ▼
Default Route (0.0.0.0/0)
      │
      ▼
Physical NIC (eth0)
      │
      ▼
LAN Gateway (192.168.1.1)
      │
      ▼
❌ No route for 10.0.2.0/24
(Packet Dropped)
      │
      ▼
VM 2 Never Receives the Packet
</code></pre>
<p>That's the multi-node boundary problem in one sentence: <strong>your per-node scripts are completely blind to the rest of the cluster's topology.</strong> VM 1 built its island, VM 2 built its island, and neither has any idea the other exists.</p>
<h2 id="heading-how-to-fix-it-manually-with-direct-routing">How to Fix It Manually with Direct Routing</h2>
<p>The fix is almost insultingly small. VM 1 doesn't need a smarter network. It needs a <em>map</em>. We just have to tell each host one fact it's missing: "the other node's pod subnet lives behind the other node's physical IP." That's a single static route per side. Nothing gets rebuilt: the bridges, namespaces, and forwarding you set up a moment ago all stay exactly as they are.</p>
<p><strong>On VM 1 (</strong><code>10.1.44.216</code><strong>)</strong>, teach it where VM 2's pods live:</p>
<pre><code class="language-bash"># VM 2's pods (10.0.2.0/24) are reachable via VM 2's physical IP
sudo ip route add 10.0.2.0/24 via 10.1.44.178
</code></pre>
<p><strong>On VM 2 (</strong><code>10.1.44.178</code><strong>)</strong>, teach it the way back:</p>
<pre><code class="language-bash"># VM 1's pods (10.0.1.0/24) are reachable via VM 1's physical IP
sudo ip route add 10.0.1.0/24 via 10.1.44.216
</code></pre>
<p>That's it. Two lines. Re-run the exact same ping from <code>red</code> on VM 1:</p>
<pre><code class="language-bash">sudo ip netns exec red ping -c 3 10.0.2.1
</code></pre>
<pre><code class="language-plaintext">PING 10.0.2.1 (10.0.2.1) 56(84) bytes of data.
64 bytes from 10.0.2.1: icmp_seq=1 ttl=62 time=0.412 ms
64 bytes from 10.0.2.1: icmp_seq=2 ttl=62 time=0.388 ms
64 bytes from 10.0.2.1: icmp_seq=3 ttl=62 time=0.401 ms

--- 10.0.2.1 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss
</code></pre>
<p>It works. (See <code>ttl=62</code>? Your packet started at 64 and lost one hop on each host it was forwarded through, proof it crossed two routers to get there.) The packet now completes the full journey:</p>
<img src="https://cdn.hashnode.com/uploads/covers/63d79ac66a29d3450a1f08f7/7547a7e5-68d8-4f06-b396-d4d8c8cce15b.png" alt="Packet Journey cross nodes between namespaces" style="display: block;" width="1536" height="1024" loading="lazy">

<p>The simpler flow looks like:</p>
<pre><code class="language-bash">1. Pod (10.0.1.1)
        │
        ▼
2. veth-red
        │
        ▼
3. VM 1 br0 (10.0.1.254)
        │
        ▼
4. VM 1 Routing Table
   ✅ Static route:
   10.0.2.0/24 → 10.1.44.178
        │
        ▼
5. VM 1 Physical NIC
        │
        ▼
6. Direct Link
   VM 1 → VM 2
        │
        ▼
7. VM 2 Physical NIC
        │
        ▼
8. VM 2 Routing Table
   ✅ 10.0.2.0/24 is directly connected
        │
        ▼
9. VM 2 br0
        │
        ▼
10. Blue Pod (10.0.2.1)
        │
        ▼
✅ Reply follows the same path back
</code></pre>
<p>That one line changed everything. Instead of dumping the packet at your LAN's clueless gateway, VM 1 now hands it <strong>directly</strong> to VM 2, which knows exactly which local namespace owns <code>10.0.2.1</code>. The reply follows the mirror route home. You just hand-built cross-node pod networking.</p>
<p>Now sit with how painful that was. Two nodes took a stack of careful commands and a hand-written route on each side.</p>
<p>Imagine a thousand nodes, pods being created and destroyed every second, each one needing a fresh IP and a route on <em>every other node</em> in the cluster. Doing that by hand isn't just tedious. It's impossible.</p>
<h2 id="heading-so-what-is-a-cni-really">So What Is a CNI, Really?</h2>
<p>Everything you just did by hand (the namespaces, the <code>veth</code> pairs, the bridges, the IP assignment, the routes) is exactly what a Container Network Interface automates dynamically, at scale, the instant a pod is scheduled.</p>
<p>When you apply a pod manifest, the CNI plugin intercepts the lifecycle event and performs three core jobs:</p>
<ol>
<li><p><strong>Namespace and interface provisioning:</strong> It creates the network namespace, generates the <code>veth</code> pair, and attaches it to the bridge (or its own datapath), cleanly, every time, with no fat-fingered typos.</p>
</li>
<li><p><strong>IP Address Management (IPAM):</strong> It hands out unique, non-colliding subnets per node and leases an individual IP to every single container in the cluster. That "unique Pod CIDR per node" rule you set up manually? IPAM enforces it automatically.</p>
</li>
<li><p><strong>Cluster-wide route distribution:</strong> It programs the routing so every node knows how to reach pods on every other node: the static routes you wrote by hand, generated and pushed everywhere, kept in sync as nodes and pods come and go.</p>
</li>
</ol>
<p>That's the mental model. A CNI is the thing that does your dozen-command lab a thousand times a second and never makes a mistake.</p>
<h2 id="heading-the-cloud-catch-and-why-cilium-changes-the-game">The Cloud Catch and Why Cilium Changes the Game</h2>
<p>Here's the part that surprises people. The manual direct-routing approach we just built works flawlessly in a bare-metal lab. In a modern public cloud (AWS, GCP, Azure), <strong>it breaks completely.</strong></p>
<p>Why? Cloud providers don't let arbitrary IP addresses roam across their network fabric. Your <code>10.0.1.0/24</code> pod subnet means nothing to the VPC. Unless those IPs are explicitly registered through heavyweight cloud-controller API calls, the underlying network sees pod traffic as illegitimate and drops it: the exact failure from the boundary-problem section, except now the cloud itself is the thing saying "no."</p>
<p>This is where advanced CNIs like <strong>Cilium</strong> stop playing by the old rules. Instead of leaning on fragile Linux bridges and hand-written host routes, Cilium reaches for two much stronger mechanisms.</p>
<ul>
<li><p><strong>Overlay networks (VXLAN / Geneve).</strong> Cilium takes your raw pod packet and <em>encapsulates</em> it, wrapping it inside an ordinary UDP packet addressed from Node 1's physical IP to Node 2's physical IP. To the cloud provider, it looks like completely normal node-to-node host traffic, so it sails straight through every VPC restriction. Your pod's real addresses are hidden inside the envelope.</p>
</li>
<li><p><strong>eBPF kernel programmability.</strong> Traditional CNIs push every packet through the full Linux bridge path and hundreds of sequential <code>iptables</code> rules: slow, and slower as your cluster grows. Cilium replaces that entire pipeline by loading compiled eBPF programs directly into the kernel at the network-interface level. Packets get short-circuited from the pod's <code>veth</code> straight toward the physical NIC, giving you near line-rate performance and deep security visibility for free.</p>
</li>
</ul>
<p>Here's the whole progression in one table:</p>
<table>
<thead>
<tr>
<th>Concern</th>
<th>Direct veth cables</th>
<th>Bridge + static routes</th>
<th>Advanced CNI (Cilium)</th>
</tr>
</thead>
<tbody><tr>
<td>Connects 2 endpoints</td>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
</tr>
<tr>
<td>Scales past a handful of pods</td>
<td>No</td>
<td>On one node only</td>
<td>Yes, cluster-wide</td>
</tr>
<tr>
<td>Crosses node boundaries</td>
<td>No</td>
<td>Manual routes per node</td>
<td>Automatic</td>
</tr>
<tr>
<td>Survives cloud VPC rules</td>
<td>No</td>
<td>No</td>
<td>Yes (VXLAN/Geneve overlay)</td>
</tr>
<tr>
<td>IP allocation</td>
<td>You, by hand</td>
<td>You, by hand</td>
<td>Automatic IPAM</td>
</tr>
<tr>
<td>Performance path</td>
<td>Kernel</td>
<td>Bridge + iptables</td>
<td>eBPF, near line-rate</td>
</tr>
<tr>
<td>Who maintains it</td>
<td>You, forever</td>
<td>You, forever</td>
<td>The CNI, automatically</td>
</tr>
</tbody></table>
<p>Look at that last column, then look at the last row. That's the entire value proposition of a CNI in two cells.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You didn't read about Kubernetes networking. You built it, broke it, and fixed it. Here's the mental model you now carry:</p>
<ol>
<li><p><strong>Kubernetes routes zero packets.</strong> It fully delegates the network to a CNI, and that CNI is doing real, physical plumbing on every node.</p>
</li>
<li><p><strong>A</strong> <code>veth</code> <strong>pair is a virtual cable</strong>, and it's the atom of container networking: great for two endpoints, useless at scale.</p>
</li>
<li><p><strong>A Linux bridge is a virtual switch</strong> that connects many namespaces on one host with nothing but Layer 2 and ARP. That's a single-node CNI in a nutshell.</p>
</li>
<li><p><strong>The node boundary is where naïve networking dies.</strong> Different subnets and an unaware physical network mean cross-node packets get dropped until <em>you</em> teach every host how to route.</p>
</li>
<li><p><strong>Static routes plus IP forwarding fix it manually</strong>, and doing that by hand for two nodes shows you instantly why nobody does it for a thousand.</p>
</li>
<li><p><strong>A CNI automates three jobs:</strong> interface provisioning, IPAM, and cluster-wide route distribution.</p>
</li>
<li><p><strong>The cloud breaks direct routing</strong>, which is precisely why Cilium leans on VXLAN/Geneve overlays and eBPF instead of bridges and <code>iptables</code>.</p>
</li>
</ol>
<p>The next time a pod flips to <code>Running</code> and the network "just works," you'll know the truth: nothing just works. A CNI just did (silently, and at a scale you now truly respect) everything you just did by hand.</p>
<p>From here, the natural next step is to tear down these scripts, deploy Cilium into a real cluster, and watch eBPF orchestrate this entire topology automatically. Having felt the manual pain first, you'll actually appreciate the elegance.</p>
<p><em>If this helped you build a clearer picture of Kubernetes networking, come say hi:</em></p>
<ul>
<li><p><em>LinkedIn:</em> <a href="https://www.linkedin.com/in/shubhamkatara/"><em>linkedin.com/in/shubhamkatara</em></a></p>
</li>
<li><p><em>YouTube:</em> <a href="https://www.youtube.com/@kubesimplify"><em>@kubesimplify</em></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build an Internal Developer Platform: A Complete Guide to Backstage, ArgoCD, and Crossplane ]]>
                </title>
                <description>
                    <![CDATA[ Every fast-growing engineering team eventually hits the same wall. A developer needs a new staging environment, so they file a ticket. The platform team queues it. Two weeks later, the environment exi ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-an-internal-developer-platform-a-complete-guide-to-backstage-argocd-and-crossplane/</link>
                <guid isPermaLink="false">6a5a912d1a97bb513c72431e</guid>
                
                    <category>
                        <![CDATA[ Platform Engineering  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ gitops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ayobami Adejumo ]]>
                </dc:creator>
                <pubDate>Fri, 17 Jul 2026 20:31:41 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4e45df2a-5af9-4feb-84fa-f7eb1c04ee91.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every fast-growing engineering team eventually hits the same wall.</p>
<p>A developer needs a new staging environment, so they file a ticket. The platform team queues it.</p>
<p>Two weeks later, the environment exists. It's configured slightly differently from the last one, with a naming convention that doesn't match the production setup, missing the observability stack the previous environment had. The developer deploys. Something breaks. Nobody knows why.</p>
<p>The problem isn't the ticket queue. The problem is the absence of a platform: a paved road where developers can self-serve infrastructure, deployments, and environments that are consistent, auditable, and safe without requiring a platform engineer for every request.</p>
<p>An Internal Developer Platform (IDP) solves this. Not by removing platform engineers from the picture, but by shifting their work from executing individual requests to building the systems that execute those requests automatically.</p>
<p>This handbook builds a production-grade IDP from the three CNCF tools that form its core in 2026: Backstage as the developer portal and software catalog, ArgoCD as the GitOps continuous delivery engine, and Crossplane as the Kubernetes-native infrastructure control plane.</p>
<p>By the end, developers on your platform will be able to provision a cloud database, deploy an application to staging, and register a new service in the catalog — all without filing a single ticket.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-learn">What You'll Learn</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-part-1-idp-architecture-the-three-layer-model">Part 1: IDP Architecture — The Three-Layer Model</a></p>
</li>
<li><p><a href="#heading-part-2-argocd-the-gitops-foundation">Part 2: ArgoCD — The GitOps Foundation</a></p>
</li>
<li><p><a href="#heading-part-3-crossplane-infrastructure-as-kubernetes-resources">Part 3: Crossplane — Infrastructure as Kubernetes Resources</a></p>
</li>
<li><p><a href="#heading-part-4-backstage-the-developer-portal">Part 4: Backstage — The Developer Portal</a></p>
</li>
<li><p><a href="#heading-part-5-wiring-it-together-the-golden-path">Part 5: Wiring It Together — The Golden Path</a></p>
</li>
<li><p><a href="#heading-part-6-finops-integration-cost-attribution-on-the-idp">Part 6: FinOps Integration — Cost Attribution on the IDP</a></p>
</li>
<li><p><a href="#heading-part-7-the-platform-maturity-model-measuring-what-youve-built">Part 7: The Platform Maturity Model — Measuring What You've Built</a></p>
</li>
<li><p><a href="#heading-best-practices-summary">Best Practices Summary</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ul>
<h2 id="heading-what-youll-learn">What You'll Learn</h2>
<ul>
<li><p>The three-layer IDP architecture and why each layer must be implemented in a specific order</p>
</li>
<li><p>How to install and configure ArgoCD with ApplicationSets for multi-environment GitOps delivery</p>
</li>
<li><p>How to define cloud infrastructure as Kubernetes custom resources using Crossplane Compositions</p>
</li>
<li><p>How to deploy and configure Backstage with a software catalog and Software Templates</p>
</li>
<li><p>How to wire Backstage, ArgoCD, and Crossplane together into a single self-service golden path</p>
</li>
<li><p>How to implement cost attribution on your IDP so every resource provisioned through it carries team and cost center metadata</p>
</li>
<li><p>How to measure your IDP's maturity using the CNCF Platform Engineering Maturity Model</p>
</li>
</ul>
<p>Let's build it.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before following along, you should have:</p>
<p><strong>Knowledge:</strong></p>
<ul>
<li><p>Working familiarity with Kubernetes: you can deploy applications, write YAML manifests, and understand namespaces and RBAC</p>
</li>
<li><p>Basic GitOps understanding: you know what "Git as source of truth" means in practice</p>
</li>
<li><p>Comfort with Helm, Terraform HCL, and TypeScript at a reading level</p>
</li>
<li><p>Understanding of AWS services: EKS, RDS, S3, IAM</p>
</li>
</ul>
<p><strong>Tools and access:</strong></p>
<ul>
<li><p>An EKS cluster running Kubernetes 1.28 or later with at least 3 nodes (m5.xlarge or equivalent)</p>
</li>
<li><p><code>kubectl</code> configured and pointing at your cluster</p>
</li>
<li><p><code>helm</code> 3.12 or later installed</p>
</li>
<li><p>AWS CLI v2 configured with admin-level permissions for the provisioning steps</p>
</li>
<li><p>Node.js 18 or later and Yarn (for Backstage)</p>
</li>
<li><p>A GitHub organisation you control (for the GitOps repositories and Backstage GitHub integration)</p>
</li>
</ul>
<p><strong>Companion repository:</strong></p>
<pre><code class="language-bash">git clone https://github.com/aayostem/platform-toolkit
cd platform-toolkit
</code></pre>
<p>The repository contains all manifests, Helm values files, Crossplane Compositions, and Backstage templates referenced in this guide. Each part maps to a directory in the repo.</p>
<p><strong>Estimated time:</strong> The full implementation takes one to two days for an experienced platform engineer. Parts 1–3 can be completed in the morning and produce a working GitOps delivery layer.</p>
<h2 id="heading-part-1-idp-architecture-the-three-layer-model">Part 1: IDP Architecture — The Three-Layer Model</h2>
<h3 id="heading-11-what-an-idp-actually-is">1.1 What an IDP Actually Is</h3>
<p>An Internal Developer Platform isn't a tool. It's a product: a collection of tools, workflows, and abstractions that platform teams build and maintain so that application developers can move fast without managing infrastructure directly.</p>
<p>The distinction matters because it shapes every architectural decision. A tool is installed and configured. A product is designed for users, iterated based on feedback, and measured by whether those users actually adopt it. The platform teams that build the IDPs that developers love think like product managers, not system administrators.</p>
<p><a href="https://cloud.google.com/resources/content/2025-dora-ai-capabilities-model-report">The DORA 2025 report</a> found that nearly 90% of enterprises now have some form of internal platform. But having a platform and having a platform that developers actually use are different things.</p>
<p>The survey found that developer satisfaction with internal platforms varied dramatically. And the gap between satisfied and unsatisfied teams correlated directly with whether the platform team treated the IDP as a product with a roadmap and user research, or as an infrastructure project with a ticket queue.</p>
<p>The three tools in this guide — Backstage, ArgoCD, and Crossplane — are the most widely adopted open-source stack for production IDPs in 2026. But the architecture that connects them matters as much as the tools themselves.</p>
<h3 id="heading-12-the-three-layer-architecture">1.2 The Three-Layer Architecture</h3>
<p>A production IDP has three distinct layers, each with a single responsibility:</p>
<pre><code class="language-plaintext">Layer 1: Developer Interface (Backstage)
├── Software catalog — inventory of all services, APIs, and resources
├── Software Templates — self-service forms that trigger provisioning workflows
├── TechDocs — documentation co-located with each catalog entity
└── Plugins — integrations with ArgoCD, Kubernetes, PagerDuty, Grafana

Layer 2: Delivery Layer (ArgoCD)
├── GitOps sync — continuous reconciliation of cluster state to Git
├── ApplicationSets — multi-environment deployment from a single definition
├── Rollout management — progressive delivery with health checks
└── Audit trail — every deployment change linked to a Git commit

Layer 3: Infrastructure Layer (Crossplane)
├── Composite Resources — cloud resources defined as Kubernetes CRDs
├── Compositions — templates that expand a simple claim into full AWS infrastructure
├── ProviderConfigs — credentials and region configuration for each cloud provider
└── Usage tracking — every provisioned resource tagged with team and cost centre
</code></pre>
<p>The critical architectural rule: Backstage never talks directly to Kubernetes or cloud APIs. When a developer submits a Software Template in Backstage, the output is a Git commit — a YAML file representing a Crossplane claim or an ArgoCD Application manifest. ArgoCD picks up that commit and applies it to the cluster. Crossplane translates the cluster resource into actual cloud infrastructure.</p>
<p>This indirect path isn't complexity for complexity's sake. It means every infrastructure change is a Git commit, with an author, a timestamp, a pull request, and a review. The audit trail is automatic. The rollback mechanism is <code>git revert</code>.</p>
<pre><code class="language-plaintext">Developer → Backstage Template → Git commit → ArgoCD → Crossplane → AWS
                                     ↑
                          Single source of truth
                          Full audit trail
                          Rollback = git revert
</code></pre>
<p>Here's what the incorrect alternative looks like — Backstage calling cloud APIs directly:</p>
<pre><code class="language-typescript">// Bad: Backstage template calling AWS SDK directly
// No audit trail, no rollback, no reconciliation loop
// If the call fails halfway, you have partial infrastructure with no record
import { S3Client, CreateBucketCommand } from "@aws-sdk/client-s3";

const client = new S3Client({ region: "us-east-1" });
await client.send(new CreateBucketCommand({ Bucket: bucketName }));
</code></pre>
<p>And the correct approach — Backstage outputting a Crossplane claim to Git:</p>
<pre><code class="language-yaml"># Good: Backstage template output — a Crossplane claim committed to Git
# ArgoCD applies it, Crossplane reconciles it, AWS creates the bucket
# Every step is tracked, auditable, and reversible
apiVersion: platform.cloudfrugal.com/v1alpha1
kind: S3Bucket
metadata:
  name: ${{ values.bucket_name }}
  namespace: ${{ values.team_namespace }}
  labels:
    team: ${{ values.team_name }}
    cost-centre: ${{ values.cost_centre }}
    environment: ${{ values.environment }}
spec:
  versioning: true
  encryption: AES256
  region: us-east-1
</code></pre>
<h3 id="heading-13-implementation-order">1.3 Implementation Order</h3>
<p>Build in this order. Deviating from it creates integration problems that are difficult to debug:</p>
<pre><code class="language-plaintext">Step 1: ArgoCD — the delivery foundation everything else depends on
Step 2: Crossplane — infrastructure control plane, delivered by ArgoCD
Step 3: Backstage — the portal, pointing at ArgoCD and Crossplane as backends
Step 4: Wire together — Software Templates that produce GitOps manifests
Step 5: FinOps layer — cost attribution metadata in every provisioned resource
</code></pre>
<h2 id="heading-part-2-argocd-the-gitops-foundation">Part 2: ArgoCD — The GitOps Foundation</h2>
<p>ArgoCD is a declarative continuous delivery tool for Kubernetes that implements the GitOps pattern. If you haven't used a GitOps tool before, the core idea is simple: your Git repository is the single source of truth for what should be running in your cluster, and ArgoCD continuously reconciles actual cluster state to match it.</p>
<p>If a developer manually changes a resource in the cluster, ArgoCD detects the drift and resyncs from Git. If Git changes, ArgoCD applies the change to the cluster. Human intervention isn't required, and is actively discouraged — the goal is a cluster whose state is always fully explained by what's in Git.</p>
<p>ArgoCD is a CNCF Graduated project, meaning it's production-ready and widely used. It runs as a set of pods in your cluster with a web UI, a CLI, and a REST API. Everything you need to manage deployments across multiple environments lives in one place.</p>
<h3 id="heading-21-installing-argocd">2.1 Installing ArgoCD</h3>
<pre><code class="language-bash"># Create the ArgoCD namespace
kubectl create namespace argocd

# Install ArgoCD using the official manifest
kubectl apply -n argocd \
  -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

# Wait for all pods to be running before proceeding
kubectl wait --for=condition=Ready pods \
  --all -n argocd --timeout=300s

# Get the initial admin password
argocd_password=$(kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath="{.data.password}" | base64 -d)

echo "ArgoCD initial password: $argocd_password"
echo "Save this somewhere secure before proceeding"

# Port-forward to access the ArgoCD UI locally
kubectl port-forward svc/argocd-server -n argocd 8080:443 &amp;

# Login via CLI
argocd login localhost:8080 \
  --username admin \
  --password "$argocd_password" \
  --insecure

# Change the password immediately
argocd account update-password \
  --current-password "$argocd_password" \
  --new-password "your-secure-password"
</code></pre>
<h3 id="heading-22-repository-structure-for-gitops">2.2 Repository Structure for GitOps</h3>
<p>The repository structure ArgoCD watches determines how you manage multiple environments. The pattern that scales best is environment-per-directory, with overlays managed by Kustomize.</p>
<p>Kustomize is a Kubernetes-native configuration management tool that lets you define a base configuration once and layer environment-specific overrides on top of it. This means your staging and production configurations share the same YAML structure but differ in replica counts, image tags, and resource limits.</p>
<pre><code class="language-plaintext">gitops-repo/
├── apps/
│   ├── base/                    # Shared configuration across all environments
│   │   ├── payment-api/
│   │   │   ├── deployment.yaml
│   │   │   ├── service.yaml
│   │   │   └── kustomization.yaml
│   │   └── user-api/
│   │       ├── deployment.yaml
│   │       ├── service.yaml
│   │       └── kustomization.yaml
│   └── overlays/
│       ├── staging/             # Staging-specific overrides
│       │   ├── payment-api/
│       │   │   └── kustomization.yaml   # Override: 1 replica, staging image tag
│       │   └── kustomization.yaml
│       └── production/          # Production-specific overrides
│           ├── payment-api/
│           │   └── kustomization.yaml   # Override: 3 replicas, pinned image tag
│           └── kustomization.yaml
└── infrastructure/
    ├── crossplane/              # Crossplane installation and providers
    ├── monitoring/              # Prometheus, Grafana
    └── ingress/                 # NGINX or ALB ingress controller
</code></pre>
<h3 id="heading-23-applicationsets-managing-multiple-environments">2.3 ApplicationSets — Managing Multiple Environments</h3>
<p>An ApplicationSet is an ArgoCD resource that generates multiple Application objects from a single template. Instead of creating one Application manifest per service per environment — which becomes unmanageable at scale — you define one ApplicationSet that covers all services across all environments. A matrix generator combines a list of environments with a Git directory scan to produce every combination automatically:</p>
<pre><code class="language-yaml"># applicationset-apps.yaml
# This single resource generates one ArgoCD Application
# for each combination of environment and application directory
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: platform-apps
  namespace: argocd
spec:
  generators:
    - matrix:
        generators:
          # Generator 1: environments
          - list:
              elements:
                - environment: staging
                  cluster: https://staging.eks.cluster.local
                - environment: production
                  cluster: https://production.eks.cluster.local

          # Generator 2: application directories in the overlay
          - git:
              repoURL: https://github.com/your-org/gitops-repo
              revision: HEAD
              directories:
                - path: apps/overlays/{{environment}}/*

  template:
    metadata:
      name: "{{environment}}-{{path.basename}}"
      labels:
        environment: "{{environment}}"
        app: "{{path.basename}}"
    spec:
      project: default
      source:
        repoURL: https://github.com/your-org/gitops-repo
        targetRevision: HEAD
        path: "apps/overlays/{{environment}}/{{path.basename}}"
      destination:
        server: "{{cluster}}"
        namespace: "{{path.basename}}"
      syncPolicy:
        automated:
          prune: true        # Delete resources removed from Git
          selfHeal: true     # Revert manual cluster changes
        syncOptions:
          - CreateNamespace=true
          - PrunePropagationPolicy=foreground
</code></pre>
<p>Verify the ApplicationSet is generating the expected Applications:</p>
<pre><code class="language-bash"># List all generated Applications
kubectl get applications -n argocd

# Expected output: one Application per environment per app
# staging-payment-api    Synced    Healthy
# staging-user-api       Synced    Healthy
# production-payment-api Synced    Healthy
# production-user-api    Synced    Healthy

# Check sync status for a specific application
argocd app get staging-payment-api
</code></pre>
<h3 id="heading-24-argocd-rbac-for-platform-teams">2.4 ArgoCD RBAC for Platform Teams</h3>
<p>In a multi-team IDP, different teams need different levels of access to ArgoCD. Application teams should be able to view and sync their own applications. Platform teams should have broader access. Nobody should have unrestricted cluster admin through ArgoCD.</p>
<p>The default policy is <code>readonly</code> — every authenticated user can see everything but change nothing:</p>
<pre><code class="language-yaml"># argocd-rbac-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-rbac-cm
  namespace: argocd
data:
  policy.default: role:readonly
  policy.csv: |
    # Platform team: full access to all applications
    p, role:platform-team, applications, *, */*, allow
    p, role:platform-team, clusters, get, *, allow
    p, role:platform-team, repositories, *, *, allow

    # Application teams: sync and get their own namespace only
    p, role:app-team, applications, get, */staging-*, allow
    p, role:app-team, applications, sync, */staging-*, allow

    # Bind roles to GitHub teams
    g, your-org:platform-engineers, role:platform-team
    g, your-org:developers, role:app-team

  scopes: '[groups]'
</code></pre>
<h2 id="heading-part-3-crossplane-infrastructure-as-kubernetes-resources">Part 3: Crossplane — Infrastructure as Kubernetes Resources</h2>
<p>Crossplane is a CNCF Graduated open-source framework that extends Kubernetes into a universal infrastructure control plane.</p>
<p>The core idea: instead of managing cloud resources with separate tools like Terraform or CloudFormation that live outside your cluster, you define cloud resources — RDS databases, S3 buckets, VPCs, IAM roles — as Kubernetes custom resource definitions.</p>
<p>Once you apply a Crossplane resource to the cluster, Crossplane's controllers take over and reconcile the desired state to the actual AWS state, exactly the way Kubernetes reconciles a Deployment to a set of running pods.</p>
<p>The key abstraction Crossplane adds on top of that is the Composite Resource. A platform team defines a high-level <code>PostgreSQLDatabase</code> type that abstracts over the thirty-plus configuration fields an actual RDS instance requires.</p>
<p>Developers interact with the simple type. Crossplane expands it into the full AWS resource configuration behind the scenes, applying the platform team's security and operational standards automatically — standards that developers can't bypass because they never see the underlying fields.</p>
<h3 id="heading-31-installing-crossplane">3.1 Installing Crossplane</h3>
<p>Crossplane is delivered to your cluster by ArgoCD — the first integration between the two tools. By installing Crossplane through an ArgoCD Application rather than running <code>helm install</code> directly, you make Crossplane itself part of the GitOps-managed infrastructure. Any change to Crossplane's configuration goes through a Git commit and review:</p>
<pre><code class="language-yaml"># infrastructure/crossplane/application.yaml
# ArgoCD Application that installs Crossplane via Helm
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: crossplane
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://charts.crossplane.io/stable
    chart: crossplane
    targetRevision: 1.15.0
    helm:
      values: |
        provider:
          packages:
            # AWS provider — manages all AWS resources
            - xpkg.upbound.io/upbound/provider-aws-s3:v1.2.0
            - xpkg.upbound.io/upbound/provider-aws-rds:v1.2.0
            - xpkg.upbound.io/upbound/provider-aws-iam:v1.2.0
  destination:
    server: https://kubernetes.default.svc
    namespace: crossplane-system
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
      - CreateNamespace=true
</code></pre>
<pre><code class="language-bash"># Apply the ArgoCD Application — ArgoCD installs Crossplane
kubectl apply -f infrastructure/crossplane/application.yaml

# Watch Crossplane pods come up
kubectl get pods -n crossplane-system -w

# Verify providers are installed and healthy
kubectl get providers
# Expected:
# NAME                          INSTALLED   HEALTHY   PACKAGE
# upbound-provider-aws-s3       True        True      xpkg.upbound.io/...
# upbound-provider-aws-rds      True        True      xpkg.upbound.io/...
</code></pre>
<h3 id="heading-32-provider-credentials">3.2 Provider Credentials</h3>
<p>Crossplane needs AWS credentials to provision resources. The recommended approach for EKS is IAM Roles for Service Accounts (IRSA) — a mechanism that lets Kubernetes pods assume IAM roles directly without storing any credentials in the cluster.</p>
<p>The pod's Kubernetes service account is annotated with an IAM role ARN, and AWS automatically provides short-lived credentials when the pod makes API calls. No access keys, no secrets to rotate, and no credentials to accidentally expose:</p>
<pre><code class="language-bash"># Create the IAM role for Crossplane with the necessary AWS permissions
aws iam create-role \
  --role-name CrossplaneProviderRole \
  --assume-role-policy-document '{
    "Version": "2012-10-17",
    "Statement": [{
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/oidc.eks.us-east-1.amazonaws.com/id/YOUR_OIDC_ID"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "oidc.eks.us-east-1.amazonaws.com/id/YOUR_OIDC_ID:sub":
            "system:serviceaccount:crossplane-system:provider-aws"
        }
      }
    }]
  }'

# Attach the permissions policy (scope this to minimum required in production)
aws iam attach-role-policy \
  --role-name CrossplaneProviderRole \
  --policy-arn arn:aws:iam::aws:policy/AdministratorAccess
</code></pre>
<pre><code class="language-yaml"># provider-config.yaml
# Configure the AWS provider with IRSA — no static credentials
apiVersion: aws.upbound.io/v1beta1
kind: ProviderConfig
metadata:
  name: default
spec:
  credentials:
    source: IRSA   # Use the IAM role attached to the provider service account
</code></pre>
<h3 id="heading-33-defining-a-composite-resource-postgresql-database">3.3 Defining a Composite Resource — PostgreSQL Database</h3>
<p>This is where the IDP abstraction lives. The platform team defines two YAML files: the <code>CompositeResourceDefinition</code> (XRD), which specifies the shape of what developers can request, and the <code>Composition</code>, which specifies how that request expands into actual AWS resources with platform standards applied.</p>
<p>The XRD is the API contract with developers. Keep it simple — only fields developers genuinely need to control should appear here:</p>
<pre><code class="language-yaml"># xrd-postgresql.yaml
# Defines the PostgreSQLDatabase type that developers can request
# Developers never see the RDS-specific configuration below
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: xpostgresqldatabases.platform.cloudfrugal.com
spec:
  group: platform.cloudfrugal.com
  names:
    kind: XPostgreSQLDatabase
    plural: xpostgresqldatabases
  claimNames:
    kind: PostgreSQLDatabase     # This is what developers create
    plural: postgresqldatabases
  versions:
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                # Developer-facing fields only — simple and bounded
                storageGB:
                  type: integer
                  minimum: 20
                  maximum: 1000
                  description: "Storage in GB. Min 20, max 1000."
                instanceClass:
                  type: string
                  enum: ["small", "medium", "large"]
                  description: "small=db.t4g.medium, medium=db.r7g.large, large=db.r7g.2xlarge"
                environment:
                  type: string
                  enum: ["staging", "production"]
</code></pre>
<p>The Composition is the platform team's implementation. It maps the simple developer fields to the full RDS configuration and enforces platform standards that developers can't override:</p>
<pre><code class="language-yaml"># composition-postgresql.yaml
# Defines what a PostgreSQLDatabase claim expands into
# Platform standards (encryption, backup, deletion protection) are applied here
# Developers cannot override them — the platform enforces them
apiVersion: apiextensions.crossplane.io/v1
kind: Composition
metadata:
  name: postgresql-aws-composition
  labels:
    provider: aws
spec:
  compositeTypeRef:
    apiVersion: platform.cloudfrugal.com/v1alpha1
    kind: XPostgreSQLDatabase

  resources:
    # The actual RDS instance — expanded from the simple developer claim
    - name: rds-instance
      base:
        apiVersion: rds.aws.upbound.io/v1beta1
        kind: Instance
        spec:
          forProvider:
            region: us-east-1
            engine: postgres
            engineVersion: "15.4"
            # Platform standards — always applied, not developer-configurable
            storageEncrypted: true           # Always encrypted
            backupRetentionPeriod: 7         # Always 7-day backup
            deletionProtection: true         # Always deletion-protected
            multiAZ: false                   # Overridden to true for production (see patches)
            dbSubnetGroupNameSelector:
              matchLabels:
                platform.cloudfrugal.com/subnet-group: private
      patches:
        # Map the developer's simple instanceClass to the actual RDS instance type
        - type: CombineFromComposite
          combine:
            variables:
              - fromFieldPath: spec.instanceClass
            strategy: string
            string:
              fmt: |
                %s
          toFieldPath: spec.forProvider.dbInstanceClass
          transforms:
            - type: map
              map:
                small:  db.t4g.medium
                medium: db.r7g.large
                large:  db.r7g.2xlarge

        # Enable Multi-AZ for production automatically
        - type: FromCompositeFieldPath
          fromFieldPath: spec.environment
          toFieldPath: spec.forProvider.multiAZ
          transforms:
            - type: map
              map:
                staging:    "false"
                production: "true"

        # Copy team labels from the claim to the RDS instance for cost attribution
        - type: FromCompositeFieldPath
          fromFieldPath: metadata.labels
          toFieldPath: spec.forProvider.tags
</code></pre>
<p>A developer requesting a PostgreSQL database now writes this — nothing more:</p>
<pre><code class="language-yaml"># Developer creates this in their team's namespace
# No RDS knowledge required. No IAM configuration. No subnet group lookup.
apiVersion: platform.cloudfrugal.com/v1alpha1
kind: PostgreSQLDatabase
metadata:
  name: payment-service-db
  namespace: payments-team
  labels:
    team: payments
    cost-centre: payments-engineering
    environment: staging
spec:
  storageGB: 100
  instanceClass: medium
  environment: staging
</code></pre>
<p>Crossplane reconciles this claim to a full RDS instance within minutes, with encryption, backup, and all platform standards applied automatically.</p>
<h3 id="heading-34-verifying-crossplane-resource-provisioning">3.4 Verifying Crossplane Resource Provisioning</h3>
<pre><code class="language-bash"># Watch the claim status — it should transition to Ready=True
kubectl get postgresqldatabases -n payments-team -w

# Check the composite resource for detailed status
kubectl describe xpostgresqldatabases.platform.cloudfrugal.com

# Verify the actual AWS resource was created
aws rds describe-db-instances \
  --query 'DBInstances[?TagList[?Key==`team` &amp;&amp; Value==`payments`]].[DBInstanceIdentifier,DBInstanceStatus]' \
  --output table
</code></pre>
<h2 id="heading-part-4-backstage-the-developer-portal">Part 4: Backstage — The Developer Portal</h2>
<p>Backstage is a CNCF incubating open-source framework originally built by Spotify. It serves as the developer-facing interface of your IDP — the single place where developers discover services, request infrastructure, and find documentation, without needing to know which underlying system provides any of it.</p>
<p>Backstage provides three core capabilities:</p>
<ol>
<li><p>A software catalog that inventories every service, API, library, and resource in your organisation</p>
</li>
<li><p>Software Templates that give developers self-service forms for provisioning infrastructure and scaffolding new services</p>
</li>
<li><p>TechDocs that co-locate documentation with the catalog entity it documents so that documentation is always findable from the same place as the service it covers.</p>
</li>
</ol>
<p>Backstage is built in TypeScript with a React frontend and a Node.js backend. It's configured rather than installed: you create a Backstage app, configure it with your organisation's specifics, and deploy it to your cluster.</p>
<h3 id="heading-41-creating-and-configuring-backstage">4.1 Creating and Configuring Backstage</h3>
<pre><code class="language-bash"># Create a new Backstage app
npx @backstage/create-app@latest

# When prompted:
# App name: platform-portal
# Choose SQLite for local development, PostgreSQL for production

cd platform-portal
</code></pre>
<p>Configure Backstage to connect to your ArgoCD instance and GitHub:</p>
<pre><code class="language-yaml"># app-config.production.yaml
app:
  title: Cloudfrugal Platform Portal
  baseUrl: https://platform.your-company.com

backend:
  baseUrl: https://platform.your-company.com
  database:
    client: pg
    connection:
      host: ${POSTGRES_HOST}
      port: 5432
      user: ${POSTGRES_USER}
      password: ${POSTGRES_PASSWORD}
      database: backstage

# GitHub integration for catalog discovery and template scaffolding
integrations:
  github:
    - host: github.com
      apps:
        - appId: ${GITHUB_APP_ID}
          webhookSecret: ${GITHUB_WEBHOOK_SECRET}
          clientId: ${GITHUB_CLIENT_ID}
          clientSecret: ${GITHUB_CLIENT_SECRET}
          privateKey: ${GITHUB_PRIVATE_KEY}

# ArgoCD plugin configuration
argocd:
  username: ${ARGOCD_USERNAME}
  password: ${ARGOCD_PASSWORD}
  appLocatorMethods:
    - type: 'config'
      instances:
        - name: main
          url: https://argocd.your-company.com

# Catalog auto-discovery — finds catalog-info.yaml files across your GitHub org
catalog:
  providers:
    github:
      your-org:
        organization: 'your-github-org'
        catalogPath: '/catalog-info.yaml'
        filters:
          branch: 'main'
</code></pre>
<h3 id="heading-42-the-software-catalog-registering-services">4.2 The Software Catalog — Registering Services</h3>
<p>Every service, API, library, and resource in your platform should be registered in the Backstage catalog via a <code>catalog-info.yaml</code> file committed to the service's repository. Backstage discovers these files automatically through the GitHub integration — no manual registration required once the file exists:</p>
<pre><code class="language-yaml"># catalog-info.yaml — committed to each service's repository root
# Backstage discovers this automatically via the GitHub integration
apiVersion: backstage.io/v1alpha1
kind: Component
metadata:
  name: payment-api
  title: Payment API
  description: "Core payment processing service. Handles transaction initiation, authorisation, and settlement."
  annotations:
    # Links ArgoCD to show deployment status in the Backstage UI
    argocd/app-name: production-payment-api
    # Links GitHub Actions workflow status
    github.com/project-slug: your-org/payment-api
    # Links Grafana dashboard for this service
    grafana/dashboard-selector: "title=Payment API"
    # Links PagerDuty on-call schedule
    pagerduty.com/service-id: P123456
  tags:
    - payments
    - typescript
    - critical
  links:
    - url: https://payment-api.docs.your-company.com
      title: Documentation
    - url: https://grafana.your-company.com/d/payment-api
      title: Grafana Dashboard
spec:
  type: service
  lifecycle: production
  owner: group:payments-team
  system: payment-platform
  dependsOn:
    - component:user-api
    - resource:payment-service-db
  providesApis:
    - payment-api-v2
</code></pre>
<h3 id="heading-43-software-templates-self-service-infrastructure">4.3 Software Templates — Self-Service Infrastructure</h3>
<p>A Software Template is a Backstage form that, when submitted, produces a Git commit. The commit contains whatever YAML, code, or configuration the template defines.</p>
<p>For infrastructure provisioning, the output is a Crossplane claim. For new service scaffolding, the output is a complete service skeleton committed to a new repository.</p>
<p>The key design decision: templates should create pull requests, not merge directly. The PR gives platform teams visibility, gives developers a review moment, and gives everyone an audit trail. Auto-merge policies can eliminate the review step for low-risk provisioning once you've built trust in the template's outputs:</p>
<pre><code class="language-yaml"># templates/postgresql-database/template.yaml
# This template gives developers a form to request a PostgreSQL database
# The output is a Crossplane PostgreSQLDatabase claim committed to the GitOps repo
apiVersion: scaffolder.backstage.io/v1beta3
kind: Template
metadata:
  name: postgresql-database
  title: PostgreSQL Database
  description: Provision a managed PostgreSQL database on AWS RDS. Encryption, backups, and deletion protection are configured automatically by the platform.
  tags:
    - database
    - postgresql
    - aws
spec:
  owner: group:platform-team
  type: infrastructure

  # The form developers fill out in the Backstage UI
  parameters:
    - title: Database Configuration
      required: [name, team, environment, storageGB, instanceClass]
      properties:
        name:
          title: Database Name
          type: string
          description: "Lowercase, hyphens only. E.g. payment-service-db"
          pattern: '^[a-z][a-z0-9-]*$'

        team:
          title: Owning Team
          type: string
          description: "Your team name. Used for cost attribution and ownership."
          ui:field: OwnerPicker
          ui:options:
            catalogFilter:
              kind: Group

        environment:
          title: Environment
          type: string
          enum: [staging, production]
          default: staging

        storageGB:
          title: Storage (GB)
          type: integer
          minimum: 20
          maximum: 1000
          default: 50

        instanceClass:
          title: Instance Size
          type: string
          enum: [small, medium, large]
          enumNames:
            - "Small (db.t4g.medium) — dev/staging workloads"
            - "Medium (db.r7g.large) — moderate production traffic"
            - "Large (db.r7g.2xlarge) — high-throughput production"
          default: small

  # What the template does when submitted
  steps:
    - id: generate-claim
      name: Generate Crossplane Claim
      action: fetch:template
      input:
        url: ./skeleton    # Contains the Crossplane claim YAML template
        values:
          name: ${{ parameters.name }}
          team: ${{ parameters.team | parseEntityRef | pick('name') }}
          environment: ${{ parameters.environment }}
          storageGB: ${{ parameters.storageGB }}
          instanceClass: ${{ parameters.instanceClass }}

    - id: create-pr
      name: Create Pull Request to GitOps Repo
      action: publish:github:pull-request
      input:
        repoUrl: github.com?repo=gitops-repo&amp;owner=your-org
        title: "Platform: Provision PostgreSQL database ${{ parameters.name }} for ${{ parameters.team }}"
        branchName: "provision-db-${{ parameters.name }}-${{ '' | now }}"
        description: |
          Requesting PostgreSQL database provisioned by Crossplane.

          - **Name:** ${{ parameters.name }}
          - **Team:** ${{ parameters.team }}
          - **Environment:** ${{ parameters.environment }}
          - **Storage:** ${{ parameters.storageGB }}GB
          - **Instance:** ${{ parameters.instanceClass }}

          Approve this PR to trigger provisioning. ArgoCD will pick up the change and Crossplane will create the RDS instance within ~5 minutes of merge.
        sourcePath: ./skeleton

  output:
    links:
      - title: View Pull Request
        url: ${{ steps['create-pr'].output.remoteUrl }}
      - title: Track Provisioning in ArgoCD
        url: https://argocd.your-company.com/applications
</code></pre>
<p>The template skeleton directory contains the Crossplane claim with template variable placeholders:</p>
<pre><code class="language-yaml"># templates/postgresql-database/skeleton/databases/${{ values.name }}.yaml
apiVersion: platform.cloudfrugal.com/v1alpha1
kind: PostgreSQLDatabase
metadata:
  name: ${{ values.name }}
  namespace: ${{ values.team }}-platform
  labels:
    team: ${{ values.team }}
    cost-centre: ${{ values.team }}-engineering
    environment: ${{ values.environment }}
    managed-by: backstage-scaffolder
spec:
  storageGB: ${{ values.storageGB }}
  instanceClass: ${{ values.instanceClass }}
  environment: ${{ values.environment }}
</code></pre>
<h2 id="heading-part-5-wiring-it-together-the-golden-path">Part 5: Wiring It Together — The Golden Path</h2>
<p>The Golden Path is the complete end-to-end workflow: a developer uses Backstage to request infrastructure, that request becomes a Git commit, ArgoCD applies the commit to the cluster, Crossplane provisions the actual AWS resource, and the result appears in both the Backstage catalog and the ArgoCD dashboard.</p>
<h3 id="heading-51-the-complete-flow">5.1 The Complete Flow</h3>
<pre><code class="language-plaintext">Developer fills form in Backstage
    ↓
Backstage Software Template renders the Crossplane claim YAML
    ↓
Backstage creates a Pull Request in the GitOps repository
    ↓
Platform engineer (or auto-merge policy) approves and merges the PR
    ↓
ArgoCD detects the new file in the GitOps repository
    ↓
ArgoCD applies the Crossplane claim to the cluster
    ↓
Crossplane reconciles the claim to an actual AWS RDS instance
    ↓
Developer receives the database endpoint via Kubernetes Secret
    ↓
Backstage catalog shows the new resource, owned by the requesting team
</code></pre>
<h3 id="heading-52-surfacing-resource-status-back-in-backstage">5.2 Surfacing Resource Status Back in Backstage</h3>
<p>The Backstage Kubernetes plugin pulls live pod and resource status from your clusters and displays it on each catalog entity page. Developers can see whether their service is running, how many replicas are healthy, and whether the last deployment synced — without leaving Backstage or learning <code>kubectl</code>:</p>
<pre><code class="language-bash"># Install the Kubernetes plugin packages
cd platform-portal
yarn --cwd packages/app add @backstage/plugin-kubernetes
yarn --cwd packages/backend add @backstage/plugin-kubernetes-backend
</code></pre>
<pre><code class="language-yaml"># app-config.production.yaml — add Kubernetes cluster configuration
kubernetes:
  serviceLocatorMethod:
    type: 'multiTenant'
  clusterLocatorMethods:
    - type: 'config'
      clusters:
        - name: production-eks
          url: ${PRODUCTION_CLUSTER_URL}
          authProvider: serviceAccount
          serviceAccountToken: ${PRODUCTION_SA_TOKEN}
          caData: ${PRODUCTION_CA_DATA}
        - name: staging-eks
          url: ${STAGING_CLUSTER_URL}
          authProvider: serviceAccount
          serviceAccountToken: ${STAGING_SA_TOKEN}
          caData: ${STAGING_CA_DATA}
</code></pre>
<p>Annotate each catalog entity to link it to its Kubernetes resources:</p>
<pre><code class="language-yaml"># In each service's catalog-info.yaml
annotations:
  backstage.io/kubernetes-label-selector: 'app=payment-api'
  backstage.io/kubernetes-namespace: payments-team
</code></pre>
<h3 id="heading-53-installing-the-argocd-plugin">5.3 Installing the ArgoCD Plugin</h3>
<p>The ArgoCD plugin shows deployment history and sync status directly in the Backstage entity page. When a developer opens the payment-api page in the catalog, they can see the last 10 deployments, the current sync state, and whether the application is healthy — all without opening the ArgoCD UI:</p>
<pre><code class="language-bash">yarn --cwd packages/app add @roadiehq/backstage-plugin-argo-cd
</code></pre>
<pre><code class="language-typescript">// packages/app/src/components/catalog/EntityPage.tsx
import { EntityArgoCDOverviewCard } from '@roadiehq/backstage-plugin-argo-cd';

// Add to the service entity page layout
const serviceEntityPage = (
  &lt;EntityLayout&gt;
    &lt;EntityLayout.Route path="/" title="Overview"&gt;
      &lt;Grid container spacing={3}&gt;
        &lt;Grid item md={6}&gt;
          &lt;EntityAboutCard variant="gridItem" /&gt;
        &lt;/Grid&gt;
        &lt;Grid item md={6}&gt;
          {/* ArgoCD deployment status — shows sync state and recent deployments */}
          &lt;EntityArgoCDOverviewCard /&gt;
        &lt;/Grid&gt;
      &lt;/Grid&gt;
    &lt;/EntityLayout.Route&gt;
  &lt;/EntityLayout&gt;
);
</code></pre>
<h2 id="heading-part-6-finops-integration-cost-attribution-on-the-idp">Part 6: FinOps Integration — Cost Attribution on the IDP</h2>
<p>An IDP that provisions resources without cost attribution creates a new problem: you now have automated infrastructure provisioning with no clear ownership of the bill it generates. Every resource created through the IDP must carry team and cost centre metadata from the moment it's provisioned.</p>
<h3 id="heading-61-mandatory-labels-on-every-crossplane-composition">6.1 Mandatory Labels on Every Crossplane Composition</h3>
<p>The Crossplane Compositions are where cost attribution is enforced — not in the developer-facing claim, but in the platform layer that the developer can't bypass. These labels flow through to the actual AWS resource as tags, which means they appear in AWS Cost Explorer and can be used to build team-level cost reports:</p>
<pre><code class="language-yaml"># In every Composition, add mandatory cost attribution patches
patches:
  # These labels flow to the actual AWS resource as tags
  # They can't be omitted or overridden by the developer claim
  - type: FromCompositeFieldPath
    fromFieldPath: metadata.labels[team]
    toFieldPath: spec.forProvider.tags[team]

  - type: FromCompositeFieldPath
    fromFieldPath: metadata.labels[cost-centre]
    toFieldPath: spec.forProvider.tags[cost-centre]

  - type: FromCompositeFieldPath
    fromFieldPath: metadata.labels[environment]
    toFieldPath: spec.forProvider.tags[environment]

  # Add a managed-by tag to identify all IDP-provisioned resources
  - type: FromCompositeFieldPath
    fromFieldPath: metadata.name
    toFieldPath: spec.forProvider.tags[managed-by]
    transforms:
      - type: string
        string:
          fmt: "idp-crossplane"
</code></pre>
<h3 id="heading-62-cost-attribution-query">6.2 Cost Attribution Query</h3>
<p>With mandatory tags on every resource, you can query actual cost by team directly from AWS Cost Explorer:</p>
<pre><code class="language-bash"># Monthly cost breakdown by team — all IDP-provisioned resources
aws ce get-cost-and-usage \
  --time-period Start=$(date -d 'last month' +%Y-%m-01),End=$(date +%Y-%m-01) \
  --granularity MONTHLY \
  --filter '{
    "Tags": {
      "Key": "managed-by",
      "Values": ["idp-crossplane"]
    }
  }' \
  --group-by Type=TAG,Key=team \
  --metrics UnblendedCost \
  --query 'ResultsByTime[0].Groups[*].{Team:Keys[0],Cost:Metrics.UnblendedCost.Amount}' \
  --output table
</code></pre>
<p>Every team that provisions resources through the IDP now has a line on the cost report with their name on it. This is the chargeback model that makes FinOps sustainable at platform scale — attribution is automatic, not manual.</p>
<h2 id="heading-part-7-the-platform-maturity-model-measuring-what-youve-built">Part 7: The Platform Maturity Model — Measuring What You've Built</h2>
<p>The CNCF Platform Engineering Maturity Model defines five levels of platform maturity. Knowing where you sit helps you decide what to build next and communicate progress to engineering leadership.</p>
<table>
<thead>
<tr>
<th>Level</th>
<th>Name</th>
<th>Characteristics</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Provisional</td>
<td>Ad hoc scripts, manual provisioning, no standard tools</td>
</tr>
<tr>
<td>2</td>
<td>Operational</td>
<td>Standardised tools, some automation, Kubernetes in use</td>
</tr>
<tr>
<td>3</td>
<td>Scalable</td>
<td>Self-service portal, GitOps delivery, documented golden paths</td>
</tr>
<tr>
<td>4</td>
<td>Optimising</td>
<td>Cost attribution, SLOs on the platform itself, user feedback loops</td>
</tr>
<tr>
<td>5</td>
<td>Optimised</td>
<td>AI-assisted provisioning, predictive scaling, full FinOps integration</td>
</tr>
</tbody></table>
<p>A complete Backstage + ArgoCD + Crossplane implementation, with cost attribution and Software Templates covering your most common developer requests, puts you at Level 3. Moving to Level 4 requires adding SLO alerting on the platform's own health, running quarterly developer experience surveys, and producing a monthly cost-by-team report from the attribution tags.</p>
<p>The most common mistake at Level 3: building more features instead of measuring adoption. A platform that has 12 Software Templates but only 2 are regularly used hasn't reached Level 3 — it's reached Level 2 with more YAML. Measure which golden paths are used, interview developers who aren't using the portal, and fix the friction before adding capabilities.</p>
<h2 id="heading-best-practices-summary">Best Practices Summary</h2>
<p>✅ <strong>Do:</strong> Build in order — ArgoCD first, then Crossplane, then Backstage. Each layer depends on the previous one.</p>
<p>✅ <strong>Do:</strong> Use Backstage as a Git commit generator, not as an infrastructure caller. All infrastructure changes must be auditable Git commits.</p>
<p>✅ <strong>Do:</strong> Apply cost attribution tags in the Crossplane Composition layer, not in the developer claim. Attribution that developers can bypass will be bypassed.</p>
<p>✅ <strong>Do:</strong> Start with two or three Software Templates and make them excellent before building more. Template adoption is your most important early metric.</p>
<p>✅ <strong>Do:</strong> Register every service in the Backstage catalog from day one. The catalog's value is proportional to its coverage.</p>
<p>✅ <strong>Do:</strong> Deliver Crossplane to your cluster via ArgoCD, not <code>helm install</code>. Everything the IDP manages should itself be managed by the IDP.</p>
<p>❌ <strong>Don't:</strong> Connect Backstage directly to cloud APIs. No audit trail, no rollback, no reconciliation.</p>
<p>❌ <strong>Don't:</strong> Give developers the Crossplane XRD directly. The Composition abstraction exists to hide RDS-specific configuration and enforce platform standards. Bypassing it defeats the purpose.</p>
<p>❌ <strong>Don't:</strong> Build the IDP in isolation and announce it as done. Platform engineering is product engineering. Schedule user interviews after the first two templates are live.</p>
<p>❌ <strong>Don't:</strong> Skip the ArgoCD RBAC configuration. An IDP that gives all developers cluster-admin through the delivery layer has created a security problem larger than the one it solved.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p><a href="https://backstage.io/docs"><strong>Backstage Documentation</strong></a> — Official reference for plugin development, Software Templates, and catalog configuration</p>
</li>
<li><p><a href="https://docs.crossplane.io"><strong>Crossplane Documentation</strong></a> — CompositeResourceDefinition and Composition reference, provider installation guides</p>
</li>
<li><p><a href="https://argo-cd.readthedocs.io"><strong>ArgoCD Documentation</strong></a> — ApplicationSet generator reference, RBAC configuration, and sync policy options</p>
</li>
<li><p><a href="https://tag-app-delivery.cncf.io/whitepapers/platform-eng-maturity-model/"><strong>CNCF Platform Engineering Maturity Model</strong></a> — The maturity framework referenced in Part 7</p>
</li>
<li><p><a href="https://marketplace.upbound.io/providers/upbound/provider-aws"><strong>AWS Provider for Crossplane</strong></a> — Complete reference for all AWS resource types available through Crossplane</p>
</li>
<li><p><a href="https://backstage.io/docs/features/kubernetes/"><strong>Backstage Kubernetes Plugin</strong></a> — Setup guide for the Kubernetes resource visibility integration in Part 5</p>
</li>
<li><p><a href="https://www.finops.org/framework/capabilities/"><strong>FinOps Foundation — FinOps for Platform Engineering</strong></a> — Framework reference for the cost attribution model in Part 6</p>
</li>
<li><p><a href="https://github.com/aayostem/platform-toolkit"><strong>Companion Repository</strong></a> — All manifests, Compositions, ApplicationSets, and Backstage templates from this guide</p>
</li>
<li><p><a href="https://cloud.google.com/resources/content/2025-dora-ai-capabilities-model-report"><strong>2025 DORA State of AI-assisted Software Development Report</strong></a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Implement Zero-Trust Workload Identity in Kubernetes with SPIFFE, SPIRE, and Cilium ]]>
                </title>
                <description>
                    <![CDATA[ Your network policy says: allow traffic from 10.0.1.45. Yesterday, 10.0.1.45 was your payment service. Today, after a rolling deployment, it's your logging agent. Your payment service is now at 10.0.1 ]]>
                </description>
                <link>https://www.freecodecamp.org/news/implement-zero-trust-workload-identity-in-kubernetes-with-spiffe-spire-and-cilium/</link>
                <guid isPermaLink="false">6a4d7406fde50672308c3931</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ computer networking ]]>
                    </category>
                
                    <category>
                        <![CDATA[ networking ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Destiny Erhabor ]]>
                </dc:creator>
                <pubDate>Tue, 07 Jul 2026 21:47:50 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/4e87cffb-7972-4dcd-a705-480154778907.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Your network policy says: allow traffic from <code>10.0.1.45</code>.</p>
<p>Yesterday, <code>10.0.1.45</code> was your payment service. Today, after a rolling deployment, it's your logging agent. Your payment service is now at <code>10.0.1.89</code>.</p>
<p>Kubernetes has already updated all the endpoints and service records — but your network policy has no idea. It silently allows traffic through based on an IP address that no longer belongs to the workload you intended to trust.</p>
<p>This is the workload identity problem. IP addresses aren't an identity, they're a location. And in a Kubernetes cluster, location changes constantly. Building security policy on top of IP addresses means your security posture silently degrades every time a pod is scheduled, rescheduled, or scaled.</p>
<p>The answer is cryptographic workload identity: every workload gets a certificate-backed identity that proves who it is, not where it is. Services authenticate each other using those certificates before exchanging any data. If the certificate doesn't match, the connection is refused, regardless of what IP address it came from.</p>
<p>This is what SPIFFE and SPIRE provide. And this is how Cilium enforces it using eBPF, without injecting a sidecar into every pod.</p>
<p>In this article you'll understand how the SPIFFE identity model works, deploy SPIRE to issue cryptographic identities to workloads, and use Cilium's built-in SPIRE integration to enforce mutual TLS between services without touching your application code.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Familiarity with Kubernetes RBAC and pod security — <a href="https://www.freecodecamp.org/news/how-to-secure-a-kubernetes-cluster-handbook/">this handbook</a> covers the foundations</p>
</li>
<li><p>Familiarity with TLS certificates and Kubernetes Secrets — <a href="https://www.freecodecamp.org/news/how-to-encrypt-kubernetes-traffic/">this handbook</a> covers cert-manager and certificate concepts</p>
</li>
<li><p>Helm 3 and the Cilium CLI installed</p>
</li>
<li><p>A kind cluster — you'll create a fresh one with Cilium as the CNI in this article</p>
</li>
<li><p>Patience: this is the most complex demo I've covered in this group of articles. SPIRE has more moving parts than anything else covered so far.</p>
</li>
</ul>
<p>All demo files are in the <a href="https://github.com/Caesarsage/DevOps-Cloud-Projects/tree/main/intermediate/k8/security/cilium-mtls">companion GitHub repository</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-workload-identity-problem">The Workload Identity Problem</a></p>
</li>
<li><p><a href="#heading-how-spiffe-works">How SPIFFE Works</a></p>
<ul>
<li><p><a href="#heading-spiffe-ids-and-trust-domains">SPIFFE IDs and Trust Domains</a></p>
</li>
<li><p><a href="#heading-svids-the-cryptographic-identity-document">SVIDs: The Cryptographic Identity Document</a></p>
</li>
<li><p><a href="#heading-the-trust-bundle">The Trust Bundle</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-spire-works">How SPIRE Works</a></p>
<ul>
<li><p><a href="#heading-spire-server-and-spire-agent">SPIRE Server and SPIRE Agent</a></p>
</li>
<li><p><a href="#heading-node-attestation">Node Attestation</a></p>
</li>
<li><p><a href="#heading-workload-attestation">Workload Attestation</a></p>
</li>
<li><p><a href="#heading-svid-issuance-and-rotation">SVID Issuance and Rotation</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-cilium-implements-mutual-tls-with-spiffe">How Cilium Implements Mutual TLS with SPIFFE</a></p>
</li>
<li><p><a href="#heading-demo-1--install-cilium-with-spire-integration">Demo 1 — Install Cilium with SPIRE Integration</a></p>
<ul>
<li><p><a href="#heading-step-1-install-the-cilium-cli">Step 1: Install the Cilium CLI</a></p>
</li>
<li><p><a href="#heading-step-2-create-a-kind-cluster-without-a-default-cni">Step 2: Create a kind cluster without a default CNI</a></p>
</li>
<li><p><a href="#heading-step-3-install-cilium-with-spire-enabled">Step 3: Install Cilium with SPIRE enabled</a></p>
</li>
<li><p><a href="#heading-step-4-verify-the-installation">Step 4: Verify the installation</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-demo-2--enforce-mutual-tls-with-a-ciliumnetworkpolicy">Demo 2 — Enforce Mutual TLS with a CiliumNetworkPolicy</a></p>
<ul>
<li><p><a href="#heading-step-1-deploy-a-client-and-server">Step 1: Deploy a client and server</a></p>
</li>
<li><p><a href="#heading-step-2-confirm-traffic-flows-without-authentication">Step 2: Confirm traffic flows without authentication</a></p>
</li>
<li><p><a href="#heading-step-3-apply-a-ciliumnetworkpolicy-requiring-mutual-authentication">Step 3: Apply a CiliumNetworkPolicy requiring mutual authentication</a></p>
</li>
<li><p><a href="#heading-step-4-verify-authenticated-traffic-still-flows">Step 4: Verify authenticated traffic still flows</a></p>
</li>
<li><p><a href="#heading-step-5-observe-the-authentication-with-hubble-optional">Step 5: Observe the authentication with Hubble (optional)</a></p>
</li>
<li><p><a href="#heading-step-6-verify-that-a-pod-without-the-matching-label-is-blocked">Step 6: Verify that a pod without the matching label is blocked</a></p>
</li>
<li><p><a href="#heading-step-7-check-the-workload-entries-in-spire">Step 7: Check the workload entries in SPIRE</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-cleanup-kind">Cleanup (kind)</a></p>
</li>
</ul>
<h2 id="heading-the-workload-identity-problem">The Workload Identity Problem</h2>
<p>The opening scenario isn't theoretical. In Kubernetes, pods are ephemeral. The scheduler can place a pod on any node, and a pod's IP address is assigned at scheduling time from the node's IP pool.</p>
<p>When a pod is deleted and recreated through a rolling deployment, a node drain, or an autoscaler event, it gets a new IP address. If you've written a NetworkPolicy that says, "allow traffic from this IP", that policy is now pointing at nothing, or worse, at a different workload.</p>
<p>Kubernetes service names help here for east-west traffic — a Service name resolves consistently regardless of which pods back it. But a NetworkPolicy based on a Service name is still a label selector match, not a cryptographic assertion. Any pod that can spoof the right labels can bypass it.</p>
<p>What you actually want is this: before service A sends a request to service B, service B proves its identity cryptographically. If service B can't prove it is who it claims to be, service A refuses the connection. This is mutual TLS, and the key question is: where do the identities come from?</p>
<p>SPIFFE answers that question.</p>
<h2 id="heading-how-spiffe-works">How SPIFFE Works</h2>
<p>SPIFFE — Secure Production Identity Framework for Everyone — is a CNCF standard that defines a model for workload identity. It doesn't implement anything by itself. It specifies the format of identities, the API for requesting them, and the trust model that makes them verifiable across services, clusters, and clouds. SPIRE is the reference implementation of that specification.</p>
<h3 id="heading-spiffe-ids-and-trust-domains">SPIFFE IDs and Trust Domains</h3>
<p>A SPIFFE identity is a URI with a specific format:</p>
<pre><code class="language-plaintext">spiffe://&lt;trust-domain&gt;/&lt;workload-path&gt;
</code></pre>
<p>The trust domain is a string that identifies the administrative boundary — typically your organisation, cluster, or environment. Everything within the same trust domain can verify each other's identities. Identities from different trust domains require explicit federation configuration.</p>
<p>Some concrete examples:</p>
<pre><code class="language-plaintext">spiffe://payments.corp/ns/production/sa/checkout
spiffe://analytics.corp/ns/data/sa/pipeline-worker
spiffe://cluster.local/ns/monitoring/sa/prometheus
</code></pre>
<p>The path after the trust domain is arbitrary — it's defined by your SPIRE configuration and typically encodes the Kubernetes namespace and service account of the workload.</p>
<h3 id="heading-svids-the-cryptographic-identity-document">SVIDs: The Cryptographic Identity Document</h3>
<p>An SVID — SPIFFE Verifiable Identity Document — is how a SPIFFE identity is materialised into something a service can actually use.</p>
<p>There are two SVID formats.</p>
<p>An <strong>X.509 SVID</strong> is a standard TLS certificate where the SPIFFE ID is embedded in the Subject Alternative Name (SAN) URI field. Because it's a standard X.509 certificate, any TLS library can use it without modification.</p>
<p>The workload presents this certificate in a TLS handshake, and the peer verifies the certificate was signed by a trusted SPIRE server. This is the format used for long-lived connections like gRPC streams.</p>
<p>A <strong>JWT SVID</strong> is a signed JSON Web Token containing the SPIFFE ID as a claim. It's suitable for request-based authentication over HTTP — pass it in an Authorization header, and the receiving service verifies the signature.</p>
<p>JWT SVIDs are shorter-lived than X.509 SVIDs and scoped to a specific audience to prevent token reuse across services.</p>
<p>For Cilium's mutual authentication, X.509 SVIDs are used. The rest of this article focuses on X.509.</p>
<h3 id="heading-the-trust-bundle">The Trust Bundle</h3>
<p>For service A to verify service B's certificate, service A needs to know which Certificate Authority signed it. In SPIFFE, this is called the trust bundle — the set of CA certificates that are trusted within a trust domain.</p>
<p>SPIRE makes the trust bundle available via the Workload API. When a workload requests its identity, it also receives the current trust bundle. When the SPIRE server rotates its CA, it distributes the new trust bundle to all agents, which push it to all workloads. Your application never has to manage trust bundles manually.</p>
<h2 id="heading-how-spire-works">How SPIRE Works</h2>
<p>SPIRE is the engine that issues SVIDs and manages the identity lifecycle. Understanding its architecture is what makes the Cilium integration make sense.</p>
<h3 id="heading-spire-server-and-spire-agent">SPIRE Server and SPIRE Agent</h3>
<p>SPIRE has two main components. The <strong>SPIRE Server</strong> is the central CA. It maintains a registry of workload entries (records that describe which SPIFFE IDs should be issued to which workloads). It issues SVIDs to agents on behalf of workloads, and it's the root of trust for the entire trust domain.</p>
<p>The <strong>SPIRE Agent</strong> runs on every node as a DaemonSet. It has two jobs. First, it proves to the SPIRE Server that it's running on a legitimate node. This is called node attestation. Second, it exposes the SPIFFE Workload API on a Unix socket on the node, which workloads use to request their SVIDs.</p>
<p>The agent caches SVIDs locally so that a temporary loss of connection to the SPIRE Server doesn't immediately break workload identity.</p>
<p>This split — central server, per-node agents — is deliberate. Workloads never contact the SPIRE Server directly. They only talk to the agent on their own node. The agent mediates all identity requests, which limits the blast radius if a node is compromised.</p>
<h3 id="heading-node-attestation">Node Attestation</h3>
<p>When a SPIRE Agent starts up on a new node, it needs to prove its own identity to the SPIRE Server before it can serve identities to workloads. This is node attestation.</p>
<p>In Kubernetes, SPIRE uses <strong>PSAT</strong> — Projected Service Account Tokens — for node attestation. The agent presents a Kubernetes service account token that is projected specifically for the SPIRE server's audience. The SPIRE Server contacts the Kubernetes API to verify the token, confirms the agent is running in the expected namespace with the expected service account, and issues the agent its own SVID.</p>
<p>This is the reason SPIRE requires specific Kubernetes API flags. The kube-apiserver must be configured to support projected service account tokens with the right audience, which is why the kind cluster config in the demo below sets <code>--api-audiences</code> and <code>--service-account-issuer</code>.</p>
<h3 id="heading-workload-attestation">Workload Attestation</h3>
<p>Once a node has been attested, its agent can attest workloads. When a workload connects to the Workload API socket and requests an SVID, the agent collects facts about that workload (like its Kubernetes namespace, service account, pod name, and labels) by querying the Kubernetes API. It matches those facts against the workload entries registered in the SPIRE Server. If a matching entry exists, the agent issues the corresponding SVID.</p>
<p>A workload entry looks like this:</p>
<pre><code class="language-plaintext">SPIFFE ID: spiffe://example.org/ns/production/sa/checkout
Parent ID: spiffe://example.org/spire/agent/k8s_psat/default/&lt;node-uid&gt;
Selectors:
  k8s:ns:production
  k8s:sa:checkout
</code></pre>
<p>The selectors describe the Kubernetes facts that must match. A pod running in the <code>production</code> namespace with service account <code>checkout</code> will receive the SPIFFE ID <code>spiffe://example.org/ns/production/sa/checkout</code>. Any other pod will not.</p>
<h3 id="heading-svid-issuance-and-rotation">SVID Issuance and Rotation</h3>
<p>SVIDs are short-lived by design. The default TTL for X.509 SVIDs in SPIRE is one hour. The SPIRE Agent automatically rotates them in the background — generating a new key pair, requesting a fresh SVID from the server, and making the new SVID available on the Workload API before the old one expires.</p>
<p>Workloads that use the Workload API directly or tools like the SPIFFE CSI driver get the new SVID transparently.</p>
<p>Short-lived credentials are the zero-trust way. If a workload's SVID is compromised, it's only valid for an hour. Compare that to a Kubernetes service account token, which was historically valid forever.</p>
<h2 id="heading-how-cilium-implements-mutual-tls-with-spiffe">How Cilium Implements Mutual TLS with SPIFFE</h2>
<p>Traditional approaches to service mesh mTLS (like Istio or Linkerd) inject a sidecar proxy into every pod. The proxy intercepts all traffic and handles the TLS handshake. The application has no idea TLS is happening. The sidecar adds memory overhead (roughly 50–100MB per pod for Envoy), an extra network hop on every request, and a complex certificate injection mechanism.</p>
<p>Cilium takes a different path. Rather than injecting a proxy, it handles authentication at the network layer using eBPF. The Cilium agent running on each node intercepts connections, performs the mutual TLS handshake using SPIFFE SVIDs, and enforces the authentication result — all in the kernel, without any user-space proxy.</p>
<p>The mechanism works like this. When pod A initiates a connection to pod B, the Cilium agent on pod A's node intercepts the connection. It retrieves pod A's SVID from the SPIRE Workload API. It checks whether there's a <code>CiliumNetworkPolicy</code> requiring mutual authentication for this connection. If there is, it performs a TLS handshake with the Cilium agent on pod B's node, presenting pod A's SVID and requesting pod B's SVID in return.</p>
<p>Both agents verify the SVID against the SPIRE trust bundle. If both SVIDs are valid and the policy allows the connection, it proceeds. If either SVID is invalid or missing, the connection is dropped.</p>
<p>The application on pod A receives data from the application on pod B. Neither application wrote any TLS code. Neither has a sidecar. The authentication happened entirely in the Cilium agents on their respective nodes.</p>
<p>In Cilium's model, the Cilium agent itself gets a SPIFFE identity from SPIRE. It acts as a delegate identity that can request SVIDs on behalf of workloads.</p>
<p>This is slightly different from the standalone SPIRE model where each workload requests its own SVID directly. The Cilium operator registers workload entries in SPIRE automatically based on the Kubernetes Identities it manages, so you don't need to manually create SPIRE entries for every pod.</p>
<h2 id="heading-demo-1-install-cilium-with-spire-integration">Demo 1 — Install Cilium with SPIRE Integration</h2>
<p>You'll create a kind cluster with Cilium as the CNI and enable its built-in SPIRE integration in a single Helm command.</p>
<h3 id="heading-step-1-install-the-cilium-cli">Step 1: Install the Cilium CLI</h3>
<pre><code class="language-bash"># macOS
brew install cilium-cli

# Linux
CILIUM_CLI_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/cilium-cli/main/stable.txt)
curl -L --remote-name-all \
  https://github.com/cilium/cilium-cli/releases/download/${CILIUM_CLI_VERSION}/cilium-linux-amd64.tar.gz
sudo tar -xzf cilium-linux-amd64.tar.gz -C /usr/local/bin
</code></pre>
<h3 id="heading-step-2-create-a-kind-cluster-without-a-default-cni">Step 2: Create a kind Cluster Without a Default CNI</h3>
<p>kind's default CNI (kindnet) must be disabled so Cilium can take its place. Save this as <code>kind-cilium.yaml</code>:</p>
<pre><code class="language-yaml"># kind-cilium.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
  - role: control-plane
  - role: worker
  - role: worker
networking:
  disableDefaultCNI: true   # Required: let Cilium be the CNI
  kubeProxyMode: none       # Cilium replaces kube-proxy too
</code></pre>
<pre><code class="language-bash">kind create cluster --name k8s-mtls --config kind-cilium.yaml
</code></pre>
<p>The nodes will be in a <code>NotReady</code> state until Cilium is installed. This is expected because there's no CNI yet.</p>
<h3 id="heading-step-3-install-cilium-with-spire-enabled">Step 3: Install Cilium with SPIRE Enabled</h3>
<p>Because Step 2 set <code>kubeProxyMode: none</code>, Cilium has to play the kube-proxy role itself. That means its bootstrap pods can't reach the API server via the <code>kubernetes</code> Service ClusterIP, because nothing is routing it yet.</p>
<p>You have to pass the API server's real address up front. Grab the kind control-plane's IP from Docker:</p>
<pre><code class="language-bash">API_SERVER_IP=$(docker inspect k8s-mtls-control-plane \
  --format='{{ .NetworkSettings.Networks.kind.IPAddress }}')
echo "API_SERVER_IP=$API_SERVER_IP"
</code></pre>
<p>Then install Cilium with SPIRE:</p>
<pre><code class="language-bash">helm repo add cilium https://helm.cilium.io/
helm repo update

helm upgrade cilium cilium/cilium \
  --install \
  --namespace kube-system \
  --set kubeProxyReplacement=true \
  --set k8sServiceHost=${API_SERVER_IP} \
  --set k8sServicePort=6443 \
  --set authentication.enabled=true \
  --set authentication.mutual.spire.enabled=true \
  --set authentication.mutual.spire.install.enabled=true \
  --set authentication.mutual.spire.install.server.dataStorage.enabled=false
</code></pre>
<p>A few of these flags are easy to miss but each is load-bearing:</p>
<ul>
<li><p><code>kubeProxyReplacement=true</code>: Cilium installs its eBPF-based replacement for kube-proxy. Mandatory whenever the kind config sets <code>kubeProxyMode: none</code>.</p>
</li>
<li><p><code>k8sServiceHost</code> / <code>k8sServicePort</code>: direct API server address used during bootstrap, before Cilium can route the Service ClusterIP. On EKS/GKE/AKS you don't need this because kube-proxy is still present during install.</p>
</li>
<li><p><code>authentication.enabled=true</code>: required alongside <code>authentication.mutual.spire.enabled=true</code>. The chart's <code>validate.yaml</code> rejects the install with <code>SPIRE integration requires .Values.authentication.enabled=true and .Values.authentication.mutual.spire.enabled=true</code> if you set only the mutual flag.</p>
</li>
<li><p><code>dataStorage.enabled=false</code>: switches the SPIRE server from a PVC-backed datastore to in-memory. Fine for a lab cluster, but in production leave this enabled and ensure your cluster has PersistentVolume support.</p>
</li>
</ul>
<p>Notice there's no <code>--wait</code> flag here. On a fresh cluster, <code>--wait</code> will appear to fail with <code>context deadline exceeded</code> because the install is racey by design. The SPIRE server has to schedule on a <code>NotReady</code> node thanks to its tolerations, then Cilium agents come up using SPIRE, then nodes flip to <code>Ready</code>. Let the install return immediately and watch the pods come up over the next ~2 minutes:</p>
<pre><code class="language-bash">kubectl get pods -A -w
</code></pre>
<h3 id="heading-step-4-verify-the-installation">Step 4: Verify the Installation</h3>
<pre><code class="language-bash">cilium status --wait
</code></pre>
<pre><code class="language-plaintext">    /¯¯\
 /¯¯\__/¯¯\    Cilium:             OK
 \__/¯¯\__/    Operator:           OK
 /¯¯\__/¯¯\    Envoy DaemonSet:    OK
 \__/¯¯\__/    Hubble Relay:       disabled
    \__/       ClusterMesh:        disabled

DaemonSet              cilium             Desired: 3, Ready: 3/3, Available: 3/3
DaemonSet              cilium-envoy       Desired: 3, Ready: 3/3, Available: 3/3
Deployment             cilium-operator    Desired: 2, Ready: 2/2, Available: 2/2
</code></pre>
<p>Three Cilium agents, one per node, including the control-plane (no taints in the kind config). Check the SPIRE components in the <code>cilium-spire</code> namespace:</p>
<pre><code class="language-bash">kubectl get all -n cilium-spire
</code></pre>
<pre><code class="language-plaintext">NAME                    READY   STATUS    RESTARTS   AGE
pod/spire-agent-2cpsr   1/1     Running   0          3m
pod/spire-agent-klhjx   1/1     Running   0          3m
pod/spire-agent-vhsnc   1/1     Running   0          3m
pod/spire-server-0      2/2     Running   0          3m

NAME                              TYPE        CLUSTER-IP    PORT(S)    AGE
service/spire-server              ClusterIP   10.96.x.x     8081/TCP   3m

NAME                          DESIRED   CURRENT   READY   AGE
daemonset.apps/spire-agent    3         3         3       3m

NAME                             READY   AGE
statefulset.apps/spire-server    1/1     3m
</code></pre>
<p>One SPIRE agent per node. The SPIRE server is a StatefulSet with two containers: the server itself plus the SPIRE controller manager, which automatically creates workload registration entries for Cilium identities.</p>
<p>Run a health check on the SPIRE server:</p>
<pre><code class="language-bash">kubectl exec -n cilium-spire spire-server-0 -c spire-server -- \
  /opt/spire/bin/spire-server healthcheck
</code></pre>
<pre><code class="language-plaintext">Server is healthy.
</code></pre>
<p>Verify the SPIRE agents have been attested:</p>
<pre><code class="language-bash">kubectl exec -n cilium-spire spire-server-0 -c spire-server -- \
  /opt/spire/bin/spire-server agent list
</code></pre>
<pre><code class="language-plaintext">Found 3 attested agents:

SPIFFE ID         : spiffe://spiffe.cilium/spire/agent/k8s_psat/default/&lt;node-uid-1&gt;
Attestation type  : k8s_psat
Expiration time   : 2026-05-17 21:08:47 +0000 UTC
Serial number     : 91532884191503307904684123063465502141
Can re-attest     : true

SPIFFE ID         : spiffe://spiffe.cilium/spire/agent/k8s_psat/default/&lt;node-uid-2&gt;
...
</code></pre>
<p>Three agents, one per node, all attested via Kubernetes PSAT. The SPIRE server trusts every node and will issue SVIDs to workloads running on them.</p>
<p>At this point the identity platform is fully in place, but nothing is using it yet. Demo 1 built the machinery that <em>issues</em> cryptographic identities. Demo 2, which we'll walk through next, puts that machinery to work, turning those SVIDs into an enforced mutual-TLS policy between two real services. Keep the cluster from Demo 1 running, as Demo 2 builds directly on it.</p>
<h2 id="heading-demo-2-enforce-mutual-tls-with-a-ciliumnetworkpolicy">Demo 2 — Enforce Mutual TLS with a CiliumNetworkPolicy</h2>
<p>Picking up in the same cluster from Demo 1, you'll deploy two services, enforce mutual authentication between them with a <code>CiliumNetworkPolicy</code>, verify that authenticated traffic flows, and confirm that unauthenticated connections are blocked.</p>
<p>Every request here is authenticated with the SVIDs that the SPIRE server you just verified hands out. These two demos are one continuous walkthrough, not standalone exercises.</p>
<h3 id="heading-step-1-deploy-a-client-and-server">Step 1: Deploy a Client and Server</h3>
<p>This file contains both the server and the client — the client is a sleeping curl pod we'll use to exec into.</p>
<pre><code class="language-yaml"># echo-workloads.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: echo-server
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: echo-server
  template:
    metadata:
      labels:
        app: echo-server
    spec:
      containers:
        - name: echo-server
          image: ealen/echo-server:latest
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: echo-server
  namespace: default
spec:
  selector:
    app: echo-server
  ports:
    - port: 80
      targetPort: 80
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: echo-client
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: echo-client
  template:
    metadata:
      labels:
        app: echo-client
    spec:
      containers:
        - name: client
          image: curlimages/curl:latest
          command: ["sleep", "infinity"]
</code></pre>
<pre><code class="language-bash">kubectl apply -f echo-workloads.yaml
# kubectl rollout status only takes one resource at a time
kubectl rollout status deployment/echo-server -n default
kubectl rollout status deployment/echo-client -n default
</code></pre>
<h3 id="heading-step-2-confirm-traffic-flows-without-authentication">Step 2: Confirm Traffic Flows Without Authentication</h3>
<p>Before enforcing mTLS, confirm the client can reach the server:</p>
<pre><code class="language-bash">CLIENT=$(kubectl get pod -l app=echo-client -o jsonpath='{.items[0].metadata.name}')
kubectl exec $CLIENT -- curl -s http://echo-server/
</code></pre>
<p>You should get a JSON response from the echo server. Traffic flows freely with no authentication.</p>
<h3 id="heading-step-3-apply-a-ciliumnetworkpolicy-requiring-mutual-authentication">Step 3: Apply a CiliumNetworkPolicy Requiring Mutual Authentication</h3>
<p>Adding <code>authentication.mode: required</code> to a <code>CiliumNetworkPolicy</code> tells Cilium to enforce mutual TLS for matching traffic. Both sides of the connection must present a valid SPIFFE SVID:</p>
<pre><code class="language-yaml"># mtls-policy.yaml
apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata:
  name: echo-server-mtls
  namespace: default
spec:
  endpointSelector:
    matchLabels:
      app: echo-server
  ingress:
    - fromEndpoints:
        - matchLabels:
            app: echo-client
      authentication:
        mode: required     # Require mutual TLS for this traffic
</code></pre>
<pre><code class="language-bash">kubectl apply -f mtls-policy.yaml
</code></pre>
<h3 id="heading-step-4-verify-authenticated-traffic-still-flows">Step 4: Verify Authenticated Traffic Still Flows</h3>
<pre><code class="language-bash">kubectl exec $CLIENT -- curl -s http://echo-server/
</code></pre>
<p>The connection succeeds. Cilium intercepted it, performed the SPIFFE mTLS handshake between the Cilium agents on both pods' nodes, verified both SVIDs, and allowed the traffic through. The application on the client sent a plain HTTP request and received a response — the mutual authentication happened transparently at the network layer.</p>
<h3 id="heading-step-5-observe-the-authentication-with-hubble-optional">Step 5: Observe the Authentication with Hubble (Optional)</h3>
<p>Hubble is Cilium's observability layer. It needs its own CLI:</p>
<pre><code class="language-bash"># macOS
brew install hubble

# Linux
HUBBLE_VERSION=$(curl -s https://raw.githubusercontent.com/cilium/hubble/master/stable.txt)
curl -L --remote-name-all \
  https://github.com/cilium/hubble/releases/download/${HUBBLE_VERSION}/hubble-linux-amd64.tar.gz
sudo tar -xzf hubble-linux-amd64.tar.gz -C /usr/local/bin
</code></pre>
<p>Enable Hubble in the cluster, then watch flows. <code>cilium hubble enable</code> deploys Hubble Relay <em>and</em> restarts the Cilium agents to switch on the Hubble server inside them, so wait for it to settle before port-forwarding. If you skip the wait, the port-forward connects before Relay is listening, then dies with <code>connection reset by peer</code> / <code>rpc error … EOF</code>:</p>
<pre><code class="language-bash">cilium hubble enable
cilium status --wait          # wait for "Hubble Relay: OK" before continuing

cilium hubble port-forward &amp;

# Watch flows for the echo-server (Ctrl-C to stop)
hubble observe --namespace default --pod echo-server --follow
</code></pre>
<p>Trigger another request in a second terminal:</p>
<pre><code class="language-bash">kubectl exec $CLIENT -- curl -s http://echo-server/
</code></pre>
<p>In the Hubble output you'll see:</p>
<pre><code class="language-plaintext">
ℹ️  Hubble Relay is available at 127.0.0.1:4245
Jul  7 12:44:42.380: default/echo-client-86d446b8f-9bn5v:47500 (ID:2822) -&gt; default/echo-server-7467b4b54d-5tvkz:80 (ID:30854) policy-verdict:none TRAFFIC_DIRECTION_UNKNOWN ALLOWED (TCP Flags: SYN)
Jul  7 12:44:42.380: default/echo-client-86d446b8f-9bn5v:47500 (ID:2822) -&gt; default/echo-server-7467b4b54d-5tvkz:80 (ID:30854) to-endpoint FORWARDED (TCP Flags: SYN)
Jul  7 12:44:42.381: default/echo-client-86d446b8f-9bn5v:47500 (ID:2822) &lt;- default/echo-server-7467b4b54d-5tvkz:80 (ID:30854) to-endpoint FORWARDED (TCP Flags: SYN, ACK)
</code></pre>
<p>The <code>ALLOWED</code> verdict with the <code>policy-verdict</code> reason confirms the CiliumNetworkPolicy matched and authentication was verified. No sidecar involved — this happened in the Cilium agents.</p>
<p><strong>Prefer a graphical view? Enable the Hubble UI.</strong> Everything above is the API + terminal path (Relay on port 4245 backs the <code>hubble</code> CLI). Hubble also ships a web dashboard with a live service map — but it's a separate component that <code>cilium hubble enable</code> does <em>not</em> start by default:</p>
<pre><code class="language-bash"># Add the UI (re-runs enable, keeps Relay, adds the hubble-ui deployment)
cilium hubble enable --ui

# Wait for it to be Ready before opening — same race as Relay. Skip this and
# `cilium hubble ui` fails with "connection refused" on port 8081, because the
# UI's frontend container isn't listening yet.
kubectl -n kube-system rollout status deployment/hubble-ui --timeout=90s

# Port-forwards hubble-ui and opens http://localhost:12000 in your browser
cilium hubble ui
</code></pre>
<p>Select the <code>default</code> namespace from the dropdown. That's where the demo pods and the policy live. The map is <em>live</em>: it renders edges from flows as they happen, so an idle namespace looks empty. Trigger a request to light it up:</p>
<pre><code class="language-bash">kubectl exec $CLIENT -- curl -s http://echo-server/
</code></pre>
<p>You'll see a forwarded edge <code>echo-client → echo-server</code>. Click it (or open the flow table at the bottom) to read the <code>policy-verdict: ALLOWED</code>. Leave the UI open through Step 6. When you run the unauthorized-client test there, its connection shows up as a red <em>dropped</em> edge, the visual counterpart to the <code>curl</code> timeout.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f2a6b76d7d55f162b5da2ee/ee0f0824-74e1-4282-82e8-fcf5a9c06835.png" alt="Hubble UI — live service map for the  namespace, with forwarded and dropped flows" style="display: block;" width="1672" height="986" loading="lazy">

<p>The UI has three parts.</p>
<p>The <strong>service map</strong> at the top draws each workload identity as a box and each observed connection as an edge colored by verdict: <code>echo-client → echo-server:80</code> is a solid green (forwarded) edge, while the box labelled <code>default</code> (that's the <code>unauthorized</code> pod, which carries only the namespace identity because it has no <code>app</code> label, so Hubble names it after that) reaches <code>echo-server</code> over a red dashed (dropped) line. The 🔒 lock on <code>echo-server</code>'s <code>→ 80 TCP</code> port marks that endpoint as mutually authenticated by the policy.</p>
<p>The <strong>flow table</strong> underneath logs one row per flow: source identity, destination identity, destination port, L7 info, <code>Verdict</code>, and timestamp. This lets you read both outcomes side by side, with <code>echo-client → echo-server</code> rows marked <strong>forwarded</strong> and <code>default → echo-server</code> rows marked <strong>dropped</strong>. This is the same allow/deny split as the CLI, one line per packet.</p>
<p>The <strong>top bar</strong> holds the namespace selector, a flow filter, the <code>Any verdict</code> / <code>Visual</code> toggle, and a live <code>flows/s</code> rate alongside the count of reporting nodes (<code>3/3</code>).</p>
<h3 id="heading-step-6-verify-that-a-pod-without-the-matching-label-is-blocked">Step 6: Verify That a Pod Without the Matching Label is Blocked</h3>
<p>Deploy a third pod without the <code>echo-client</code> label and try to reach the server:</p>
<pre><code class="language-yaml"># unauthorized-client.yaml
apiVersion: v1
kind: Pod
metadata:
  name: unauthorized
  namespace: default
spec:
  containers:
    - name: client
      image: curlimages/curl:latest
      command: ["sleep", "infinity"]
</code></pre>
<pre><code class="language-bash">kubectl apply -f unauthorized-client.yaml
kubectl wait --for=condition=Ready pod/unauthorized --timeout=60s
kubectl exec unauthorized -- curl -sS --max-time 5 http://echo-server/
</code></pre>
<pre><code class="language-plaintext">curl: (28) Connection timed out after 5000 milliseconds
</code></pre>
<p>The connection times out. The <code>CiliumNetworkPolicy</code> only permits ingress from pods with <code>app: echo-client</code>. A pod without that label gets no SVID match and no policy match. Cilium drops the traffic silently.</p>
<p>There are two gotchas to watch out for here. Run <code>kubectl wait</code> before exec. Run exec too soon after <code>apply</code> and you get <code>container not found ("client")</code> because the pod's container hasn't started yet.</p>
<p>And use <code>curl -sS</code>, not plain <code>-s</code>. With only <code>-s</code>, curl swallows the error text and you just see <code>command terminated with exit code 28</code>. That's the same result — 28 <em>is</em> curl's timeout code — but the <code>-S</code> restores the readable message. The fact that it times out (rather than "connection refused") is the signature of a policy <em>drop</em>: the packets are silently blackholed, not actively rejected. A refusal would return instantly with a different error.</p>
<h3 id="heading-step-7-check-the-workload-entries-in-spire">Step 7: Check the Workload Entries in SPIRE</h3>
<p>Cilium's SPIRE controller manager automatically created SPIFFE identities for the Cilium security identities in this cluster. You can see them:</p>
<pre><code class="language-bash">kubectl exec -n cilium-spire spire-server-0 -c spire-server -- \
  /opt/spire/bin/spire-server entry show \
  -selector cilium:mutual-auth
</code></pre>
<p>Each entry maps a Cilium security identity to a SPIFFE ID. The Cilium operator manages this registry automatically, so you never need to register workloads manually when using Cilium's built-in integration.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>IP addresses are location, not identity. And in Kubernetes, location changes with every deployment, so any policy built on address matching silently degrades over time.</p>
<p>Cryptographic workload identity fixes that at the foundation. SPIFFE defines the model (a SPIFFE ID names a workload within a trust domain, an X.509 SVID materialises it into a certificate any TLS library can verify), and SPIRE implements it: the server is the CA and registry, while per-node agents attest via Kubernetes PSAT and issue short-lived, auto-rotating SVIDs.</p>
<p>Cilium wires that identity layer into the network. Add <code>authentication.mode: required</code> to a CiliumNetworkPolicy and its eBPF agents fetch both workloads' SVIDs, run the mutual TLS handshake, and enforce the verdict. There's no sidecar, no application changes, and near-zero overhead versus a service mesh. And you deployed the whole stack in a single Helm command: the complexity lives in the infrastructure, not in your code.</p>
<h2 id="heading-cleanup-kind">Cleanup (kind)</h2>
<pre><code class="language-bash"># Delete demo workloads
kubectl delete deployment echo-server echo-client -n default
kubectl delete service echo-server -n default
kubectl delete pod unauthorized -n default
kubectl delete ciliumnetworkpolicy echo-server-mtls -n default

# Uninstall Cilium (helm doesn't delete the cilium-spire namespace it created)
helm uninstall cilium -n kube-system
kubectl delete namespace cilium-spire

# Delete the cluster (easiest reset on kind)
kind delete cluster --name k8s-mtls
</code></pre>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Durable, Autoscaling AI Agent with Temporal, Composio, KEDA, and Kubernetes ]]>
                </title>
                <description>
                    <![CDATA[ Most AI agents are great at quick tasks. Send a message, the agent calls a few tools, and you get a response back in seconds. That works perfectly when you're asking it to summarize a document or do s ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-durable-autoscaling-ai-agent-with-temporal-composio-keda-and-kubernetes/</link>
                <guid isPermaLink="false">6a3ab180022a80fcba0df6e8</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ keda ]]>
                    </category>
                
                    <category>
                        <![CDATA[ temporal ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ai agents ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shrijal Acharya ]]>
                </dc:creator>
                <pubDate>Tue, 23 Jun 2026 16:17:04 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/93e5088e-4cac-48c3-911d-982ef96dfe13.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most AI agents are great at quick tasks. Send a message, the agent calls a few tools, and you get a response back in seconds. That works perfectly when you're asking it to summarize a document or do some internet research.</p>
<p>But what happens when the task actually takes time? Something like "go through the last three days of my emails, draft replies for anything urgent, and create Linear tickets for the engineering-related ones." That's not a quick job. It might take minutes, hours, or even longer. And this is just one example.</p>
<p>That's a full workflow, and the moment your server crashes or your process restarts, you lose everything. No retry, and no resume. You're starting from scratch.</p>
<p>That's the problem this article is about.</p>
<p>In this article, you'll build a durable background agent runtime that holds up under real conditions. Dispatch a task, walk away, and it gets done.</p>
<p>Under the hood, it runs on Kubernetes with KEDA autoscaling so workers scale to zero when idle and spin back up the moment work arrives. For crash recovery and durable execution we'll use Temporal, and for agentic capabilities and tool usage we'll use Composio.</p>
<h2 id="heading-whats-covered">What's Covered?</h2>
<p>In this tutorial, you'll build a durable background agent runtime that runs on Kubernetes and scales based on actual workload. Here's what you'll learn along the way:</p>
<ul>
<li><p>What an agent loop is and how to build one with Claude and Composio</p>
</li>
<li><p>How to make long-running agent tasks handle crashes using Temporal</p>
</li>
<li><p>How to build a gateway that decouples task dispatch from execution</p>
</li>
<li><p>How to containerize the worker and gateway with Docker</p>
</li>
<li><p>How to deploy the full system to a local Kubernetes cluster</p>
</li>
<li><p>How to autoscale workers to zero with KEDA based on queue depth</p>
</li>
</ul>
<p>This gets into some advanced concepts, but follow along and you'll learn a lot along the way.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-whats-the-plan-the-architecture">What's the Plan (the Architecture)</a></p>
<ul>
<li><p><a href="#heading-dispatching-a-task">Dispatching a Task:</a></p>
</li>
<li><p><a href="#heading-running-the-task">Running the Task</a></p>
</li>
<li><p><a href="#heading-scaling">Scaling</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-set-up-the-project">How to Set Up the Project</a></p>
</li>
<li><p><a href="#heading-core-components-in-the-application">Core Components in the Application</a></p>
<ul>
<li><p><a href="#heading-the-agent-loop">The Agent Loop</a></p>
</li>
<li><p><a href="#heading-making-it-durable-with-temporal">Making it Durable with Temporal</a></p>
</li>
<li><p><a href="#heading-the-agent-gateway">The Agent Gateway</a></p>
</li>
<li><p><a href="#heading-containerizing-the-application">Containerizing the Application</a></p>
</li>
<li><p><a href="#heading-deploying-to-kubernetes">Deploying to Kubernetes</a></p>
</li>
<li><p><a href="#heading-autoscaling-with-keda">Autoscaling with KEDA</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-agent-in-action">Agent in Action</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-whats-the-plan-the-architecture">What's the Plan (the Architecture)</h2>
<p>Before diving into the code, it helps to understand how everything fits together.</p>
<p>The system is split into two distinct planes: a control plane that handles user-facing interactions (Next.js frontend), and an execution plane where the actual agent work happens. They never directly call each other, and that separation is intentional.</p>
<img src="https://cdn.hashnode.com/uploads/covers/641fd8b0be4ca15b2ad2a590/d7fd737e-6c38-4e61-abd1-26246bec2169.png" alt="Agent Architecture" style="display: block;" width="1461" height="950" loading="lazy">

<p>Here's the flow from start to finish:</p>
<h3 id="heading-dispatching-a-task">Dispatching a Task</h3>
<p>When a user submits a goal, the gateway first runs a pre-flight check to verify the required Composio tool connections are active for that user. If they are, it hands the task off to Temporal and returns immediately. The user doesn't wait around.</p>
<p><strong>NOTE:</strong> You don't wait for the response to be back from the agent. It just happens all in the background. This isn't your regular chat app. You just launch the task and you forget.</p>
<h3 id="heading-running-the-task">Running the Task</h3>
<p>Temporal puts the task on a queue and a worker pod picks it up. The worker runs the agent loop, LLM reasons over the goal, Composio executes the tools, and the result gets written back to Temporal. The frontend automatically polls the gateway for status updates so the user can see progress without doing anything.</p>
<h3 id="heading-scaling">Scaling</h3>
<p>KEDA watches the Temporal queue depth and scales worker pods based on how much work is pending. When the queue is empty, workers scale down to zero. When tasks come in, they load back up. That's the beauty!</p>
<p>The reason the gateway never touches agent code is straightforward: agent tasks can take minutes, or even hours based on the work, and you don't want that in your API layer. Keeping them separate helps the control plane stay fast regardless of what's happening in the background.</p>
<p>Also, the application supports Linux CronJob-style task scheduling with no human involved. So, having a pre-flight check helps there, because failing fast at dispatch is much better than having a task silently fail because a tool connection was missing.</p>
<p>That's pretty much the high level architecture of our application. To put it simply:</p>
<ul>
<li><p><strong>Kubernetes (k8s):</strong> Orchestration Layer</p>
</li>
<li><p><strong>KEDA:</strong> Auto-scaling Layer</p>
</li>
<li><p><strong>Temporal:</strong> Durability Layer</p>
</li>
<li><p><strong>Composio:</strong> Tool Layer</p>
</li>
<li><p><strong>Any LLM of your choice (in our case, Anthropic)</strong> = Reasoning layer</p>
</li>
</ul>
<h2 id="heading-how-to-set-up-the-project">How to Set Up the Project</h2>
<p>Before you start, make sure you have the following installed:</p>
<ul>
<li><p>Docker</p>
</li>
<li><p>k3d (for running a local Kubernetes cluster)</p>
</li>
<li><p>kubectl</p>
</li>
<li><p>Helm</p>
</li>
<li><p>Node.js and Python 3.11+</p>
</li>
</ul>
<p>You'll also need API keys for <a href="https://www.anthropic.com/">Anthropic</a> and <a href="https://dashboard.composio.dev">Composio</a>.</p>
<p>Start by cloning the repository:</p>
<pre><code class="language-shell">git clone https://github.com/shricodev/kron-k8s-agent.git
cd kron-k8s-agent
</code></pre>
<p>Next, create the cluster, build the images, and load them in:</p>
<pre><code class="language-shell"># Create the local cluster
k3d cluster create agent --wait

# Build both images and import them into the cluster
bash scripts/build-images.sh
bash scripts/load-images.sh

# Deploy Temporal (creates the temporal namespace, Postgres, and server)
kubectl apply -f infra/k8s/temporal/temporal-dev.yaml
</code></pre>
<p>Next, create the namespace and your secret. The secret has to exist before the app gets deployed, since the pods read their keys from it:</p>
<pre><code class="language-shell"># Create the agent namespace
kubectl apply -f infra/k8s/00-namespace.yaml

# Create the secret with your keys (you're supposed to remove the placeholders with the actual values...)
kubectl create secret generic agent-secrets -n agent \
 --from-literal=ANTHROPIC_API_KEY=sk-ant-... \
 --from-literal=COMPOSIO_API_KEY=ak_... \
 --from-literal=JWT_SECRET=$(openssl rand -hex 32)
</code></pre>
<p>With that in place, deploy the app and set up autoscaling:</p>
<pre><code class="language-shell"># Install KEDA, then apply the scalers
helm repo add kedacore https://kedacore.github.io/charts
helm repo update
helm install keda kedacore/keda -n keda --create-namespace --wait

kubectl apply -f infra/k8s/40-keda-worker-scaledobject.yaml -f infra/k8s/41-gateway-hpa.yaml
</code></pre>
<p>Finally, port-forward the gateway so you can reach it from your machine:</p>
<pre><code class="language-shell"># Port-forward the gateway to localhost:8000
kubectl -n agent port-forward svc/gateway 8000:8000
</code></pre>
<p>Point the frontend at <code>http://localhost:8000</code> and you're ready to start tasks.</p>
<p><strong>Note:</strong> You don't need to touch the <code>.env</code> files in <code>apps/worker/</code> or <code>apps/gateway/</code> for this. Those are only for running the apps directly on your machine.</p>
<p>In the cluster, the pods get their config from the ConfigMap and the secret you just created gets injected as environment variables at runtime.</p>
<h2 id="heading-core-components-in-the-application">Core Components in the Application</h2>
<p>The project is huge. Walking through every single line from scratch would turn this into an hours-long read, so instead I'll focus on the core components that actually make the system work.</p>
<h3 id="heading-the-agent-loop">The Agent Loop</h3>
<p>The agent loop is the brain of the entire system. Every time a task gets dispatched, this is what runs.</p>
<p>The idea is simple even if the implementation isn't. Give the LLM a goal, let it reason, let it call tools, feed the results back, and repeat until it's done.</p>
<pre><code class="language-python">async def run_agent(
user_id: str,
goal: str,
toolkit_hint: str | None = None,
) -&gt; dict:
</code></pre>
<p>It takes three things: the user's ID (so Composio knows which connected accounts to use), the goal itself, and an optional toolkit hint. The hint lets you scope which tools get loaded. If the task is clearly a Gmail job, passing "gmail" avoids loading every tool the user has connected.</p>
<p>Before the loop starts, it creates a Composio session and fetches the tools for that user:</p>
<pre><code class="language-python">session = await create_session(user_id, toolkit_hint);
tools = await get_tools(session);
</code></pre>
<p>Then the actual loop runs:</p>
<pre><code class="language-python">for turn in range(1, settings.max_iterations + 1):
    response = await client.messages.create(
        model=settings.model,
        max_tokens=settings.max_tokens,
        system=SYSTEM_PROMPT,
        tools=tools,
        messages=messages,
    )

if response.stop_reason == "end_turn":
return finish("completed", _extract_text(response.content))

if response.stop_reason == "tool_use":
      # execute the tools, append results, continue
      # ...
</code></pre>
<p>Each turn, Claude looks at the goal and the conversation history, then decides what to do next. The <code>stop_reason</code> tells what happened:</p>
<ul>
<li><p><code>"end_turn"</code>: Claude is done. It completed the task and is returning a final answer.</p>
</li>
<li><p><code>"tool_use"</code>: it wants to call one or more tools. The loop executes those through Composio, appends the results back into the message history, and goes around again.</p>
</li>
</ul>
<p>If a tool call fails, the error gets fed back into the conversation rather than crashing the run:</p>
<pre><code class="language-python">except ComposioError as exc:
tool_result_blocks = [
    {
        "type": "tool_result",
        "tool_use_id": block.id,
        "content": f"Tool execution failed: {exc}",
        "is_error": True,
    }
        for block in tool_use_blocks
]
</code></pre>
<p>The loop runs for at most <code>max_iterations</code> turns which is 20 by default, defined in <code>apps/worker/agent/config.py</code>. If it hits that ceiling without finishing, it returns a <code>max_iterations_reached</code> status instead of hanging indefinitely.</p>
<p>Every <code>run_agent</code> call returns the same dict shape: a status, a summary, and a list of every step taken. That consistent shape is what makes it straightforward for Temporal to store and inspect the result, which we'll get to next.</p>
<h3 id="heading-making-it-durable-with-temporal">Making it Durable with Temporal</h3>
<p>The agent loop by itself has a real problem. If the worker process crashes partway through a 15-step task, everything is gone. You have no way to know how far it got, and you have to start from scratch.</p>
<h4 id="heading-workflows-and-activities">Workflows and Activities</h4>
<p>Temporal splits your code into two distinct pieces: workflows and activities.</p>
<p>A workflow describes what should happen and in what order, but it never does the actual work itself. No network calls, nothing. That constraint is exactly what lets Temporal safely replay it to reconstruct state after a crash.</p>
<p>An activity is where the real work happens. Network calls, LLM requests, tool executions – all of that goes inside an activity. Activities can fail and be retried independently without affecting the workflow state.</p>
<p>In this project, <code>AgentWorkflow</code> in <code>apps/worker/workflows.py</code> is the workflow, and <code>run_agent_activity</code> in <code>apps/worker/activities.py</code> is the activity that wraps the agent loop.</p>
<p><strong>The Workflow</strong></p>
<pre><code class="language-python">@workflow.defn(name="AgentWorkflow")
class AgentWorkflow:
    def init(self) -&gt; None:
        self._status: str = "running"
        self._result: dict | None = None
</code></pre>
<p>When a task gets dispatched, Temporal starts this workflow. It sets up a retry policy and hands all the real work off to the activity:</p>
<pre><code class="language-python">retry = RetryPolicy(
  (initial_interval = timedelta((seconds = 2))),
  (backoff_coefficient = 2.0),
  (maximum_interval = timedelta((minutes = 2))),
  (maximum_attempts = 5),
  (non_retryable_error_types = ["ValueError", "AuthenticationError"]),
);

result = await workflow.execute_activity(
  run_agent_activity,
  (args = [user_id, goal, toolkit_hint]),
  (start_to_close_timeout = timedelta((minutes = 30))),
  (retry_policy = retry),
);
</code></pre>
<p>The <code>start_to_close_timeout</code> is set to 30 minutes and caps at 5 attempts, because agent tasks can genuinely take that long. You can increase or decrease the timer based on your work requirement.</p>
<p><strong>Querying the Workflow</strong></p>
<p>One thing that makes Temporal convenient here is query handlers. The workflow exposes its current status and result without needing a separate database to track it:</p>
<pre><code class="language-python">@workflow.query
def status(self) -&gt; str:
return self._status

@workflow.query
def result(self) -&gt; dict | None:
return self._result
</code></pre>
<p>The gateway can ask Temporal "what is the status of workflow X?" at any point and get a live answer back. That's how the frontend polling works.</p>
<p><strong>The Activity</strong></p>
<p>The activity is straightforward. It wraps <code>run_agent</code> and logs what happens:</p>
<pre><code class="language-python">@activity.defn(name="run_agent_activity")
async def run_agent_activity(user_id: str, goal: str, toolkit_hint: str | None) -&gt; dict:
    result = await run_agent(user_id=user_id, goal=goal,             toolkit_hint=toolkit_hint)
    return result
</code></pre>
<p>Anything that touches the network lives here, not in the workflow. That separation is what lets Temporal do its job.</p>
<p><strong>The Worker</strong></p>
<p>The worker process is what registers everything and starts polling the queue:</p>
<pre><code class="language-python">worker = Worker(
            client,
            task_queue=temporal_settings.temporal_task_queue,
            workflows=[AgentWorkflow],
            activities=[run_agent_activity, notify_activity],
            max_concurrent_activities=5,
)
</code></pre>
<p>It connects to Temporal, registers the workflow and activities, and listens on the task queue. When a task arrives, it picks it up and runs it. This is the process running inside the Kubernetes pod, and it's exactly what KEDA will scale based on queue depth later.</p>
<h3 id="heading-the-agent-gateway">The Agent Gateway</h3>
<p>The gateway is a FastAPI app that sits between the user and Temporal. It handles task dispatch, status polling, and cancellation. Crucially, it never runs agent code itself. Its only job is to talk to Temporal and return quickly.</p>
<h4 id="heading-dispatching-a-task">Dispatching a Task</h4>
<p>The dispatch endpoint in <code>apps/gateway/routes/tasks.py</code> is where everything begins:</p>
<pre><code class="language-python">  @router.post("/dispatch", response_model=DispatchResponse)
  async def dispatch(
      body: DispatchRequest,
      user_id: str = Depends(current_user_id),
  ) -&gt; DispatchResponse:
      if body.toolkit:
          access = await check_toolkit_access(user_id, body.toolkit)
          if not access["allowed"]:
              raise HTTPException(
                  status_code=status.HTTP_409_CONFLICT,
                  detail={
                      "error": "toolkit_not_connected",
                      "connect_url": access["connect_url"],
                  },
              )

      workflow_id = f"agent-{user_id}-{uuid.uuid4().hex[:8]}"
      await client.start_workflow(
          WORKFLOW_NAME,
          args=[user_id, body.goal, body.toolkit],
          id=workflow_id,
          task_queue=settings.temporal_task_queue,
          cron_schedule=body.schedule or "",
      )
      return DispatchResponse(workflow_id=workflow_id, status="dispatched")
</code></pre>
<p>The request carries three fields: the goal, an optional toolkit name (to not spend time figuring out toolkit names), and an optional cron schedule. The endpoint runs a preflight check, hands the task off to Temporal, and returns the workflow ID immediately. The user doesn't wait for the agent to finish.</p>
<p>Notice the <code>cron_schedule</code> field. Passing a standard cron expression here turns the task into a recurring job. Temporal handles the scheduling itself, no extra infra needed.</p>
<h4 id="heading-the-preflight-check">The Preflight Check</h4>
<p>The preflight check lives in <code>apps/gateway/routes/preflight.py</code>. Before a task gets dispatched, it verifies that the user actually has the required toolkit connected in Composio:</p>
<pre><code class="language-python">  async def check_toolkit_access(user_id: str, toolkit_hint: str | None) -&gt; dict:
      if not toolkit_hint:
          return {"allowed": True}

      connected = await asyncio.to_thread(
          _has_active_account, composio, user_id, toolkit_hint
      )
      if connected:
          return {"allowed": True}

      connect_url = await asyncio.to_thread(
          _connect_link, composio, user_id, toolkit_hint
      )
      return {"allowed": False, "toolkit": toolkit_hint, "connect_url": connect_url}
</code></pre>
<p>If the connection is missing, the gateway returns a <code>connect_url</code> so the user can authorize the app right away. This matters especially for scheduled tasks.</p>
<h4 id="heading-checking-status">Checking Status</h4>
<p>Once a task is running, the frontend polls this endpoint:</p>
<pre><code class="language-python">  @router.get("/{workflow_id}", response_model=TaskStatusResponse)
  async def get_task(workflow_id: str, ...) -&gt; TaskStatusResponse:
      if not _owns(workflow_id, user_id):
          raise HTTPException(status_code=404, detail="task not found")

      handle = client.get_workflow_handle(workflow_id, run_id=run_id)
      agent_status = await handle.query("status")

      if desc.status == WorkflowExecutionStatus.COMPLETED:
          result = await handle.query("result")

      return TaskStatusResponse(...)
</code></pre>
<p>The <code>status</code> and <code>result</code> come straight from Temporal's query handlers that you saw in the workflow. There's no separate status table, and no database write after each step. Temporal is the <strong>source of truth</strong>.</p>
<h3 id="heading-containerizing-the-application">Containerizing the Application</h3>
<p>The gateway and the worker are packaged as two separate images. They share nothing at runtime, which is exactly what you want since they scale independently and have different responsibilities.</p>
<p>Both Dockerfiles live in the <code>/docker</code> directory, and use a multi-stage build.</p>
<h4 id="heading-why-multi-stage">Why Multi-Stage? 🤔</h4>
<p>The builder stage installs compilers and build tools to compile Python packages. The runtime stage gets only the finished dependencies and the application code. There's no point in putting the build tools into the final image.</p>
<h4 id="heading-the-gateway-image">The Gateway Image</h4>
<pre><code class="language-dockerfile">FROM python:3.14-slim-bookworm AS builder

RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"

COPY requirements.txt .
RUN pip install -r requirements.txt

FROM python:3.14-slim-bookworm AS runtime

ENV PYTHONUNBUFFERED=1 PATH="/opt/venv/bin:$PATH"

RUN useradd --create-home --uid 10001 app
WORKDIR /app

COPY --from=builder /opt/venv /opt/venv
COPY . .

USER app
EXPOSE 8000
CMD ["python", "main.py"]
</code></pre>
<p>The runtime stage copies the <code>venv</code> from the builder, drops into a non-root user, and starts the FastAPI app. And as always, we run it as a non-root user (be a good dev and follow proper security practices 😺).</p>
<h4 id="heading-the-worker-image">The Worker Image</h4>
<p>The worker Dockerfile is nearly identical with one small difference:</p>
<pre><code class="language-dockerfile"># procps gives us pgrep for the liveness probe
RUN apt-get update \
 &amp;&amp; apt-get install -y --no-install-recommends procps \
 &amp;&amp; rm -rf /var/lib/apt/lists/*
</code></pre>
<p>It installs procps so the Kubernetes liveness probe can run pgrep to check that the process is still alive.</p>
<h4 id="heading-building-and-loading-the-images">Building and Loading the Images</h4>
<p>The build script in <code>scripts/build-images.sh</code> builds both images, passing each app directory as the build context:</p>
<pre><code class="language-shell">docker build \
 -f "$ROOT/docker/Dockerfile.gateway" \
    -t "agent-gateway:$TAG" \
 "$ROOT/apps/gateway"

docker build \
 -f "$ROOT/docker/Dockerfile.worker" \
    -t "agent-worker:$TAG" \
 "$ROOT/apps/worker"
</code></pre>
<p>The Dockerfiles live under <code>docker/</code> but each one is built against its own app directory. That's what <code>COPY . .</code> actually copies.</p>
<p>After building, there's one more step before the images can run in the cluster. A local k3d cluster has no access to your Docker daemon, so images built locally aren't accessible to it. You have to import them explicitly:</p>
<pre><code class="language-shell">k3d image import "agent-gateway:dev" "agent-worker:dev" -c agent
</code></pre>
<p><code>scripts/load-images.sh</code> does this for you. Once the import completes, the cluster can pull the images like it usually does and your pods will start. 🎊</p>
<h3 id="heading-deploying-to-kubernetes">Deploying to Kubernetes</h3>
<p>With the images built and loaded into the cluster, the next step is applying the manifests. The setup is organized into two tiers. Tier 1 is the core application: the <code>namespace</code>, <code>config</code>, and <code>deployments</code>. Tier 2 is autoscaling, covered in the next section.</p>
<h4 id="heading-config-and-secrets">Config and Secrets</h4>
<p>Non-sensitive config lives in a <code>ConfigMap</code> at <code>infra/k8s/01-configmap.yaml</code>:</p>
<pre><code class="language-yaml">data:
  MODEL: "claude-opus-4-8"
  MAX_TOKENS: "4096"
  MAX_ITERATIONS: "20"
  TEMPORAL_HOST: "temporal-frontend.temporal.svc.cluster.local:7233"
  TEMPORAL_TASK_QUEUE: "agent-tasks"
  GATEWAY_HOST: "0.0.0.0"
  GATEWAY_PORT: "8000"
</code></pre>
<p>This is where the Temporal host address comes from. Notice that it uses the full in-cluster DNS name pointing at the Temporal frontend service in the temporal namespace. That address only resolves from inside the cluster, which is fine since both the gateway and the worker run there.</p>
<p>API keys go in a Kubernetes Secret that you create manually and never commit in Git. Both the <code>ConfigMap</code> and the <code>Secret</code> are mounted as environment variables using <code>envFrom</code> in each deployment.</p>
<h4 id="heading-the-gateway-deployment">The Gateway Deployment</h4>
<pre><code class="language-yaml">spec:
  replicas: 2
  containers:
    - name: gateway
      image: agent-gateway:dev
      imagePullPolicy: IfNotPresent
      command:
        [
          "python",
          "-m",
          "uvicorn",
          "main:/app",
          "--host",
          "0.0.0.0",
          "--port",
          "8000",
        ]
      readinessProbe:
        httpGet:
          path: /health
          port: 8000
      resources:
        requests:
          cpu: 100m
          memory: 256Mi
        limits:
          cpu: 500m
          memory: 512Mi
</code></pre>
<p>A few things worth noting. <code>imagePullPolicy: IfNotPresent</code> tells Kubernetes to use the locally loaded image instead of trying to pull from a registry. The startup command bypasses the reload=True flag that main.py uses when run directly locally. The readiness probe hits <code>/health</code> before Kubernetes sends any traffic to the pod, so the gateway only receives requests once it's actually up.</p>
<p>The gateway also gets a <code>ClusterIP</code> Service so other pods and the port-forward can reach it:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Service
metadata:
  name: gateway
spec:
  type: ClusterIP
  ports:
    - port: 8000
      targetPort: 8000
</code></pre>
<h4 id="heading-the-worker-deployment">The Worker Deployment</h4>
<pre><code class="language-yaml"># just polls Temporal.
spec:
  replicas: 1
  containers:
    - name: worker
      image: agent-worker:dev
      livenessProbe:
        exec:
          command: ["pgrep", "-f", "worker.py"]
        initialDelaySeconds: 15
        periodSeconds: 20
      resources:
        requests:
          cpu: 250m
          memory: 512Mi
        limits:
          cpu: "1"
          memory: 1Gi
</code></pre>
<p>The worker has no Service. It never accepts incoming connections. It connects outward to Temporal and polls for work, so nothing needs to reach it. That's why <strong>procps was installed in the Dockerfile</strong>.</p>
<p>The worker also gets more resources than the gateway. It's the one running LLM calls and executing tools, so it needs more resources. You can cap it depending on your requirements.</p>
<h4 id="heading-applying-everything">Applying Everything</h4>
<p>The deploy script at <code>scripts/deploy.sh</code> applies Tier 1 in the correct order:</p>
<pre><code class="language-bash">kubectl apply -f "$K8S/00-namespace.yaml"
kubectl apply -f "$K8S/01-configmap.yaml"
kubectl apply -f "$K8S/10-gateway-deployment.yaml"
kubectl apply -f "$K8S/20-worker-deployment.yaml"
</code></pre>
<p>Order matters here. The namespace has to exist before anything else can be created inside it, and the <code>ConfigMap</code> has to exist before the pods that read from it start up.</p>
<h3 id="heading-autoscaling-with-keda">Autoscaling with KEDA</h3>
<p>Kubernetes scales pods based on CPU or memory. That works fine for the gateway, which handles HTTP requests and actually uses CPU proportional to traffic. But it's the not the right signal for workers.</p>
<p>The worker sits completely idle when no tasks are queued. It doesn't burn CPU waiting. When tasks arrive, it gets busy fast. What you actually want to scale on is queue depth: how many tasks are waiting to be picked up.</p>
<p>That's what KEDA does. It reads external metrics like queue lengths, message counts, or in this case Temporal task queue depth, and scales your deployments accordingly.</p>
<h4 id="heading-scaling-the-worker">Scaling the Worker</h4>
<p>The <code>ScaledObject</code> in <code>infra/k8s/40-keda-worker-scaledobject.yaml</code> is what KEDA watches:</p>
<pre><code class="language-yaml">spec:
  scaleTargetRef:
    name: worker
  minReplicaCount: 0
  maxReplicaCount: 10
  cooldownPeriod: 120
  triggers:
    - type: temporal
      metadata:
        endpoint: temporal-frontend.temporal.svc.cluster.local:7233
        namespace: default
        taskQueue: agent-tasks
        queueTypes: "workflow,activity"
        targetQueueSize: "5"
        activationTargetQueueSize: "0"
</code></pre>
<p>Let's walk through the important fields:</p>
<ul>
<li><p><code>minReplicaCount</code>: 0 is the big one. KEDA can scale to zero, which a standard HPA can't do. When the queue is empty, every worker pod shuts down. You pay for nothing while the system is idle.</p>
</li>
<li><p><code>activationTargetQueueSize</code>: "0" means KEDA wakes the deployment the moment a single task enters the queue. Zero tasks, zero pods. One task, pods start spinning up.</p>
</li>
<li><p><code>targetQueueSize</code>: "5" tells KEDA to target roughly one worker pod per 5 pending tasks. Ten tasks in the queue means two pods.</p>
</li>
<li><p><code>cooldownPeriod</code>: 120 adds a 120-second buffer before KEDA scales back down after the queue clears.</p>
</li>
<li><p><code>queueTypes</code>: "workflow,activity" watches both queues. Without this, KEDA would only see part of the pending work.</p>
</li>
</ul>
<p><strong>Note</strong>: The Temporal scaler requires KEDA v2.17 or later. Make sure your Helm install is on that version or above.</p>
<h4 id="heading-scaling-the-gateway">Scaling the Gateway</h4>
<p>The gateway gets a plain CPU-based HPA at <code>infra/k8s/41-gateway-hpa.yaml</code>:</p>
<pre><code class="language-yaml">spec:
  minReplicas: 2
  maxReplicas: 6
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 60
</code></pre>
<p>CPU is the right signal here because the gateway does real work proportional to incoming HTTP requests. It stays at a minimum of 2 replicas so there's no cold start delay on the API side.</p>
<h4 id="heading-installing-keda">Installing KEDA</h4>
<p>KEDA is installed via Helm before applying the <code>ScaledObject</code>:</p>
<pre><code class="language-shell">helm install keda kedacore/keda -n keda --create-namespace --wait
kubectl apply -f infra/k8s/40-keda-worker-scaledobject.yaml -f infra/k8s/41-gateway-hpa.yaml
</code></pre>
<p>Once those are applied, the system is fully operational. Submit a task and watch a worker pod appear. Let the queue empty and watch it disappear. That's the whole point.</p>
<p>And just like that, you have a fully durable, autoscaling AI Agent that you can schedule to run anytime. How cool is that? 😎</p>
<h2 id="heading-agent-in-action">Agent in Action</h2>
<p>Here's a quick demo of the agent in action (running inside a Kubernetes Cluster):</p>
<div class="embed-wrapper"><iframe width="560" height="315" src="https://www.youtube.com/embed/aZy_scANmU4" style="aspect-ratio: 16 / 9; width: 100%; height: auto;" title="YouTube video player" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen="" loading="lazy"></iframe></div>

<h2 id="heading-conclusion">Conclusion</h2>
<p>Running AI agents in production is a completely different problem than building them. I tried to focus on that gap here, and hopefully it gave you a solid reference for how to think about durability and scaling. And I hope it also helped you build or understand something different from a regular AI chat application.</p>
<p>The Temporal and KEDA combination is really something you should learn and know more about if you're into building AI agents or doing DevOps in general. Temporal helps with the biggest issue with AI agents (the durability), and KEDA makes sure that you aren't paying for idle workers at 2am (if used in prod) if nothing is running. You aren't just scaling on CPU, but based on events and that is important.</p>
<p>There's a lot of room to extend this from here. You could swap the dev JWT for proper OIDC, or expand the toolkit coverage through Composio to support more of your workflows.</p>
<p>The foundation is there. The rest is just building on top of it.</p>
<p>You can find the complete source code here: <a href="https://github.com/shricodev/kron-k8s-agent">shricodev/kron-k8s-agent</a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Deploy a Spring Boot App with MySQL on Amazon EKS ]]>
                </title>
                <description>
                    <![CDATA[ If you've been looking to deploy your Spring Boot app to the cloud but feel a little overwhelmed by all the moving pieces, don't worry, you're not alone. Kubernetes can seem intimidating at first, but ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-deploy-a-spring-boot-app-with-mysql-on-amazon-eks/</link>
                <guid isPermaLink="false">6a20609578a43e3153ae5422</guid>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ EKS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Springboot ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chisom Uma ]]>
                </dc:creator>
                <pubDate>Wed, 03 Jun 2026 17:12:53 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/5a7cd6a7-7850-4e3c-9a45-b577c2f91598.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've been looking to deploy your Spring Boot app to the cloud but feel a little overwhelmed by all the moving pieces, don't worry, you're not alone.</p>
<p>Kubernetes can seem intimidating at first, but Amazon EKS (Elastic Kubernetes Service) makes it much more approachable, especially when you have a step-by-step guide to follow.</p>
<p>In this tutorial, we'll walk through exactly how to get a Spring Boot application with a MySQL database up and running on Amazon EKS. I'll take you from from containerizing your app to connecting it to a managed database, all the way to accessing it live in the cloud. Let’s get started.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-application-overview">Application Overview</a></p>
</li>
<li><p><a href="#heading-what-is-amazon-eks">What is Amazon EKS?</a></p>
</li>
<li><p><a href="#heading-how-to-deploy-a-spring-boot-app-with-mysql-on-amazon-eks">How to Deploy a Spring Boot App with MySQL on Amazon EKS</a></p>
<ul>
<li><p><a href="#heading-step-1-create-the-vpc">Step 1: Create the VPC</a></p>
</li>
<li><p><a href="#heading-step-2-set-up-the-mysql-database-in-a-private-subnet">Step 2: Set Up the MySQL Database in a Private Subnet</a></p>
</li>
<li><p><a href="#heading-step-3-deploy-ec2-instance-in-a-public-subnet">Step 3: Deploy EC2 Instance in a Public Subnet</a></p>
</li>
<li><p><a href="#heading-step-4-create-ssh-tunneling-for-the-database">Step 4: Create SSH Tunneling for the Database</a></p>
</li>
<li><p><a href="#heading-step-5-set-up-a-simple-springboot-application-development">Step 5: Set Up a Simple SpringBoot Application Development</a></p>
</li>
<li><p><a href="#heading-step-6-configure-springboot-app-for-database">Step 6: Configure SpringBoot App for Database</a></p>
</li>
<li><p><a href="#heading-step-7-dockerize-the-spring-boot-application">Step 7: Dockerize the Spring Boot Application</a></p>
</li>
<li><p><a href="#heading-step-8-push-the-image-to-elastic-container-registry-ecr">Step 8: Push the Image to Elastic Container Registry (ECR)</a></p>
</li>
<li><p><a href="#heading-step-9-implement-aws-app-load-balancer">Step 9: Implement AWS App Load Balancer</a></p>
</li>
<li><p><a href="#heading-step-10-create-a-cluster-in-eks">Step 10: Create a Cluster in EKS</a></p>
</li>
<li><p><a href="#heading-step-11-install-aws-load-balancing">Step 11: Install AWS Load Balancing</a></p>
</li>
<li><p><a href="#heading-step-12-create-and-deploy-kubernetes">Step 12: Create and Deploy Kubernetes</a></p>
</li>
<li><p><a href="#heading-step-13-delete-cluster">Step 13: Delete Cluster</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you begin, ensure you have the following:</p>
<ul>
<li><p>Basic knowledge of AWS (AWS Console access).</p>
</li>
<li><p>Basic knowledge of containerization.</p>
</li>
<li><p>Working knowledge of Kubernetes.</p>
</li>
<li><p>Basic knowledge of databases.</p>
</li>
<li><p><a href="https://helm.sh/docs/intro/install/">Helm</a> installed</p>
</li>
<li><p><a href="https://kubernetes.io/docs/tasks/tools/">Kubectl</a> installed</p>
</li>
<li><p><a href="https://docs.aws.amazon.com/emr/latest/EMR-on-EKS-DevelopmentGuide/setting-up-eksctl.html">Eksctl</a> installed</p>
</li>
<li><p>An IDE</p>
</li>
</ul>
<h2 id="heading-application-overview">Application Overview</h2>
<p>The application runs inside an AWS VPC spread across two availability zones for high availability. When a user makes a request, it flows through an Internet Gateway into an AWS Application Load Balancer sitting in the public subnet, which handles incoming traffic via an Ingress rule.</p>
<p>The Load Balancer routes requests to the App Service, which distributes them across multiple App Pods running inside AWS EKS (Elastic Kubernetes Service) in the private subnets.</p>
<p>The Docker images for these pods are pulled from AWS ECR (Elastic Container Registry). For data persistence, the app pods connect to Amazon RDS MySQL databases through a MySQL External Service, with an RDS instance in each availability zone to ensure redundancy.</p>
<p>A NAT Gateway in the public subnet allows the private resources to make outbound internet calls without being directly exposed to the internet.</p>
<h2 id="heading-what-is-amazon-eks">What is Amazon EKS?</h2>
<p>If you've ever tried to manage containers manually, you already know it can get messy pretty quickly, tracking which containers are running, restarting ones that crash, scaling up when traffic spikes... It's a lot.</p>
<p>That's exactly the problem Kubernetes was built to solve. It automates the deployment, scaling, and management of containerized applications. But setting up and maintaining your own Kubernetes cluster from scratch? That's a whole other challenge.</p>
<p>That's where <a href="https://aws.amazon.com/pm/eks/">Amazon EKS</a> comes in. EKS is a fully managed Kubernetes service provided by AWS, which means AWS handles the heavy lifting of setting up, securing, and maintaining the Kubernetes control plane for you. You just focus on deploying your application.</p>
<h2 id="heading-how-to-deploy-a-spring-boot-app-with-mysql-on-amazon-eks">How to Deploy a Spring Boot App with MySQL on Amazon EKS</h2>
<p>In this section, we’ll cover the steps to follow in deploying your SpringBoot application with MySQL on Amazon EKS.</p>
<h3 id="heading-step-1-create-the-vpc">Step 1: Create the VPC</h3>
<p>To create a VPC, log in to the <a href="https://signin.aws.amazon.com/signin?redirect_uri=https%3A%2F%2Fus-east-1.console.aws.amazon.com%2Fiam%3Fca-oauth-flow-id%3Df7d2%26hashArgs%3D%2523%26isauthcode%3Dtrue%26oauthStart%3D1777888354778%26region%3Dus-east-1%26state%3DhashArgsFromTB_us-east-1_0481039a94bc47bd&amp;client_id=arn%3Aaws%3Asignin%3A%3A%3Aconsole%2Fiamv2&amp;forceMobileApp=0&amp;code_challenge=USO5m22DxkRMX1kvbC19ZE-zr5Eyzp52MXY5jnbANB8&amp;code_challenge_method=SHA-256">AWS IAM Console</a> and search for “VPC,” then click create VPC.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/9a1f57fd-7665-469f-a0c2-7d548590c20f.png" alt="vpc interface" style="display: block;" width="714" height="192" loading="lazy">

<p>Select the "VPC and more option:, and give your VPC a name for your project, for example, spring-demo. Set the IPv4 CIDR block to 10.4.0.0/16. For the NAT gateway configuration, select Zonal, then In 1 AZ.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/960002c0-9d53-481d-90be-79a7092088ce.png" alt="NAT gateway config" style="display: block;" width="465" height="228" loading="lazy">

<p>Select None for VPC endpoints configuration. Next, click Create VPC, then click View VPC. This takes you to the VPC resource map.</p>
<h3 id="heading-step-2-set-up-the-mysql-database-in-a-private-subnet">Step 2: Set Up the MySQL Database in a Private Subnet</h3>
<p>First, you need to create the security group for the MySQL and EC2 instance deployment. To do that, navigate to EC2 &gt; Security Groups. For the inbound rule, select Type: All traffic and Source: Anywhere-IPv4. Then click Create security group.</p>
<p>Next, we’ll create the subnet group for the database. To do that, navigate to Aurora and RDS &gt; Subnet groups and click Create DB subnet group. Next, configure the DB subnet to include:</p>
<ul>
<li><p><strong>Name</strong>: private-subnet-db</p>
</li>
<li><p><strong>Description</strong>: private-subnet-db</p>
</li>
<li><p><strong>VPC</strong>: Select VPC</p>
</li>
<li><p><strong>Add subnets</strong>: Choose <code>us-east-1a</code> and <code>us-east-1b</code> as the availability zones, then select the private and public subnets</p>
</li>
</ul>
<p>Click Create**.**</p>
<p>Now, navigate to Databases, click Create database, and select Full configuration. Select MySQL as the engine type.</p>
<p>Select the Free tier when choosing a sample template. Next, give your DB a username and a strong password. Choose <code>db.t3.micro</code> as the instance type.</p>
<p>Select your VPC and associated private subnet. Now, uncheck the "Enable auto minor version upgrade" option in the Additional configuration section and click Create database.</p>
<p>While our database initializes, let's create a key pair for the EC2 instance, which will be launched in a public subnet. To do that, navigate to EC2 &gt; Network &amp; Security &gt; Key Pairs and click Create key pair.</p>
<p>Give your key pair a name, for example, ece-db-key-pair. Leave everything else as-is and click Create key pair. This automatically downloads the key-pair into your local machine.</p>
<h3 id="heading-step-3-deploy-ec2-instance-in-a-public-subnet">Step 3: Deploy EC2 Instance in a Public Subnet</h3>
<p>Now it’s time to create an EC2 instance. To do this, navigate to EC2 &gt; Instances and click Launch instances. Select the key pair you just created in the Key pair section.</p>
<p>Next, in the Network section, select the VPC created earlier for the project. For Auto-assign public IP, choose Enable. Next, choose the Select existing security group option and select the all-access-sg security group created earlier. Next, click Launch instance.</p>
<h3 id="heading-step-4-create-ssh-tunneling-for-the-database">Step 4: Create SSH Tunneling for the Database</h3>
<p>For this step, go into your terminal and navigate to the folder where your key pair is downloaded. Run the ls command, and you should see your key pair there.</p>
<p>Next, you need to change the permission of the key pair file. Use the command below:</p>
<pre><code class="language-shell">chmod 0400 ece-db-key-pair.pem&nbsp;
</code></pre>
<p>Now, run the SSH tunneling command below:</p>
<pre><code class="language-shell">ssh -i &lt;YOUR-KEY-PAIR&gt;.pem -f -N -L &lt;LOCAL-PORT&gt;:&lt;YOUR-RDS-ENDPOINT&gt;:&lt;RDS-PORT&gt; &lt;EC2-USERNAME&gt;@&lt;YOUR-EC2-PUBLIC-DNS&gt; -v
</code></pre>
<ul>
<li><p><code>&lt;YOUR-KEY-PAIR&gt;.pem</code>: the name of your downloaded key pair file</p>
</li>
<li><p><code>&lt;LOCAL-PORT&gt;</code>:&nbsp; the port on your laptop (3306 for MySQL, 5432 for PostgreSQL)</p>
</li>
<li><p><code>&lt;YOUR-RDS-ENDPOINT&gt;</code>: found in AWS Console &gt; RDS &gt; Your database &gt; Connectivity &amp; Security &gt; Endpoint</p>
</li>
<li><p><code>&lt;RDS-PORT&gt;</code>: same as local port (3306 for MySQL, 5432 for PostgreSQL)</p>
</li>
<li><p><code>&lt;EC2-USERNAME&gt;</code>: usually ec2-user for Amazon Linux, ubuntu for Ubuntu</p>
</li>
<li><p><code>&lt;YOUR-EC2-PUBLIC-DNS&gt;</code>: found in AWS Console &gt; EC2 &gt; Your instance &gt; Public IPv4 DNS</p>
</li>
</ul>
<p>This command lets your laptop or local machine talk directly to your remote database, as if the database were sitting on your own computer.</p>
<p>After running this command, you can open a database tool (like MySQL Workbench, DBeaver, or TablePlus) on your laptop and connect to:</p>
<ul>
<li><p>Host: localhost</p>
</li>
<li><p>Port: 3306</p>
</li>
</ul>
<p>For this tutorial, I’ll be using the community version of DBeaver. You can use other similar tools, but if you prefer to use the same tool for the purpose of this guide, you can install the community version from the official <a href="https://dbeaver.io/download/">DBeaver download page</a>.</p>
<p>After download and installation, open the DBeaver client and click the Connect to a database icon in the top-left corner of the app.</p>
<p>Select MySQL and click Next. On the next window, enter your database username and password, and set Server Host to 127.0.0.1.</p>
<p>Click Test Connection.</p>
<p>You should see a window appear on your screen, indicating that the connection is successful.</p>
<p>Click OK and Finish.</p>
<p>Now, on the left panel, you should see your connection. Expand it to see the database structure.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/5c8115eb-020a-4b8d-9c84-9a1b4c10a071.png" alt="database structure" style="display: block;" width="730" height="212" loading="lazy">

<p>Now, you have successfully created SSH tunneling for your database.</p>
<h4 id="heading-troubleshooting">Troubleshooting</h4>
<p>While attempting to test the database connection, I initially ran into a “Plugin 'mysql_native_password' is not loaded” error. If you encounter this error, follow the steps below to fix it.</p>
<ol>
<li><p>On the Connection Settings window, navigate to the Driver properties tab.</p>
</li>
<li><p>Look for allowPublicKeyRetrieval and set it to FALSE.</p>
</li>
<li><p>Navigate back to the Main tab and click Test Connection.</p>
</li>
</ol>
<p>Everything should work fine now.</p>
<h3 id="heading-step-5-set-up-a-simple-springboot-application-development">Step 5: Set Up a Simple SpringBoot Application Development</h3>
<p>To get started, head over to the <a href="https://start.spring.io/">Spring Initializr website</a>. Rename Artifact to “springboot-mysql-eks”. Then click ADD DEPENDENCIES… to add dependencies for the REST APIs. Search for the following dependencies:</p>
<ul>
<li><p><strong>Spring Web:</strong> Build web apps, including RESTful applications using Spring MVC. Uses Apache Tomcat as the default embedded container.</p>
</li>
<li><p><strong>Spring Data JPA:</strong> Persist data in SQL stores with the Java Persistence API using Spring Data and Hibernate.</p>
</li>
<li><p><strong>IBM DB2 Driver:</strong> A JDBC driver that provides access to IBM DB2.</p>
</li>
<li><p><strong>Lombok:</strong> A Java annotation library that helps to reduce boilerplate code.</p>
</li>
</ul>
<p>Next, click GENERATE at the bottom center of the page. This action downloads a zip file to your local machine. Open this file in an IDE, such as VSCode or IntelliJ IDEA. For this tutorial, I use VSCode. In the build.gradle file, you can see all the added dependencies:</p>
<pre><code class="language-json">dependencies {
   implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
   implementation 'org.springframework.boot:spring-boot-starter-webmvc'
   compileOnly 'org.projectlombok:lombok'
   runtimeOnly 'com.ibm.db2:jcc'
   annotationProcessor 'org.projectlombok:lombok'
   testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa-test'
   testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
   testCompileOnly 'org.projectlombok:lombok'
   testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
   testAnnotationProcessor 'org.projectlombok:lombok'
}
</code></pre>
<h4 id="heading-what-were-building">What we're building</h4>
<p>The Spring Boot app is a currency exchange rate and conversion app:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/eef403da-eb1d-47e8-8edd-d0e4d845a3d1.png" alt="image of counter " style="display: block;" width="750" height="434" loading="lazy">

<p>We'll be inserting the exchange data into the database table.</p>
<p>To continue with this tutorial, you can clone the project repo <a href="https://github.com/ChisomUma/sprint-boot-msql-eks">here</a> to save time.</p>
<p>In main &gt; java &gt; com.. &gt; model &gt; ExchangeRate, you’ll see the code below:</p>
<pre><code class="language-java">package com.example.springbootmysqleks.model;

import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;

import java.sql.Date;

@Getter
@Setter
@Entity
@Table(name = "exchange-rate")
public class ExchangeRate {
   @Id
   @GeneratedValue(strategy=GenerationType.AUTO)
   private Integer transactionId;
   private String sourceCurrency;
   private String targetCurrency;
   private double amount;
   private Date lastUpdated;
}
</code></pre>
<p>This class is essentially a blueprint for storing currency exchange rate data in our database. It uses the libraries and dependencies added earlier. Lombok handles all the repetitive getter/setter boilerplate so you don't have to write it yourself, while JPA annotations like <code>@Entity</code> and <code>@Table</code> tell Spring, "hey, this class maps to a database table called exchange-rate."</p>
<p>Inside the class, there are five fields that become database columns:</p>
<ul>
<li><p>A self-incrementing transactionId as the primary key.</p>
</li>
<li><p>sourceCurrency and targetCurrency to track which currencies are being converted,</p>
</li>
<li><p>The amount holding the actual exchange rate</p>
</li>
<li><p>lastUpdated date, so you always know how fresh your data is.</p>
</li>
</ul>
<p>To store the data, create a repository file in main &gt; java &gt; com.. &gt; repository &gt; ExchangeRateRepository:</p>
<pre><code class="language-java">package com.example.springbootmysqleks.repository;

import com.example.springbootmysqleks.model.ExchangeRate;
import org.springframework.data.jpa.repository.JpaRepository;

public interface ExchangeRateRepository extends JpaRepository&lt;ExchangeRate, Integer&gt; {
   ExchangeRate findBySourceCurrencyAndTargetCurrency(String sourceCurrency, String targetCurrency);
}
</code></pre>
<p>This file acts as the middleman between your code and the database. By simply extending JpaRepository, you instantly get a whole suite of built-in database operations (like save, delete, findAll, and so on) completely for free, without writing a single SQL query.</p>
<p>The interface is typed to work with the <code>ExchangeRate</code> model we just looked at, using Integer as the primary key type.</p>
<p>The one custom method, <code>findBySourceCurrencyAndTargetCurrency</code>, is where Spring's magic really shines. Just by following a naming convention, Spring automatically figures out the SQL query it needs to run, so you can look up an exchange rate by simply passing in two currency codes like "USD" and "EUR" without writing any query logic yourself.</p>
<p>To use the <code>findBySourceCurrencyAndTargetCurrency</code> method, create a service file in main &gt; java &gt; com.. &gt; service &gt; ExchangeRateService with the code below:</p>
<pre><code class="language-java">package com.example.springbootmysqleks.service;

import com.example.springbootmysqleks.model.ExchangeRate;
import com.example.springbootmysqleks.repository.ExchangeRateRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

@Service
public class ExchangeRateService {

   @Autowired
   private ExchangeRateRepository exchangeRateRepository;

   public ExchangeRate addExchangeRate(ExchangeRate exchangeRate) {
       return exchangeRateRepository.save(exchangeRate);
   }

   public double getAmount(String sourceCurrency, String targetCurrency) {
       ExchangeRate exchangeRate =  exchangeRateRepository.findBySourceCurrencyAndTargetCurrency(sourceCurrency, targetCurrency);
       return exchangeRate == null ? 0 : exchangeRate.getAmount();
   }
}
</code></pre>
<p>Here, we created a <code>@Service</code> class that interacts with the repository.</p>
<p>The class has two methods, the <code>addExchangeRate</code>, which simply takes an <code>ExchangeRate</code> object and saves it to the database, and <code>getAmount</code>, which takes a source and target currency, uses our custom repository method to look up the matching record, and then either returns the exchange rate amount or a safe default of 0 if no record is found.</p>
<p>That little ternary check (<code>exchangeRate == null ? 0 : exchangeRate.getAmount()</code>) ensures the app doesn't crash if you query a currency pair that doesn't exist in the database yet.</p>
<p>In main &gt; java &gt; com.. &gt; controller &gt; ExchangeRateService, we have the following code:</p>
<pre><code class="language-java">package com.example.springbootmysqleks.controller;

import com.example.springbootmysqleks.model.ExchangeRate;
import com.example.springbootmysqleks.service.ExchangeRateService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

@RestController
public class ExchangeRateController {

   @Autowired
   ExchangeRateService exchangeRateService;

   @GetMapping("/getAmount")
   public double getAmount(@RequestParam String sourceCurrency, @RequestParam String targetCurrency) {
       return exchangeRateService.getAmount(sourceCurrency, targetCurrency);
   }

   @PostMapping("/addExchangeRate")
   public ExchangeRate addExchangeRate(@RequestBody ExchangeRate exchangeRate) {
       return exchangeRateService.addExchangeRate(exchangeRate);
   }

   @GetMapping("/")
   public String getHealth() {
       return "up";
   }

}
</code></pre>
<p>The <code>@RestController</code> annotation tells Spring this class will be serving up REST API endpoints, and again <code>@Autowired</code> wires in the service layer automatically.</p>
<p>There are three endpoints:</p>
<ol>
<li><p>a GET request to <code>/getAmount</code> that accepts <code>sourceCurrency</code> and <code>targetCurrency</code> as query parameters and returns the exchange rate amount</p>
</li>
<li><p>a POST request to <code>/addExchangeRate</code> that accepts a full <code>ExchangeRate</code> object as a JSON body and saves it to the database</p>
</li>
<li><p>and finally a simple health check endpoint at / that just returns "up",&nbsp; which is a common pattern in cloud deployments to let load balancers and orchestration tools know the app is alive and running.</p>
</li>
</ol>
<h3 id="heading-step-6-configure-springboot-app-for-database">Step 6: Configure SpringBoot App for Database</h3>
<p>Now, it’s time to configure the application for the database. Navigate to src &gt; main &gt; resources &gt; application.properties, and you should see this:</p>
<pre><code class="language-java">spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://\({MYSQL_HOSTNAME}:\){MYSQL_PORT}/${MYSQL_DATABASE}?createDatabaseIfNotExist=true
spring.datasource.username=${MYSQL_USERNAME}
spring.datasource.password=${MYSQL_PASSWORD}

spring.jpa.hibernate.ddl-auto=update

spring.jpa.show-sql: true
</code></pre>
<p>These are the configurations that allow your app to connect with the database.</p>
<ul>
<li><p><code>spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver</code>: The driver class for the MySQL database.</p>
</li>
<li><p><code>spring.datasource.url=jdbc:mysql://\({MYSQL_HOSTNAME}:\){MYSQL_PORT}/${MYSQL_DATABASE}?createDatabaseIfNotExist=true</code>: This is the data source URL in which we are using the MySQL hostname (127.0.0.1), port name, and database name.</p>
</li>
<li><p><code>spring.datasource.username=${MYSQL_USERNAME}</code>: your database user name.</p>
</li>
<li><p><code>spring.datasource.password=${MYSQL_PASSWORD}</code>: your database password.</p>
</li>
</ul>
<p>One thing to note: the process of configuring environment variables with your actual credentials varies depending on the IDE you're using. If you're using IntelliJ IDEA, this process is pretty straightforward. If you're using VS Code, the process is different.</p>
<p>To configure your actual credentials for the <code>env</code> variables, create a <code>.vscode/launch.json</code> file in your project root folder and paste in the following configuration:</p>
<pre><code class="language-json">{
 "version": "0.2.0",
 "configurations": [
   {
     "type": "java",
     "name": "Spring Boot App",
     "request": "launch",
     "mainClass": "com.example.springbootmysqleks.SpringbootMysqlEksApplication",
     "projectName": "springboot-mysql-eks",
     "env": {
       "MYSQL_HOSTNAME": "localhost",
       "MYSQL_PORT": "3306",
       "MYSQL_DATABASE": "exchangedb",
       "MYSQL_USERNAME": "root",
       "MYSQL_PASSWORD": "CHANGE_ME"
     }
   }
 ]
}
</code></pre>
<p>Configure the credentials to use your actual credentials.</p>
<p>Now, when you run the app, you should be able to see the created <code>exchangedb</code> table in DBeaver:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/9ea1b85c-25a8-4587-a013-4d592d1664eb.png" alt="exchnage db image" style="display: block;" width="724" height="224" loading="lazy">

<p>Use an API testing tool like Postman to send a POST request to the database:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/5fa9e950-17ff-4fd6-a650-b6a33b607744.png" alt="postman request image" style="display: block;" width="446" height="97" loading="lazy">

<p>Next, run the <code>select * from exchange_rate er</code> script in the <code>exchangedb</code> SQL script editor:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/a7a2ffea-f930-4f27-b29a-f5aaf8f8543e.png" alt="sql editor image" style="display: block;" width="2048" height="1051" loading="lazy">

<p>At the bottom of the editor, you should see the created table from the Postman request.</p>
<p>Now, run a GET request to the endpoint below:</p>
<pre><code class="language-json">http://localhost:8080/getAmount?sourceCurrency=USD&amp;targetCurrency=EUR&amp;transactionId=1
</code></pre>
<p>You should get a 200 OK response with the currency exchange value, for example, 0.93.</p>
<h3 id="heading-step-7-dockerize-the-springboot-application">Step 7: Dockerize the SpringBoot Application</h3>
<p>To Dockerize your application, create a file named Dockerfile and paste in the configuration below:</p>
<pre><code class="language-dockerfile">FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
COPY build/libs/springboot-mysql-eks.jar /app
EXPOSE 8080
CMD ["java", "-jar", "springboot-mysql-eks.jar"]
</code></pre>
<p>Our Dockerfile starts by pulling the lightweight <code>eclipse-temurin:17-jre-jammy</code> base image to keep things lean, then sets /app as the working directory inside the container. It copies our compiled Spring Boot JAR file from the local build/libs/ folder into that directory, exposes port 8080 for incoming traffic, and finally runs the app with <code>java -jar</code> when the container starts up.</p>
<p>Next, build the app to create the <code>.jar</code> file. To do that, run the command below:</p>
<pre><code class="language-shell">./gradlew clean assemble 
</code></pre>
<p>You should get a successful build output as shown below:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/bde4395b-bc30-46e4-9687-bd03836f574d.png" alt="bde4395b-bc30-46e4-9687-bd03836f574d" style="display: block;" width="471" height="93" loading="lazy">

<p>Navigate to build &gt; the libs folder. You’ll see the <code>springboot-mysql-eks</code> file created.</p>
<p>If you run into an “operation couldn’t be completed.” error, try running the export commands to fix this issue. If you’re using a Mac, then run the command below:</p>
<pre><code class="language-shell">brew install openjdk@21
</code></pre>
<p>Next, run the export commands:</p>
<pre><code class="language-shell">export JAVA_HOME=/opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home

export PATH=\(JAVA_HOME/bin:\)PATH
</code></pre>
<p>Then run the <code>./gradlew clean assemble</code> command again.</p>
<h3 id="heading-step-8-push-the-image-to-elastic-container-registry-ecr">Step 8: Push the Image to Elastic Container Registry (ECR)</h3>
<p>In this next step, we’ll create an Amazon ECR and push our image to the registry.</p>
<p>To get started, head back into your AWS Console and search for “ECR”. On the ECR page, click Create**.** Then, enter a repository name, for example, “springboot-mysql-eks.” Next, click Create.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/21c56eef-c656-49b6-a624-b1da66cf1096.png" alt="ECR image" style="display: block;" width="1176" height="248" loading="lazy">

<p>Next, select the repo and click View push commands at the top of the page. This presents a window with a bunch of commands you can use to push your image to the registry. Open your terminal and run these commands. You'll need to ensure Docker is running on your local machine before running the commands.</p>
<p>After running the commands, you should see that your image has been successfully pushed to the registry.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/b39d0b9c-ea99-4cf3-bfb0-3496ac79dea9.png" alt="ECR image" style="display: block;" width="1118" height="196" loading="lazy">

<h3 id="heading-step-9-implement-aws-app-load-balancer">Step 9: Implement AWS App Load Balancer</h3>
<p>Before getting started with this step, make sure you check out the installation steps and link to additional AWS documentation in the project README. This will help you follow along.</p>
<p>Now, to get started, create a new folder in your root directory named <code>cluster</code> . This is where you'll download the AWS IAM policy for the load balancer. To download the policy, go into your terminal and <code>cd</code> into <code>cluster</code>, then run the command below:</p>
<pre><code class="language-shell">curl -O https://raw.githubusercontent.com/kubernetes-sigs/aws-load-balancer-controller/v2.14.1/docs/install/iam_policy.json
</code></pre>
<p>This command is gotten from the <a href="https://docs.aws.amazon.com/eks/latest/userguide/lbc-helm.html">AWS documentation</a>. Now, when you go to the folder, you’ll see an iam_policy.json file automatically generated.</p>
<p>Next, apply the IAM policy using the command below:</p>
<pre><code class="language-shell">aws iam create-policy \
    --policy-name AWSLoadBalancerControllerIAMPolicy \
    --policy-document file://iam_policy.json
</code></pre>
<p>You should get an output like this in your terminal:</p>
<img alt="terminal image" style="display: block;" width="600" height="400" loading="lazy">

<p>This shows that the IAM policy has been successfully created. To confirm this, head over to the IAM section in your console, navigate to Policies**,** and search for “AWSLoad…”. You should see the policy created there.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/c022959a-c732-4dfd-bd58-4b80d3011b71.png" alt="load balancer policy image" style="display: block;" width="577" height="229" loading="lazy">

<p>The next step is creating the Kubernetes service account. But before that, you need to tag your public and private subnets as described in this <a href="https://docs.aws.amazon.com/eks/latest/userguide/alb-ingress.html">documentation</a>.</p>
<p>Now, head over to the VPC dashboard, navigate to Subnets, click into a subnet, and navigate to Tags. Then, click Manage tags.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/615f3ec2-d266-413e-8936-d0767d03316d.png" alt="tag image" style="display: block;" width="1180" height="207" loading="lazy">

<p>Click Add new tag, then enter the key/pair value in the documentation.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/253abe00-cb7e-4276-81de-79ba0ffc249a.png" alt="tag image" style="display: block;" width="1162" height="167" loading="lazy">

<h3 id="heading-step-10-create-a-cluster-in-eks">Step 10: Create a Cluster in EKS</h3>
<p>To create a Kubernetes cluster on EKS, you need the eksctl CLI. Follow the instructions in the <a href="https://docs.aws.amazon.com/eks/latest/eksctl/installation.html">AWS eksctl documentation</a> to install the CLI. Next, you need a <a href="https://docs.aws.amazon.com/eks/latest/eksctl/schema.html">config file schema</a> to create the cluster. To use this schema, create a new file called cluster.yaml in the cluster folder.</p>
<p>Next, paste in the following configurations:</p>
<pre><code class="language-dockerfile">apiVersion: eksctl.io/v1alpha5
kind: ClusterConfig

metadata:
  name: spring-test-cluster
  region: us-east-1
  version: "1.30"

vpc:
  id: "&lt;your-vpc-id&gt;"
  subnets:
    private:
      us-east-1a:
        id: "&lt;your-private-subnet-1a-id&gt;" # spring-demo-subnet-private1-us-east-1a
      us-east-1b:
        id: "&lt;your-private-subnet-1b-id&gt;" # spring-demo-subnet-private2-us-east-1b
    public:
      us-east-1a:
        id: "&lt;your-public-subnet-1a-id&gt;" # spring-demo-subnet-public1-us-east-1a
      us-east-1b:
        id: "&lt;your-public-subnet-1b-id&gt;" # spring-demo-subnet-public2-us-east-1b

nodeGroups:
  - name: ng-1
    labels: { role: backend }
    instanceType: t2.micro
    desiredCapacity: 3
    minSize: 3
    maxSize: 5
    privateNetworking: true
    ssh:
      allow: true
      publicKeyName: &lt;your-ec2-key-name&gt;
    iam:
      withAddonPolicies:
        imageBuilder: true
        awsLoadBalancerController: true
        autoScaler: true
iam:
  withOIDC: true
  serviceAccounts:
    - metadata:
        name: aws-load-balancer-controller
        namespace: kube-system
      attachPolicyARNs:
        - arn:aws:iam::&lt;YOUR_AWS_ACCOUNT_ID&gt;:policy/AWSLoadBalancerControllerIAMPolicy
</code></pre>
<p>Th <code>ClusterConfig</code> file is used by eksctl to create our EKS cluster called <code>spring-test-cluster</code> in the <code>us-east-1 region</code>, running Kubernetes version 1.30. It plugs into our existing VPC, placing the worker nodes across private subnets in two availability zones <code>us-east-1a</code> and <code>us-east-1b</code>) for high availability, while keeping public subnets available for the load balancer.</p>
<p>The node group spins up t2.micro EC2 instances with a desired count of 3 (scaling up to 5 if needed), all with private networking enabled for security. It also sets up the necessary IAM permissions for the AWS Load Balancer Controller, Auto Scaler, and ECR image access so our cluster has everything it needs to manage traffic and pull our Docker images automatically.</p>
<p>Now, after updating your configuration with your credentials, run the command below:</p>
<pre><code class="language-shell">eksctl create cluster -f cluster.yaml
</code></pre>
<p>This creates the cluster. You should see an output like this on your terminal:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/758c6b7d-9cf3-4fa6-a0d5-2276adc82147.png" alt="cluster creation image" style="display: block;" width="1466" height="514" loading="lazy">

<p>Now, in your AWS console, navigate to CloudFormation, and you’ll see your cluster creation process in progress.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/4aa96465-45c1-4e43-b8ca-d44a8802e02d.png" alt="stack creation image" style="display: block;" width="1249" height="211" loading="lazy">

<p>Now, when you go into the EC2 instance page, you should see the three nodes created.</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/d946d993-0c64-4870-90fb-1132df51544f.png" alt="running cluster image" style="display: block;" width="950" height="99" loading="lazy">

<h3 id="heading-step-11-install-aws-load-balancing">Step 11: Install AWS Load Balancing</h3>
<p>The next step is installing a load balancer for our application. To get started, run the command below:</p>
<pre><code class="language-shell"> kubectl apply -k "github.com/aws/eks-charts/stable/aws-load-balancer-controller/crds?ref=master"
</code></pre>
<p>This installs <a href="https://www.geeksforgeeks.org/devops/custom-resource-definitions-crds/">custom resource definitions (CRDs)</a> for our controller. Next, run the command below to add the Helm chart repo.</p>
<pre><code class="language-shell">helm repo add eks https://aws.github.io/eks-charts
</code></pre>
<p>Update your local repo to ensure you have the most recent charts:</p>
<pre><code class="language-shell">helm repo update eks
</code></pre>
<p>Next, install the Helm chart:</p>
<pre><code class="language-shell">helm install aws-load-balancer-controller eks/aws-load-balancer-controller \
&nbsp; -n kube-system \
&nbsp; --set clusterName=my-cluster \
&nbsp; --set serviceAccount.create=false \
&nbsp; --set serviceAccount.name=aws-load-balancer-controller \
&nbsp; --version 1.14.0
</code></pre>
<p>Next, verify that the controller is installed:</p>
<pre><code class="language-shell">kubectl get deployment -n kube-system aws-load-balancer-controller
</code></pre>
<p>You should see this on your terminal:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/61d954f3-b09d-4691-9b1c-02134c2d8bf1.png" alt="61d954f3-b09d-4691-9b1c-02134c2d8bf1" style="display: block;" width="1226" height="118" loading="lazy">

<p>This indicates that your controller is ready.</p>
<h3 id="heading-step-12-create-and-deploy-kubernetes">Step 12: Create and Deploy Kubernetes</h3>
<p>To get started, you'll first need to create a Kubernetes manifest file. For that, we’ll use <a href="https://www.freecodecamp.org/news/what-is-a-helm-chart-tutorial-for-kubernetes-beginners/">Helm Chart</a>.</p>
<pre><code class="language-shell">helm create ytchart
</code></pre>
<p>The command above creates a folder named <code>ytchart</code> with the templates for the components. In this folder, you need to make some configurations for your use case. First, navigate to ytchart &gt; templates and delete the <code>serviceaccount.yaml</code> file, since we already created the service account earlier.</p>
<p>Next, go to values.yaml and make the following changes:</p>
<ul>
<li><p>For <code>repository</code>, navigate to the ECR service page on the AWS Console and copy the image URI.</p>
</li>
<li><p>Tag is <code>latest</code>.</p>
</li>
<li><p>Set database name</p>
</li>
</ul>
<pre><code class="language-dockerfile">mysql:
 databaseName: exchangedb
</code></pre>
<ul>
<li><p>Change service account creation to <code>false</code>.</p>
</li>
<li><p>Scroll down a bit more and change the service <code>type</code> to <code>NodePort</code> and <code>port</code> to <code>8080</code>.</p>
</li>
</ul>
<p>You also need to store the database username and password using secrets. Navigate to the <code>templates</code> folder and go into the file named <code>secrets.yaml</code>. Here, set your database username and password, then comment out the liveness and readiness probe in <code>deployment.yaml</code>.</p>
<p>Next, we’ll create a service to connect to the database. To do that, navigate to the <code>mysql.yaml</code> file, then for <code>externalName</code>. Navigate to the RDS service page on the AWS console and copy the database endpoint.</p>
<p>Now, in the <code>deployment.yaml</code> file, paste in the following configuration:</p>
<pre><code class="language-dockerfile">          env:
            - name: SPRING_DATASOURCE_URL
              value: jdbc:mysql://spring-mysql:3306/{{ .Values.mysql.databaseName }}?createDatabaseIfNotExist=true&amp;characterEncoding=UTF-8&amp;useUnicode=true&amp;useSSL=false&amp;allowPublicKeyRetrieval=true
            - name: SPRING_DATASOURCE_USERNAME
              valueFrom:
                secretKeyRef:
                  name: mysql-username
                  key: username
            - name: SPRING_DATASOURCE_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: mysql-root-password
                  key: password
</code></pre>
<p>You have successfully created environment variables to secure your database credentials.</p>
<p>In the <code>ingress.yaml</code> file, paste in the following configuration:</p>
<pre><code class="language-dockerfile">apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: "spring-microservice-ingress"
  annotations:
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/load-balancer-name: spring-alb-test
  labels:
    app: spring-microservice
spec:
  ingressClassName: alb
  rules:
    - http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: {{ include "ytchart.fullname" . }}
                port:
                  number: 8080
</code></pre>
<p>This is your configuration for the ingress service.</p>
<p>Run the command below to see all your configuration values:</p>
<pre><code class="language-shell">helm template ytchart/
</code></pre>
<p>Next, run the command below to deploy the chart:</p>
<pre><code class="language-shell">helm install mychart ytchart
</code></pre>
<p>You should see an output like this on your terminal:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/4178105c-f469-4bd6-94f0-58046d12c080.png" alt="helm chart image" style="display: block;" width="970" height="398" loading="lazy">

<p>Now, when you run kubectl get all, you should see this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/62754329317fc95a74ca62a8/23e91fff-6368-42a1-adda-1af45616e9ef.png" alt="deployment image" style="display: block;" width="576" height="127" loading="lazy">

<p>Now, navigate to EC2 &gt; Load balancers, copy the DNS name, and enter it into a browser. You should see the “up” text. This indicates that your application is working properly.</p>
<p>Now, when you call the API using the DNS URL as such:</p>
<pre><code class="language-shell">http://spring-alb-test-260424558.us-east-1.elb.amazonaws.com/addExchangeRate
</code></pre>
<p>You should get a 200 OK response. Congratulations, you have successfully deployed a SpringBoot app in Kubernetes!</p>
<h3 id="heading-step-13-delete-cluster">Step 13: Delete Cluster</h3>
<p>If you’re familiar with AWS and the cloud, you should already be aware of how costly it can be to leave resources running for extended periods, especially when you’re not using them actively.</p>
<p>Now that we've come to the end of this tutorial, it’s time to delete the resources.</p>
<p>These are the resources to delete:</p>
<ul>
<li><p>RDS database.</p>
</li>
<li><p>Cluster using the command eksctl delete cluster -f cluster.yaml.</p>
</li>
<li><p>Navigate to VPC and delete the NAT Gateway</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Deploying a Spring Boot application with MySQL on Amazon EKS involves a lot of moving parts, but each step builds logically on the last.</p>
<p>In this tutorial, you've gone from setting up a VPC and provisioning a managed database to containerizing your app, pushing it to ECR, and finally orchestrating everything with Kubernetes and an Application Load Balancer.</p>
<p>What you get is a production-grade setup with high availability, private networking, secure credential management, and auto-scaling built in. This is the kind of infrastructure that would take significant manual effort to replicate without managed services like EKS and RDS.</p>
<p>As a next step, consider adding HTTPS support via AWS Certificate Manager, setting up horizontal pod autoscaling, or integrating a CI/CD pipeline to automate future deployments. And remember to clean up your AWS resources when you're done experimenting. Your wallet will thank you.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The LLM Gateway Pattern: Why Every Kubernetes-Based AI App Needs One ]]>
                </title>
                <description>
                    <![CDATA[ You ship your first LLM-powered feature. It works and the users love it. A second team adds another feature calling a different model, and a third integrates a completely different provider. Six month ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-llm-gateway-pattern-why-every-kubernetes-based-ai-app-needs-one/</link>
                <guid isPermaLink="false">6a20607178a43e3153ae3cc4</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Temitope Oyedele ]]>
                </dc:creator>
                <pubDate>Wed, 03 Jun 2026 17:12:17 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/35be7043-56b7-4df6-b56b-a48620be2dd8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You ship your first LLM-powered feature. It works and the users love it. A second team adds another feature calling a different model, and a third integrates a completely different provider.</p>
<p>Six months later, you have fourteen microservices, each holding their own API keys, writing their own retry logic, and failing in their own unique ways.</p>
<p>Nobody knows how much you're spending on tokens or which service is hammering the rate limit. And when OpenAI goes down, everything goes down with it.</p>
<p>That scenario plays out across engineering teams every single day, and the root cause is almost always the same: moving fast with LLMs while skipping the infrastructure thinking that holds everything together at scale.</p>
<p>Fortunately, a well-established architectural pattern solves exactly these problems. If you already run Kubernetes, you're more than halfway to implementing it. That pattern is called the LLM Gateway Pattern, and this article walks you through what it is, why it matters, and how to put it into practice.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-the-llm-gateway-pattern">What Is the LLM Gateway Pattern?</a></p>
<ul>
<li><a href="#heading-how-it-works">How It Works</a></li>
</ul>
</li>
<li><p><a href="#heading-the-problem-without-a-gateway">The Problem Without a Gateway</a></p>
</li>
<li><p><a href="#heading-deploying-an-llm-gateway-on-kubernetes">Deploying an LLM Gateway on Kubernetes</a></p>
<ul>
<li><p><a href="#heading-storing-api-keys-securely">Storing API Keys Securely</a></p>
</li>
<li><p><a href="#heading-defining-routing-rules-in-a-configmap">Defining Routing Rules in a ConfigMap</a></p>
</li>
<li><p><a href="#heading-scaling-the-gateway">Scaling the Gateway</a></p>
</li>
<li><p><a href="#heading-wiring-up-observability">Wiring Up Observability</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-features-of-an-llm-gateway">Features of an LLM Gateway</a></p>
<ul>
<li><p><a href="#heading-multi-provider-routing">Multi-Provider Routing</a></p>
</li>
<li><p><a href="#heading-semantic-caching">Semantic Caching</a></p>
</li>
<li><p><a href="#heading-rate-limiting-per-consumer">Rate Limiting Per Consumer</a></p>
</li>
<li><p><a href="#heading-fallback-and-failover">Fallback and Failover</a></p>
</li>
<li><p><a href="#heading-token-usage-tracking">Token Usage Tracking</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-what-is-the-llm-gateway-pattern">What Is the LLM Gateway Pattern?</h2>
<p>The LLM Gateway Pattern is an architectural approach where all LLM API traffic from your applications flows through a single, centralized proxy service before reaching any external provider. Think of it as the AI equivalent of an API gateway, except it's purpose-built for the unique challenges that come with language models: token budgets, streaming responses, model routing, semantic caching, and multi-provider fallback.</p>
<p>Instead of every service in your cluster talking directly to OpenAI or Anthropic, they all talk to one internal gateway. That gateway handles authentication, routing, rate limiting, logging, and failover. Your application services stay clean and focused on business logic, while the gateway takes on all the messy operational concerns of working with LLMs at scale.</p>
<p>The pattern itself is not new in concept. Engineers have used API gateways for years to manage REST traffic. What makes LLM gateways distinct is that they understand the specific shape of LLM requests, including token counts, model parameters, prompt structure, and streaming semantics.</p>
<h3 id="heading-how-it-works">How It Works</h3>
<p>The core components of an LLM Gateway on Kubernetes are straightforward. Here is the high-level flow:</p>
<img src="https://cdn.hashnode.com/uploads/covers/627d043a4903bec29b5871be/2aaa42ed-d6b4-4a9e-9d4c-2faa42e76783.png" alt="Diagram showing how LLM Gateway works on Kubernetes" style="display: block;" width="1162" height="718" loading="lazy">

<p><strong>App Pods</strong> send requests to the gateway using a standard OpenAI-compatible API format. Because of this, most existing LLM client libraries work without modification — you just change the base URL to point at your internal gateway service.</p>
<p><strong>The Gateway Service</strong> receives each incoming request, authenticates the caller, applies any configured rate limits, checks the cache, selects the appropriate upstream provider based on routing rules, and forwards the request. On the way back, it logs token usage and latency before returning the response to the caller.</p>
<p><strong>ConfigMap</strong> holds the routing rules. Which model should handle requests tagged as fast? Which provider should the system fall back to if the primary one is unavailable? All of this lives in configuration, not code, so you can update routing behaviour without redeploying anything.</p>
<p><strong>Secrets</strong> hold the actual API keys for each provider. The gateway is the only service in the cluster that needs access to them. Application pods never touch provider credentials directly.</p>
<p><strong>Provider endpoints</strong> are the actual LLM APIs: OpenAI, Anthropic, a self-hosted vLLM instance running in your cluster, or any other provider that exposes an OpenAI-compatible interface.</p>
<h2 id="heading-the-problem-without-a-gateway">The Problem Without a Gateway</h2>
<p>To appreciate why this pattern matters, it helps to look at what happens when you skip it.</p>
<h3 id="heading-1-scattered-secrets-and-no-central-control">1. Scattered Secrets and No Central Control</h3>
<p>Every service that calls an LLM needs an API key. In Kubernetes, this usually means creating a <a href="https://kubernetes.io/docs/concepts/configuration/secret/">Secret</a> per namespace or per deployment.</p>
<p>When that key rotates or gets compromised, you're hunting through dozens of manifests to update it. There's no single place to revoke access or audit who is calling what.</p>
<h3 id="heading-2-no-visibility-into-cost-or-usage">2. No Visibility into Cost or Usage</h3>
<p>LLM APIs charge per token. Without a centralized layer collecting usage data, you have no reliable way to know which service is responsible for that spike in your monthly bill.</p>
<h3 id="heading-3-provider-lock-in-at-the-application-level">3. Provider Lock-in at the Application Level</h3>
<p>When you hardcode <a href="https://api.openai.com">https://api.openai.com</a> into your service, switching to a different provider or routing certain requests to a cheaper model becomes a code change. You need to redeploy your application just to change which model handles a request type.</p>
<h3 id="heading-4-no-caching">4. No Caching</h3>
<p>Many LLM applications send semantically similar or identical prompts repeatedly. Without a shared caching layer, each one incurs full token costs and full latency. The savings from even basic caching can be significant.</p>
<p>All of these problems compound as your team grows and more services start calling LLMs. The gateway pattern cuts through all of them in one architectural decision.</p>
<h2 id="heading-deploying-an-llm-gateway-on-kubernetes">Deploying an LLM Gateway on Kubernetes</h2>
<p>There are several tools that can serve as an LLM gateway in a Kubernetes environment, including <a href="https://docs.litellm.ai/docs/simple_proxy">LiteLLM Proxy</a>, <a href="https://portkey.ai/">Portkey</a>, <a href="https://openrouter.ai/">OpenRouter</a>, and Envoy with custom filters.</p>
<p>For the rest of this walkthrough, we'll use LiteLLM Proxy. It ships with a Helm chart, supports over a hundred models across all major providers, and comes with a management UI that makes initial configuration straightforward.</p>
<h3 id="heading-storing-api-keys-securely">Storing API Keys Securely</h3>
<p>Start by creating a Kubernetes Secret that holds your provider API keys. Your gateway pods will consume these credentials as environment variables, which means no provider key ever needs to live inside your application containers:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Secret
metadata:
  name: llm-gateway-secrets
  namespace: ai-platform
type: Opaque
stringData:
  OPENAI_API_KEY: "sk-..."
  ANTHROPIC_API_KEY: "sk-ant-..."
</code></pre>
<h3 id="heading-defining-routing-rules-in-a-configmap">Defining Routing Rules in a <code>ConfigMap</code></h3>
<p>The routing configuration tells the gateway which models are available and how to reach each one. Keeping this in a <code>ConfigMap</code> means you can update your routing rules without touching a single line of application code:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: ConfigMap
metadata:
  name: llm-gateway-config
  namespace: ai-platform
data:
  config.yaml: |
    model_list:
      - model_name: gpt-4o
        litellm_params:
          model: openai/gpt-4o
          api_key: os.environ/OPENAI_API_KEY
      - model_name: claude-sonnet
        litellm_params:
          model: anthropic/claude-sonnet-4-20250514
          api_key: os.environ/ANTHROPIC_API_KEY
      - model_name: fast
        litellm_params:
          model: openai/gpt-4o-mini
          api_key: os.environ/OPENAI_API_KEY
</code></pre>
<p>With this configuration in place, any application in your cluster can reach the gateway at <a href="http://llm-gateway.ai-platform.svc.cluster.local">http://llm-gateway.ai-platform.svc.cluster.local</a> using the standard OpenAI client format, regardless of which actual provider sits behind it.</p>
<h3 id="heading-scaling-the-gateway">Scaling the Gateway</h3>
<p>Because the gateway is stateless, horizontal scaling is straightforward. You can attach a <code>HorizontalPodAutoscaler</code> to scale based on CPU utilization or request rate:</p>
<pre><code class="language-yaml">apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: llm-gateway-hpa
  namespace: ai-platform
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: llm-gateway
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 60
</code></pre>
<h3 id="heading-wiring-up-observability">Wiring Up Observability</h3>
<p>A gateway you can't observe is a gateway you can't trust, so wiring up monitoring before you go to production is worth the extra hour it takes.</p>
<p>LiteLLM exposes a <code>/metrics</code> endpoint in Prometheus format. You can scrape it with a standard <code>ServiceMonitor</code> if you run the Prometheus Operator, or configure Prometheus directly to target the gateway service.</p>
<p>The metrics that matter most in day-to-day operations are token throughput per model, request latency percentiles, error rates per provider, and cache hit ratio.</p>
<p>Once Prometheus is collecting that data, you can build Grafana dashboards that show token spend broken down by caller, model, and time period. This gives engineering managers and finance teams the cost visibility they've been asking for, and it takes surprisingly little effort to set up once the metrics pipeline is in place.</p>
<p>If you run an OpenTelemetry collector in your cluster, you can also configure the gateway to emit trace spans for every LLM request. This lets you see the full latency breakdown from the moment a user action triggers a call in your application all the way through to the provider response. So when something is slow, you can tell immediately whether the bottleneck sits in your service, the gateway, or upstream with the provider.</p>
<h2 id="heading-features-of-an-llm-gateway">Features of an LLM Gateway</h2>
<p>Not all gateway implementations are equal, so as your needs grow, these are the core capabilities worth evaluating.</p>
<h3 id="heading-multi-provider-routing">Multi-Provider Routing</h3>
<p>A well-built gateway routes requests to different providers based on declarative, configurable rules that live entirely outside your application code. This means that changing a model never requires a redeployment.</p>
<h3 id="heading-semantic-caching">Semantic Caching</h3>
<p>Rather than only caching byte-for-byte identical prompts, a semantic cache uses embedding similarity to recognise when two different prompts are asking essentially the same thing. This can cut redundant API calls dramatically.</p>
<h3 id="heading-rate-limiting-per-consumer">Rate Limiting Per Consumer</h3>
<p>The gateway should let you set token budgets and request limits per team, per namespace, or per application, so no single runaway service can starve the rest of your cluster or drive up costs unchecked.</p>
<h3 id="heading-fallback-and-failover">Fallback and Failover</h3>
<p>When a primary provider fails or exceeds acceptable latency thresholds, the gateway should automatically retry against a configured fallback. This centralizes logic that is notoriously hard to get right inside individual services.</p>
<h3 id="heading-token-usage-tracking">Token Usage Tracking</h3>
<p>Every request should produce a detailed usage record capturing input tokens, output tokens, model, caller identity, and latency. This gives engineering managers the clear, actionable picture of AI spending they need.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The LLM Gateway Pattern solves a set of operational problems that every team building on language models at scale will eventually run into. Scattered secrets, invisible costs, inconsistent failure handling, and provider lock-in are all symptoms of the same underlying issue: infrastructure concerns leaking into services that shouldn't have to deal with them.</p>
<p>A centralized gateway on Kubernetes gives your application teams a stable, provider-agnostic interface while giving your platform team the visibility and controls they need to manage cost and reliability effectively. When a provider goes down in the middle of the night, your configured fallback kicks in automatically instead of someone waking up to a page.</p>
<p>Start with LiteLLM Proxy, wire up the Prometheus metrics, build a simple Grafana dashboard, and watch how quickly the pattern pays for itself. Once you have seen what centralized LLM traffic management looks like in practice, it becomes very hard to go back to doing it any other way.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Hybrid Cloud Platform with Google Cloud Services and On-Premise Kubernetes Infrastructure ]]>
                </title>
                <description>
                    <![CDATA[ In this article, you'll learn how to design and build a secure, scalable hybrid cloud platform that connects your on‑premises Kubernetes infrastructure to Google Cloud Platform. This allows on‑prem ap ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-hybrid-cloud-platform-with-google-cloud-services-and-on-premise-k8s-infra/</link>
                <guid isPermaLink="false">6a18c124782587548340fa90</guid>
                
                    <category>
                        <![CDATA[ google cloud ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloud native ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CNCF ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Hybrid Cloud ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Shubham Katara ]]>
                </dc:creator>
                <pubDate>Thu, 28 May 2026 22:26:44 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/a86db163-f513-48bd-8194-18c6cb894615.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this article, you'll learn how to design and build a secure, scalable hybrid cloud platform that connects your on‑premises Kubernetes infrastructure to Google Cloud Platform. This allows on‑prem apps can consume cloud services (notably GPUs) without brittle long‑lived keys, manual credential management, or risky network patterns.</p>
<p>Who this is for:</p>
<ul>
<li><p>Platform engineers, SREs, and security-focused cloud architects who operate mixed on‑prem and cloud Kubernetes estates.</p>
</li>
<li><p>Teams that need scalable, auditable access from on‑prem workloads to GCP resources (especially GPU instances) while minimizing operational overhead and blast radius.</p>
</li>
</ul>
<p>What you’ll get from this guide:</p>
<ul>
<li><p>The motivation and economics behind a hybrid approach (why GPUs often push workloads to the cloud).</p>
</li>
<li><p>Common pitfalls with service account keys and how “accidental air gaps” occur in real environments.</p>
</li>
<li><p>A practical, end‑to‑end pattern that uses Workload Identity Federation to give on‑prem pods short‑lived, auditable access to GCP without embedding keys.</p>
</li>
</ul>
<p>What’s included:</p>
<ul>
<li><p>Conceptual explanations, security tradeoffs, and operational best practices.</p>
</li>
<li><p>Concrete examples and Kubernetes/Terraform artifacts (linked in the GitHub repo at the end of this article) so you can reproduce the setup in your environment.</p>
</li>
</ul>
<p>Read on for the theory, then follow the hands‑on sections to provision GCP resources, configure federation, enforce policies with CEL and Kyverno, and validate secure, scalable GPU access from your on‑prem Kubernetes clusters.</p>
<p><strong>Note:</strong> Kubernetes and Terraform artifacts are linked in the GitHub repo at the end of this article.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-why-hybrid-cloud-matters">Why Hybrid Cloud Matters</a></p>
</li>
<li><p><a href="#heading-the-economics-of-hybrid-gpus-changed-everything">The Economics of Hybrid: GPUs Changed Everything</a></p>
</li>
<li><p><a href="#heading-why-service-account-keys-fail-at-scale">Why Service Account Keys Fail at Scale</a></p>
</li>
<li><p><a href="#heading-how-the-accidental-air-gap-happens">How the Accidental Air Gap Happens</a></p>
</li>
<li><p><a href="#heading-how-workload-identity-federation-bridges-the-gap">How Workload Identity Federation Bridges the Gap</a></p>
</li>
<li><p><a href="#heading-how-kubernetes-identity-works">How Kubernetes Identity Works</a></p>
</li>
<li><p><a href="#heading-how-to-prepare-google-cloud-platform-resources">How to prepare Google Cloud Platform resources</a></p>
</li>
<li><p><a href="#heading-how-to-use-cel-for-fine-grained-access-control">How to Use CEL for Fine-Grained Access Control</a></p>
</li>
<li><p><a href="#heading-how-to-inject-credentials-automatically-with-kyverno">How to Inject Credentials Automatically with Kyverno</a></p>
</li>
<li><p><a href="#heading-how-to-grant-iam-permissions-to-federated-identities">How to Grant IAM Permissions to Federated Identities</a></p>
</li>
<li><p><a href="#heading-how-to-verify-the-setup">How to Verify the Setup</a></p>
</li>
<li><p><a href="#heading-how-to-connect-on-prem-apps-to-cloud-gpus">How to Connect On-Prem Apps to Cloud GPUs</a></p>
</li>
<li><p><a href="#heading-how-to-scale-gpu-access-with-cel-conditions">How to Scale GPU Access with CEL Conditions</a></p>
</li>
<li><p><a href="#heading-the-security-properties-compared">The Security Properties Compared</a></p>
</li>
<li><p><a href="#heading-the-complete-infrastructure-as-code-layout">The Complete Infrastructure as Code Layout</a></p>
</li>
<li><p><a href="#heading-how-to-run-a-proof-of-concept-with-vcluster">How to Run a Proof of Concept with vCluster</a></p>
</li>
<li><p><a href="#heading-common-issues-and-how-to-solve-them">Common Issues and How to Solve Them</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before following along, you'll need:</p>
<ul>
<li><p>A Kubernetes cluster that is <strong>not</strong> GKE (on-premises, bare-metal, or a virtual cluster)</p>
</li>
<li><p>A Google Cloud project with the following APIs enabled: IAM, Security Token Service (STS), and Workload Identity</p>
</li>
<li><p><a href="https://developer.hashicorp.com/terraform/install">Terraform</a> installed and configured</p>
</li>
<li><p><a href="https://kyverno.io/docs/installation/">Kyverno</a> installed in your cluster</p>
</li>
<li><p>Python 3 with <code>google-cloud-secret-manager</code> and <code>google-cloud-aiplatform</code> libraries (for the verification steps. Code available in the github repository.)</p>
</li>
<li><p><code>kubectl</code> access to your cluster</p>
</li>
</ul>
<h2 id="heading-why-hybrid-cloud-matters">Why Hybrid Cloud Matters</h2>
<p>If everything goes right, a hybrid cloud platform lets your on-premises and cloud workloads talk to each other as if they were part of the same network.</p>
<p>There are many practical reasons to run a hybrid cloud setup:</p>
<ul>
<li><p><strong>Offloading analytics to BigQuery:</strong> You keep your analytics apps on-prem for data sovereignty, but pipe large datasets into BigQuery for world-class processing power — without buying extra servers.</p>
</li>
<li><p><strong>Creating a unified network with Cloud Interconnect:</strong> Using Cloud Interconnect or Cloud VPN, your on-premises datacenter becomes an extension of the Google Cloud Platform (GCP) Virtual Private Cloud (VPC). Your on-prem invoice apps can talk to cloud-based user services with low latency and no public internet exposure.</p>
</li>
<li><p><strong>Cost-effective scalability via Cloud Storage:</strong> You can use cloud storage as a backend for local apps, storing logs, backups, and historical data while paying only for what you use.</p>
</li>
<li><p><strong>Event-driven syncing with Pub/Sub:</strong> When something happens on-prem, a message through Cloud Pub/Sub lets cloud services react instantly — no manual polling required.</p>
</li>
</ul>
<h2 id="heading-the-economics-of-hybrid-gpus-changed-everything">The Economics of Hybrid: GPUs Changed Everything</h2>
<p>Before diving into the technical problem, it's worth understanding why hybrid clouds matter more than ever.</p>
<p>Your organization, like most enterprises, has made significant investments in on-premises datacenters. Servers are bought. Racks are filled. Network infrastructure is paid for. The marginal cost of running one more workload is essentially zero.</p>
<p>Then came the AI wave.</p>
<p>Suddenly every team needs Graphics Processing Units (GPUs). Not one or two — dozens of A100s for training, fleets of inference endpoints, vector databases that need to sit close to the models. GPUs are scarce. Lead times for on-prem GPU hardware stretch into months. Cloud providers have them available in minutes.</p>
<p>The architecture that actually makes economic sense looks like this:</p>
<ul>
<li><p><strong>The on-prem datacenter handles the bulk of compute</strong> — web servers, business logic, databases, batch processing. This is commodity compute you've already paid for.</p>
</li>
<li><p><strong>The cloud handles what's scarce</strong> — GPU-accelerated inference, model training, AI/ML endpoints. You pay per request, scale on demand, and don't wait six months for hardware.</p>
</li>
</ul>
<p>The cloud isn't a full migration destination — it's an extension for capabilities you can't easily build on-prem.</p>
<p>But those on-prem workloads need to authenticate to cloud services. Every API call from the datacenter to a Vertex AI endpoint, every request to a GPU-powered inference service, every write to Cloud Storage for model artifacts — all of it needs credentials. That's the problem this article solves.</p>
<h2 id="heading-why-service-account-keys-fail-at-scale">Why Service Account Keys Fail at Scale</h2>
<p>Here's a scenario that plays out in thousands of enterprises daily.</p>
<p>A development team needs their on-prem application to write to Google Cloud Storage. The "obvious" solution? Generate a GCP service account key, base64 encode it, store it in a Kubernetes Secret, and mount it in the pod:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Secret
metadata:
  name: gcp-credentials
type: Opaque
data:
  key.json: eyJ0eXBlIjoic2VydmljZV9hY2NvdW50IiwicHJvamVjdF9pZCI6…
</code></pre>
<p>This works. It also introduces serious problems:</p>
<ul>
<li><p><strong>Never expires.</strong> That key is valid until someone remembers to rotate it (they won't) or it gets compromised (it will).</p>
</li>
<li><p><strong>Can be exfiltrated trivially.</strong> Anyone with read access to that namespace can run <code>kubectl get secret -o yaml</code> and walk away with permanent GCP access.</p>
</li>
<li><p><strong>Has no audit trail for the actual workload.</strong> GCP sees "service-account-xyz accessed this bucket" — not "pod frontend-abc-123 in namespace production."</p>
</li>
<li><p><strong>Scales terribly.</strong> 50 teams × 3 environments × 4 GCP projects = 600 keys to track, rotate, and hope haven't been committed to git.</p>
</li>
</ul>
<p>Security teams know this. That's why many organizations have done the only sensible thing: they have disabled service account key generation entirely.</p>
<h2 id="heading-how-the-accidental-air-gap-happens">How the Accidental Air Gap Happens</h2>
<p>When you disable key generation, you haven't solved the hybrid cloud platform problem — you've just made it someone else's problem. That someone is usually a platform team staring at a Jira ticket that says "cannot access GCP from on-prem, P1, blocking release."</p>
<p>The result? Your "hybrid cloud platform" isn't hybrid at all. It's two disconnected systems.</p>
<p>Teams resort to building intermediary services, API gateways that proxy requests, or finding creative ways to get keys anyway. None of this is a platform. It's duct tape.</p>
<h2 id="heading-how-workload-identity-federation-bridges-the-gap">How Workload Identity Federation Bridges the Gap</h2>
<p>Every Kubernetes cluster already issues cryptographically signed identity tokens to every pod. And Google Cloud has a service specifically designed to trust those tokens.</p>
<p>This is <strong>Workload Identity Federation</strong> — and combined with OpenID Connect (OIDC), it's the missing piece that makes hybrid platforms actually work.</p>
<p>The service is quite well named because of the word Federation. it means GCP doesn't store your identity — it agrees to trust identities issued by another system, as long as they can be cryptographically verified. This all works with a very well orchestrated set of steps in the following order:</p>
<ol>
<li><p>Pod presents its Kubernetes-issued JWT to GCP's STS endpoint.</p>
</li>
<li><p>STS verifies the signature against your cluster's public JWKS.</p>
</li>
<li><p>STS checks the JWT's claims against the Workload Identity Pool's rules (audience, issuer, CEL conditions).</p>
</li>
<li><p>STS returns a short-lived Google access token (typically 1 hour) that the pod uses for API calls.</p>
</li>
</ol>
<p>It is also worth mentioning that Workload Identity Federation is not Kubernetes specific. It works with AWS IAM, Azure AD, GitHub Actions OIDC, and any OIDC-compliant identity provider.</p>
<h2 id="heading-how-kubernetes-identity-works">How Kubernetes Identity Works</h2>
<p>Every pod with a ServiceAccount gets a JSON Web Token (JWT) automatically mounted at <code>/run/secrets/kubernetes.io/serviceaccount/token</code>. This isn't just an opaque blob — it's a signed assertion of identity:</p>
<pre><code class="language-json">{
  "iss": "https://kubernetes.default.svc.cluster.local",
  "sub": "system:serviceaccount:production:backend-api",
  "aud": ["https://iam.googleapis.com/..."],
  "kubernetes.io": {
    "namespace": "production",
    "serviceaccount": {
      "name": "backend-api"
    }
  },
  "exp": 1735689600
}
</code></pre>
<p>In a JWT, claims are just the key-value pairs inside the token's payload — each one is a claim the issuer is making about the subject. Think of them as facts the token is asserting, signed cryptographically so the verifier can trust them.</p>
<p>The critical insight: this token is created by a set of JSON Web Key Set (JWKS) and is verifiable by anyone who has your cluster's public keys, exposed via the JSON Web Key Set (JWKS) endpoint:</p>
<pre><code class="language-bash">kubectl get --raw /openid/v1/jwks
</code></pre>
<p>Google Cloud's Security Token Service (STS) can validate these tokens. No keys are exchanged. No secrets are stored. Just cryptographic proof of identity.</p>
<h2 id="heading-how-to-prepare-google-cloud-platform-resources">How to Prepare Google Cloud Platform resources</h2>
<p>The Workload Identity Pool is a trust boundary — a declaration that says "I accept identities from external sources." The OIDC Provider configures how to validate those identities.</p>
<pre><code class="language-hcl">resource "google_iam_workload_identity_pool" "pool" {
  workload_identity_pool_id = "hybrid-platform-pool"
  project                   = "my-project"
}

resource "google_iam_workload_identity_pool_provider" "k8s_provider" {
  project                            = "my-project"
  workload_identity_pool_id          = google_iam_workload_identity_pool.pool.workload_identity_pool_id
  workload_identity_pool_provider_id = "on-prem-cluster"

  attribute_mapping = {
    "google.subject"      = "assertion.sub"
    "attribute.namespace" = "assertion['kubernetes.io']['namespace']"
  }

  attribute_condition = "attribute.namespace in [\"production\", \"staging\"]"

  oidc {
    issuer_uri = "https://kubernetes.default.svc.cluster.local"
    jwks_json  = file("jwks.json")  # Your cluster's public keys
  }
}
</code></pre>
<p>Two things to note here:</p>
<ol>
<li><p><code>attribute_mapping</code> extracts claims from the Kubernetes JWT and makes them available as GCP attributes. By using `assertion['kubernetes.io']['namespace']`, the namespace is pulled out so you can use it for access control.</p>
</li>
<li><p><code>attribute_condition</code> is where security policy lives. More on this in the next section.</p>
</li>
</ol>
<h2 id="heading-how-to-use-cel-for-fine-grained-access-control">How to Use CEL for Fine-Grained Access Control</h2>
<p>The <code>attribute_condition</code> field uses Common Expression Language (CEL). This single line of policy can replace dozens of Identity and Access Management (IAM) bindings:</p>
<pre><code class="language-plaintext">attribute.namespace in ["production", "staging"]
</code></pre>
<p>With this condition, a pod in the <code>kube-system</code> namespace cannot authenticate to GCP at all — the token exchange is rejected before IAM is even consulted.</p>
<p>You can get more sophisticated:</p>
<pre><code class="language-plaintext">// Only production namespace, and only specific service accounts
attribute.namespace == "production" &amp;&amp;
  attribute.service_account in ["payment-processor", "order-service"]

// Allow staging, but only during business hours
attribute.namespace == "staging" &amp;&amp;
  request.time.getHours("America/New_York") &gt;= 9 &amp;&amp;
  request.time.getHours("America/New_York") &lt; 17
</code></pre>
<p>This is defense in depth. Even if someone creates a rogue ServiceAccount or has <code>kubectl</code> access, they cannot authenticate to GCP unless the CEL condition passes. The security boundary is enforced by Google's infrastructure, not by hoping developers follow policy.</p>
<h2 id="heading-how-to-inject-credentials-automatically-with-kyverno">How to Inject Credentials Automatically with Kyverno</h2>
<p>Having a working identity federation is only half the battle. Your customers and developers shouldn't need to understand OIDC, STS, or credential configuration files. They should deploy their app and have it work.</p>
<p>Before we get to the automation, it's worth pausing on what a <em>credential configuration file</em> actually is — because the name is a little misleading.</p>
<p>A credential configuration file (sometimes called an "external account config" or "ADC config") is a small JSON document that tells Google's client libraries <strong>how to obtain</strong> a credential at runtime. It is <strong>not</strong> itself a credential. You'll see the actual file later in this article — it contains no secrets. Just metadata: the Workload Identity Pool audience, the STS token-exchange endpoint, the source token type, and the path on the pod's filesystem where the real (short-lived) Kubernetes ServiceAccount token lives.</p>
<p>Compare that to a traditional service account key:</p>
<table>
<thead>
<tr>
<th></th>
<th>Service Account Key (<code>key.json</code>)</th>
<th>Credential Config (<code>credential-configuration.json</code>)</th>
</tr>
</thead>
<tbody><tr>
<td>What's inside the file</td>
<td>An RSA private key that <em>is</em> the credential</td>
<td>Instructions for exchanging an external token</td>
</tr>
<tr>
<td>Lifetime of the secret material</td>
<td>Forever, until manually rotated</td>
<td>Source token rotates automatically (~1h TTL)</td>
</tr>
<tr>
<td>If the file leaks</td>
<td>Long-lived access to a GCP service account</td>
<td>Useless on its own — points to a token only the pod can read</td>
</tr>
<tr>
<td>Identity model</td>
<td>Impersonates a GCP service account directly</td>
<td>Federates an external identity into GCP via STS</td>
</tr>
<tr>
<td>Who handles rotation</td>
<td>A human (or no one)</td>
<td>The Kubernetes API server, transparently</td>
</tr>
</tbody></table>
<p>Both files end up referenced by <code>GOOGLE_APPLICATION_CREDENTIALS</code> and look interchangeable from the application's point of view — but only one of them is dangerous to lose. The credential config file is safe to ship in a ConfigMap precisely because there's nothing to steal.</p>
<p>Having this file in the ConfigMap is half the solution. It actually needs to end up in the workload pods that need access to GCP services. This is where Kyverno comes in. A single ClusterPolicy automatically injects everything a pod needs:</p>
<pre><code class="language-yaml">apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: workload-identity-federation
spec:
  rules:
    - name: inject-gcp-credentials
      match:
        any:
          - resources:
              kinds:
                - Deployment
              selector:
                matchLabels:
                  workload-identity-federation: "enabled"
      mutate:
        patchStrategicMerge:
          spec:
            template:
              spec:
                volumes:
                  - name: workload-identity-credential-configuration
                    configMap:
                      name: workload-identity-federation-config
                containers:
                  - (name): "*"
                    volumeMounts:
                      - name: workload-identity-credential-configuration
                        mountPath: /etc/workload-identity
                        readOnly: true
                    env:
                      - name: GOOGLE_APPLICATION_CREDENTIALS
                        value: "/etc/workload-identity/credential-configuration.json"
</code></pre>
<p>The above cluster policy does three things:</p>
<ol>
<li><p>Mounts the configmap inside the containers in the deployment at <code>/etc/workload-identity</code>.</p>
</li>
<li><p>Injects an environment variable called <code>GOOGLE_APPLICATION_CREDENTIALS</code> that points to the absolute path of the credential config file.</p>
</li>
</ol>
<p>From a developer's perspective, this is their entire integration:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-app
  labels:
    workload-identity-federation: "enabled" # That's it.
spec:
  # ... normal deployment spec
</code></pre>
<p>The credential configuration file (created by Terraform as a ConfigMap) tells Google's client libraries how to exchange tokens:</p>
<pre><code class="language-json">{
  "type": "external_account",
  "audience": "//iam.googleapis.com/projects/PROJECT_NUMBER/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID",
  "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
  "token_url": "https://sts.googleapis.com/v1/token",
  "credential_source": {
    "file": "/run/secrets/kubernetes.io/serviceaccount/token"
  }
}
</code></pre>
<p>This JSON file is a credential configuration for Google's Workload Identity Federation. It instructs Google Cloud client libraries to obtain cloud access tokens by exchanging a Kubernetes ServiceAccount token (located at <code>/run/secrets/kubernetes.io/serviceaccount/token</code>) for a Google Cloud access token, using an external identity provider configured via a Workload Identity Pool. This allows workloads running outside of GCP, such as on-premises Kubernetes clusters, to authenticate to Google Cloud services without needing to manage long-lived service account keys.</p>
<p>Every Google Cloud SDK and client library understands this format. Python, Go, Java, and Node.js all just work.</p>
<h2 id="heading-how-to-grant-iam-permissions-to-federated-identities">How to Grant IAM Permissions to Federated Identities</h2>
<p>The service account token that has been trusted by the STS service, also known as a federated identity, need permissions to access resources. You bind IAM roles to the identity pool attributes:</p>
<pre><code class="language-hcl">resource "google_project_iam_member" "secret_access" {
  for_each = toset(["production", "staging"])
  project  = "my-project"
  role     = "roles/secretmanager.secretAccessor"
  member   = "principalSet://iam.googleapis.com/projects/\({PROJECT_NUMBER}/locations/global/workloadIdentityPools/\){POOL_ID}/attribute.namespace/${each.value}"
}
</code></pre>
<p>This grants Secret Manager access to all pods authenticated from the <code>production</code> or <code>staging</code> namespaces. The <code>principalSet</code> syntax allows matching on attributes. You can also restrict to specific service accounts:</p>
<pre><code class="language-plaintext">member = "principal://iam.googleapis.com/.../subject/system:serviceaccount:production:payment-processor"
</code></pre>
<h2 id="heading-how-to-verify-the-setup">How to Verify the Setup</h2>
<p>You can verify the setup with a simple Python script that lists secrets from Secret Manager. This runs inside a pod on your on-premises cluster:</p>
<pre><code class="language-python"># list_secrets.py - running on-prem, accessing GCP Secret Manager
from google.cloud import secretmanager

def list_secrets(project_id: str):
    """
    List all secrets in a GCP project.

    No credentials are passed explicitly. The google-cloud-secret-manager
    library automatically:
    1. Reads GOOGLE_APPLICATION_CREDENTIALS env var (set by Kyverno)
    2. Loads the credential configuration JSON
    3. Reads the K8s ServiceAccount token from /run/secrets/...
    4. Exchanges it for a GCP access token via STS
    5. Uses that token to call the Secret Manager API
    """
    client = secretmanager.SecretManagerServiceClient()
    parent = f"projects/{project_id}"

    print(f"Secrets in {project_id}:")
    print("-" * 40)

    for secret in client.list_secrets(request={"parent": parent}):
        secret_name = secret.name.split("/")[-1]
        print(f"  - {secret_name}")

    print("-" * 40)
    print("Authentication: Workload Identity Federation")
    print("Credentials: None stored, token exchanged at runtime")

if __name__ == "__main__":
    list_secrets("my-project-id")
</code></pre>
<p>Run this inside your labeled pod:</p>
<pre><code class="language-bash">$ kubectl exec -it my-app-xyz -- python list_secrets.py

Secrets in my-project-id:
----------------------------------------
  - database-password
  - api-key-stripe
  - oauth-client-secret
  - ml-model-api-key
----------------------------------------
Authentication: Workload Identity Federation
Credentials: None stored, token exchanged at runtime
</code></pre>
<p>No service account key. No secret mounted. Just a Kubernetes ServiceAccount token exchanged for GCP credentials at runtime.</p>
<p>This same pattern works for any GCP service — Secret Manager, Cloud Storage, BigQuery, Pub/Sub, and Vertex AI.</p>
<h2 id="heading-how-to-connect-on-prem-apps-to-cloud-gpus">How to Connect On-Prem Apps to Cloud GPUs</h2>
<p>Consider a typical flow: an on-prem order processing service needs to call a Vertex AI endpoint for fraud detection. The model runs on GPUs in Google Cloud (you can spin up A100s in minutes, not months). The application logic stays on-prem (you've already paid for that compute).</p>
<p>With the IAM bindings in place, any pod in the allowed namespaces can call Vertex AI:</p>
<pre><code class="language-python"># fraud_detector.py - running on-prem, calling cloud GPUs
from google.cloud import aiplatform

def check_fraud(transaction: dict) -&gt; float:
    """
    Call a Vertex AI endpoint for fraud detection.

    The model runs on A100 GPUs in Google Cloud.
    This code runs on-prem in the datacenter.

    Authentication is automatic:
    1. Kyverno injected GOOGLE_APPLICATION_CREDENTIALS
    2. The aiplatform SDK reads the credential config
    3. K8s SA token is exchanged for GCP token via STS
    4. Request is authenticated to Vertex AI
    """
    endpoint = aiplatform.Endpoint(
        endpoint_name="projects/my-project/locations/us-central1/endpoints/fraud-model"
    )
    prediction = endpoint.predict(instances=[transaction])
    return prediction.predictions[0]["fraud_score"]


def generate_embeddings(texts: list[str]) -&gt; list[list[float]]:
    """
    Generate text embeddings using a cloud-hosted model.

    Embedding models are GPU-intensive. Running them on-prem
    would require dedicated hardware. In the cloud, you pay per request.
    """
    from vertexai.language_models import TextEmbeddingModel

    model = TextEmbeddingModel.from_pretrained("text-embedding-004")
    embeddings = model.get_embeddings(texts)
    return [e.values for e in embeddings]
</code></pre>
<p>The developer doesn't think about authentication at all. They add the label to their deployment, and their on-prem pod can call:</p>
<ul>
<li><p><strong>Vertex AI endpoints</strong> for ML inference on cloud GPUs</p>
</li>
<li><p><strong>Cloud Storage</strong> for model artifacts and training data</p>
</li>
<li><p><strong>BigQuery</strong> for feature stores and analytics</p>
</li>
<li><p><strong>Pub/Sub</strong> for event streaming between environments</p>
</li>
<li><p><strong>Secret Manager</strong> for API keys and configuration</p>
</li>
</ul>
<p>This is the hybrid platform working as intended.</p>
<h2 id="heading-how-to-scale-gpu-access-with-cel-conditions">How to Scale GPU Access with CEL Conditions</h2>
<p>CEL conditions become especially powerful when you want to restrict GPU access to specific namespaces. For example, to allow only ML-related namespaces to access Vertex AI:</p>
<pre><code class="language-plaintext">attribute.namespace in ["ml-inference", "ml-training", "data-science"] &amp;&amp;
  attribute.service_account.startsWith("ml-")
</code></pre>
<p>You can also grant different access levels per namespace:</p>
<pre><code class="language-hcl"># ML inference namespace gets prediction access
resource "google_project_iam_member" "ml_inference" {
  project = "my-project"
  role    = "roles/aiplatform.user"
  member  = "principalSet://iam.googleapis.com/.../attribute.namespace/ml-inference"
}

# Data science namespace gets full Vertex AI access (for experimentation)
resource "google_project_iam_member" "data_science" {
  project = "my-project"
  role    = "roles/aiplatform.admin"
  member  = "principalSet://iam.googleapis.com/.../attribute.namespace/data-science"
}
</code></pre>
<p>The on-prem application teams don't need to know or care about GCP IAM. They deploy to the right namespace, add a label, and the platform handles the rest.</p>
<h2 id="heading-the-security-properties-compared">The Security Properties Compared</h2>
<p>Here's a side-by-side comparison of the two authentication approaches:</p>
<table>
<thead>
<tr>
<th>Property</th>
<th>Service Account Keys</th>
<th>Workload Identity Federation</th>
</tr>
</thead>
<tbody><tr>
<td>Credential lifetime</td>
<td>Until manually rotated (often years)</td>
<td>Short-lived (1 hour for GCP tokens)</td>
</tr>
<tr>
<td>Exfiltration risk</td>
<td>High — static key can be copied anywhere</td>
<td>Low — token expires quickly</td>
</tr>
<tr>
<td>Audit trail</td>
<td>Service account name only</td>
<td>Namespace + service account name</td>
</tr>
<tr>
<td>Key management overhead</td>
<td>600+ keys at scale</td>
<td>Zero keys to manage</td>
</tr>
<tr>
<td>Security policy enforcement</td>
<td>Manual / trust-based</td>
<td>Enforced by GCP infrastructure via CEL</td>
</tr>
<tr>
<td>Developer experience</td>
<td>Copy key, create secret, mount volume</td>
<td>Add one label to the deployment</td>
</tr>
</tbody></table>
<p>The short-lived nature of tokens deserves emphasis. Even in a worst-case scenario where a token is somehow exfiltrated, it expires. Kubernetes ServiceAccount tokens have a configurable lifetime, and the GCP access tokens issued by STS are valid for one hour. A service account key, by contrast, remains valid until someone explicitly rotates it — often years.</p>
<h2 id="heading-the-complete-infrastructure-as-code-layout">The Complete Infrastructure as Code Layout</h2>
<p>The entire solution is codified in Terraform, managing both GCP and Kubernetes resources:</p>
<pre><code class="language-plaintext">workload-identity-federation/
├── providers.tf      # Google + Kubernetes providers
├── locals.tf         # Configuration (namespaces, project ID, etc.)
├── gcp.tf            # Identity pool, provider, IAM bindings
└── kubernetes.tf     # ConfigMap with credential configuration
</code></pre>
<p>A single <code>terraform apply</code>:</p>
<ol>
<li><p>Creates the Workload Identity Pool in GCP</p>
</li>
<li><p>Configures the OIDC provider with your cluster's JWKS</p>
</li>
<li><p>Sets up IAM bindings for allowed namespaces</p>
</li>
<li><p>Creates ConfigMaps in each namespace with the credential configuration</p>
</li>
</ol>
<p>Combined with the Kyverno policy, you get a fully automated pipeline:</p>
<pre><code class="language-plaintext">New namespace added to allowed list
        │
        ▼
Terraform creates ConfigMap in that namespace
        │
        ▼
Developer deploys with label
        │
        ▼
Kyverno injects credentials automatically
        │
        ▼
Pod authenticates to GCP via OIDC
        │
        ▼
Application accesses GCP services
</code></pre>
<p>No tickets. No key requests. No secrets to manage.</p>
<h2 id="heading-how-to-run-a-proof-of-concept-with-vcluster">How to Run a Proof of Concept with vCluster</h2>
<p>To validate this works outside GKE, you can set up a demonstration using <a href="https://www.vcluster.com/">vCluster</a> — a virtual Kubernetes cluster that runs inside another Kubernetes cluster. This proves the solution works for any cluster. You can setup vCluster in Docker using <a href="https://github.com/loft-sh/vind/blob/main/docs/getting-started.md">vind</a></p>
<pre><code class="language-yaml"># vcluster.yaml
experimental:
  docker:
    nodes:
      - name: worker-1
      - name: worker-2
deploy:
  cni:
    flannel:
      enabled: true
controlPlane:
  distro:
    k8s:
      version: "v1.35.0"
</code></pre>
<pre><code class="language-shell">[root@localhost #] vcluster create hybrid --driver docker -f vcluster.yaml
[root@localhost #] kubectl get nodes
hybrid-control-plane   Ready    control-plane   14d   v1.34.0   192.168.107.2   &lt;none&gt;        Debian GNU/Linux 12 (bookworm)   7.0.5-orbstack-00330-ge3df4e19b0a0-dirty   containerd://2.1.3
hybrid-worker          Ready    &lt;none&gt;          14d   v1.34.0   192.168.107.3   &lt;none&gt;        Debian GNU/Linux 12 (bookworm)   7.0.5-orbstack-00330-ge3df4e19b0a0-dirty   containerd://2.1.3
hybrid-worker2         Ready    &lt;none&gt;          14d   v1.34.0   192.168.107.4   &lt;none&gt;        Debian GNU/Linux 12 (bookworm)   7.0.5-orbstack-00330-ge3df4e19b0a0-dirty   containerd://2.1.3
</code></pre>
<p>Inside the vCluster, deploy a simple test deployment:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: gcp-test
  labels:
    workload-identity-federation: "enabled"
spec:
  replicas: 1
  selector:
    matchLabels:
      app: gcp-test
  template:
    metadata:
      labels:
        app: gcp-test
    spec:
      containers:
        - name: test
          image: google/cloud-sdk:slim
          command: ["sleep", "infinity"]
</code></pre>
<p>Exec into the pod and verify:</p>
<pre><code class="language-bash">$ kubectl exec -it gcp-test-xxx -- bash

# Inside the pod:
\( gcloud auth login --cred-file=\)GOOGLE_APPLICATION_CREDENTIALS
Authenticated with external account credentials for: [principal://iam.googleapis.com/...]

$ gcloud secrets list --project=my-project
NAME                 CREATED
database-password    2024-01-15T10:30:00Z
api-key              2024-01-14T09:15:00Z
</code></pre>
<p>No keys. No secrets mounted. Just identity federation working as designed.</p>
<h2 id="heading-common-issues-and-how-to-solve-them">Common Issues and How to Solve Them</h2>
<h3 id="heading-how-to-handle-jwks-retrieval-for-air-gapped-clusters">How to Handle JWKS Retrieval for Air-Gapped Clusters</h3>
<p>If your cluster's OIDC discovery endpoint isn't publicly reachable (most on-prem clusters aren't), you need to manually export the JWKS and upload it to GCP:</p>
<pre><code class="language-bash">kubectl get --raw /openid/v1/jwks &gt; jwks.json
</code></pre>
<p>This file must be updated if the cluster's signing keys rotate. Set up a periodic job that checks for key changes and updates the Terraform configuration.</p>
<h3 id="heading-how-to-fix-issuer-url-mismatches">How to Fix Issuer URL Mismatches</h3>
<p>The <code>iss</code> claim in the Kubernetes token must exactly match the issuer URL configured in the OIDC provider. For clusters using internal DNS:</p>
<pre><code class="language-plaintext">issuer_uri = "https://kubernetes.default.svc.cluster.local"
</code></pre>
<p>This URL doesn't need to be reachable from GCP — the JWKS file provides the validation keys. But it must match what's in the token exactly.</p>
<h3 id="heading-how-to-debug-token-exchange-failures">How to Debug Token Exchange Failures</h3>
<p>When authentication fails, the error messages can be cryptic. Common causes and fixes:</p>
<table>
<thead>
<tr>
<th>Error</th>
<th>Likely Cause</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td><code>invalid_grant</code></td>
<td>Issuer URL mismatch</td>
<td>Check <code>iss</code> claim in JWT against configured <code>issuer_uri</code></td>
</tr>
<tr>
<td><code>audience mismatch</code></td>
<td>Wrong <code>audience</code> in credential config</td>
<td>Regenerate the credential configuration JSON via Terraform</td>
</tr>
<tr>
<td><code>CEL condition failed</code></td>
<td>Namespace not in allowed list</td>
<td>Add namespace to <code>attribute_condition</code> and re-apply</td>
</tr>
<tr>
<td><code>JWKS validation failed</code></td>
<td>Signing keys have rotated</td>
<td>Re-export JWKS and update Terraform config</td>
</tr>
</tbody></table>
<h2 id="heading-conclusion">Conclusion</h2>
<p>After implementing this setup, on-premises workloads authenticate to Google Cloud exactly like GKE workloads do — without a single long-lived credential. The security team is happy (no keys to audit), developers are happy (just add a label), and the platform team is happy (no more credential management tickets).</p>
<p>Here's what you accomplished in this tutorial:</p>
<ol>
<li><p>/Understood why service account keys fail at scale and the security risks they introduce</p>
</li>
<li><p>Created a Workload Identity Pool and OIDC provider in GCP to trust your cluster's token issuer</p>
</li>
<li><p>Used CEL conditions to enforce fine-grained, namespace-level access policies</p>
</li>
<li><p>Automated credential injection into pods using a Kyverno ClusterPolicy</p>
</li>
<li><p>Bound IAM roles to federated identity attributes — no long-lived keys anywhere</p>
</li>
<li><p>Verified the setup by calling GCP APIs (Secret Manager, Vertex AI) from an on-prem pod</p>
</li>
<li><p>Proved the solution works on any Kubernetes cluster using vCluster</p>
</li>
</ol>
<p>The technologies used here aren't new. OIDC has been in Kubernetes since version 1.20. Workload Identity Federation has been in GCP for years. Kyverno and Terraform are mature tools. What this tutorial puts together is an end-to-end solution that developers can adopt with minimal effort.</p>
<p>If your organization has disabled service account keys (or should), this is the path forward. Your on-prem and cloud clusters can finally be what they were always meant to be: secure extensions of each other.</p>
<p><em>The complete implementation is available as a Terraform module with Kyverno policies:</em> <a href="https://github.com/shkatara/hybrid-platform-gcp-workload-identity-federation"><em>github.com/shkatara/hybrid-platform-gcp-workload-identity-federation</em></a></p>
<p>If this helps, you can follow me on <a href="https://www.linkedin.com/in/shubhamkatara/">https://www.linkedin.com/in/shubhamkatara/</a>, <a href="https://www.youtube.com/@kubesimplify">https://www.youtube.com/@kubesimplify</a>, <a href="https://www.linkedin.com/company/kubesimplify/">https://www.linkedin.com/company/kubesimplify/</a> and</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Avoid Rebuilding Infrastructure for Every New Project ]]>
                </title>
                <description>
                    <![CDATA[ Every production engineering team knows the pattern. A new project begins with energy. Product goals are clear. Deadlines are ambitious. Teams want to move quickly and deliver something customers can  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-avoid-rebuilding-infrastructure-for-every-new-project/</link>
                <guid isPermaLink="false">6a0f78aad8e265f60d5f7b56</guid>
                
                    <category>
                        <![CDATA[ PaaS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ distributed system ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Thu, 21 May 2026 21:27:06 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/6c414744-42af-430a-8bbd-76a33b564e4b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every production engineering team knows the pattern. A new project begins with energy. Product goals are clear. Deadlines are ambitious. Teams want to move quickly and deliver something customers can use.</p>
<p>Then the real work starts. Infrastructure must be provisioned. CI/CD pipelines need to be set up. Secrets require management. Monitoring needs wiring. Databases need deployment. Logging needs configuration. Security policies need implementation. Networking rules need review.</p>
<p>Weeks disappear before users see anything useful. Many organizations treat this as normal. They call it engineering rigour. They assume this operational setup phase is simply part of software development.</p>
<p>It is not.</p>
<p>For teams already running production systems, rebuilding infrastructure foundations for every new project is organizational waste. It is repetitive operational labour disguised as an engineering discipline.</p>
<p>The uncomfortable question is not, “How can we do this setup faster?” The real question is: why are we still doing it ourselves at all?</p>
<p>This is where <a href="https://www.freecodecamp.org/news/from-metrics-to-meaning-how-paas-helps-developers-understand-production/">Platform as a Service</a> changes the conversation. A good PaaS shifts the starting point from “rebuild the foundations” to “start shipping. Because new projects should begin closer to customer value, not closer to infrastructure assembly.</p>
<p>In this article, we'll look at why many production teams waste time rebuilding the same infrastructure for every new project, how PaaS helps remove that work, and why engineering teams should question if managing complex infrastructure still makes sense for most projects.</p>
<h2 id="heading-what-well-cover">What We'll Cover:</h2>
<ul>
<li><p><a href="#heading-most-teams-were-not-hired-to-build-infrastructure">Most Teams Were Not Hired to Build Infrastructure</a></p>
</li>
<li><p><a href="#heading-aws-primitives-are-not-a-competitive-advantage">AWS Primitives Are Not a Competitive Advantage</a></p>
</li>
<li><p><a href="#heading-most-teams-should-not-be-managing-kubernetes">Most Teams Should Not Be Managing Kubernetes</a></p>
</li>
<li><p><a href="#heading-paas-changes-the-starting-point">PaaS Changes the Starting Point</a></p>
</li>
<li><p><a href="#heading-repetition-creates-hidden-organizational-waste">Repetition Creates Hidden Organizational Waste</a></p>
</li>
<li><p><a href="#heading-standardization-is-usually-faster-than-flexibility">Standardization Is Usually Faster Than Flexibility</a></p>
</li>
<li><p><a href="#heading-platform-teams-become-multipliers">Platform Teams Become Multipliers</a></p>
</li>
<li><p><a href="#heading-easier-starts-create-more-innovation">Easier Starts Create More Innovation</a></p>
</li>
<li><p><a href="#heading-when-specialized-control-actually-matters">When Specialized Control Actually Matters</a></p>
</li>
<li><p><a href="#heading-starting-from-zero-is-a-process-failure">Starting From Zero Is a Process Failure</a></p>
</li>
</ul>
<h2 id="heading-most-teams-were-not-hired-to-build-infrastructure"><strong>Most Teams Were Not Hired to Build Infrastructure</strong></h2>
<p>Software teams exist to solve business problems. Customers do not care whether Kubernetes manifests were structured elegantly. They do not admire carefully designed Terraform modules. They do not celebrate handcrafted networking policies.</p>
<p>Customers care about outcomes. They care about faster onboarding. Better recommendations. Smoother payments. Fewer bugs. Simpler workflows.</p>
<p>Yet many engineering organizations spend huge portions of time doing work customers never see.</p>
<p>Teams repeatedly create deployment pipelines. Configure environments. Manage certificates. Set up observability stacks. Tune infrastructure rules. Assemble cloud primitives.</p>
<p>Infrastructure matters. Reliability matters. Security matters.</p>
<p>The problem is duplication. If every project independently recreates the same operational systems, organizations keep rebuilding internal platforms over and over again without admitting it.</p>
<p>This behaviour has become so normalized that teams barely notice it anymore. But rebuilding the same foundation repeatedly is not operational maturity. It is inefficiency scaled across the organization.</p>
<h2 id="heading-aws-primitives-are-not-a-competitive-advantage"><strong>AWS Primitives Are Not a Competitive Advantage</strong></h2>
<p>Many teams confuse cloud ownership with strategic advantage. Owning Kubernetes clusters does not create differentiation. Managing IAM rules does not create customer value. Writing infrastructure glue code does not strengthen market position.</p>
<p>These are implementation details. Yet many organizations spend extraordinary energy managing them as if they are core business assets.</p>
<p>Some teams effectively become part-time infrastructure companies without realizing it. Their engineers slowly accumulate operational responsibilities until maintaining systems consumes more effort than delivering products.</p>
<p>The outcome becomes predictable. Infrastructure expands. Operational complexity grows. Delivery speed declines. Nobody notices because the pain arrives gradually.</p>
<p>A team starts with one Kubernetes cluster. Then another environment appears. More deployment pipelines emerge. Additional tooling gets layered on top. Logging systems become fragmented. Monitoring evolves differently across products.</p>
<p>Eventually, teams spend increasing amounts of time maintaining systems they never intended to own.</p>
<p>Infrastructure ownership is often not a strategy. It is inertia.</p>
<h2 id="heading-most-teams-should-not-be-managing-kubernetes"><strong>Most Teams Should Not Be Managing Kubernetes</strong></h2>
<p><a href="https://www.freecodecamp.org/news/what-does-k8s-mean-kubernetes-setup-guide/">Kubernetes</a> has become an engineering culture. It appears in architecture diagrams, conference talks, hiring requirements, and internal roadmaps. Its adoption often feels inevitable.</p>
<p>But normalization and necessity are not the same thing. Many organizations adopted Kubernetes because industry momentum made it seem like the default path. Not because they had workloads that required its complexity. But the result is predictable.</p>
<p>Small and medium teams end up managing orchestration systems designed for massive operational environments.</p>
<p>They maintain YAML configurations, networking layers, ingress systems, deployment strategies, and operational tooling stacks before delivering meaningful product value. This has become strangely accepted.</p>
<p>A ten-person engineering team maintaining infrastructure patterns designed for internet-scale organizations should raise serious questions. A small team pretending to be a platform team is an operational dysfunction.</p>
<p>Many companies adopt infrastructure complexity built for organizations operating at a vastly different scale. They inherit the burden without inheriting the benefits.</p>
<h2 id="heading-paas-changes-the-starting-point"><strong>PaaS Changes the Starting Point</strong></h2>
<p><a href="https://www.freecodecamp.org/news/the-hidden-tax-of-infrastructure-why-your-team-shouldn-t-be-running-it-anymore/">Traditional infrastructure</a> approaches force teams to think from the bottom upward. Servers come first. Then operating systems. Then networking. Then deployment systems. Then monitoring. Eventually, applications arrive.</p>
<p>PaaS reverses this sequence. Developers begin with applications and business goals. The platform absorbs operational complexity.</p>
<p>Teams stop asking, “How do we provision resources?” They start asking, “What problem are we solving?” That sounds like a small shift. In practice, it changes everything.</p>
<p>A mature PaaS environment often provides deployment pipelines, integrated observability, databases, scaling behaviour, security controls, and operational standards before a team writes meaningful application logic.</p>
<p>Projects begin with product development rather than infrastructure construction. That dramatically changes time-to-value.</p>
<h2 id="heading-repetition-creates-hidden-organizational-waste"><strong>Repetition Creates Hidden Organizational Waste</strong></h2>
<p>Organizations often underestimate operational waste because repetitive work feels familiar. Setting up a deployment pipeline may consume only a few days. Configuring logging may feel routine. Creating security rules may seem manageable.</p>
<p>No individual task appears expensive. The cost appears when repetition scales.</p>
<p>If ten projects independently spend two weeks rebuilding nearly identical operational systems, months of engineering capacity disappear. Those engineers could have shipped customer capabilities. They could have reduced friction. They could have tested new ideas. Instead, they rebuilt plumbing.</p>
<p>Engineering teams understand leverage in nearly every other area. Nobody rewrites sorting algorithms for every application. Nobody recreates database engines from scratch. Nobody builds networking stacks repeatedly.</p>
<p>Reuse is accepted as basic engineering wisdom. Infrastructure should not receive special treatment. Build once. Reuse many times.</p>
<p>PaaS simply applies software engineering principles to operational systems.</p>
<h2 id="heading-standardization-is-usually-faster-than-flexibility"><strong>Standardization Is Usually Faster Than Flexibility</strong></h2>
<p>Engineering teams often resist standardization because they fear losing control. Every project feels unique. Every system appears different. The desire for flexibility sounds reasonable.</p>
<p>But complete flexibility often creates operational chaos. Different teams deploy applications differently. Logging behaves inconsistently. Monitoring varies across systems. Security implementations drift.</p>
<p>Documentation fragments. Onboarding slows. Incident response becomes harder. Complexity quietly accumulates.</p>
<p>PaaS introduces constraints, and many engineers instinctively resist constraints. They should not. Useful constraints often increase speed.</p>
<p>Predictable deployment patterns reduce confusion. Shared monitoring standards simplify troubleshooting. Consistent environments reduce cognitive overhead.</p>
<p>Developers spend less energy understanding infrastructure differences and more time delivering product functionality.</p>
<p>Consistency compounds.</p>
<h2 id="heading-platform-teams-become-multipliers"><strong>Platform Teams Become Multipliers</strong></h2>
<p>Many organizations interpret PaaS as buying a vendor product. That misses the bigger idea.</p>
<p>PaaS is fundamentally about creating reusable capabilities. Some organizations buy platforms. Others build internal platforms.</p>
<p>The principle remains the same.</p>
<p>A platform team creates systems once and allows everyone else to benefit. Instead of dozens of product teams independently solving operational problems, a dedicated group centralizes expertise and builds reusable solutions.</p>
<p>The effect becomes substantial. One deployment improvement accelerates every future release. One observability improvement strengthens every application. One security enhancement protects every team.</p>
<p>Platform teams create organizational leverage. Without this model, expertise stays fragmented. With it, expertise compounds.</p>
<h2 id="heading-easier-starts-create-more-innovation"><strong>Easier Starts Create More Innovation</strong></h2>
<p>Operational friction changes behaviour. When launching projects becomes expensive, organizations become cautious. Teams avoid experiments. Small ideas feel risky. Prototypes become difficult to justify.</p>
<p>Over time, innovation slows. Not because organizations lack ideas, but because starting became too expensive.</p>
<p>Teams running mature platforms understand this relationship. Reducing startup friction increases experimentation. Smaller projects become practical. Learning cycles become shorter.</p>
<p>New ideas appear more often because the cost of testing them falls dramatically. The easier it becomes to launch something, the more opportunities organizations create.</p>
<p>PaaS reduces startup friction. That reduction changes culture.</p>
<h2 id="heading-when-specialized-control-actually-matters"><strong>When Specialized Control Actually Matters</strong></h2>
<p>There are exceptions. Massive data platforms, highly specialized machine learning systems, and extremely customized environments may require lower-level infrastructure ownership.</p>
<p>Some workloads genuinely need deeper operational control. But these scenarios are exceptions, not defaults. Too many teams inherit infrastructure complexity designed for edge cases and treat it as standard practice.</p>
<p>Most production applications do not need custom orchestration layers. Most teams do not need to own Kubernetes. Most engineering groups do not need to spend weeks assembling infrastructure before shipping software.</p>
<p>The default assumption should be the opposite.</p>
<h2 id="heading-starting-from-zero-is-a-process-failure"><strong>Starting From Zero Is a Process Failure</strong></h2>
<p>Many organizations normalize unnecessary operational drag. Long setup cycles become accepted. Infrastructure duplication becomes routine. Cloud complexity becomes expected.</p>
<p>Eventually, teams stop questioning it. They assume this is simply how engineering works. It is not.</p>
<p>If launching a new application requires weeks of foundational setup before customer value appears, that is not an engineering discipline.</p>
<p>The goal was never to become an infrastructure company. It was to ship software.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Encrypt Kubernetes Traffic with cert-manager, Let's Encrypt, and Internal TLS ]]>
                </title>
                <description>
                    <![CDATA[ Most engineers assume their Kubernetes cluster encrypts all of its traffic. It doesn't. The commands you run with kubectl are encrypted — your client and the API server speak TLS. The API server talki ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-encrypt-kubernetes-traffic/</link>
                <guid isPermaLink="false">6a0df3b68b034602219e482c</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ containers ]]>
                    </category>
                
                    <category>
                        <![CDATA[ distributed system ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Destiny Erhabor ]]>
                </dc:creator>
                <pubDate>Wed, 20 May 2026 17:47:34 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/c1cf9847-fa0f-49f3-93f4-3c5c1e8ac4c0.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most engineers assume their Kubernetes cluster encrypts all of its traffic. It doesn't. The commands you run with <code>kubectl</code> are encrypted — your client and the API server speak TLS. The API server talking to etcd is usually encrypted too, depending on how the cluster was provisioned.</p>
<p>But traffic between your pods? Plaintext by default. Ingress traffic from the internet to your services? Only encrypted if you explicitly configure TLS. And certificates for internal services? You have to provision those yourself.</p>
<p>This is not a Kubernetes oversight. It's a deliberate design choice — Kubernetes provides the primitives and leaves the implementation to you. The problem is that certificate management is notoriously painful. Certificates expire. Provisioning them manually doesn't scale. Forgetting to rotate them causes outages.</p>
<p>cert-manager solves this. It runs as a controller inside your cluster, watches for <code>Certificate</code> resources, requests certificates from configured issuers, stores them in Kubernetes Secrets, and rotates them automatically before they expire. You declare what you want, cert-manager makes it happen and keeps it that way.</p>
<p>In this article you'll work through how cert-manager's core model works, automate public Ingress TLS using Let's Encrypt, set up an internal Certificate Authority for service-to-service encryption, and understand how certificate rotation works so outages caused by expired certificates become a thing of the past.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>A kind cluster with the nginx Ingress controller installed</p>
</li>
<li><p>Helm 3 installed</p>
</li>
<li><p>A domain name with DNS you control — needed for the Let's Encrypt demo</p>
</li>
<li><p>Basic understanding of TLS: you know what a certificate, a private key, and a CA are</p>
</li>
</ul>
<p>All demo files are in the <a href="https://github.com/Caesarsage/DevOps-Cloud-Projects/tree/main/intermediate/k8/security/cert-manager">DevOps-Cloud-Projects GitHub repository</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-and-isnt-encrypted-in-kubernetes">What Is and Isn't Encrypted in Kubernetes</a></p>
</li>
<li><p><a href="#heading-how-cert-manager-works">How cert-manager Works</a></p>
<ul>
<li><p><a href="#heading-the-four-core-resources">The Four Core Resources</a></p>
</li>
<li><p><a href="#heading-issuers-and-clusterissuers">Issuers and ClusterIssuers</a></p>
</li>
<li><p><a href="#heading-the-certificate-lifecycle">The Certificate Lifecycle</a></p>
</li>
<li><p><a href="#heading-acme-challenges-http-01-vs-dns-01">ACME Challenges: HTTP-01 vs DNS-01</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-demo-1--install-cert-manager-and-issue-a-lets-encrypt-certificate">Demo 1 — Install cert-manager and Issue a Let's Encrypt Certificate</a></p>
</li>
<li><p><a href="#heading-how-to-get-a-wildcard-certificate-with-dns-01">How to Get a Wildcard Certificate with DNS-01</a></p>
</li>
<li><p><a href="#heading-demo-2--set-up-an-internal-ca-for-service-to-service-tls">Demo 2 — Set Up an Internal CA for Service-to-Service TLS</a></p>
</li>
<li><p><a href="#heading-how-certificate-rotation-works">How Certificate Rotation Works</a></p>
</li>
<li><p><a href="#heading-cleanup">Cleanup</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-and-isnt-encrypted-in-kubernetes">What Is and Isn't Encrypted in Kubernetes?</h2>
<p>Before installing anything, it's worth being precise about what the cluster already protects and what it leaves open.</p>
<table>
<thead>
<tr>
<th>Traffic path</th>
<th>Encrypted by default?</th>
<th>Notes</th>
</tr>
</thead>
<tbody><tr>
<td><code>kubectl</code> → API server</td>
<td>Yes</td>
<td>TLS with the cluster CA</td>
</tr>
<tr>
<td>API server → etcd</td>
<td>Usually</td>
<td>Depends on cluster provisioner — verify with your setup</td>
</tr>
<tr>
<td>API server → kubelet</td>
<td>Yes</td>
<td>TLS, but kubelet cert verification depends on configuration</td>
</tr>
<tr>
<td>Pod → Pod (same cluster)</td>
<td><strong>No</strong></td>
<td>Plaintext unless you add a service mesh or mTLS</td>
</tr>
<tr>
<td>Internet → Ingress</td>
<td><strong>No</strong></td>
<td>Opt-in — requires TLS configuration on the Ingress resource</td>
</tr>
<tr>
<td>Pod → Kubernetes API</td>
<td>Yes</td>
<td>Via the service account token and cluster CA</td>
</tr>
</tbody></table>
<p>The two gaps that matter most in practice are pod-to-pod traffic and Ingress TLS. This article covers both Ingress TLS with Let's Encrypt and internal service-to-service encryption using a private CA.</p>
<h2 id="heading-how-cert-manager-works">How cert-manager Works</h2>
<p>cert-manager is a Kubernetes operator. It extends the Kubernetes API with custom resources that represent certificate requests and their configuration. When you create a <code>Certificate</code> resource, cert-manager's controller picks it up, requests a certificate from the configured issuer, and stores the resulting certificate and private key in a Kubernetes Secret. When the certificate approaches its expiry, cert-manager renews it automatically.</p>
<p>This model means your application doesn't know or care about certificate management. It reads a Secret. cert-manager keeps that Secret fresh.</p>
<h3 id="heading-the-four-core-resources">The Four Core Resources</h3>
<p>cert-manager introduces four custom resources that you'll use regularly:</p>
<table>
<thead>
<tr>
<th>Resource</th>
<th>What it represents</th>
</tr>
</thead>
<tbody><tr>
<td><code>Issuer</code></td>
<td>A certificate authority or ACME account — namespace-scoped</td>
</tr>
<tr>
<td><code>ClusterIssuer</code></td>
<td>Same as Issuer, but available cluster-wide</td>
</tr>
<tr>
<td><code>Certificate</code></td>
<td>A request for a certificate — describes what you want</td>
</tr>
<tr>
<td><code>CertificateRequest</code></td>
<td>An individual signing request — created automatically by cert-manager, rarely touched directly</td>
</tr>
</tbody></table>
<p>In practice you'll mostly deal with <code>ClusterIssuer</code> and <code>Certificate</code>. The <code>ClusterIssuer</code> defines where certificates come from. The <code>Certificate</code> defines what certificate you want and where to store it.</p>
<h3 id="heading-issuers-and-clusterissuers">Issuers and ClusterIssuers</h3>
<p>An <code>Issuer</code> can only issue certificates within its own namespace. A <code>ClusterIssuer</code> can issue certificates in any namespace. For shared infrastructure like Let's Encrypt, you almost always want a <code>ClusterIssuer</code>. For application-specific internal CAs, an <code>Issuer</code> scoped to that application's namespace is the safer choice.</p>
<p>cert-manager supports several issuer types. The three you'll encounter most often are:</p>
<p><strong>ACME</strong> — for public certificates from Let's Encrypt or any ACME-compatible CA. Ownership of the domain is proven via an HTTP-01 or DNS-01 challenge.</p>
<p><strong>CA</strong> — for internal certificates signed by a CA whose private key is stored in a Kubernetes Secret. Used for service-to-service TLS within the cluster.</p>
<p><strong>Self-signed</strong> — generates self-signed certificates. Rarely useful on its own, but essential as the bootstrap step when creating an internal CA.</p>
<h3 id="heading-the-certificate-lifecycle">The Certificate Lifecycle</h3>
<p>When you create a <code>Certificate</code> resource, cert-manager follows this sequence:</p>
<ol>
<li><p>Creates a <code>CertificateRequest</code> with a CSR (Certificate Signing Request)</p>
</li>
<li><p>Passes the CSR to the configured issuer</p>
</li>
<li><p>For ACME issuers: creates a <code>Challenge</code> resource and fulfils it (more on this below)</p>
</li>
<li><p>Receives the signed certificate from the issuer</p>
</li>
<li><p>Stores the certificate and private key in the Kubernetes Secret named in <code>spec.secretName</code></p>
</li>
<li><p>Monitors the certificate's expiry — by default, renews when 2/3 of the validity period has elapsed</p>
</li>
</ol>
<p>Your application mounts the Secret. cert-manager updates it silently. Most applications that watch for file changes will pick up the new certificate without a restart.</p>
<h3 id="heading-acme-challenges-http-01-vs-dns-01">ACME Challenges: HTTP-01 vs DNS-01</h3>
<p>Let's Encrypt needs proof that you control the domain before it issues a certificate. ACME defines two challenge types for this.</p>
<p><strong>HTTP-01</strong> works by having cert-manager create a temporary HTTP endpoint at <code>http://&lt;your-domain&gt;/.well-known/acme-challenge/&lt;token&gt;</code>. Let's Encrypt sends a request to that URL. If the response matches the expected token, the challenge passes. This requires your cluster to be reachable from the internet on port 80.</p>
<p><strong>DNS-01</strong> works by having cert-manager create a temporary DNS TXT record at <code>_acme-challenge.&lt;your-domain&gt;</code>. Let's Encrypt checks for that record. This doesn't require inbound HTTP access, which makes it the right choice for private clusters, and it's the only way to get wildcard certificates (<code>*.example.com</code>).</p>
<p>The trade-off: HTTP-01 is simpler to set up but only works for single domains and requires internet-accessible infrastructure. DNS-01 requires API access to your DNS provider but works for internal clusters and wildcards.</p>
<h2 id="heading-demo-1-install-cert-manager-and-issue-a-certificate-using-pebble-and-lets-encrypt">Demo 1 — Install cert-manager and Issue a Certificate Using Pebble and Let's Encrypt</h2>
<p>Pebble is Let's Encrypt's local ACME test server. It runs inside your cluster, issues certificates using the same ACME protocol as Let's Encrypt, and requires no public domain or internet access. Using Pebble lets you test the full cert-manager flow — challenge, issuance, renewal — on a plain kind cluster.</p>
<p>Once you understand the flow locally, switching to real Let's Encrypt is a one-line change: replace the ClusterIssuer server URL and point a DNS record at a publicly reachable cluster. The rest of the configuration is identical.</p>
<p>You'll install cert-manager, create a <code>ClusterIssuer</code> for Let's Encrypt, deploy a sample application with an Ingress, and watch a real certificate be issued and stored automatically.</p>
<h3 id="heading-step-1-install-cert-manager">Step 1: Install cert-manager</h3>
<p>cert-manager is now distributed via OCI Helm charts from <code>quay.io/jetstack</code>. The <code>--set crds.enabled=true</code> flag installs the Custom Resource Definitions as part of the chart:</p>
<pre><code class="language-bash">helm upgrade cert-manager oci://quay.io/jetstack/charts/cert-manager \
  --install \
  --create-namespace \
  --namespace cert-manager \
  --set crds.enabled=true \
  --version v1.17.0 \
  --wait
</code></pre>
<p>You also need the nginx Ingress controller — cert-manager routes HTTP-01 challenges through it. The <code>controller.service.type=ClusterIP</code> override is for kind specifically: the default <code>LoadBalancer</code> Service never gets an <code>EXTERNAL-IP</code> on kind (there's no cloud LB), which makes <code>--wait</code> hang forever. On a real cluster, drop the override and keep <code>LoadBalancer</code>.</p>
<pre><code class="language-bash">helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm repo update

helm install ingress-nginx ingress-nginx/ingress-nginx \
  --namespace ingress-nginx \
  --create-namespace \
  --set controller.service.type=ClusterIP \
  --wait
</code></pre>
<p>Confirm all four components are running:</p>
<pre><code class="language-bash">kubectl get pods -n cert-manager
kubectl get pods -n ingress-nginx
</code></pre>
<pre><code class="language-plaintext">NAME                                       READY   STATUS    RESTARTS   AGE
cert-manager-76f84784c8-r4fx4              1/1     Running   0          6m45s
cert-manager-cainjector-66fbf49587-gv25n   1/1     Running   0          6m45s
cert-manager-webhook-577fddf86-l5wj4       1/1     Running   0          6m45s

NAME                                        READY   STATUS    RESTARTS   AGE
ingress-nginx-controller-6c7cd85885-h7zgx   1/1     Running   0          3m34s
</code></pre>
<blockquote>
<p>kind-specific gotcha — remove the nginx admission webhook now.** On kind, the nginx admission webhook serves with a self-signed certificate that the Kubernetes API server cannot verify. The first time you try to create <em>any</em> Ingress resource you'll see <code>failed calling webhook "validate.nginx.ingress.kubernetes.io": ... x509: certificate signed by unknown authority</code>. Delete the webhook up front so the rest of the demo doesn't trip over it:</p>
</blockquote>
<pre><code class="language-bash">kubectl delete validatingwebhookconfiguration ingress-nginx-admission
</code></pre>
<h3 id="heading-step-2-install-pebble">Step 2: Install Pebble</h3>
<p>Pebble is the local ACME test server, distributed by the JupyterHub project. It ships with a companion CoreDNS deployment (<code>pebble-coredns</code>) that Pebble uses to resolve names during ACME validation.</p>
<pre><code class="language-bash">helm install pebble pebble \
  --repo https://jupyterhub.github.io/helm-chart/ \
  --namespace pebble \
  --create-namespace \
  --wait
</code></pre>
<p>Confirm both pods are running:</p>
<pre><code class="language-bash">kubectl get pods -n pebble
</code></pre>
<pre><code class="language-plaintext">NAME                              READY   STATUS    RESTARTS   AGE
pebble-8d8d49d64-lz8ck            1/1     Running   0          36s
pebble-coredns-7fb5c7cbf4-4jw9h   1/1     Running   0          36s
</code></pre>
<h3 id="heading-step-3-wire-up-dns-for-the-fake-hostname">Step 3: Wire up DNS for the fake hostname</h3>
<p>We're going to issue a cert for <code>echo.pebble.local</code>. That hostname is fake — it doesn't exist in any real DNS — so we have to teach <strong>two</strong> independent resolvers about it before issuance will work:</p>
<table>
<thead>
<tr>
<th>Resolver</th>
<th>Used by</th>
<th>What we need it to do</th>
</tr>
</thead>
<tbody><tr>
<td><code>pebble-coredns</code> (in the <code>pebble</code> namespace)</td>
<td>Pebble itself, when it makes the HTTP-01 validation request</td>
<td>Resolve <code>echo.pebble.local</code> → ingress-nginx ClusterIP</td>
</tr>
<tr>
<td>Cluster CoreDNS (<code>kube-system</code>)</td>
<td>cert-manager's HTTP-01 <strong>self-check</strong> before reporting the challenge ready</td>
<td>Forward <code>pebble.local</code> lookups to <code>pebble-coredns</code></td>
</tr>
</tbody></table>
<p>If you skip either layer, the Order will go to <code>invalid</code> state with a DNS lookup failure.</p>
<p>First grab the two IPs you'll need:</p>
<pre><code class="language-bash">NGINX_IP=$(kubectl get svc -n ingress-nginx ingress-nginx-controller \
  -o jsonpath='{.spec.clusterIP}')
PEBBLE_DNS_IP=$(kubectl get svc pebble-coredns -n pebble \
  -o jsonpath='{.spec.clusterIP}')
echo "NGINX_IP=\(NGINX_IP  PEBBLE_DNS_IP=\)PEBBLE_DNS_IP"
</code></pre>
<p><strong>Patch</strong> <code>pebble-coredns</code> to answer for <code>*.pebble.local</code> with the ingress controller's IP. The CoreDNS <code>template</code> plugin parses unreliably when the whole block is collapsed onto one line, so apply a real multi-line ConfigMap:</p>
<pre><code class="language-bash">cat &lt;&lt;EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
  name: pebble-coredns
  namespace: pebble
data:
  Corefile: |
    .:8053 {
      errors
      health
      ready
      template ANY ANY pebble.local {
        answer "{{ .Name }} 60 IN A ${NGINX_IP}"
      }
      forward . /etc/resolv.conf
      cache 2
      reload
    }
EOF

kubectl rollout restart deploy/pebble-coredns -n pebble
kubectl rollout status deploy/pebble-coredns -n pebble
</code></pre>
<p>Verify it answers correctly:</p>
<pre><code class="language-bash">kubectl run dnstest --rm -it --restart=Never --image=busybox -- \
  nslookup echo.pebble.local ${PEBBLE_DNS_IP}
</code></pre>
<p>You should see <code>Address: &lt;NGINX_IP&gt;</code> in the response. If you get <code>SERVFAIL</code>, check <code>kubectl logs -n pebble deploy/pebble-coredns</code> — a parser error like <code>not a TTL: "}"</code> means the template block collapsed onto one line again.</p>
<p><strong>Patch the cluster CoreDNS</strong> so cert-manager's self-check can resolve the same name. Add a stub zone that forwards <code>pebble.local</code> to <code>pebble-coredns</code>:</p>
<pre><code class="language-bash">cat &lt;&lt;EOF | kubectl apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
  name: coredns
  namespace: kube-system
data:
  Corefile: |
    .:53 {
        errors
        health {
           lameduck 5s
        }
        ready
        kubernetes cluster.local in-addr.arpa ip6.arpa {
           pods insecure
           fallthrough in-addr.arpa ip6.arpa
           ttl 30
        }
        forward . /etc/resolv.conf {
           max_concurrent 1000
        }
        cache 30
        loop
        reload
        loadbalance
    }
    pebble.local:53 {
        forward . ${PEBBLE_DNS_IP}
    }
EOF

kubectl rollout restart deploy/coredns -n kube-system
kubectl rollout status deploy/coredns -n kube-system
</code></pre>
<p>Verify the cluster resolver now answers for <code>echo.pebble.local</code> (without specifying a server — it'll use the default kube-dns):</p>
<pre><code class="language-bash">kubectl run dnstest --rm -it --restart=Never --image=busybox -- \
  nslookup echo.pebble.local
</code></pre>
<p>Both <code>Server: 10.96.0.10</code> and <code>Address: &lt;NGINX_IP&gt;</code> should appear.</p>
<h3 id="heading-step-4-fetch-the-pebble-ca-and-create-the-clusterissuer">Step 4: Fetch the Pebble CA and create the ClusterIssuer</h3>
<p>Pebble signs its certificates with a self-signed root that lives in the <code>pebble</code> ConfigMap under <code>root-cert.pem</code>. cert-manager needs to trust this CA to talk to Pebble's ACME directory, so we pass it as a base64-encoded <code>caBundle</code> in the ClusterIssuer:</p>
<pre><code class="language-bash">kubectl get configmap pebble -n pebble \
  -o jsonpath='{.data.root-cert\.pem}' &gt; pebble-ca.crt

head -1 pebble-ca.crt   # should print -----BEGIN CERTIFICATE-----

CA_BUNDLE=$(base64 -i pebble-ca.crt | tr -d '\n')
echo "CA_BUNDLE length: ${#CA_BUNDLE}"   # ~1600 chars, one continuous line
</code></pre>
<p>Create the ClusterIssuer using the heredoc — the <code>${CA_BUNDLE}</code> shell variable gets substituted into the YAML before kubectl reads it:</p>
<pre><code class="language-bash">kubectl apply -f - &lt;&lt;EOF
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: pebble
spec:
  acme:
    server: https://pebble.pebble.svc.cluster.local/dir
    email: test@example.com
    privateKeySecretRef:
      name: pebble-account-key
    caBundle: ${CA_BUNDLE}
    solvers:
      - http01:
          ingress:
            ingressClassName: nginx
EOF
</code></pre>
<p>Check the issuer is ready:</p>
<pre><code class="language-bash">kubectl get clusterissuer pebble
</code></pre>
<pre><code class="language-plaintext">NAME     READY   AGE
pebble   True    5s
</code></pre>
<p>If <code>READY</code> stays <code>False</code>, the two most common causes are a malformed caBundle (verify it's a single unbroken base64 line with no newlines) or Pebble being unreachable from the <code>cert-manager</code> namespace. To check reachability:</p>
<pre><code class="language-bash">kubectl run test-curl --rm -it --restart=Never \
  --image=curlimages/curl:latest \
  --namespace cert-manager -- \
  curl -k https://pebble.pebble.svc.cluster.local/dir
</code></pre>
<p>If that returns JSON, Pebble is reachable.</p>
<h3 id="heading-step-5-deploy-a-sample-application">Step 5: Deploy a sample application</h3>
<pre><code class="language-yaml"># echo-app.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: echo
  namespace: default
spec:
  replicas: 1
  selector:
    matchLabels:
      app: echo
  template:
    metadata:
      labels:
        app: echo
    spec:
      containers:
        - name: echo
          image: ealen/echo-server:latest
          ports:
            - containerPort: 80
---
apiVersion: v1
kind: Service
metadata:
  name: echo
  namespace: default
spec:
  selector:
    app: echo
  ports:
    - port: 80
      targetPort: 80
</code></pre>
<pre><code class="language-bash">kubectl apply -f echo-app.yaml
</code></pre>
<p>Verify the resources came up:</p>
<pre><code class="language-bash">kubectl get deploy,pod,svc -n default
</code></pre>
<pre><code class="language-plaintext">NAME                   READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/echo   1/1     1            1           32s

NAME                        READY   STATUS    RESTARTS   AGE
pod/echo-5665fbcfdd-mbgxj   1/1     Running   0          36s

NAME                 TYPE        CLUSTER-IP      EXTERNAL-IP   PORT(S)   AGE
service/echo         ClusterIP   10.96.103.114   &lt;none&gt;        80/TCP    40s
service/kubernetes   ClusterIP   10.96.0.1       &lt;none&gt;        443/TCP   32m
</code></pre>
<h3 id="heading-step-6-create-an-ingress-with-tls">Step 6: Create an Ingress with TLS</h3>
<p>The <code>cert-manager.io/cluster-issuer: pebble</code> annotation tells cert-manager to automatically create a <code>Certificate</code> resource for this Ingress, using the issuer we just created. The hostname <code>echo.pebble.local</code> doesn't need to resolve externally — we taught both DNS resolvers about it in Step 3.</p>
<pre><code class="language-yaml"># echo-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: echo
  namespace: default
  annotations:
    cert-manager.io/cluster-issuer: pebble
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - echo.pebble.local
      secretName: echo-tls     # cert-manager will create this Secret
  rules:
    - host: echo.pebble.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: echo
                port:
                  number: 80
</code></pre>
<pre><code class="language-bash">kubectl apply -f echo-ingress.yaml
</code></pre>
<h3 id="heading-step-7-watch-the-certificate-being-issued">Step 7: Watch the certificate being issued</h3>
<pre><code class="language-bash"># Watch the Certificate resource (Ctrl-C once Ready=True)
kubectl get certificate echo-tls -n default -w
</code></pre>
<pre><code class="language-plaintext">NAME       READY   SECRET     AGE
echo-tls   False   echo-tls   5s
echo-tls   True    echo-tls   28s
</code></pre>
<p>When <code>READY</code> becomes <code>True</code>, the certificate has been issued and stored in the <code>echo-tls</code> Secret. The full chain — CertificateRequest → Order → Challenge → solver pod → Secret — happens in well under a minute on a healthy cluster:</p>
<pre><code class="language-bash">kubectl get certificate,certificaterequest,order,challenge -n default
</code></pre>
<pre><code class="language-plaintext">NAME                                   READY   SECRET     AGE
certificate.cert-manager.io/echo-tls   True    echo-tls   81s

NAME                                            APPROVED   DENIED   READY   ISSUER   AGE
certificaterequest.cert-manager.io/echo-tls-1   True                True    pebble   81s

NAME                                               STATE   AGE
order.acme.cert-manager.io/echo-tls-1-1824732543   valid   81s
</code></pre>
<p>(Challenges are deleted automatically once an Order completes, so <code>kubectl get challenge -n default</code> typically shows nothing at this point — that's success, not failure.)</p>
<p>If <code>READY</code> stays <code>False</code> for more than a minute, see the troubleshooting tips at the end of this section.</p>
<p>Inspect the issued certificate to confirm Pebble signed it:</p>
<pre><code class="language-bash">kubectl get secret echo-tls -n default -o jsonpath='{.data.tls\.crt}' | \
  base64 -d | openssl x509 -noout -issuer -subject -dates
</code></pre>
<pre><code class="language-plaintext">issuer=CN=Pebble Intermediate CA 05478c
subject=
notBefore=May 17 19:09:22 2026 GMT
notAfter=Aug 15 19:09:21 2026 GMT
</code></pre>
<p>Issuer is Pebble's intermediate CA — proof the full ACME flow worked end-to-end. The cert is valid for 90 days, and cert-manager will renew it automatically at day 60.</p>
<p>Hit the ingress over HTTPS from inside the cluster to confirm everything is wired together:</p>
<pre><code class="language-bash">kubectl run curltest --rm -it --restart=Never --image=curlimages/curl -- \
  curl -sk https://echo.pebble.local/
</code></pre>
<p>The echo server should return a JSON blob — note the <code>"x-forwarded-proto":"https"</code> field, which proves the request came through nginx over TLS.</p>
<p><strong>Troubleshooting if the cert never goes Ready:</strong></p>
<ul>
<li><p><code>kubectl describe order -n default</code> — look for "DNS problem" or "Connection refused" in the events.</p>
</li>
<li><p><code>kubectl logs -n pebble deploy/pebble --tail=50</code> — Pebble logs the exact URL it tried to fetch during validation and any errors.</p>
</li>
<li><p>If the Order is stuck pending with no events: cert-manager hasn't reconciled yet. Wait 30s.</p>
</li>
<li><p>If the Order is <code>invalid</code>: one of the two DNS layers (Step 3) is misconfigured. Re-run both <code>nslookup</code> checks.</p>
</li>
<li><p>If the Ingress apply itself failed with an x509 webhook error: you skipped the <code>kubectl delete validatingwebhookconfiguration ingress-nginx-admission</code> step in Step 1.</p>
</li>
</ul>
<h3 id="heading-step-8-switch-to-lets-encrypt-staging-real-public-domain">Step 8: Switch to Let's Encrypt staging (real public domain)</h3>
<p>Pebble proved the flow works locally. Now move to a publicly-reachable domain pointed at a publicly-reachable cluster. The DNS gymnastics from Step 3 go away — the domain is real, so both resolvers find it without intervention.</p>
<p>Use Let's Encrypt <strong>staging</strong> first. It speaks the same ACME protocol as production but with generous rate limits, so failed attempts during testing won't lock you out:</p>
<pre><code class="language-yaml"># clusterissuer-staging.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: your-email@example.com
    privateKeySecretRef:
      name: letsencrypt-staging-account-key
    solvers:
      - http01:
          ingress:
            ingressClassName: nginx
</code></pre>
<pre><code class="language-bash">kubectl apply -f clusterissuer-staging.yaml

# Point the Ingress at staging and the real hostname, then force re-issuance
kubectl annotate ingress echo \
  cert-manager.io/cluster-issuer=letsencrypt-staging --overwrite -n default
kubectl delete secret echo-tls -n default
</code></pre>
<p>The new cert's issuer will look something like <code>(STAGING) Let's Encrypt</code>.</p>
<h3 id="heading-step-9-switch-to-lets-encrypt-production">Step 9: Switch to Let's Encrypt production</h3>
<p>Once staging works, repeat with the production ClusterIssuer. The only difference is the <code>server</code> URL:</p>
<pre><code class="language-yaml"># clusterissuer-prod.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: your-email@example.com
    privateKeySecretRef:
      name: letsencrypt-prod-account-key
    solvers:
      - http01:
          ingress:
            ingressClassName: nginx
</code></pre>
<pre><code class="language-bash">kubectl apply -f clusterissuer-prod.yaml
kubectl annotate ingress echo \
  cert-manager.io/cluster-issuer=letsencrypt-prod --overwrite -n default
kubectl delete secret echo-tls -n default
</code></pre>
<p>cert-manager detects the missing Secret and immediately requests a browser-trusted certificate from production Let's Encrypt.</p>
<p>cert-manager detects the missing Secret and immediately triggers a new certificate request using the production issuer.</p>
<h2 id="heading-how-to-get-a-wildcard-certificate-with-dns-01">How to Get a Wildcard Certificate with DNS-01</h2>
<p>HTTP-01 challenges work well for single domains with public ingress. But there are two situations where you need DNS-01 instead: when your cluster is not publicly accessible (internal clusters, air-gapped environments, staging namespaces behind a VPN), and when you want a wildcard certificate that covers all subdomains of your domain.</p>
<p>DNS-01 requires cert-manager to be able to create and delete TXT records in your DNS provider. cert-manager has built-in support for Route53, Cloud DNS, Cloudflare, Azure DNS, and many others.</p>
<p>Here is a <code>ClusterIssuer</code> for DNS-01 using AWS Route53:</p>
<pre><code class="language-yaml"># clusterissuer-dns01.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-dns01
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: your-email@example.com
    privateKeySecretRef:
      name: letsencrypt-dns01-account-key
    solvers:
      - dns01:
          route53:
            region: us-east-1
            # Use IRSA (IAM Roles for Service Accounts) in production
            # rather than static credentials
            hostedZoneID: YOUR_HOSTED_ZONE_ID
</code></pre>
<p>A wildcard <code>Certificate</code> using that issuer:</p>
<pre><code class="language-yaml"># wildcard-cert.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: wildcard-example-com
  namespace: default
spec:
  secretName: wildcard-example-com-tls
  issuerRef:
    name: letsencrypt-dns01
    kind: ClusterIssuer
  commonName: "*.example.com"
  dnsNames:
    - "*.example.com"
    - "example.com"        # Also cover the apex domain
  duration: 2160h           # 90 days
  renewBefore: 720h         # Renew 30 days before expiry
</code></pre>
<p>The resulting Secret <code>wildcard-example-com-tls</code> can be referenced by any Ingress in the <code>default</code> namespace. All subdomains — <code>api.example.com</code>, <code>dashboard.example.com</code>, <code>staging.example.com</code> — are covered by a single certificate that rotates automatically.</p>
<p>For Cloudflare instead of Route53, the solver section looks like this:</p>
<pre><code class="language-yaml">    solvers:
      - dns01:
          cloudflare:
            email: your-email@example.com
            apiTokenSecretRef:
              name: cloudflare-api-token
              key: api-token
</code></pre>
<h2 id="heading-demo-2-set-up-an-internal-ca-for-service-to-service-tls">Demo 2 — Set Up an Internal CA for Service-to-Service TLS</h2>
<p>Let's Encrypt certificates are great for public-facing services. But for internal services — a gRPC microservice calling another, a web application talking to its database — you don't need public trust. You need a CA that the cluster trusts, and you need it to issue certificates for service names that don't exist as public DNS records.</p>
<p>cert-manager's CA issuer handles this. You create a root CA, tell cert-manager about it, and then issue certificates for internal services using that CA. Every service that trusts the root CA trusts every certificate it issues.</p>
<h3 id="heading-step-1-create-a-self-signed-clusterissuer">Step 1: Create a self-signed ClusterIssuer</h3>
<p>A self-signed issuer generates certificates that are signed by the certificate itself — it is its own CA. You use this as a bootstrap step to create the root CA certificate:</p>
<pre><code class="language-yaml"># selfsigned-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: selfsigned
spec:
  selfSigned: {}
</code></pre>
<pre><code class="language-bash">kubectl apply -f selfsigned-issuer.yaml
</code></pre>
<h3 id="heading-step-2-create-the-root-ca-certificate">Step 2: Create the root CA certificate</h3>
<p>Use the self-signed issuer to create a CA certificate. The <code>isCA: true</code> field tells cert-manager this certificate can sign other certificates:</p>
<pre><code class="language-yaml"># internal-ca.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: internal-ca
  namespace: cert-manager    # Store in cert-manager namespace
spec:
  isCA: true
  commonName: internal-ca
  secretName: internal-ca-secret
  duration: 87600h           # 10 years — this is a root CA
  renewBefore: 720h
  privateKey:
    algorithm: ECDSA
    size: 256
  issuerRef:
    name: selfsigned
    kind: ClusterIssuer
</code></pre>
<pre><code class="language-bash">kubectl apply -f internal-ca.yaml
kubectl get certificate internal-ca -n cert-manager
</code></pre>
<pre><code class="language-plaintext">NAME          READY   SECRET               AGE
internal-ca   True    internal-ca-secret   8s
</code></pre>
<h3 id="heading-step-3-create-a-ca-clusterissuer-backed-by-the-root-ca">Step 3: Create a CA ClusterIssuer backed by the root CA</h3>
<p>Now create a <code>ClusterIssuer</code> that uses the root CA Secret you just created. This is the issuer that will sign certificates for your internal services:</p>
<pre><code class="language-yaml"># internal-ca-issuer.yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: internal-ca
spec:
  ca:
    secretName: internal-ca-secret   # References the Secret in cert-manager namespace
</code></pre>
<pre><code class="language-bash">kubectl apply -f internal-ca-issuer.yaml
kubectl get clusterissuer internal-ca
</code></pre>
<pre><code class="language-plaintext">NAME          READY   AGE
internal-ca   True    5s
</code></pre>
<h3 id="heading-step-4-issue-a-certificate-for-an-internal-service">Step 4: Issue a certificate for an internal service</h3>
<p>Now issue a certificate for an internal gRPC service. The <code>dnsNames</code> use Kubernetes internal DNS names — <code>&lt;service&gt;.&lt;namespace&gt;.svc.cluster.local</code>:</p>
<pre><code class="language-yaml"># payments-cert.yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: payments-tls
  namespace: production
spec:
  secretName: payments-tls-secret
  issuerRef:
    name: internal-ca
    kind: ClusterIssuer
  commonName: payments.production.svc.cluster.local
  dnsNames:
    - payments.production.svc.cluster.local
    - payments.production.svc
    - payments
  duration: 2160h     # 90 days
  renewBefore: 360h   # Renew 15 days before expiry
</code></pre>
<pre><code class="language-bash">kubectl create namespace production
kubectl apply -f payments-cert.yaml
kubectl get certificate payments-tls -n production
</code></pre>
<pre><code class="language-plaintext">NAME           READY   SECRET                AGE
payments-tls   True    payments-tls-secret   6s
</code></pre>
<p>The Secret <code>payments-tls-secret</code> now contains <code>tls.crt</code>, <code>tls.key</code>, and <code>ca.crt</code>. Mount this into your application pod:</p>
<pre><code class="language-yaml"># In your Deployment spec
volumes:
  - name: tls
    secret:
      secretName: payments-tls-secret
containers:
  - name: payments
    volumeMounts:
      - name: tls
        mountPath: /etc/tls
        readOnly: true
</code></pre>
<p>Your application reads <code>/etc/tls/tls.crt</code> and <code>/etc/tls/tls.key</code> to configure TLS. Other services that need to trust it read <code>/etc/tls/ca.crt</code>.</p>
<h3 id="heading-step-5-distribute-the-ca-bundle-with-trust-manager">Step 5: Distribute the CA bundle with trust-manager</h3>
<p>The problem with a custom CA is that every service needs to know about it. cert-manager's companion tool, trust-manager, handles this by distributing the CA bundle as a <code>ConfigMap</code> to every namespace:</p>
<pre><code class="language-bash">helm upgrade trust-manager oci://quay.io/jetstack/charts/trust-manager \
  --install \
  --namespace cert-manager \
  --wait
</code></pre>
<p>Create a <code>Bundle</code> resource that takes the CA certificate from the <code>internal-ca-secret</code> and distributes it cluster-wide:</p>
<pre><code class="language-yaml"># ca-bundle.yaml
apiVersion: trust.cert-manager.io/v1alpha1
kind: Bundle
metadata:
  name: internal-ca-bundle
spec:
  sources:
    - secret:
        name: internal-ca-secret
        key: ca.crt
  target:
    configMap:
      key: ca-bundle.crt
    namespaceSelector:
      matchLabels:
        # Distribute to all namespaces with this label
        kubernetes.io/metadata.name: production
</code></pre>
<pre><code class="language-bash">kubectl apply -f ca-bundle.yaml
</code></pre>
<p>After a few seconds, every matching namespace has a ConfigMap named <code>internal-ca-bundle</code> containing the CA certificate. Applications mount this ConfigMap to trust internally-issued certificates without any per-service configuration.</p>
<h3 id="heading-step-6-verify-the-certificate-chain">Step 6: Verify the certificate chain</h3>
<pre><code class="language-bash"># Extract the CA cert and service cert
kubectl get secret payments-tls-secret -n production \
  -o jsonpath='{.data.ca\.crt}' | base64 -d &gt; ca.crt

kubectl get secret payments-tls-secret -n production \
  -o jsonpath='{.data.tls\.crt}' | base64 -d &gt; payments.crt

# Verify the cert was signed by the CA
openssl verify -CAfile ca.crt payments.crt
</code></pre>
<pre><code class="language-plaintext">payments.crt: OK
</code></pre>
<h2 id="heading-how-certificate-rotation-works">How Certificate Rotation Works</h2>
<p>Certificate rotation is the part of certificate management that breaks production clusters most often. cert-manager handles it automatically, but understanding the mechanism helps you tune it and debug it when things go wrong.</p>
<p>cert-manager watches every <code>Certificate</code> resource it manages and checks the expiry of the underlying certificate in the Secret. When the remaining validity drops below the <code>renewBefore</code> threshold, cert-manager triggers a renewal. The default <code>renewBefore</code> is 1/3 of the certificate's total validity period — so a 90-day certificate starts renewing at day 60.</p>
<p>The renewal creates a new <code>CertificateRequest</code>, goes through the full issuance flow, and updates the Secret in place. The new certificate replaces the old one atomically. Applications that use file mounts and watch for changes (most modern web servers and gRPC frameworks do) will pick up the new certificate without restarting.</p>
<pre><code class="language-bash"># See the current rotation status
kubectl describe certificate echo-tls -n default
</code></pre>
<p>Look for these fields in the output:</p>
<pre><code class="language-plaintext">Status:
  Not After:   2024-06-18T10:00:00Z
  Not Before:  2024-03-20T10:00:00Z
  Renewal Time: 2024-05-18T10:00:00Z   # When cert-manager will start renewing
  Conditions:
    Type:    Ready
    Status:  True
    Message: Certificate is up to date and has not expired
</code></pre>
<p>If a renewal fails — for example, because the HTTP-01 challenge can't be completed — cert-manager retries with exponential backoff. The existing certificate continues to serve until it actually expires, giving you a window to debug the issue.</p>
<p>To see renewal events in real time:</p>
<pre><code class="language-bash">kubectl get events -n default --field-selector reason=Issued
kubectl get events -n default --field-selector reason=Failed
</code></pre>
<p><strong>Setting</strong> <code>renewBefore</code> <strong>correctly:</strong> For public-facing services, 30 days before a 90-day certificate is a sensible buffer. For internal short-lived certificates (24-hour validity), set <code>renewBefore</code> to 8 hours so rotation happens well before expiry even if the first attempt fails. Never set <code>renewBefore</code> to more than half the certificate's validity — cert-manager will immediately try to renew a certificate it just issued.</p>
<h2 id="heading-cleanup">Cleanup</h2>
<pre><code class="language-bash"># Remove demo resources
kubectl delete ingress echo -n default
kubectl delete service echo -n default
kubectl delete deployment echo -n default
kubectl delete secret echo-tls -n default
kubectl delete certificate payments-tls -n production
kubectl delete namespace production

# Uninstall cert-manager and trust-manager
helm uninstall trust-manager -n cert-manager
helm uninstall cert-manager -n cert-manager
kubectl delete namespace cert-manager

# Remove ClusterIssuers
kubectl delete clusterissuer letsencrypt-staging letsencrypt-prod \
  internal-ca selfsigned 2&gt;/dev/null
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Kubernetes leaves TLS configuration entirely to you. In this article you worked through both the public and internal sides of that responsibility.</p>
<p>On the public side, you installed cert-manager using the current OCI Helm chart, created a <code>ClusterIssuer</code> backed by Let's Encrypt, and watched cert-manager go through the full ACME HTTP-01 challenge flow — from creating a temporary solver pod to storing a valid certificate in a Kubernetes Secret. You saw how switching from staging to production is a one-line annotation change, and how cert-manager renews certificates automatically before they expire.</p>
<p>On the internal side, you bootstrapped a private CA using cert-manager's self-signed issuer, created a <code>ClusterIssuer</code> backed by that CA, and issued certificates for internal service names that only exist inside the cluster. You used trust-manager to distribute the CA bundle cluster-wide so services can trust each other's certificates without per-service configuration. And you saw how to verify the certificate chain with <code>openssl</code> so you can confirm it's working before deploying to production.</p>
<p>Understanding certificate rotation is what separates teams that manage TLS confidently from teams that get woken up at 3am by an expired certificate. cert-manager automates the renewal, but the <code>renewBefore</code> field is your safety margin — set it correctly and know how to read the renewal status.</p>
<p>All YAML manifests and Helm values from this article are available in the <a href="https://github.com/Caesarsage/DevOps-Cloud-Projects/tree/main/intermediate/k8/security/cert-manager">DevOps-Cloud-Projects GitHub repository</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Local DevOps HomeLab with Docker, Kubernetes, and Ansible ]]>
                </title>
                <description>
                    <![CDATA[ The first time I tried to follow a DevOps tutorial, it told me to sign up for AWS. I did. I spun up an EC2 instance, followed along for an hour, and then forgot to shut it down. A week later I had a $ ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-local-devops-homelab-with-docker-kubernetes-and-ansible/</link>
                <guid isPermaLink="false">69dd667c217f5dfcbd55b7b4</guid>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Homelab ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops articles ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Osomudeya Zudonu ]]>
                </dc:creator>
                <pubDate>Mon, 13 Apr 2026 21:56:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/1e970f8b-eb52-4582-9c98-13cbce867c89.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The first time I tried to follow a DevOps tutorial, it told me to sign up for AWS.</p>
<p>I did. I spun up an EC2 instance, followed along for an hour, and then forgot to shut it down. A week later I had a $34 bill for a machine running nothing.</p>
<p>That was the last time I practiced on someone else's infrastructure.</p>
<p>Everything in this guide runs on your laptop. No cloud account, no credit card, no bill at the end of the month. By the end, you'll be able to spin up a multi-server environment from scratch, configure it automatically with Ansible, serve a site you wrote yourself, and diagnose what breaks when you intentionally destroy it.</p>
<p>That last part is where the actual learning happens.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have:</p>
<ul>
<li><p>A laptop with at least 8GB of RAM (16GB is better)</p>
</li>
<li><p>At least 20GB of free disk space</p>
</li>
<li><p>Windows, macOS, or Linux operating system</p>
</li>
<li><p>Administrator access to your computer</p>
</li>
<li><p>Virtualization enabled in your BIOS/UEFI settings</p>
</li>
<li><p>A stable internet connection for the initial downloads</p>
</li>
</ul>
<p>Knowledge and comfort level:</p>
<ul>
<li><p>You should be comfortable using a terminal (running commands, changing directories, and editing small text files with whatever editor you like).</p>
</li>
<li><p>Basic familiarity with concepts like “a server,” “SSH,” and “a port” helps, but you don't need prior experience with Docker, Kubernetes, Vagrant, or Ansible. This guide introduces them as you go.</p>
</li>
</ul>
<p>If you can follow step-by-step instructions and read error output without panicking, you're ready.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-what-is-devops">What is DevOps?</a></p>
</li>
<li><p><a href="#heading-why-build-a-local-lab">Why Build a Local Lab?</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-docker">How to Set Up Docker</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-kubernetes">How to Set Up Kubernetes</a></p>
</li>
<li><p><a href="#heading-how-to-install-kubectl">How to Install kubectl</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-vagrant">How to Set Up Vagrant</a></p>
</li>
<li><p><a href="#heading-how-to-install-ansible">How to Install Ansible</a></p>
</li>
<li><p><a href="#heading-how-to-build-your-first-devops-project">How to Build Your First DevOps Project</a></p>
</li>
<li><p><a href="#heading-how-to-break-your-lab-on-purpose">How to Break Your Lab on Purpose</a></p>
</li>
<li><p><a href="#heading-what-you-can-now-do">What You Can Now Do</a></p>
</li>
</ol>
<h2 id="heading-what-is-devops">What is DevOps?</h2>
<p>DevOps is the practice of breaking down the wall between software development and IT operations teams.</p>
<p>Traditionally, developers write code and hand it off to operations teams to deploy and maintain. That handoff causes delays, misunderstandings, and outages. DevOps is what happens when both teams work together from the start.</p>
<p>The tools you'll install in this guide each solve a specific part of that process:</p>
<ul>
<li><p><strong>Docker</strong> packages your application and everything it needs into a portable container that runs the same way on any machine.</p>
</li>
<li><p><strong>Kubernetes</strong> manages multiple containers at scale, handling restarts, networking, and load balancing automatically.</p>
</li>
<li><p><strong>Vagrant</strong> creates and manages virtual machine environments so your whole team always works on identical setups.</p>
</li>
<li><p><strong>Ansible</strong> automates repetitive configuration tasks across many servers without writing a script for each one.</p>
</li>
</ul>
<h2 id="heading-why-build-a-local-lab">Why Build a Local Lab?</h2>
<p>A local lab gives you a safe place to break things, fix them, and learn from that process without any cost or risk.</p>
<p>Here's what you get with a local setup:</p>
<ul>
<li><p><strong>Zero cost.</strong> No cloud bills, no surprise charges, and no credit card required.</p>
</li>
<li><p><strong>Works offline.</strong> Practice anywhere, even without internet after the initial setup.</p>
</li>
<li><p><strong>Full control.</strong> You manage every layer from the OS up to the application.</p>
</li>
<li><p><strong>Safe experimentation.</strong> Break things freely. Nothing here affects production.</p>
</li>
<li><p><strong>Fast feedback.</strong> No waiting for cloud resources to spin up. Everything runs on your machine.</p>
</li>
</ul>
<p>The tradeoff is resource limits. Your laptop's CPU and RAM are the ceiling. You can't simulate large-scale deployments, and some cloud-native services like AWS Lambda or S3 have no direct local equivalent. But for learning core DevOps workflows, none of that matters.</p>
<h2 id="heading-how-to-set-up-docker">How to Set Up Docker</h2>
<p>Docker is the foundation of this lab. Every other tool in this guide either runs inside Docker containers or works alongside them.</p>
<h3 id="heading-how-to-install-docker-on-windows">How to Install Docker on Windows</h3>
<p>First, enable virtualization in your BIOS:</p>
<ol>
<li><p>Restart your computer and enter BIOS/UEFI setup. The key is usually F2, F10, Del, or Esc during boot.</p>
</li>
<li><p>Find the virtualization setting. It's usually listed as Intel VT-x, AMD-V, SVM, or Virtualization Technology.</p>
</li>
<li><p>Enable it, save your changes, and exit.</p>
</li>
</ol>
<p>Then install Docker Desktop:</p>
<ol>
<li><p>Download Docker Desktop from <a href="https://www.docker.com/products/docker-desktop/">Docker's official website</a>.</p>
</li>
<li><p>Run the installer and follow the prompts.</p>
</li>
<li><p>Enable WSL 2 (Windows Subsystem for Linux) when asked.</p>
</li>
<li><p>Restart your computer.</p>
</li>
<li><p>Open Docker Desktop from the Start menu and wait for the whale icon in the taskbar to stop animating.</p>
</li>
</ol>
<p><strong>Troubleshooting:</strong> If Docker fails to start, run this in PowerShell as Administrator to verify virtualization is active:</p>
<pre><code class="language-powershell">systeminfo | findstr "Hyper-V Requirements"
</code></pre>
<p>All items should show "Yes". If they don't, revisit your BIOS settings.</p>
<h3 id="heading-how-to-install-docker-on-mac">How to Install Docker on Mac</h3>
<ol>
<li><p>Download Docker Desktop for Mac from <a href="https://www.docker.com/products/docker-desktop/">Docker's website</a>.</p>
</li>
<li><p>Open the downloaded <code>.dmg</code> file and drag Docker to your Applications folder.</p>
</li>
<li><p>Open Docker from Applications.</p>
</li>
<li><p>Enter your password when prompted.</p>
</li>
<li><p>Wait for the whale icon in the menu bar to stop animating.</p>
</li>
</ol>
<h3 id="heading-how-to-install-docker-on-linux">How to Install Docker on Linux</h3>
<p>Run these commands in order:</p>
<pre><code class="language-bash"># Update your package lists
sudo apt-get update

# Install prerequisites
sudo apt-get install apt-transport-https ca-certificates curl software-properties-common

# Add Docker's official GPG key
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add -

# Add the Docker repository
sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"

# Update and install Docker
sudo apt-get update
sudo apt-get install docker-ce

# Start and enable Docker
sudo systemctl start docker
sudo systemctl enable docker

# Add your user to the docker group
sudo usermod -aG docker $USER
</code></pre>
<p>Log out and back in for the group change to take effect.</p>
<h3 id="heading-how-to-test-docker">How to Test Docker</h3>
<p>Run this command:</p>
<pre><code class="language-bash">docker run hello-world
</code></pre>
<p>If you see "Hello from Docker!" then Docker is working correctly.</p>
<p>Docker is set up. Next, you'll install Kubernetes to manage containers at scale.</p>
<h2 id="heading-how-to-set-up-kubernetes">How to Set Up Kubernetes</h2>
<p>Kubernetes manages containers at scale. For a local lab, you have four options. Here's how to choose:</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>Best for</th>
<th>RAM needed</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Minikube</strong></td>
<td>Beginners. Easiest setup, built-in dashboard</td>
<td>2GB+</td>
</tr>
<tr>
<td><strong>Kind</strong></td>
<td>Faster startup, works well inside CI pipelines</td>
<td>1GB+</td>
</tr>
<tr>
<td><strong>k3s</strong></td>
<td>Low-resource machines. Lightweight but production-like</td>
<td>512MB+</td>
</tr>
<tr>
<td><strong>kubeadm</strong></td>
<td>Learning how clusters are actually bootstrapped in production</td>
<td>2GB+ per node</td>
</tr>
</tbody></table>
<p>If you're just starting out, use Minikube. It has the simplest setup and a visual dashboard that helps you understand what's happening inside the cluster.</p>
<p>If your laptop has 8GB RAM or less, use k3s. It runs lean and behaves closer to a real cluster than Minikube does.</p>
<p>Use kubeadm only if you want to understand how Kubernetes nodes join a cluster — it requires more manual steps and isn't beginner-friendly.</p>
<h3 id="heading-how-to-install-minikube-recommended-for-beginners">How to Install Minikube (Recommended for Beginners)</h3>
<p>Minikube creates a single-node Kubernetes cluster on your laptop.</p>
<p>On Windows:</p>
<ol>
<li><p>Download the Minikube installer from <a href="https://github.com/kubernetes/minikube/releases">Minikube's GitHub releases page</a>.</p>
</li>
<li><p>Run the <code>.exe</code> installer.</p>
</li>
<li><p>Open Command Prompt as Administrator and start Minikube:</p>
</li>
</ol>
<pre><code class="language-cmd">minikube start --driver=docker
</code></pre>
<p>On Mac:</p>
<pre><code class="language-bash">brew install minikube
minikube start --driver=docker
</code></pre>
<p>On Linux:</p>
<pre><code class="language-bash">curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
chmod +x minikube-linux-amd64
sudo mv minikube-linux-amd64 /usr/local/bin/minikube
minikube start --driver=docker
</code></pre>
<p>Test your cluster:</p>
<pre><code class="language-bash">minikube status
minikube dashboard
</code></pre>
<h3 id="heading-how-to-install-k3s-recommended-for-low-ram-machines">How to Install k3s (Recommended for Low-RAM Machines)</h3>
<p>k3s is a lightweight version of Kubernetes that installs in under a minute. It runs lean and behaves like a real cluster — not a simplified demo version.</p>
<p>On Linux (and Mac via Multipass):</p>
<pre><code class="language-bash">curl -sfL https://get.k3s.io | sh -
</code></pre>
<p>That single command installs k3s and runs it automatically in the background. Check that it is running:</p>
<pre><code class="language-bash">sudo k3s kubectl get nodes
</code></pre>
<p>You should see one node with status <code>Ready</code>.</p>
<p>On Mac directly — k3s doesn't run natively on macOS. Use <a href="https://multipass.run">Multipass</a> to spin up a lightweight Ubuntu VM first, then run the install command inside it.</p>
<p>On Windows — use WSL2 (Ubuntu), then run the install command inside your WSL2 terminal.</p>
<h3 id="heading-how-to-install-kind-kubernetes-in-docker">How to Install Kind (Kubernetes IN Docker)</h3>
<p>Kind runs a full Kubernetes cluster inside Docker containers. It starts faster than Minikube and is useful if you want to run multiple clusters simultaneously.</p>
<pre><code class="language-bash"># Mac or Linux
brew install kind

# Windows
choco install kind
</code></pre>
<p>Create a cluster:</p>
<pre><code class="language-bash">kind create cluster --name my-local-lab
</code></pre>
<h3 id="heading-how-to-install-kubeadm-for-understanding-cluster-bootstrap">How to Install kubeadm (For Understanding Cluster Bootstrap)</h3>
<p>kubeadm is the tool Kubernetes uses to initialize and join nodes in a real cluster. Use this when you want to understand what happens under the hood — not as your daily driver.</p>
<p>It requires at least two machines (or VMs). The setup is more involved than the options above. Follow the <a href="https://kubernetes.io/docs/setup/production-environment/tools/kubeadm/install-kubeadm/">official kubeadm installation guide</a> for your OS, then initialize your cluster:</p>
<pre><code class="language-bash">sudo kubeadm init --pod-network-cidr=10.244.0.0/16
</code></pre>
<p>After init, join worker nodes using the command kubeadm prints at the end of the output.</p>
<h3 id="heading-how-to-install-kubectl">How to Install kubectl</h3>
<p>kubectl is the command-line tool you use to interact with any Kubernetes cluster.</p>
<p>On Windows:</p>
<p>Download <code>kubectl.exe</code> from <a href="https://kubernetes.io/docs/tasks/tools/install-kubectl-windows/">Kubernetes' website</a> and place it in a directory that is in your PATH. Or install with Chocolatey:</p>
<pre><code class="language-cmd">choco install kubernetes-cli
</code></pre>
<p>On Mac:</p>
<pre><code class="language-bash">brew install kubectl
</code></pre>
<p>On Linux:</p>
<pre><code class="language-bash">curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/kubectl
</code></pre>
<p>Test it:</p>
<pre><code class="language-bash">kubectl get pods --all-namespaces
</code></pre>
<p>On a fresh cluster, you'll see system pods running in the <code>kube-system</code> namespace — things like <code>coredns</code> and <code>storage-provisioner</code>. That's the expected output. It means your cluster is up and kubectl can talk to it.</p>
<p>Kubernetes is running. Next is Vagrant. But before that, there's one important distinction worth making.</p>
<h4 id="heading-docker-vs-vagrant-they-arent-the-same-thing">Docker vs Vagrant — they aren't the same thing</h4>
<p>Docker creates containers: lightweight processes that share your operating system's kernel. Vagrant creates full virtual machines: isolated computers with their own OS running inside your laptop.</p>
<p>Containers are fast and small. VMs are heavier but behave exactly like real servers. You'll use both in this lab for different reasons.</p>
<h2 id="heading-how-to-set-up-vagrant">How to Set Up Vagrant</h2>
<p>Vagrant lets you create and manage reproducible virtual machine environments. It is ideal for simulating multi-server setups on a single laptop.</p>
<h3 id="heading-how-to-install-vagrant-on-windows">How to Install Vagrant on Windows</h3>
<ol>
<li><p>Download and install <a href="https://www.virtualbox.org/wiki/Downloads">VirtualBox</a> with default options.</p>
</li>
<li><p>Download and install <a href="https://developer.hashicorp.com/vagrant/downloads">Vagrant</a>.</p>
</li>
<li><p>Restart your computer if prompted.</p>
</li>
</ol>
<p><strong>Note:</strong> VirtualBox and Hyper-V can't run at the same time on Windows. Check if Hyper-V is active:</p>
<pre><code class="language-cmd">systeminfo | findstr "Hyper-V"
</code></pre>
<p>If it's enabled, you have two options: switch to the Hyper-V Vagrant provider, or disable Hyper-V with:</p>
<pre><code class="language-powershell">Disable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All
</code></pre>
<p>Restart after disabling.</p>
<h3 id="heading-how-to-install-vagrant-on-mac-and-linux">How to Install Vagrant on Mac and Linux</h3>
<p>On Mac:</p>
<ol>
<li><p>Download and install <a href="https://www.virtualbox.org/wiki/Downloads">VirtualBox</a>.</p>
</li>
<li><p>After installation, open <strong>System Preferences &gt; Security &amp; Privacy &gt; General</strong>. You will see a message saying system software from Oracle was blocked. Click <strong>Allow</strong> and restart your Mac. Without this step, VirtualBox will not run.</p>
</li>
<li><p>Download and install <a href="https://developer.hashicorp.com/vagrant/downloads">Vagrant</a>.</p>
</li>
</ol>
<p><strong>Note for Apple Silicon (M1/M2/M3) Macs:</strong> VirtualBox support on Apple Silicon is still limited. If you're on an M-series Mac, use <a href="https://mac.getutm.app/">UTM</a> as your VM provider instead, or use Multipass which works natively on Apple Silicon.</p>
<p>On Linux:</p>
<ol>
<li><p>Download and install <a href="https://www.virtualbox.org/wiki/Downloads">VirtualBox</a>.</p>
</li>
<li><p>Download and install <a href="https://developer.hashicorp.com/vagrant/downloads">Vagrant</a>.</p>
</li>
</ol>
<p>Verify both are installed:</p>
<pre><code class="language-bash">vboxmanage --version
vagrant --version
</code></pre>
<h3 id="heading-how-to-create-your-first-vagrant-environment">How to Create Your First Vagrant Environment</h3>
<p>Create a new directory for your project. Inside it, create a file named <code>Vagrantfile</code> with this content:</p>
<pre><code class="language-ruby">Vagrant.configure("2") do |config|
  config.vm.box = "ubuntu/focal64"

  # Create a private network between VMs
  config.vm.network "private_network", type: "dhcp"

  # Forward port 8080 on your laptop to port 80 on the VM
  config.vm.network "forwarded_port", guest: 80, host: 8080

  # Install Nginx when the VM starts
  config.vm.provision "shell", inline: &lt;&lt;-SHELL
    apt-get update
    apt-get install -y nginx
    echo "Hello from Vagrant!" &gt; /var/www/html/index.html
  SHELL
end
</code></pre>
<p>Start the VM:</p>
<pre><code class="language-bash">vagrant up
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/342f11ad-7c7d-40d2-a810-113b8c71edac.png" alt="screnshot showing VB server and terminal installation processes" style="display: block;" width="1848" height="323" loading="lazy">

<p>Visit <code>http://localhost:8080</code> in your browser. You should see "Hello from Vagrant!"</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/bcd66a76-4a5b-4f26-bb7e-e203672968d8.png" alt="screenshot showing &quot;Hello from Vagrant!&quot; in browser" style="display: block;" width="643" height="483" loading="lazy">

<h4 id="heading-troubleshooting-ssh-on-windows">Troubleshooting SSH on Windows</h4>
<p>If <code>vagrant ssh</code> fails, try:</p>
<pre><code class="language-bash">vagrant ssh -- -v
</code></pre>
<p>Or connect manually:</p>
<pre><code class="language-bash">ssh -i .vagrant/machines/default/virtualbox/private_key vagrant@127.0.0.1 -p 2222
</code></pre>
<h3 id="heading-how-to-create-a-local-vagrant-box-without-internet">How to Create a Local Vagrant Box Without Internet</h3>
<p><strong>Note:</strong> Most readers can skip this. Only do this if you want to work fully offline after the initial setup.</p>
<ol>
<li><p>Download <a href="https://ubuntu.com/download/server">Ubuntu 20.04 LTS</a> and save the <code>.iso</code> file locally.</p>
</li>
<li><p>Open VirtualBox and create a new VM: Name it <code>ubuntu-devops</code>, Type: Linux, Version: Ubuntu (64-bit).</p>
</li>
<li><p>Assign 2048MB RAM and a 20GB VDI disk.</p>
</li>
<li><p>Attach the <code>.iso</code> under Storage &gt; Optical Drive.</p>
</li>
<li><p>Start the VM and complete the Ubuntu installation.</p>
</li>
<li><p>Once installed, shut down the VM and run:</p>
</li>
</ol>
<pre><code class="language-bash">VBoxManage list vms
vagrant package --base "ubuntu-devops" --output ubuntu2004.box
vagrant box add ubuntu2004 ubuntu2004.box
</code></pre>
<p>You now have a reusable local box that works without internet.</p>
<p>You can spin up virtual machines. Next is Ansible, which automates what goes inside them.</p>
<h2 id="heading-how-to-install-ansible">How to Install Ansible</h2>
<p>Ansible automates configuration and software installation across multiple servers. Instead of SSH-ing into ten machines and running the same commands manually, you write a playbook once and Ansible handles the rest.</p>
<h3 id="heading-how-to-install-ansible-on-windows">How to Install Ansible on Windows</h3>
<p>Ansible doesn't run natively on Windows. You need to use it through WSL (Windows Subsystem for Linux).</p>
<ol>
<li>Open PowerShell as Administrator and enable WSL:</li>
</ol>
<pre><code class="language-powershell">dism.exe /online /enable-feature /featurename:Microsoft-Windows-Subsystem-Linux /all /norestart
dism.exe /online /enable-feature /featurename:VirtualMachinePlatform /all /norestart
</code></pre>
<ol>
<li><p>Restart your computer.</p>
</li>
<li><p>Install Ubuntu from the Microsoft Store.</p>
</li>
<li><p>Open Ubuntu and install Ansible:</p>
</li>
</ol>
<pre><code class="language-bash">sudo apt update
sudo apt install software-properties-common
sudo apt-add-repository --yes --update ppa:ansible/ansible
sudo apt install ansible
</code></pre>
<h3 id="heading-how-to-install-ansible-on-mac">How to Install Ansible on Mac</h3>
<pre><code class="language-bash">brew install ansible
</code></pre>
<h3 id="heading-how-to-install-ansible-on-linux">How to Install Ansible on Linux</h3>
<pre><code class="language-bash"># Ubuntu/Debian
sudo apt update
sudo apt install software-properties-common
sudo apt-add-repository --yes --update ppa:ansible/ansible
sudo apt install ansible

# Red Hat/CentOS
sudo yum install ansible
</code></pre>
<h3 id="heading-how-to-test-ansible">How to Test Ansible</h3>
<p>Create a file called <code>hosts</code> in your current directory:</p>
<pre><code class="language-ini">[local]
localhost ansible_connection=local
</code></pre>
<p>Create a file called <code>playbook.yml</code> in the same directory:</p>
<pre><code class="language-yaml">---
- name: Test playbook
  hosts: local
  tasks:
    - name: Print a message
      debug:
        msg: "Ansible is working!"
</code></pre>
<p>Run the playbook, passing the local <code>hosts</code> file with <code>-i</code>:</p>
<pre><code class="language-bash">ansible-playbook -i hosts playbook.yml
</code></pre>
<p>You should see the message "Ansible is working!" in the output.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/081e6ff3-b983-42a0-960e-5340bbd24e3b.png" alt="screenshot showing ansible playbook complete terminal installation" style="display: block;" width="849" height="287" loading="lazy">

<p>Alright, all your tools are installed. Now you'll use them together to build something real.</p>
<h2 id="heading-how-to-build-your-first-devops-project">How to Build Your First DevOps Project</h2>
<p>You can find the entire code for this lab in this repo: <a href="https://github.com/Osomudeya/homelab-demo-article">https://github.com/Osomudeya/homelab-demo-article</a></p>
<p>Now you'll put these tools together in one project. Each tool will perform its actual job, and nothing is forced.</p>
<p><strong>Before you start,</strong> create a fresh directory for this project. Don't run it inside the directory you used to test Vagrant earlier, as the Vagrantfile here is different and will conflict.</p>
<p>You'll be building a two-VM environment: one machine serves a web page you write yourself inside a Docker container, and the other runs a MariaDB database. Vagrant creates the machines and Ansible configures them. The page you see at the end is yours.</p>
<h3 id="heading-step-1-create-the-project-directory">Step 1: Create the Project Directory</h3>
<pre><code class="language-bash">mkdir devops-lab-project &amp;&amp; cd devops-lab-project
</code></pre>
<h3 id="heading-step-2-write-your-site-content">Step 2: Write Your Site Content</h3>
<p>Create a file called <code>index.html</code> in the project directory. Write whatever you want on this page — it's what you'll see in your browser at the end:</p>
<pre><code class="language-html">&lt;!DOCTYPE html&gt;
&lt;html&gt;
  &lt;head&gt;&lt;title&gt;My DevOps Lab&lt;/title&gt;&lt;/head&gt;
  &lt;body&gt;
    &lt;h1&gt;My DevOps Lab&lt;/h1&gt;
    &lt;p&gt;Provisioned by Vagrant. Configured by Ansible. Served by Docker.&lt;/p&gt;
    &lt;p&gt;Built on a laptop. No cloud account needed.&lt;/p&gt;
  &lt;/body&gt;
&lt;/html&gt;
</code></pre>
<p>Change the text to whatever you like. This is your page.</p>
<h3 id="heading-step-3-write-the-vagrantfile">Step 3: Write the Vagrantfile</h3>
<p>Create a file called <code>Vagrantfile</code> in the same directory:</p>
<pre><code class="language-ruby">Vagrant.configure("2") do |config|
  config.vm.box = "ubuntu/focal64"

  config.vm.define "web" do |web|
    web.vm.network "private_network", ip: "192.168.33.10"
    web.vm.network "forwarded_port", guest: 80, host: 8080
  end

  config.vm.define "db" do |db|
    db.vm.network "private_network", ip: "192.168.33.11"
  end
end
</code></pre>
<h3 id="heading-step-4-start-the-virtual-machines">Step 4: Start the Virtual Machines</h3>
<pre><code class="language-bash">vagrant up
</code></pre>
<p>The first run downloads the <code>ubuntu/focal64</code> box, which is around 500MB.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/264866b0-9977-490e-96a3-69b3070be589.png" alt="screenshot showing virtualbox installation processes in terminal" style="display: block;" width="867" height="377" loading="lazy">

<p>Expect this to take 10–30 minutes depending on your connection. Subsequent runs will be much faster since the box is cached locally.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/118d2fb2-70f6-41e8-afb2-6f45fb895e98.png" alt="screenshot showing 2 virtualbox servers &quot;running&quot; in VB manager" style="display: block;" width="926" height="396" loading="lazy">

<h3 id="heading-step-5-create-the-ansible-inventory">Step 5: Create the Ansible Inventory</h3>
<p>Create a file called <code>inventory</code> in the same directory:</p>
<pre><code class="language-ini">[webservers]
192.168.33.10 ansible_user=vagrant ansible_ssh_private_key_file=.vagrant/machines/web/virtualbox/private_key

[dbservers]
192.168.33.11 ansible_user=vagrant ansible_ssh_private_key_file=.vagrant/machines/db/virtualbox/private_key
</code></pre>
<p>Ansible uses the Vagrant-generated private keys so it can SSH in as the <code>vagrant</code> user. Host key checking for this lab is turned off in <code>ansible.cfg</code> (next step), not in the inventory.</p>
<h3 id="heading-step-6-create-the-ansible-config-file">Step 6: Create the Ansible Config File</h3>
<p>Before running the playbook, create a file called <code>ansible.cfg</code> in the same directory:</p>
<pre><code class="language-ini">[defaults]
inventory = inventory
host_key_checking = False
</code></pre>
<p>The inventory line tells Ansible to use the inventory file in this folder by default. host_key_checking = False tells Ansible not to verify SSH host keys when connecting to your Vagrant VMs. Without it, Ansible will fail with a Host key verification failed error on first connection because the VM's key is not yet in your known_hosts file.</p>
<p>These settings are for a local lab only. Do not use host_key_checking = False for production systems.</p>
<h3 id="heading-step-7-create-the-ansible-playbook">Step 7: Create the Ansible Playbook</h3>
<p>Create a file called <code>playbook.yml</code>:</p>
<pre><code class="language-yaml">---
- name: Configure web server
  hosts: webservers
  become: yes
  tasks:

    - name: Install Docker
      apt:
        name: docker.io
        state: present
        update_cache: yes

    - name: Start Docker service
      service:
        name: docker
        state: started
        enabled: yes

    # Create the directory that will hold your site content
    - name: Create web content directory
      file:
        path: /var/www/html
        state: directory
        mode: '0755'

    # This copies your index.html from your laptop into the VM
    - name: Copy site content to web server
      copy:
        src: index.html
        dest: /var/www/html/index.html

    # This mounts that file into the Nginx container so it serves your page
    # The -v flag connects /var/www/html on the VM to /usr/share/nginx/html inside the container
    - name: Run Nginx serving your content
      shell: |
        docker rm -f webapp 2&gt;/dev/null || true
        docker run -d --name webapp --restart always -p 80:80 \
          -v /var/www/html:/usr/share/nginx/html:ro nginx

- name: Configure database server
  hosts: dbservers
  become: yes
  tasks:

    # Hash sum mismatch on .deb downloads is often stale lists, a flaky mirror, or apt pipelining
    # behind NAT; fresh indices + Pipeline-Depth 0 usually fixes it on lab VMs.
    - name: Disable apt HTTP pipelining (mirror/proxy hash mismatch workaround)
      copy:
        dest: /etc/apt/apt.conf.d/99disable-pipelining
        content: 'Acquire::http::Pipeline-Depth "0";'
        mode: "0644"

    - name: Clear apt package index cache
      shell: apt-get clean &amp;&amp; rm -rf /var/lib/apt/lists/* /var/lib/apt/lists/auxfiles/*
      changed_when: true

    - name: Update apt cache after reset
      apt:
        update_cache: yes

    - name: Install MariaDB
      apt:
        name: mariadb-server
        state: present
        update_cache: no

    - name: Start MariaDB service
      service:
        name: mariadb
        state: started
        enabled: yes
</code></pre>
<p>Two lines worth paying attention to:</p>
<ul>
<li><p><code>src: index.html</code> — Ansible looks for this file in the same directory as the playbook. That is the file you wrote in Step 2.</p>
</li>
<li><p><code>-v /var/www/html:/usr/share/nginx/html:ro</code> — this mounts the directory from the VM into the Nginx container. The <code>:ro</code> means read-only. Nginx serves whatever is in that folder.</p>
</li>
</ul>
<h3 id="heading-step-8-run-the-playbook">Step 8: Run the Playbook</h3>
<pre><code class="language-bash">ansible-playbook -i inventory playbook.yml
</code></pre>
<p>You'll see task-by-task output as Ansible connects to each VM over SSH and configures it. A green <code>ok</code> or yellow <code>changed</code> next to each task means it worked. Red <code>fatal</code> means something failed.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/91241b41-981c-4e23-9dc4-8531e551c39e.png" alt="terminal screenshot of A green ok or yellow changed next to each task means it worked. Red fatal means something failed." style="display: block;" width="875" height="267" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/c02db252-8aff-42e5-b937-d812d070a75b.png" alt="terminal screenshot of playbook run completion" style="display: block;" width="867" height="425" loading="lazy">

<h3 id="heading-step-9-verify-the-setup">Step 9: Verify the Setup</h3>
<p>Open <code>http://localhost:8080</code> in your browser. You should see the page you wrote in Step 2 served from inside a Docker container, running on a Vagrant VM, configured automatically by Ansible.</p>
<p>If you see the page, every tool in this lab is working together.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/0d3d897b-3f51-46fb-b548-832cc5ec3272.png" alt="Browser showing localhost:8082 with the heading &quot;My DevOps Lab&quot; and the text &quot;Provisioned by Vagrant. Configured by Ansible. Served by Docker.&quot;" style="display: block;" width="746" height="418" loading="lazy">

<h3 id="heading-step-9-clean-up-optional">Step 9: Clean Up (Optional)</h3>
<p>When you're done:</p>
<pre><code class="language-bash">vagrant destroy -f
</code></pre>
<p>This shuts down and deletes both VMs. Your <code>Vagrantfile</code>, <code>inventory</code>, <code>playbook.yml</code>, and <code>index.html</code> stay on disk — run <code>vagrant up</code> followed by <code>ansible-playbook -i inventory playbook.yml</code> any time to bring it all back.</p>
<p>Now that you have a working lab, let's use it properly.</p>
<h2 id="heading-how-to-break-your-lab-on-purpose">How to Break Your Lab on Purpose</h2>
<p>Following these steps has gotten you a running lab. Breaking things teaches you how everything actually works.</p>
<p>Here are five things to break and what to look for when you do.</p>
<h3 id="heading-break-1-crash-the-main-process-inside-the-container-and-watch-it-come-back">Break 1: Crash the Main Process Inside the Container (and Watch It Come Back)</h3>
<p>Doing this just proves that something inside the container can die (like a real bug or OOM), Docker can restart the container because of <code>--restart always</code>, and your site can come back without re-running Ansible.</p>
<p>After <code>vagrant ssh web</code>, every <code>docker</code> command below runs <strong>on the web VM</strong>. So keep your browser on your laptop at <a href="http://localhost:8080"><code>http://localhost:8080</code></a> (Vagrant forwards your host port to the VM’s port 80).</p>
<h4 id="heading-troubleshooting-if-your-lab-isnt-ready">Troubleshooting: If Your Lab Isn't Ready</h4>
<p>From your project folder on the host (your laptop) – unless the step says to run it on the VM:</p>
<ul>
<li><p>You ran <code>vagrant destroy -f</code>. Run <code>vagrant up</code>, then <code>ansible-playbook -i inventory playbook.yml</code>.</p>
</li>
<li><p><code>docker ps</code> shows <code>webapp</code> but status is Exited. On the web VM, run <code>sudo docker start webapp</code>, then <code>sudo docker ps</code> again.</p>
</li>
<li><p>There's no <code>webapp</code> row in <code>docker ps -a</code><strong>.</strong> Re-run <code>ansible-playbook -i inventory playbook.yml</code> on the host.</p>
</li>
</ul>
<p>If the playbook is already applied and <code>webapp</code> is Up, skip this section and start at step 1 under Steps (happy path) below. (Don't skip SSH or <code>docker ps</code>. You need the VM shell and a quick check before you run <code>docker exec</code>.)</p>
<h4 id="heading-steps-happy-path">Steps (happy path)</h4>
<ol>
<li>SSH into the web VM:</li>
</ol>
<pre><code class="language-plaintext">vagrant ssh web
</code></pre>
<ol>
<li><p>Confirm <code>webapp</code> is <strong>Up</strong>:</p>
<pre><code class="language-plaintext">sudo docker ps
</code></pre>
</li>
<li><p><strong>Break it on purpose:</strong> kill the container’s main process <strong>from inside</strong> (PID 1). That ends the container the same way a crashing app would, not the same as <code>docker stop</code> on the host:</p>
</li>
</ol>
<pre><code class="language-bash">sudo docker exec webapp sh -c 'sleep 5 &amp;&amp; kill 1'
</code></pre>
<p>The <code>sleep</code> 5 gives you a moment to switch to the browser. Right after you run the command, open or refresh <a href="http://localhost:8080"><code>http://localhost:8080</code></a>. You may catch a brief error or blank page while nothing is listening on port 80.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/3ac89703-63f3-45d8-954f-35adbd2c7dec.png" alt="Browser showing ERR_CONNECTION_RESET on localhost:8082 after the Nginx container process was killed" style="display: block;" width="1242" height="1057" loading="lazy">

<ol>
<li>Watch Docker restart the container:</li>
</ol>
<pre><code class="language-bash">watch sudo docker ps -a
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/5c61d90d-61d6-4023-b3f5-e3eb427e8492.png" alt="Terminal running watch docker ps showing webapp container status as Up 10 seconds after automatic restart" style="display: block;" width="1011" height="393" loading="lazy">

<p>Within a few seconds you should see <strong>Exited (137)</strong> become <strong>Up</strong> again. (Press Ctrl+C to exit <code>watch</code>.)</p>
<p>5. Refresh the browser. You should see the same HTML as before, because the files live on the VM under <code>/var/www/html</code> and are bind-mounted into the container; restarting only replaced the Nginx process, not those files.</p>
<h4 id="heading-why-not-docker-stop-or-docker-kill-on-the-host-for-this-demo"><strong>Why not</strong> <code>docker stop</code> <strong>or</strong> <code>docker kill</code> <strong>on the host for this demo?</strong></h4>
<p>Those commands go through Docker’s API. On many setups (including recent Docker), Docker treats them as you choosing to stop the container (<code>hasBeenManuallyStopped</code>), and <code>--restart always</code> may not bring the container back until you <code>docker start</code> it or similar.</p>
<p>Killing PID 1 from inside the container is treated more like an internal crash, so the restart policy you set in the playbook is the one you actually get to observe here.</p>
<p><strong>Kubernetes analogy:</strong> A pod whose containers exit can be restarted by the kubelet; a pod you delete does not come back by itself.</p>
<p><strong>What to observe (three separate checks):</strong></p>
<ol>
<li><p><strong>Exit code:</strong> After <code>kill 1</code>, <code>docker ps -a</code> should show the container exited with code 137, meaning the main process was killed by a signal. That confirms the container really died, not that you ran <code>docker stop</code> on the host.</p>
</li>
<li><p><strong>Restart delay vs browser:</strong> Watch how many seconds pass between Exited and Up in <code>docker ps -a</code>; that interval is Docker applying <code>--restart always</code>. That's separate from what you see in the browser: the browser only shows whether something is accepting connections on port 80 on the VM, so it may show an error or blank page during the gap even while Docker is about to restart the container.</p>
</li>
<li><p><strong>Content after recovery:</strong> After status is Up again, refresh the page. You should see the same HTML as before. That shows your content lives on the VM disk (mounted into the container with <code>-v</code>), not inside a file that vanishes when the container process restarts. The process was replaced, not your <code>index.html</code> on the host path.</p>
</li>
</ol>
<h3 id="heading-break-2-cause-a-container-name-conflict">Break 2: Cause a Container Name Conflict</h3>
<p>On a single Docker daemon (here, on your web VM), a container name is a <strong>unique label</strong>. Two running (or stopped) containers can't share the same name. Scripts and playbooks that always use <code>docker run --name webapp</code> without cleaning up first hit this error constantly and recognizing it saves time in real work.</p>
<p><strong>Before you start:</strong> Ansible already created one container named <code>webapp</code>.<br>Stay on the web VM (for example still inside <code>vagrant ssh web</code>) so the commands below run where that container lives.</p>
<p>So now, try to start a second container and also call it <code>webapp</code>. The image is plain <code>nginx</code> here on purpose – the point is the <strong>name clash</strong>, not matching your site’s ports or volume mounts.</p>
<pre><code class="language-plaintext">sudo docker run -d --name webapp nginx
</code></pre>
<p>What actually happens here is that Docker <strong>doesn't</strong> create a second container. It returns an error immediately. Your original <code>webapp</code> is unchanged.</p>
<p>This is because the name <code>webapp</code> is already registered to the existing container (the error shows that container’s ID). Docker refuses to reuse the name until the old container is removed or renamed.</p>
<p>Example error (your ID will differ):</p>
<pre><code class="language-plaintext">docker: Error response from daemon: Conflict. The container name "/webapp" is already in use by container "2e48b81a311c4b71cdc1e25e0df75a22296845c7eb53aab82f9ae739fb6410ec". You have to remove (or rename) that container to be able to reuse that name.
See 'docker run --help'.
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/1fd42c16-c28e-4539-9290-3583206eb8ff.png" alt="container name conflict terminal error screenshot" style="display: block;" width="914" height="252" loading="lazy">

<p>To fix it, free the name, then create <code>webapp</code> again the same way the playbook does (publish port 80, mount your HTML, restart policy):</p>
<pre><code class="language-plaintext">sudo docker rm -f webapp
sudo docker run -d --name webapp --restart always -p 80:80 \
  -v /var/www/html:/usr/share/nginx/html:ro nginx
</code></pre>
<p>After that, your site should behave as before (refresh <a href="http://localhost:8080"><code>http://localhost:8080</code></a> from your laptop).</p>
<h4 id="heading-what-to-observe">What to observe:</h4>
<p>Read Docker’s Conflict message end to end. You should see that the name <code>/webapp</code> is already in use and a container ID pointing at the existing box. In production, that pattern means “something already claimed this name. Just remove it, rename it, or pick a different name before you run <code>docker run</code> again.”</p>
<h3 id="heading-break-3-make-ansible-fail-to-reach-a-vm">Break 3: Make Ansible Fail to Reach a VM</h3>
<p>Ansible separates “could not connect” from “connected, but a task broke.” The first is <strong>UNREACHABLE</strong>, the second is <strong>FAILED</strong>. Knowing which one you have tells you whether to fix network / SSH or playbook / packages / permissions.</p>
<p>On your laptop, in the project folder, edit <code>inventory</code> and change the web server address from <code>192.168.33.10</code> to an IP <strong>no VM uses</strong>, for example <code>192.168.33.99</code>. Save the file.</p>
<pre><code class="language-ini">[webservers]
192.168.33.99 ansible_user=vagrant ansible_ssh_private_key_file=.vagrant/machines/web/virtualbox/private_key
</code></pre>
<p>What you run (from the same project folder on the host):</p>
<pre><code class="language-bash">ansible-playbook -i inventory playbook.yml
</code></pre>
<p>After this, Ansible tries to SSH to <code>192.168.33.99</code>. Nothing on your lab network answers as that host (or SSH never succeeds), so Ansible <strong>never runs tasks</strong> on the web server. It stops that host with UNREACHABLE:</p>
<pre><code class="language-plaintext">fatal: [192.168.33.99]: UNREACHABLE! =&gt; {"msg": "Failed to connect to the host via ssh"}
</code></pre>
<p>This is realistic because the same message shape appears when the IP is wrong, the VM isn't running, a firewall blocks port 22, or the network is misconfigured. The common thread is <strong>no working SSH session</strong>.</p>
<p>Now it's time to put it back: restore <code>192.168.33.10</code> in <code>inventory</code> and run <code>ansible-playbook -i inventory playbook.yml</code> again. The web play should reach the VM and complete (assuming your lab is up).</p>
<p><strong>UNREACHABLE vs FAILED – what to observe:</strong></p>
<ul>
<li><p>If Ansible prints UNREACHABLE, you should assume it never opened SSH on that host and never ran tasks there. Go ahead and fix the connection (IP, VM up, firewall, key path) before you debug playbook logic.</p>
</li>
<li><p>If Ansible prints FAILED, you should assume SSH worked and a task returned an error. Read the task output for the real cause (package name, permissions, syntax), not the network first.</p>
</li>
</ul>
<p>When you debug later, you should look at the keyword Ansible prints: <strong>UNREACHABLE</strong> points to reachability while <strong>FAILED</strong> points to task output and the first failed task under that host.</p>
<h3 id="heading-break-4-fill-the-vms-disk">Break 4: Fill the VM's Disk</h3>
<p>Databases and other services need free disk for logs, temp files, and data. When the filesystem is full or nearly full, a service may fail to start or fail at runtime. This break walks through the same diagnosis habit you would use on a real server: check space, then read systemd and journal output for the service.</p>
<p>All commands below run <strong>on the db VM</strong> after <code>vagrant ssh db</code>. MariaDB was installed there by your playbook.</p>
<h4 id="heading-what-you-do">What you do:</h4>
<ol>
<li><p>Open a shell on the db VM:</p>
<pre><code class="language-plaintext">vagrant ssh db
</code></pre>
</li>
<li><p>Allocate a large file full of zeros (here 1GB) to simulate something eating disk space:</p>
<pre><code class="language-plaintext">sudo dd if=/dev/zero of=/tmp/bigfile bs=1M count=1024

df -h
</code></pre>
<p>Use <code>df -h</code> to see how full the root filesystem (or relevant mount) is. Your Vagrant disk may be large enough that 1GB only raises usage. If MariaDB still starts, you still practiced the checks. To see a stronger effect, you can repeat with a larger <code>count=</code> <strong>only in a lab</strong> (never fill production disks on purpose without a plan).</p>
</li>
<li><p>Ask systemd to restart MariaDB and show status:</p>
<pre><code class="language-plaintext">sudo systemctl restart mariadb
sudo systemctl status mariadb
</code></pre>
<p>If the disk is critically full, restart may fail or the service may show failed or not running.</p>
</li>
<li><p>If something looks wrong, read recent logs for the MariaDB unit:</p>
<pre><code class="language-plaintext">sudo journalctl -u mariadb --no-pager | tail -20
</code></pre>
<p>Errors often mention disk, space, read-only filesystem, or InnoDB being unable to write.</p>
</li>
<li><p>Clean up so your VM stays usable:</p>
<pre><code class="language-plaintext">sudo rm /tmp/bigfile
</code></pre>
<p>Optionally run <code>sudo systemctl restart mariadb</code> again and confirm it is active (running).</p>
</li>
</ol>
<p><strong>What to observe:</strong></p>
<ul>
<li><p>You should use <code>df -h</code> first to confirm whether the filesystem is actually tight. That avoids blaming the database when disk space is fine.</p>
</li>
<li><p>You should read <code>systemctl status mariadb</code> to see whether systemd thinks the service is active, failed, or flapping.</p>
</li>
<li><p>You should read <code>journalctl -u mariadb</code> when status is bad, so you can tie the failure to concrete errors from MariaDB or the kernel (often mentioning disk, space, or read-only filesystem). <strong>Space + status + logs</strong> is the same order you would use on a production server.</p>
</li>
</ul>
<h3 id="heading-break-5-run-minikube-out-of-resources">Break 5: Run Minikube Out of Resources</h3>
<p>Kubernetes schedules pods onto nodes that have enough CPU and memory. If you ask for more than the cluster can place, some pods stay <strong>Pending</strong> and <strong>Events</strong> explain why (for example <em>Insufficient cpu</em>). That is not the same as a pod that starts and then crashes.</p>
<p>To do this, you'll need a local cluster (we're using <a href="https://minikube.sigs.k8s.io/docs/start/?arch=%2Fmacos%2Fx86-64%2Fstable%2Fbinary+download"><strong>Minikube</strong></a> in this guide) and <code>kubectl</code> on your laptop. This break doesn't use the Vagrant VMs. If you haven't installed Minikube yet, complete the "How to Set Up Kubernetes" section first, or skip this break until you do.</p>
<p>You'll run this on your <strong>Mac, Linux, or Windows terminal</strong> (host), not inside <code>vagrant ssh</code>. If you're still inside a VM, type <code>exit</code> until your prompt is back on the host.</p>
<h4 id="heading-what-you-do">What you do:</h4>
<ol>
<li><p>Check Minikube:</p>
<pre><code class="language-plaintext">minikube status
</code></pre>
<p>If it's stopped, start it (Docker driver matches earlier sections):</p>
<pre><code class="language-plaintext">minikube start --driver=docker
</code></pre>
</li>
<li><p>Create a deployment with many replicas so your single Minikube node can't run them all at once:</p>
<pre><code class="language-plaintext">kubectl create deployment stress --image=nginx --replicas=20

#watch pods start
kubectl get pods -w
</code></pre>
<p>Press Ctrl+C when you're done watching. Some pods may stay <strong>Pending</strong> while others are <strong>Running</strong>.</p>
</li>
<li><p>Pick one Pending pod name from <code>kubectl get pods</code> and inspect it:</p>
<pre><code class="language-plaintext">kubectl describe pod &lt;pod-name&gt;
</code></pre>
<p>Under Events, look for FailedScheduling and a line similar to:</p>
<pre><code class="language-plaintext">Warning  FailedScheduling  0/1 nodes are available: 1 Insufficient cpu.
</code></pre>
<p>You might see <strong>Insufficient memory</strong> instead, depending on your machine.</p>
</li>
<li><p>Fix the lab by scaling back so the cluster can catch up:</p>
<pre><code class="language-plaintext">kubectl scale deployment stress --replicas=2
</code></pre>
<p>You can delete the deployment entirely when finished: <code>kubectl delete deployment stress</code>.</p>
</li>
</ol>
<p><strong>What to observe:</strong></p>
<ul>
<li><p>You should see Pending pods stay unscheduled until capacity frees up. That means the scheduler hasn't placed them on any <strong>node</strong> yet, usually because the node is out of CPU or memory for that workload.</p>
</li>
<li><p>You should read <code>kubectl describe pod &lt;pod-name&gt;</code> and scroll to <strong>Events</strong>. Messages like Insufficient cpu or Insufficient memory mean the cluster ran out of schedulable capacity, not that the container image image is corrupt.</p>
</li>
<li><p>You should contrast that with a pod that reaches Running and then CrashLoopBackOff, which usually means the process inside the container keeps exiting. that is an application or config problem, not a “nowhere to run” problem.</p>
</li>
</ul>
<h2 id="heading-what-you-can-now-do">What You Can Now Do</h2>
<p>You didn't just install tools in this tutorial. You also used them.</p>
<p>You can now spin up two servers from a single file. You can write a playbook that installs software and deploys a container without touching either machine manually.</p>
<p>You can serve a page you wrote from inside a Docker container running on a Vagrant VM, and bring the whole thing back from scratch in one command.</p>
<p>You also broke it. You saw what a container conflict looks like, what Ansible prints when it can't reach a machine, what disk pressure does to a running service, and what a Kubernetes scheduler says when it runs out of resources. Those error messages aren't unfamiliar anymore.</p>
<p>That's the difference between someone who has read about DevOps and someone who has run it.</p>
<p><strong>Here are four free projects you can run in this same lab to go further:</strong></p>
<ul>
<li><p><strong>DevOps Home-Lab 2026</strong> — Build a multi-service app (frontend, API, PostgreSQL, Redis) end-to-end with Docker Compose, Kubernetes, Prometheus/Grafana monitoring, GitOps with ArgoCD, and Cloudflare for global exposure.</p>
</li>
<li><p><strong>KubeLab</strong> — Trigger real Kubernetes failure scenarios, pod crashes, OOMKills, node drains, cascading failures, and watch how the cluster responds using live metrics.</p>
</li>
<li><p><strong>K8s Secrets Lab</strong> — Build a full secret management pipeline from AWS Secrets Manager into your cluster, including rotation behavior and IRSA.</p>
</li>
<li><p><strong>DevOps Troubleshooting Toolkit</strong> — Structured debugging guides across Linux, containers, Kubernetes, cloud, databases, and observability with copy-paste commands for real incidents.</p>
</li>
</ul>
<p>All free and open source: <a href="https://github.com/Osomudeya/List-Of-DevOps-Projects">github.com/Osomudeya/List-Of-DevOps-Projects</a>.</p>
<p>If you want to go deeper, you can find six full chapters covering Terraform, Ansible, monitoring, CI/CD, and a simulated three-VM production environment at <a href="https://osomudeya.gumroad.com/l/BuildYourOwnDevOpsLab">Build Your Own DevOps Lab</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build and Deploy Multi-Architecture Docker Apps on Google Cloud Using ARM Nodes (Without QEMU)
 ]]>
                </title>
                <description>
                    <![CDATA[ If you've bought a laptop in the last few years, there's a good chance it's running an ARM processor. Apple's M-series chips put ARM on the map for developers, but the real revolution is happening ins ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-and-deploy-multi-architecture-docker-apps-on-google-cloud-using-arm-nodes/</link>
                <guid isPermaLink="false">69dcf2c3f57346bc1e05a01d</guid>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ google cloud ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ARM ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Amina Lawal ]]>
                </dc:creator>
                <pubDate>Mon, 13 Apr 2026 13:42:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e89ae65a-4b3a-44b7-94d8-d0638f017bf6.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've bought a laptop in the last few years, there's a good chance it's running an ARM processor. Apple's M-series chips put ARM on the map for developers, but the real revolution is happening inside cloud data centers.</p>
<p>Google Cloud Axion is Google's own custom ARM-based chip, built to handle the demands of modern cloud workloads. The performance and cost numbers are striking: Google claims Axion delivers up to 60% better energy efficiency and up to 65% better price-performance compared to comparable x86 machines.</p>
<p>AWS has Graviton. Azure has Cobalt. ARM is no longer niche. It's the direction the entire cloud industry is moving.</p>
<p>But there's a problem that catches almost every team off guard when they start this transition: <strong>container architecture mismatch</strong>.</p>
<p>If you build a Docker image on your M-series Mac and push it to an x86 server, it crashes on startup with a cryptic <code>exec format error</code>.</p>
<p>The server isn't broken. It just can't read the compiled instructions inside your image. An ARM binary and an x86 binary are written in fundamentally different languages at the machine level. The CPU literally can't execute instructions it wasn't designed for.</p>
<p>We're going to solve this problem completely in this tutorial. You'll build a single Docker image tag that automatically serves the correct binary on both ARM and x86 machines — no separate pipelines, no separate tags. Then you'll provision Google Cloud ARM nodes in GKE and configure your Kubernetes deployment to route workloads precisely to those cost-efficient nodes.</p>
<p><strong>Here's what you'll build, step by step:</strong></p>
<ul>
<li><p>A Go HTTP server that reports the CPU architecture it's running on at runtime</p>
</li>
<li><p>A multi-stage Dockerfile that cross-compiles for both <code>linux/amd64</code> and <code>linux/arm64</code> without slow QEMU emulation</p>
</li>
<li><p>A multi-arch image in Google Artifact Registry that acts as a single entry point for any architecture</p>
</li>
<li><p>A GKE cluster with two node pools: a standard x86 pool and an ARM Axion pool</p>
</li>
<li><p>A Kubernetes Deployment that pins your workload exclusively to the ARM nodes</p>
</li>
</ul>
<p>By the end, you'll hit a live endpoint and see the word <code>arm64</code> staring back at you from a Google Cloud ARM node. Let's get into it.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-step-1-set-up-your-google-cloud-project">Step 1: Set Up Your Google Cloud Project</a></p>
</li>
<li><p><a href="#heading-step-2-create-the-gke-cluster">Step 2: Create the GKE Cluster</a></p>
</li>
<li><p><a href="#heading-step-3-write-the-application">Step 3: Write the Application</a></p>
</li>
<li><p><a href="#heading-step-4-enable-multi-arch-builds-with-docker-buildx">Step 4: Enable Multi-Arch Builds with Docker Buildx</a></p>
</li>
<li><p><a href="#heading-step-5-write-the-dockerfile">Step 5: Write the Dockerfile</a></p>
</li>
<li><p><a href="#heading-step-6-build-and-push-the-multi-arch-image">Step 6: Build and Push the Multi-Arch Image</a></p>
</li>
<li><p><a href="#heading-step-7-add-the-axion-arm-node-pool">Step 7: Add the Axion ARM Node Pool</a></p>
</li>
<li><p><a href="#heading-step-8-deploy-the-app-to-the-arm-node-pool">Step 8: Deploy the App to the ARM Node Pool</a></p>
</li>
<li><p><a href="#heading-step-9-verify-the-deployment">Step 9: Verify the Deployment</a></p>
</li>
<li><p><a href="#heading-step-10-cost-savings-and-tradeoffs">Step 10: Cost Savings and Tradeoffs</a></p>
</li>
<li><p><a href="#heading-cleanup">Cleanup</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-project-file-structure">Project File Structure</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have the following ready:</p>
<ul>
<li><p><strong>A Google Cloud project</strong> with billing enabled. If you don't have one, create it at <a href="https://console.cloud.google.com">console.cloud.google.com</a>. The total cost to follow this tutorial is around $5–10.</p>
</li>
<li><p><code>gcloud</code> <strong>CLI</strong> installed and authenticated. Run <code>gcloud auth login</code> to sign in and <code>gcloud config set project YOUR_PROJECT_ID</code> to point it at your project.</p>
</li>
<li><p><strong>Docker Desktop</strong> version 19.03 or later. Docker Buildx (the tool we'll use for multi-arch builds) ships bundled with it.</p>
</li>
<li><p><code>kubectl</code> installed. This is the CLI for interacting with Kubernetes clusters.</p>
</li>
<li><p>Basic familiarity with <strong>Docker</strong> (images, layers, Dockerfile) and <strong>Kubernetes</strong> (pods, deployments, services). You don't need to be an expert, but you should know what these things are.</p>
</li>
</ul>
<h2 id="heading-step-1-set-up-your-google-cloud-project">Step 1: Set Up Your Google Cloud Project</h2>
<p>Before writing a single line of application code, let's get the cloud infrastructure side ready. This is the foundation everything else will build on.</p>
<h3 id="heading-enable-the-required-apis">Enable the Required APIs</h3>
<p>Google Cloud services are off by default in any new project. Run this command to turn on the three APIs we'll need:</p>
<pre><code class="language-bash">gcloud services enable \
  artifactregistry.googleapis.com \
  container.googleapis.com \
  containeranalysis.googleapis.com
</code></pre>
<p>Here's what each one does:</p>
<ul>
<li><p><code>artifactregistry.googleapis.com</code> — enables <strong>Artifact Registry</strong>, where we'll store our Docker images</p>
</li>
<li><p><code>container.googleapis.com</code> — enables <strong>Google Kubernetes Engine (GKE)</strong>, where our cluster will run</p>
</li>
<li><p><code>containeranalysis.googleapis.com</code> — enables vulnerability scanning for images stored in Artifact Registry</p>
</li>
</ul>
<h3 id="heading-create-a-docker-repository-in-artifact-registry">Create a Docker Repository in Artifact Registry</h3>
<p>Artifact Registry is Google Cloud's managed container image store — the place where our built images will live before being deployed to the cluster. Create a dedicated repository for this tutorial:</p>
<pre><code class="language-bash">gcloud artifacts repositories create multi-arch-repo \
  --repository-format=docker \
  --location=us-central1 \
  --description="Multi-arch tutorial images"
</code></pre>
<p>Breaking down the flags:</p>
<ul>
<li><p><code>--repository-format=docker</code> — tells Artifact Registry this repository stores Docker images (as opposed to npm packages, Maven artifacts, and so on)</p>
</li>
<li><p><code>--location=us-central1</code> — the Google Cloud region where your images will be stored. Use a region that's close to where your cluster will run to minimize image pull latency. Run <code>gcloud artifacts locations list</code> to see all options.</p>
</li>
<li><p><code>--description</code> — a human-readable label for the repository, shown in the console.</p>
</li>
</ul>
<h3 id="heading-authenticate-docker-to-push-to-artifact-registry">Authenticate Docker to Push to Artifact Registry</h3>
<p>Docker needs credentials before it can push images to Google Cloud. Run this command to wire up authentication automatically:</p>
<pre><code class="language-bash">gcloud auth configure-docker us-central1-docker.pkg.dev
</code></pre>
<p>This adds a credential helper entry to your <code>~/.docker/config.json</code> file. What that means in practice: any time Docker tries to push or pull from a URL under <code>us-central1-docker.pkg.dev</code>, it will automatically call <code>gcloud</code> to get a valid auth token. You won't need to run <code>docker login</code> manually.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/31fd020f-ffa2-40bd-9057-57b16a61b325.png" alt="Terminal output of the gcloud artifacts repositories list command, showing a row for multi-arch-repo with format DOCKER, location us-central1" style="display: block;" width="2870" height="1512" loading="lazy">

<h2 id="heading-step-2-create-the-gke-cluster">Step 2: Create the GKE Cluster</h2>
<p>With Artifact Registry ready to receive images, let's create the Kubernetes cluster. We'll start with a standard cluster using x86 nodes and add an ARM node pool later once we have an image to deploy.</p>
<pre><code class="language-bash">gcloud container clusters create axion-tutorial-cluster \
  --zone=us-central1-a \
  --num-nodes=2 \
  --machine-type=e2-standard-2 \
  --workload-pool=PROJECT_ID.svc.id.goog
</code></pre>
<p>Replace <code>PROJECT_ID</code> with your actual Google Cloud project ID.</p>
<p>What each flag does:</p>
<ul>
<li><p><code>--zone=us-central1-a</code> — creates a zonal cluster in a single availability zone. A regional cluster (using <code>--region</code>) would spread nodes across three zones for higher resilience, but for this tutorial a single zone keeps things simple and avoids capacity issues that can affect specific zones. If <code>us-central1-a</code> is unavailable, try <code>us-central1-b</code>.</p>
</li>
<li><p><code>--num-nodes=2</code> — two x86 nodes in this zone. We need at least 2 to have enough capacity alongside our ARM node pool later.</p>
</li>
<li><p><code>--machine-type=e2-standard-2</code> — the machine type for this default node pool. <code>e2-standard-2</code> is a cost-effective x86 machine with 2 vCPUs and 8 GB of memory, good for general workloads.</p>
</li>
<li><p><code>--workload-pool=PROJECT_ID.svc.id.goog</code> — enables <strong>Workload Identity</strong>, which is Google's recommended way for pods to authenticate with Google Cloud APIs. It avoids the need to download and store service account key files inside your cluster.</p>
</li>
</ul>
<p>This command takes a few minutes. While it runs, you can move on to writing the application. We'll come back to the cluster in Step 6.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/332250a8-3f99-4eb1-849f-51ab054c9567.png" alt="GCP Console Kubernetes Engine Clusters page showing axion-tutorial-cluster with a green checkmark status, the zone us-central1-a, and Kubernetes version in the table." style="display: block;" width="1457" height="720" loading="lazy">

<h2 id="heading-step-3-write-the-application">Step 3: Write the Application</h2>
<p>We need an application to containerize. We'll use <strong>Go</strong> for three specific reasons:</p>
<ol>
<li><p>Go compiles into a single, statically-linked binary. There's no runtime to install, no interpreter — just the binary. This makes for extremely lean container images.</p>
</li>
<li><p>Go has first-class, built-in cross-compilation support. We can compile an ARM64 binary from an x86 Mac, or vice versa, by setting two environment variables. This will matter a lot when we get to the Dockerfile.</p>
</li>
<li><p>Go exposes the architecture the binary was compiled for via <code>runtime.GOARCH</code>. Our server will report this at runtime, giving us hard proof that the correct binary is running on the correct hardware.</p>
</li>
</ol>
<p>Start by creating the project directories:</p>
<pre><code class="language-bash">mkdir -p hello-axion/app hello-axion/k8s
cd hello-axion/app
</code></pre>
<p>Initialize the Go module from inside <code>app/</code>. This creates <code>go.mod</code> in the current directory:</p>
<pre><code class="language-bash">go mod init hello-axion
</code></pre>
<p><code>go mod init</code> is Go's built-in command for starting a new module. It writes a <code>go.mod</code> file that declares the module name (<code>hello-axion</code>) and the minimum Go version required. Every modern Go project needs this file — without it, the compiler doesn't know how to resolve packages.</p>
<p>Now create the application at <code>app/main.go</code>:</p>
<pre><code class="language-go">package main

import (
    "fmt"
    "net/http"
    "os"
    "runtime"
)

func handler(w http.ResponseWriter, r *http.Request) {
    hostname, _ := os.Hostname()
    fmt.Fprintf(w, "Hello from freeCodeCamp!\n")
    fmt.Fprintf(w, "Architecture : %s\n", runtime.GOARCH)
    fmt.Fprintf(w, "OS           : %s\n", runtime.GOOS)
    fmt.Fprintf(w, "Pod hostname : %s\n", hostname)
}

func healthz(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    fmt.Fprintln(w, "ok")
}

func main() {
    http.HandleFunc("/", handler)
    http.HandleFunc("/healthz", healthz)
    fmt.Println("Server starting on port 8080...")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        fmt.Fprintf(os.Stderr, "server error: %v\n", err)
        os.Exit(1)
    }
}
</code></pre>
<p>Verify both files were created:</p>
<pre><code class="language-bash">ls -la
</code></pre>
<p>You should see <code>go.mod</code> and <code>main.go</code> listed.</p>
<p>Let's walk through what this code does:</p>
<ul>
<li><p><code>import "runtime"</code> — imports Go's built-in <code>runtime</code> package, which exposes information about the Go runtime environment, including the CPU architecture.</p>
</li>
<li><p><code>runtime.GOARCH</code> — returns a string like <code>"arm64"</code> or <code>"amd64"</code> representing the architecture this binary was compiled for. When we deploy to an ARM node, this value will be <code>arm64</code>. This is the core of our proof.</p>
</li>
<li><p><code>os.Hostname()</code> — returns the pod's hostname, which Kubernetes sets to the pod name. This lets us see which specific pod responded when we test the app later.</p>
</li>
<li><p><code>handler</code> — the main HTTP handler, registered on the root path <code>/</code>. It writes the architecture, OS, and hostname to the response.</p>
</li>
<li><p><code>healthz</code> — a separate handler registered on <code>/healthz</code>. It returns HTTP 200 with the text <code>ok</code>. Kubernetes will use this endpoint to check whether the container is alive and ready to serve traffic — we'll wire this up in the deployment manifest later.</p>
</li>
<li><p><code>http.ListenAndServe(":8080", nil)</code> — starts the server on port 8080. If it fails to start (for example, if the port is already in use), it prints the error and exits with a non-zero code so Kubernetes knows something went wrong.</p>
</li>
</ul>
<h2 id="heading-step-4-enable-multi-arch-builds-with-docker-buildx">Step 4: Enable Multi-Arch Builds with Docker Buildx</h2>
<p>Before we write the Dockerfile, we need to understand a fundamental constraint, because it directly shapes how the Dockerfile must be written.</p>
<h3 id="heading-why-your-docker-images-are-architecture-specific-by-default">Why Your Docker Images Are Architecture-Specific By Default</h3>
<p>A CPU only understands instructions written for its specific <strong>Instruction Set Architecture (ISA)</strong>. ARM64 and x86_64 are different ISAs — different vocabularies of machine-level operations. When you compile a Go program, the compiler translates your source code into binary instructions for exactly one ISA. That binary can't run on a different ISA.</p>
<p>When you build a Docker image the normal way (<code>docker build</code>), the binary inside that image is compiled for your local machine's ISA. If you're on an Apple Silicon Mac, you get an ARM64 binary. Push that image to an x86 server, and when Docker tries to execute the binary, the kernel rejects it:</p>
<pre><code class="language-shell">standard_init_linux.go:228: exec user process caused: exec format error
</code></pre>
<p>That's the operating system saying: "This binary was written for a different processor. I have no idea what to do with it."</p>
<h3 id="heading-the-solution-a-single-image-tag-that-serves-any-architecture">The Solution: A Single Image Tag That Serves Any Architecture</h3>
<p>Docker solves this with a structure called a <strong>Manifest List</strong> (also called a multi-arch image index). Instead of one image, a Manifest List is a pointer table. It holds multiple image references — one per architecture — all under the same tag.</p>
<p>When a server pulls <code>hello-axion:v1</code>, here's what actually happens:</p>
<ol>
<li><p>Docker contacts the registry and requests the manifest for <code>hello-axion:v1</code></p>
</li>
<li><p>The registry returns the Manifest List, which looks like this internally:</p>
</li>
</ol>
<pre><code class="language-json">{
  "manifests": [
    { "digest": "sha256:a1b2...", "platform": { "architecture": "amd64", "os": "linux" } },
    { "digest": "sha256:c3d4...", "platform": { "architecture": "arm64", "os": "linux" } }
  ]
}
</code></pre>
<ol>
<li>Docker checks the current machine's architecture, finds the matching entry, and pulls only that specific image layer. The x86 image never downloads onto your ARM server, and vice versa.</li>
</ol>
<p>One tag, two actual images. Completely transparent to your deployment manifests.</p>
<h3 id="heading-set-up-docker-buildx">Set Up Docker Buildx</h3>
<p><strong>Docker Buildx</strong> is the CLI tool that builds these Manifest Lists. It's powered by the <strong>BuildKit</strong> engine and ships bundled with Docker Desktop. Run the following to create and activate a new builder instance:</p>
<pre><code class="language-bash">docker buildx create --name multiarch-builder --use
</code></pre>
<ul>
<li><p><code>--name multiarch-builder</code> — gives this builder a memorable name. You can have multiple builders. This command creates a new one named <code>multiarch-builder</code>.</p>
</li>
<li><p><code>--use</code> — immediately sets this new builder as the active one, so all future <code>docker buildx build</code> commands use it.</p>
</li>
</ul>
<p>Now boot the builder and confirm it supports the platforms we need:</p>
<pre><code class="language-bash">docker buildx inspect --bootstrap
</code></pre>
<ul>
<li><code>--bootstrap</code> — starts the builder container if it isn't already running, and prints its full configuration.</li>
</ul>
<p>You should see output like this:</p>
<pre><code class="language-plaintext">Name:          multiarch-builder
Driver:        docker-container
Platforms:     linux/amd64, linux/arm64, linux/arm/v7, linux/386, ...
</code></pre>
<p>The <code>Platforms</code> line lists every architecture this builder can produce images for. As long as you see <code>linux/amd64</code> and <code>linux/arm64</code> in that list, you're ready to build for both x86 and ARM.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/1c19aca1-30c4-406d-9c37-679ee4f2928f.png" alt="Terminal output showing the multiarch-builder details with Name, Driver set to docker-container, and a Platforms list that includes linux/amd64 and linux/arm64 highlighted." style="display: block;" width="2188" height="1258" loading="lazy">

<h2 id="heading-step-5-write-the-dockerfile">Step 5: Write the Dockerfile</h2>
<p>Now we can write the Dockerfile. We'll use two techniques together: a <strong>multi-stage build</strong> to keep the final image tiny, and a <strong>cross-compilation trick</strong> to avoid slow CPU emulation.</p>
<p>Create <code>app/Dockerfile</code> with the following content:</p>
<pre><code class="language-dockerfile"># -----------------------------------------------------------
# Stage 1: Build
# -----------------------------------------------------------
# $BUILDPLATFORM = the machine running this build (your laptop)
# \(TARGETOS / \)TARGETARCH = the platform we are building FOR
# -----------------------------------------------------------
FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS builder

ARG TARGETOS
ARG TARGETARCH

WORKDIR /app

COPY go.mod .
RUN go mod download

COPY main.go .

RUN GOOS=\(TARGETOS GOARCH=\)TARGETARCH go build -ldflags="-w -s" -o server main.go

# -----------------------------------------------------------
# Stage 2: Runtime
# -----------------------------------------------------------

FROM alpine:latest

RUN addgroup -S appgroup &amp;&amp; adduser -S appuser -G appgroup
USER appuser

WORKDIR /app
COPY --from=builder /app/server .

EXPOSE 8080
CMD ["./server"]
</code></pre>
<p>There's a lot happening here. Let's go through it carefully.</p>
<h3 id="heading-stage-1-the-builder">Stage 1: The Builder</h3>
<p><code>FROM --platform=$BUILDPLATFORM golang:1.23-alpine AS builder</code></p>
<p>This is the most important line in the file. <code>\(BUILDPLATFORM</code> is a special build argument that Docker Buildx automatically injects — it equals the platform of the machine <em>running the build</em> (your laptop). By pinning the builder stage to <code>\)BUILDPLATFORM</code>, the Go compiler always runs natively on your machine, not inside a CPU emulator. This is what makes multi-arch builds fast.</p>
<p>Without <code>--platform=$BUILDPLATFORM</code>, Buildx would have to use <strong>QEMU</strong> — a full CPU emulator — to run an ARM64 build environment on your x86 machine (or vice versa). QEMU works, but it's typically 5–10 times slower than native execution. For a project with many dependencies, that's the difference between a 2-minute build and a 20-minute build.</p>
<p><code>ARG TARGETOS</code> <strong>and</strong> <code>ARG TARGETARCH</code></p>
<p>These two lines declare that our Dockerfile expects build arguments named <code>TARGETOS</code> and <code>TARGETARCH</code>. Buildx injects these automatically based on the <code>--platform</code> flag you pass at build time. For a <code>linux/arm64</code> target, <code>TARGETOS</code> will be <code>linux</code> and <code>TARGETARCH</code> will be <code>arm64</code>.</p>
<p><code>COPY go.mod .</code> <strong>and</strong> <code>RUN go mod download</code></p>
<p>We copy <code>go.mod</code> first, before copying the rest of the source code. Docker builds images layer by layer and caches each layer. By copying only the module file first, we create a cached layer for <code>go mod download</code>.</p>
<p>On future builds, as long as <code>go.mod</code> hasn't changed, Docker skips the download step entirely — even if the source code changed. This speeds up iterative development significantly.</p>
<p><code>RUN GOOS=\(TARGETOS GOARCH=\)TARGETARCH go build -ldflags="-w -s" -o server main.go</code></p>
<p>This is the cross-compilation step. <code>GOOS</code> and <code>GOARCH</code> are Go's built-in cross-compilation environment variables. Setting them tells the Go compiler to produce a binary for a different target than the machine it's running on. We set them from the <code>\(TARGETOS</code> and <code>\)TARGETARCH</code> build args injected by Buildx.</p>
<p>The <code>-ldflags="-w -s"</code> flag strips the debug symbol table and the DWARF debugging information from the binary. This has no effect on runtime behavior but reduces the binary size by roughly 30%.</p>
<h3 id="heading-stage-2-the-runtime-image">Stage 2: The Runtime Image</h3>
<p><code>FROM alpine:latest</code></p>
<p>This starts a brand-new image from Alpine Linux — a minimal Linux distribution that weighs about 5 MB. Critically, <code>alpine:latest</code> is itself a multi-arch image, so Docker automatically selects the <code>arm64</code> or <code>amd64</code> Alpine variant depending on which platform this stage is built for.</p>
<p>Everything from Stage 1 — the Go toolchain, the source files, the intermediate object files — is discarded. The final image contains <em>only</em> Alpine Linux plus our binary. Compared to a naive single-stage Go image (~300 MB), this approach produces an image under 15 MB.</p>
<p><code>RUN addgroup -S appgroup &amp;&amp; adduser -S appuser -G appgroup</code> and <code>USER appuser</code></p>
<p>These two lines create a non-root user and set it as the active user for the container. Running containers as root is a security risk — if an attacker exploits a vulnerability in your application, they gain root access inside the container. Running as a non-root user limits the blast radius.</p>
<p><code>COPY --from=builder /app/server .</code></p>
<p>This is how multi-stage builds work: the <code>--from=builder</code> flag tells Docker to copy files from the <code>builder</code> stage (Stage 1), not from your local disk. Only the compiled binary (<code>server</code>) makes it into the final image.</p>
<h2 id="heading-step-6-build-and-push-the-multi-arch-image">Step 6: Build and Push the Multi-Arch Image</h2>
<p>With the application and Dockerfile in place, we can now build images for both architectures and push them to Artifact Registry — all in a single command.</p>
<p>From inside the <code>app/</code> directory, run:</p>
<pre><code class="language-bash">docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1 \
  --push \
  .
</code></pre>
<p>Replace <code>PROJECT_ID</code> with your actual GCP project ID.</p>
<p>Here's what each part of this command does:</p>
<ul>
<li><p><code>docker buildx build</code> — uses the Buildx CLI instead of the standard <code>docker build</code>. Buildx is required for multi-platform builds.</p>
</li>
<li><p><code>--platform linux/amd64,linux/arm64</code> — instructs Buildx to build the image twice: once targeting x86 Intel/AMD machines, and once targeting ARM64. Both builds run in parallel. Because our Dockerfile uses the <code>$BUILDPLATFORM</code> cross-compilation trick, both builds run natively on your machine without QEMU emulation.</p>
</li>
<li><p><code>-t us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1</code> — the full image path in Artifact Registry. The format is always <code>REGION-docker.pkg.dev/PROJECT_ID/REPO_NAME/IMAGE_NAME:TAG</code>.</p>
</li>
<li><p><code>--push</code> — multi-arch images can't be loaded into your local Docker daemon (which only understands single-architecture images). This flag tells Buildx to skip local storage and push the completed Manifest List — with both architecture variants — directly to the registry.</p>
</li>
<li><p><code>.</code> — the build context, the directory Docker scans for the Dockerfile and any files the build needs.</p>
</li>
</ul>
<p>Watch the output as the build runs. You'll see BuildKit working on both platforms simultaneously:</p>
<pre><code class="language-plaintext"> =&gt; [linux/amd64 builder 1/5] FROM golang:1.23-alpine
 =&gt; [linux/arm64 builder 1/5] FROM golang:1.23-alpine
 ...
 =&gt; pushing manifest for us-central1-docker.pkg.dev/.../hello-axion:v1
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/dc88f558-b4ee-4100-bfe1-eaa943bec9bc.png" alt="Terminal showing docker buildx build output with two parallel build tracks labeled linux/amd64 and linux/arm64, and a final line reading pushing manifest for the Artifact Registry image path." style="display: block;" width="2188" height="1258" loading="lazy">

<h3 id="heading-verify-the-multi-arch-image-in-artifact-registry">Verify the Multi-Arch Image in Artifact Registry</h3>
<p>Once the push completes, navigate to <strong>GCP Console → Artifact Registry → Repositories → multi-arch-repo</strong> and click on <code>hello-axion</code>.</p>
<p>You won't see a single image — you'll see something labelled <strong>"Image Index"</strong>. That's the Manifest List we created. Click into it, and you'll find two child images with separate digests, one for <code>linux/amd64</code> and one for <code>linux/arm64</code>.</p>
<p>You can also inspect this from the command line:</p>
<pre><code class="language-bash">docker buildx imagetools inspect \
  us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/28d0e4a4-1d45-4c0b-ac47-34dc3b72c11d.png" alt="Google Cloud Artifact Registry console showing hello-axion as an Image Index with two child images: one labeled linux/amd64 and one labeled linux/arm64, each with its own digest and size." style="display: block;" width="2188" height="1258" loading="lazy">

<p>The output lists every manifest inside the image index. You'll see entries for <code>linux/amd64</code> and <code>linux/arm64</code> — those are our two real images. You'll also see two entries with <code>Platform: unknown/unknown</code> labelled as <code>attestation-manifest</code>. These are <strong>build provenance records</strong> that Docker Buildx automatically attaches to prove how and where the image was built (a supply chain security feature called SLSA attestation).</p>
<p>The two entries you care about are <code>linux/amd64</code> and <code>linux/arm64</code>. Note the digest for the <code>arm64</code> entry — we'll use it in the verification step to confirm the cluster pulled the right variant.</p>
<h2 id="heading-step-7-add-the-axion-arm-node-pool">Step 7: Add the Axion ARM Node Pool</h2>
<p>We have a universal image. Now we need somewhere to run it.</p>
<p>Recall the cluster we created in Step 2 — it's running <code>e2-standard-2</code> x86 machines. We're going to add a second node pool running ARM machines. This is the key architectural move: a <strong>mixed-architecture cluster</strong> where different workloads can be routed to different hardware.</p>
<h3 id="heading-choosing-your-arm-machine-type">Choosing Your ARM Machine Type</h3>
<p>Google Cloud currently offers two ARM-based machine series in GKE:</p>
<table>
<thead>
<tr>
<th>Series</th>
<th>Example type</th>
<th>What it is</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Tau T2A</strong></td>
<td><code>t2a-standard-2</code></td>
<td>First-gen Google ARM (Ampere Altra). Broadly available across regions. Great for getting started.</td>
</tr>
<tr>
<td><strong>Axion (C4A)</strong></td>
<td><code>c4a-standard-2</code></td>
<td>Google's custom ARM chip (Arm Neoverse V2 core). Newest generation, best price-performance. Still expanding availability.</td>
</tr>
</tbody></table>
<p>This tutorial uses <code>t2a-standard-2</code> because it's widely available. The commands are identical for <code>c4a-standard-2</code> — just swap the <code>--machine-type</code> value. If <code>t2a-standard-2</code> isn't available in your zone, GKE will tell you immediately when you run the node pool creation command below, and you can try a neighbouring zone.</p>
<h3 id="heading-create-the-arm-node-pool">Create the ARM Node Pool</h3>
<p>Add the ARM node pool to your existing cluster:</p>
<pre><code class="language-bash">gcloud container node-pools create axion-pool \
  --cluster=axion-tutorial-cluster \
  --zone=us-central1-a \
  --machine-type=t2a-standard-2 \
  --num-nodes=2 \
  --node-labels=workload-type=arm-optimized
</code></pre>
<p>What each flag does:</p>
<ul>
<li><p><code>--cluster=axion-tutorial-cluster</code> — the name of the cluster we created in Step 2. Node pools are always added to an existing cluster.</p>
</li>
<li><p><code>--zone=us-central1-a</code> — must match the zone you used when creating the cluster.</p>
</li>
<li><p><code>--machine-type=t2a-standard-2</code> — GKE detects this is an ARM machine type and automatically provisions the nodes with an ARM-compatible version of Container-Optimized OS (COS). You don't need to configure anything special for ARM at the OS level.</p>
</li>
<li><p><code>--num-nodes=2</code> — two ARM nodes in the zone, enough to schedule our 3-replica deployment alongside other cluster overhead.</p>
</li>
<li><p><code>--node-labels=workload-type=arm-optimized</code> — attaches a custom label to every node in this pool. We'll use this label in our deployment manifest to target these specific nodes. Using a descriptive custom label (rather than just relying on the automatic <code>kubernetes.io/arch=arm64</code> label) is good practice in real clusters — it communicates the <em>intent</em> of the pool, not just its hardware.</p>
</li>
</ul>
<p>This command takes a few minutes. Once it completes, let's confirm our cluster now has both node pools:</p>
<pre><code class="language-bash">gcloud container clusters get-credentials axion-tutorial-cluster --zone=us-central1-a

kubectl get nodes --label-columns=kubernetes.io/arch
</code></pre>
<p>The <code>get-credentials</code> command configures <code>kubectl</code> to authenticate with your new cluster. The <code>get nodes</code> command then lists all nodes and adds a column showing the <code>kubernetes.io/arch</code> label.</p>
<p>You should see something like:</p>
<pre><code class="language-plaintext">NAME                                    STATUS   ARCH    AGE
gke-...default-pool-abc...              Ready    amd64   15m
gke-...default-pool-def...              Ready    amd64   15m
gke-...axion-pool-jkl...                Ready    arm64   3m
gke-...axion-pool-mno...                Ready    arm64   3m
</code></pre>
<p><code>amd64</code> for the default x86 pool, <code>arm64</code> for our new Axion pool. This <code>kubernetes.io/arch</code> label is applied automatically by GKE — you don't set it, it's derived from the hardware.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/6389f4c6-17fe-4086-982f-39d94dbfa252.png" alt="Terminal output of kubectl get nodes with a ARCH column showing amd64 for two default-pool nodes and arm64 for two axion-pool nodes." style="display: block;" width="2330" height="646" loading="lazy">

<h2 id="heading-step-8-deploy-the-app-to-the-arm-node-pool">Step 8: Deploy the App to the ARM Node Pool</h2>
<p>We have a multi-arch image and a mixed-architecture cluster. Here's something important to understand before writing the deployment manifest: <strong>Kubernetes doesn't know or care about image architecture by default</strong>.</p>
<p>If you applied a standard Deployment right now, the scheduler would look for any available node with enough CPU and memory and place pods there — potentially landing on x86 nodes instead of your ARM Axion nodes. The multi-arch Manifest List would handle this gracefully (the right binary would run regardless), but you'd lose the cost efficiency you provisioned Axion nodes for in the first place.</p>
<p>To guarantee that pods land on ARM nodes and only ARM nodes, we use a <code>nodeSelector</code>.</p>
<h3 id="heading-how-nodeselector-works">How nodeSelector Works</h3>
<p>A <code>nodeSelector</code> is a set of key-value pairs in your pod spec. Before the Kubernetes scheduler places a pod, it checks every available node's labels. If a node doesn't have all the labels in the <code>nodeSelector</code>, the scheduler skips it — the pod will remain in <code>Pending</code> state rather than land on the wrong node.</p>
<p>This is a hard constraint, which is exactly what we want for cost optimization. Contrast this with Node Affinity's soft preference mode (<code>preferredDuringSchedulingIgnoredDuringExecution</code>), which says "try to use ARM, but fall back to x86 if needed." Soft preferences are useful for resilience, but they undermine the whole point of dedicated ARM pools. We want the hard constraint.</p>
<h3 id="heading-write-the-deployment-manifest">Write the Deployment Manifest</h3>
<p>Create <code>k8s/deployment.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: hello-axion
  labels:
    app: hello-axion
spec:
  replicas: 3
  selector:
    matchLabels:
      app: hello-axion
  template:
    metadata:
      labels:
        app: hello-axion
    spec:
      nodeSelector:
        kubernetes.io/arch: arm64

      containers:
      - name: hello-axion
        image: us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1
        ports:
        - containerPort: 8080
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 5
          periodSeconds: 10
        readinessProbe:
          httpGet:
            path: /healthz
            port: 8080
          initialDelaySeconds: 3
          periodSeconds: 5
        resources:
          requests:
            cpu: "250m"
            memory: "64Mi"
          limits:
            cpu: "500m"
            memory: "128Mi"
</code></pre>
<p>Replace <code>PROJECT_ID</code> with your project ID. Here's what the key sections do:</p>
<p><code>replicas: 3</code> — tells Kubernetes to keep three instances of this pod running at all times. If one crashes or a node goes down, the scheduler spins up a replacement. Three replicas also means one pod per ARM node in <code>us-central1</code>, which distributes load across availability zones.</p>
<p><code>selector.matchLabels</code> and <code>template.metadata.labels</code> — these two blocks must match. The <code>selector</code> tells the Deployment which pods it "owns," and the <code>template.metadata.labels</code> is what those pods will be tagged with. If they don't match, Kubernetes won't be able to manage the pods.</p>
<p><code>nodeSelector: kubernetes.io/arch: arm64</code> — this is the pin. The Kubernetes scheduler filters out every node that doesn't carry this label before considering resource availability. Since GKE automatically applies <code>kubernetes.io/arch=arm64</code> to all ARM nodes, our pods will schedule only onto the <code>axion-pool</code> nodes.</p>
<p><code>livenessProbe</code> — periodically calls <code>GET /healthz</code>. If this check fails a certain number of times in a row (indicating the container has deadlocked or is otherwise unresponsive), Kubernetes restarts the container. <code>initialDelaySeconds: 5</code> gives the server 5 seconds to start up before the first check.</p>
<p><code>readinessProbe</code> — similar to the liveness probe, but with a different purpose. While the readiness probe is failing, Kubernetes removes the pod from the service's load balancer, so no traffic is sent to it. This is important during startup — the pod won't receive traffic until it signals it's ready.</p>
<p><code>resources.requests</code> — reserves <code>250m</code> (25% of a CPU core) and <code>64Mi</code> of memory on the node for this pod. The scheduler uses these numbers to decide whether a node has enough room for the pod. Setting requests is required for sensible bin-packing. Without them, nodes can be silently overcommitted.</p>
<p><code>resources.limits</code> — caps the container at <code>500m</code> CPU and <code>128Mi</code> memory. If the container exceeds these limits, Kubernetes throttles the CPU or kills the container (for memory). This prevents a single misbehaving pod from starving other workloads on the same node.</p>
<h3 id="heading-a-note-on-taints-and-tolerations">A Note on Taints and Tolerations</h3>
<p>Once you're comfortable with <code>nodeSelector</code>, the next step in production clusters is adding a <strong>taint</strong> to your ARM node pool. A taint is a repellent — any pod without an explicit <strong>toleration</strong> for that taint is blocked from landing on the tainted node.</p>
<p>This means other workloads in your cluster can't accidentally consume your ARM capacity. You'd add the taint when creating the pool:</p>
<pre><code class="language-bash"># Add --node-taints to the pool creation command:
--node-taints=workload-type=arm-optimized:NoSchedule
</code></pre>
<p>And a matching toleration in the pod spec:</p>
<pre><code class="language-yaml">tolerations:
- key: "workload-type"
  operator: "Equal"
  value: "arm-optimized"
  effect: "NoSchedule"
</code></pre>
<p>We're not doing this in the tutorial to keep things simple, but it's the pattern production multi-tenant clusters use to enforce hard separation between workload types.</p>
<h3 id="heading-write-the-service-manifest">Write the Service Manifest</h3>
<p>We also need a Kubernetes Service to expose the pods over the network. Create <code>k8s/service.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Service
metadata:
  name: hello-axion-svc
spec:
  selector:
    app: hello-axion
  ports:
  - protocol: TCP
    port: 80
    targetPort: 8080
  type: LoadBalancer
</code></pre>
<ul>
<li><p><code>selector: app: hello-axion</code> — the Service discovers pods using labels. Any pod with <code>app: hello-axion</code> on it will be added to this Service's load balancer pool.</p>
</li>
<li><p><code>port: 80</code> — the port the Service is reachable on from outside the cluster.</p>
</li>
<li><p><code>targetPort: 8080</code> — the port on the pod that traffic gets forwarded to. Our Go server listens on port 8080, so this must match.</p>
</li>
<li><p><code>type: LoadBalancer</code> — tells GKE to provision an external Google Cloud load balancer and assign it a public IP. This is what makes the Service reachable from the internet.</p>
</li>
</ul>
<h3 id="heading-apply-both-manifests">Apply Both Manifests</h3>
<pre><code class="language-bash">kubectl apply -f k8s/deployment.yaml
kubectl apply -f k8s/service.yaml
</code></pre>
<p><code>kubectl apply</code> reads each manifest file and creates or updates the resources described in it. If the resources don't exist yet, they're created. If they already exist, Kubernetes only applies the diff — it won't restart pods unnecessarily.</p>
<p>Watch the pods come up in real time:</p>
<pre><code class="language-bash">kubectl get pods -w
</code></pre>
<p>The <code>-w</code> flag watches for changes and prints updates as they happen. You should see pods transition from <code>Pending</code> → <code>ContainerCreating</code> → <code>Running</code>. Once all three show <code>Running</code>, press <code>Ctrl+C</code> to stop watching.</p>
<h2 id="heading-step-9-verify-the-deployment">Step 9: Verify the Deployment</h2>
<p>Everything is running. Now we need evidence — not just that pods are up, but that they're on the right nodes and serving the right binary.</p>
<h3 id="heading-confirm-pod-placement">Confirm Pod Placement</h3>
<pre><code class="language-bash">kubectl get pods -o wide
</code></pre>
<p>The <code>-o wide</code> flag adds extra columns to the output, including the name of the node each pod was scheduled on. Look at the <code>NODE</code> column:</p>
<pre><code class="language-plaintext">NAME                          READY   STATUS    NODE
hello-axion-7b8d9f-abc12      1/1     Running   gke-axion-tutorial-axion-pool-a-...
hello-axion-7b8d9f-def34      1/1     Running   gke-axion-tutorial-axion-pool-b-...
hello-axion-7b8d9f-ghi56      1/1     Running   gke-axion-tutorial-axion-pool-c-...
</code></pre>
<p>All three pods should show node names containing <code>axion-pool</code>. None should show <code>default-pool</code>.</p>
<h3 id="heading-confirm-the-nodes-are-arm">Confirm the Nodes Are ARM</h3>
<p>Take one of those node names and verify its architecture label:</p>
<pre><code class="language-bash">kubectl get node NODE_NAME --show-labels | grep kubernetes.io/arch
</code></pre>
<p>Replace <code>NODE_NAME</code> with one of the node names from the previous command. You should see:</p>
<pre><code class="language-plaintext">kubernetes.io/arch=arm64
</code></pre>
<p>That's the automatic label GKE applied when it provisioned the ARM hardware. Our <code>nodeSelector</code> matched on this label to pin the pods here.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/815312ea-e2bf-4106-863e-55cd0bdad5f7.png" alt="Terminal split into two sections: the top showing kubectl get pods -o wide with all pods scheduled on nodes containing axion-pool in the name, and the bottom showing kubectl get node with kubernetes.io/arch=arm64 in the labels output." style="display: block;" width="2848" height="1500" loading="lazy">

<h3 id="heading-ask-the-application-itself">Ask the Application Itself</h3>
<p>This is the most satisfying verification step. Our Go server reports the architecture of the binary that's running. Let's ask it directly.</p>
<p>Use <code>kubectl port-forward</code> to create a secure tunnel from port 8080 on your local machine to port 8080 on the Deployment:</p>
<pre><code class="language-bash">kubectl port-forward deployment/hello-axion 8080:8080
</code></pre>
<p>This command stays running in the foreground — open a <strong>second terminal window</strong> and run:</p>
<pre><code class="language-bash">curl http://localhost:8080
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">Hello from freeCodeCamp!
Architecture : arm64
OS           : linux
Pod hostname : hello-axion-7b8d9f-abc12
</code></pre>
<p><code>Architecture : arm64</code>. That's our Go binary confirming that it was compiled for ARM64 and is executing on an ARM64 CPU. The single image tag we built does the right thing automatically.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/114ff82d-950f-4059-a1fa-89baffb90b6c.png" alt="Terminal output of curl http://localhost:8080 showing the four-line response: Hello from freeCodeCamp, Architecture: arm64, OS: linux, and the pod hostname." style="display: block;" width="1042" height="292" loading="lazy">

<h3 id="heading-the-bonus-see-the-manifest-list-in-action">The Bonus: See the Manifest List in Action</h3>
<p>Want to see the multi-arch image indexing at work? Stop the port-forward, then run:</p>
<pre><code class="language-bash">docker buildx imagetools inspect \
  us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1
</code></pre>
<p>Replace <code>PROJECT_ID</code> with your actual Google Cloud project ID.</p>
<p>You'll see four entries in the manifest list. Two are real images — <code>Platform: linux/amd64</code> and <code>Platform: linux/arm64</code>. The other two show <code>Platform: unknown/unknown</code> with an <code>attestation-manifest</code> annotation. These are <strong>build provenance records</strong> that Docker Buildx automatically attaches to every image — a supply chain security feature (SLSA attestation) that proves how and where the image was built.</p>
<p>You may notice that if you check the image digest recorded in a running pod:</p>
<pre><code class="language-bash">kubectl get pod POD_NAME \
  -o jsonpath='{.status.containerStatuses[0].imageID}'
</code></pre>
<p>Replace <code>POD_NAME</code> with one of the pod names from earlier.</p>
<p>The digest returned matches the <strong>top-level manifest list digest</strong>, not the <code>arm64</code>-specific one. This is expected behaviour. Modern Kubernetes (using containerd) records the manifest list digest, not the resolved platform digest. The platform resolution already happened when the node pulled the correct image variant.</p>
<p>The definitive proof that the right binary is running is what you already have: the node labeled <code>kubernetes.io/arch=arm64</code> and the application reporting <code>Architecture: arm64</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/7dffe0c8-28cf-4a5d-8459-1e8db3da7dc0.png" alt="top-level manifest list digest" style="display: block;" width="2302" height="1000" loading="lazy">

<h2 id="heading-step-10-cost-savings-and-tradeoffs">Step 10: Cost Savings and Tradeoffs</h2>
<p>The hands-on work is done. Let's talk about why any of this is worth the effort.</p>
<h3 id="heading-the-cost-math">The Cost Math</h3>
<p>At the time of writing, here's how ARM compares to equivalent x86 machines on Google Cloud (prices are approximate and change over time — check the <a href="https://cloud.google.com/compute/vm-instance-pricing">official pricing page</a> before making decisions):</p>
<table>
<thead>
<tr>
<th>Instance</th>
<th>vCPU</th>
<th>Memory</th>
<th>Approx. $/hour</th>
</tr>
</thead>
<tbody><tr>
<td><code>n2-standard-4</code> (x86)</td>
<td>4</td>
<td>16 GB</td>
<td>~$0.19</td>
</tr>
<tr>
<td><code>t2a-standard-4</code> (Tau ARM)</td>
<td>4</td>
<td>16 GB</td>
<td>~$0.14</td>
</tr>
<tr>
<td><code>c4a-standard-4</code> (Axion)</td>
<td>4</td>
<td>16 GB</td>
<td>~$0.15</td>
</tr>
</tbody></table>
<p>That's a raw 25–30% reduction in compute cost per node. Factor in Google's published claim of up to 65% better price-performance for Axion on relevant workloads — meaning you may need fewer nodes to handle the same traffic — and the savings compound further.</p>
<p>Here's how that looks at scale, for a service running 20 nodes continuously for a year:</p>
<ul>
<li><p>20 × <code>n2-standard-4</code> × \(0.19 × 8,760 hours = <strong>\)33,288/year</strong></p>
</li>
<li><p>20 × <code>t2a-standard-4</code> × \(0.14 × 8,760 hours = <strong>\)24,528/year</strong></p>
</li>
</ul>
<p>That's roughly <strong>$8,760 saved annually</strong> on compute, before committed use discounts (which further widen the gap).</p>
<h3 id="heading-when-arm-is-the-right-choice">When ARM Is the Right Choice</h3>
<p>ARM works best for:</p>
<ul>
<li><p><strong>Stateless API servers and web applications</strong> — like the app we built. ARM excels at high-throughput, low-latency network workloads.</p>
</li>
<li><p><strong>Background workers and queue processors</strong> — long-running services that don't depend on x86-specific binaries.</p>
</li>
<li><p><strong>Microservices written in Go, Rust, or Python</strong> — these languages have excellent ARM64 support and are built cross-platform by default.</p>
</li>
</ul>
<h3 id="heading-when-to-proceed-carefully">When to Proceed Carefully</h3>
<ul>
<li><p><strong>Native library dependencies</strong> — some older C libraries, proprietary SDKs, or compiled ML model-serving runtimes don't have ARM64 builds. Always audit your dependency tree before migrating.</p>
</li>
<li><p><strong>CI pipelines need ARM too</strong> — your automated tests should run on ARM, not just x86. An image that silently fails only on ARM is harder to debug than one that never claimed ARM support.</p>
</li>
<li><p><strong>Profile before optimizing</strong> — the cost savings are real, but measure your actual workload behavior on ARM before committing. Not every workload benefits equally.</p>
</li>
</ul>
<h2 id="heading-cleanup">Cleanup</h2>
<p>When you're done, clean up to avoid ongoing charges:</p>
<pre><code class="language-bash"># Remove the Kubernetes resources from the cluster
kubectl delete -f k8s/

# Delete the ARM node pool
gcloud container node-pools delete axion-pool \
  --cluster=axion-tutorial-cluster \
  --zone=us-central1-a

# Delete the cluster itself
gcloud container clusters delete axion-tutorial-cluster \
  --zone=us-central1-a

# Delete the images from Artifact Registry (optional — storage costs are minimal)
gcloud artifacts docker images delete \
  us-central1-docker.pkg.dev/PROJECT_ID/multi-arch-repo/hello-axion:v1
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Let's recap what you built and why each part matters.</p>
<p>You started with a Go application, a Dockerfile, and a <code>docker buildx build</code> command that produced two images — one for x86, one for ARM64 — wrapped in a single Manifest List tag. Any server that pulls that tag gets the right binary automatically, without you maintaining separate pipelines or separate tags.</p>
<p>You provisioned a GKE cluster with two node pools running different CPU architectures, then used <code>nodeSelector</code> to make sure your ARM-optimized workload lands only on the ARM Axion nodes — not on x86 by accident. The result is a deployment that's both architecture-correct and cost-efficient.</p>
<p>The patterns you practiced here don't stop at this demo. The same Dockerfile technique works for any language with cross-compilation support. The same <code>nodeSelector</code> approach works for any workload you want to pin to ARM. As more teams migrate services to ARM over the coming years, having these skills will be a real asset.</p>
<p><strong>Where to go from here:</strong></p>
<ul>
<li><p>Add a GitHub Actions workflow that runs <code>docker buildx build --platform linux/amd64,linux/arm64</code> on every push, automating this entire process in CI.</p>
</li>
<li><p>Audit one of your existing stateless services for ARM compatibility and try migrating it.</p>
</li>
<li><p>Explore <strong>Node Affinity</strong> as a softer alternative to <code>nodeSelector</code> for workloads that can run on either architecture but prefer ARM.</p>
</li>
<li><p>Look into <strong>GKE Autopilot</strong>, which now supports ARM nodes and handles node pool management automatically.</p>
</li>
</ul>
<p>Happy building.</p>
<h2 id="heading-project-file-structure">Project File Structure</h2>
<pre><code class="language-plaintext">hello-axion/
├── app/
│   ├── main.go          — Go HTTP server
│   ├── go.mod           — Go module definition
│   └── Dockerfile       — Multi-stage Dockerfile
└── k8s/
    ├── deployment.yaml  — Deployment with nodeSelector and probes
    └── service.yaml     — LoadBalancer Service
</code></pre>
<p>All source files for this tutorial are available in the companion GitHub repository: <a href="https://github.com/Amiynarh/multi-arch-docker-gke-arm">https://github.com/Amiynarh/multi-arch-docker-gke-arm</a></p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
