<?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>Sun, 15 Nov 2026 15:22:41 +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 Migrate an Application from AWS to GKE [Full Book] ]]>
                </title>
                <description>
                    <![CDATA[ This guided lab walks you through migrating a small payments app, with a React UI and Express API running on a single AWS EC2 instance, while PostgreSQL stays on AWS. You’ll move the UI and API to Goo ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-migrate-an-application-from-aws-to-gke-full-book/</link>
                <guid isPermaLink="false">6ad245772c56514e6fd78db7</guid>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Migration ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ gke ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ book ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Osomudeya Zudonu ]]>
                </dc:creator>
                <pubDate>Fri, 16 Oct 2026 15:40:39 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8a1803ba-c9c9-496d-b2c4-58392cc655eb.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>This guided lab walks you through migrating a small payments app, with a React UI and Express API running on a single AWS EC2 instance, while PostgreSQL stays on AWS.</p>
<p>You’ll move the UI and API to Google Kubernetes Engine without changing the public URL customers already use, gradually shifting traffic from AWS to GKE a few percent at a time.</p>
<p>By the end, you’ll have run the app locally, stood up AWS, built GKE in eight Terraform stages, tested the new environment, shifted production traffic to GKE, completed the cutover, and torn down what you no longer need.</p>
<p>The image below shows the middle of the migration, with both clouds live, customers still using one URL, and the database remaining on AWS.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/a72eb451-8e75-4125-ba27-070c73054436.png" alt="Image showing the middle of the migration, with both clouds live, customers still using one URL, and the database remaining on AWS." style="display: block;" width="1536" height="1024" loading="lazy">

<p>Around that request path, three more systems do their jobs:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/8abff4c2-ba02-41d0-907f-5c167d485d31.png" alt="Image demonstrating the request path" style="display: block;" width="1536" height="1024" loading="lazy">

<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-before-you-start">Before You Start</a></p>
<ul>
<li><p><a href="#heading-what-you-need-installed">What You Need Installed</a></p>
</li>
<li><p><a href="#heading-skills-you-need">Skills You Need</a></p>
</li>
<li><p><a href="#heading-what-this-costs">What This Costs</a></p>
</li>
<li><p><a href="#heading-how-this-book-works">How This Book Works</a></p>
</li>
<li><p><a href="#heading-the-team">The Team</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-1-how-to-run-clearpay-and-put-it-on-aws">Part 1. How to Run ClearPay and Put It on AWS</a></p>
<ul>
<li><p><a href="#heading-chapter-1-run-clearpay-on-your-laptop">Chapter 1. Run ClearPay on Your Laptop</a></p>
</li>
<li><p><a href="#heading-chapter-2-read-the-code-that-matters">Chapter 2. Read the Code That Matters</a></p>
</li>
<li><p><a href="#heading-chapter-3-prove-a-retry-never-charges-twice">Chapter 3. Prove a Retry Never Charges Twice</a></p>
</li>
<li><p><a href="#heading-chapter-4-build-the-aws-server-with-terraform">Chapter 4. Build the AWS Server With Terraform</a></p>
</li>
<li><p><a href="#heading-chapter-5-deploy-clearpay-to-the-aws-server">Chapter 5. Deploy ClearPay to the AWS Server</a></p>
</li>
<li><p><a href="#heading-chapter-6-record-the-aws-baseline">Chapter 6. Record the AWS Baseline</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-2-how-to-plan-the-move">Part 2. How to Plan the Move</a></p>
<ul>
<li><p><a href="#heading-chapter-7-read-the-migration-assignment">Chapter 7. Read the Migration Assignment</a></p>
</li>
<li><p><a href="#heading-chapter-8-map-what-clearpay-depends-on">Chapter 8. Map What ClearPay Depends On</a></p>
</li>
<li><p><a href="#heading-chapter-9-understand-the-gcp-design">Chapter 9. Understand the GCP Design</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-3-how-to-build-google-cloud-with-terraform">Part 3. How to Build Google Cloud With Terraform</a></p>
<ul>
<li><p><a href="#heading-chapter-10-set-up-gcloud-and-pick-your-project">Chapter 10. Set Upgcloudand Pick Your Project</a></p>
</li>
<li><p><a href="#heading-chapter-11-build-gcp-in-eight-terraform-stages">Chapter 11. Build GCP in Eight Terraform Stages</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-4-how-to-get-clearpay-onto-gke-staging">Part 4. How to Get ClearPay Onto GKE Staging</a></p>
<ul>
<li><p><a href="#heading-chapter-12-check-the-api-image">Chapter 12. Check the API Image</a></p>
</li>
<li><p><a href="#heading-chapter-13-prepare-the-cluster-for-clearpay">Chapter 13. Prepare the Cluster for ClearPay</a></p>
</li>
<li><p><a href="#heading-chapter-14-build-the-cicd-pipeline">Chapter 14. Build the CI/CD Pipeline</a></p>
</li>
<li><p><a href="#heading-chapter-15-check-staging-and-run-the-promotion-gate">Chapter 15. Check Staging and Run the Promotion Gate</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-5-how-to-make-gke-production-ready">Part 5. How to Make GKE Production-Ready</a></p>
<ul>
<li><p><a href="#heading-chapter-16-run-the-security-review">Chapter 16. Run the Security Review</a></p>
</li>
<li><p><a href="#heading-chapter-17-watch-clearpay-with-prometheus-and-grafana-optional">Chapter 17. Watch ClearPay With Prometheus and Grafana (Optional)</a></p>
</li>
<li><p><a href="#heading-chapter-18-load-test-and-break-staging-on-purpose">Chapter 18. Load-Test and Break Staging on Purpose</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-6-how-to-move-customers-to-gke">Part 6. How to Move Customers to GKE</a></p>
<ul>
<li><p><a href="#heading-chapter-19-deploy-production-and-the-gke-gateway">Chapter 19. Deploy Production and the GKE Gateway</a></p>
</li>
<li><p><a href="#heading-chapter-20-walk-the-pre-cutover-checklist">Chapter 20. Walk the Pre-Cutover Checklist</a></p>
</li>
<li><p><a href="#heading-chapter-21-send-1-percent-of-traffic-to-gke">Chapter 21. Send 1 Percent of Traffic to GKE</a></p>
</li>
<li><p><a href="#heading-chapter-22-raise-traffic-to-100-percent">Chapter 22. Raise Traffic to 100 Percent</a></p>
</li>
<li><p><a href="#heading-chapter-23-when-the-numbers-go-bad">Chapter 23. When the Numbers Go Bad</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-7-how-to-finish-the-migration">Part 7. How to Finish the Migration</a></p>
<ul>
<li><p><a href="#heading-chapter-24-prove-the-cutover-with-numbers">Chapter 24. Prove the Cutover With Numbers</a></p>
</li>
<li><p><a href="#heading-chapter-25-run-on-gke-and-set-a-date-for-aws">Chapter 25. Run on GKE and Set a Date for AWS</a></p>
</li>
<li><p><a href="#heading-chapter-26-retire-the-aws-server">Chapter 26. Retire the AWS Server</a></p>
</li>
<li><p><a href="#heading-chapter-27-tear-down-gcp-and-the-database">Chapter 27. Tear Down GCP and the Database</a></p>
</li>
<li><p><a href="#heading-chapter-28-write-the-migration-record">Chapter 28. Write the Migration Record</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-comes-after-this-project">What Comes After This Project</a></p>
<ul>
<li><a href="#heading-what-question-this-project-leaves-open">What Question This Project Leaves Open</a></li>
</ul>
</li>
<li><p><a href="#heading-what-you-should-have-at-the-end">What You Should Have at the End</a></p>
</li>
<li><p><a href="#heading-how-to-discuss-this-project-in-an-interview">How to Discuss This Project in an Interview</a></p>
</li>
</ul>
<h2 id="heading-before-you-start">Before You Start</h2>
<h3 id="heading-what-you-need-installed">What You Need Installed</h3>
<p>Install each tool when its chapter needs it. You don't need everything on day one.</p>
<table>
<thead>
<tr>
<th>Chapter</th>
<th>Tools</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Docker Desktop (or Docker Engine with Compose on Linux), Git, and <code>python3</code></td>
</tr>
<tr>
<td>4</td>
<td>An AWS account, the AWS CLI (run <code>aws configure</code> once), Terraform 1.5 or newer, <code>rsync</code>, and an SSH key</td>
</tr>
<tr>
<td>6</td>
<td>Node.js 20 or newer on your laptop. The check scripts in <code>scripts/</code> use <code>node</code> to read JSON</td>
</tr>
<tr>
<td>10</td>
<td>A GCP project with billing turned on, the <code>gcloud</code> CLI, and <code>kubectl</code></td>
</tr>
<tr>
<td>14</td>
<td>A GitHub account and your own copy of this repository on GitHub</td>
</tr>
<tr>
<td>19</td>
<td><code>kustomize</code> (install with <code>brew install kustomize</code> on a Mac, or see <a href="https://kubectl.docs.kubernetes.io/installation/kustomize/">kubectl.docs.kubernetes.io</a>)</td>
</tr>
</tbody></table>
<p>On Windows, use Git Bash or WSL for every command in this book. The commands are written for a Bash shell.</p>
<h3 id="heading-skills-you-need">Skills You Need</h3>
<p>Prior Kubernetes, Terraform, GCP, or load-balancer experience isn't required. Don't buy a domain. On AWS, you open <code>https://</code> plus the Elastic IP. The browser warns about a self-signed certificate. Click through it and you get the same ClearPay login as on your laptop. On GCP you add one <code>/etc/hosts</code> line so <code>https://api.clearpay.test</code> points at the load balancer, unless you turn on Cloud DNS.</p>
<h3 id="heading-what-this-costs">What This Costs</h3>
<p>AWS costs about $1.25 per day. GCP costs about $1.60 to $3 per day once it's up. A weekend run of the whole book costs a few dollars.</p>
<p>Neither environment should run for a month. If you stop for more than a couple of days, tear both down:</p>
<pre><code class="language-bash">./scripts/teardown-gcp.sh
cd infra/aws/terraform &amp;&amp; terraform destroy
</code></pre>
<p>Two files hold the detailed numbers: <a href="https://github.com/Osomudeya/migrate-api-aws-gke-devops/blob/main/infra/aws/COST.md">infra/aws/</a><a href="http://COST.md">COST.md</a> and <a href="https://github.com/Osomudeya/migrate-api-aws-gke-devops/blob/main/infra/gcp/COST.md">infra/gcp/</a><a href="http://COST.md">COST.md</a>. The book sends you to those two files and no other documents.</p>
<h3 id="heading-how-this-book-works">How This Book Works</h3>
<p>Clone the repository and run every command from its root folder unless a step says <code>cd</code> somewhere else:</p>
<pre><code class="language-bash">git clone https://github.com/Osomudeya/migrate-api-aws-gke-devops.git

cd migrate-api-aws-gke-devops
</code></pre>
<p>The repository already holds the app, the Terraform, the Kubernetes files, the pipeline, and the scripts.</p>
<p>You won't write new code. Your work is to run each step, read what the system prints, and decide whether to continue.</p>
<p>This book reads like a story where you join a small payments company in week one, and every chapter is a task someone on the team hands you.</p>
<p>Each chapter opens with a time stamp and a scene, then follows the same loop:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/6fd0931a-5d4d-44ff-8531-74ce76b75023.png" alt="Flow diagram of the loop the book follows" style="display: block;" width="2048" height="738" loading="lazy">

<h4 id="heading-most-important-rule">Most Important Rule</h4>
<p>Don't run a command you don't understand. If the book hasn't told you why yet, keep reading, because the "why" is the point.</p>
<p>This book has two kinds of steps.</p>
<p>The first simply asks whether the step worked, such as whether the file copied or the container started. If it did, continue.</p>
<p>The second asks you to think about what happened. Once the command finishes, read the output and understand what it means before moving to the next chapter.</p>
<h4 id="heading-the-chapters">The Chapters</h4>
<p>Read the chapters in order and skip none. Chapter 1 doesn't ask you to install Kubernetes or open GCP. Those come later.</p>
<table>
<thead>
<tr>
<th>Part</th>
<th>Chapters</th>
<th>What happens</th>
</tr>
</thead>
<tbody><tr>
<td>1. Run ClearPay and Put It on AWS</td>
<td>1–6</td>
<td>You run the app on your laptop, deploy it to one AWS server, and write down how fast it is</td>
</tr>
<tr>
<td>2. Plan the Move</td>
<td>7–9</td>
<td>You read the assignment, map what the app depends on, and read the GCP design</td>
</tr>
<tr>
<td>3. Build Google Cloud With Terraform</td>
<td>10–11</td>
<td>You build GCP in eight Terraform stages, one at a time</td>
</tr>
<tr>
<td>4. Get ClearPay Onto GKE Staging</td>
<td>12–15</td>
<td>The pipeline puts ClearPay on GKE staging, and the promotion gate checks it</td>
</tr>
<tr>
<td>5. Make GKE Production-Ready</td>
<td>16–18</td>
<td>You review security, set up a dashboard, and break staging on purpose</td>
</tr>
<tr>
<td>6. Move Customers to GKE</td>
<td>19–23</td>
<td>You deploy production and move traffic from 1 percent to 100 percent</td>
</tr>
<tr>
<td>7. Finish the Migration</td>
<td>24–28</td>
<td>You prove the cutover, retire AWS, tear everything down, and write it up</td>
</tr>
</tbody></table>
<h4 id="heading-if-something-doesnt-work">If Something Doesn't Work</h4>
<p>Stay in that chapter. Use its <em>"If it fails"</em> steps. Don't skip ahead. A problem you carry forward gets harder to find, because the next chapter adds another cloud on top of it.</p>
<p>The job isn't to memorize Kubernetes or Terraform. It's to learn how to work through a real migration: understand what's running, make decisions based on what you find, verify the result, and know what to do when something breaks.</p>
<h4 id="heading-how-long-this-takes">How Long This Takes</h4>
<p>This might take more than one day. If you'll be away for more than a day or two, destroy the AWS and GCP resources so you're not billed for them while it's idle. The commands are in "What this costs" above.</p>
<h3 id="heading-the-team">The Team</h3>
<p>This lab starts as Zee, a junior DevOps engineer on the ClearPay team, week one. You play Zee.</p>
<ul>
<li><p>Maya is the engineering manager. She asks for evidence.</p>
</li>
<li><p>Daniel is a senior engineer. He pairs with you on servers, Kubernetes, and capacity.</p>
</li>
<li><p>Amara maintains the payments API. She cares about one thing: no customer is charged twice.</p>
</li>
<li><p>Chris handles customer support. He hears first when transfers fail.</p>
</li>
</ul>
<p>Your job is to move the payments API from one AWS EC2 server to GKE without a customer-visible outage, while PostgreSQL stays on AWS.</p>
<h2 id="heading-part-1-how-to-run-clearpay-and-put-it-on-aws">Part 1. How to Run ClearPay and Put It on AWS</h2>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/b613b3b6-75c8-4b75-9cca-02750c9bab8a.png" alt="what next?" style="display: block;" width="2089" height="753" loading="lazy">

<p>A migration needs a starting point you trust. In this part, you run ClearPay on your laptop, deploy it to AWS the way the company runs it today, and write down how fast it is. Every later chapter compares GKE against those numbers.</p>
<h3 id="heading-chapter-1-run-clearpay-on-your-laptop">Chapter 1. Run ClearPay on Your Laptop</h3>
<p><em>Week one, Monday, 9:05 a.m.</em></p>
<p>Your laptop is new. The Wi-Fi password is on a sticky note. The first message in your inbox is from Maya, the engineering manager, and it's short.</p>
<p>Maya: "Get ClearPay running on your laptop first. Log in, send $20, and then we'll talk about moving the API to GKE."</p>
<p>The code is in the repository: <code>api/</code>, <code>ui/</code>, and <code>db/</code>. Nothing is running yet.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>Later chapters need a picture of a good login and a good $20 send. When something fails on AWS or GKE, you compare it with this.</p>
<h4 id="heading-what-youre-about-to-run">What You're About to Run</h4>
<p>ClearPay has three parts, and each part has one job.</p>
<ul>
<li><p>The <strong>UI</strong> is what a customer sees and taps: the login page, the balance, and the Send button. UI stands for user interface.</p>
</li>
<li><p>The <strong>API</strong> is the part that does the work. It has no screen. The UI sends it a message, such as "send $20 to Bob". The API checks the message, does the work, and sends back a reply, such as "done". API stands for application programming interface. Think of it as a door with rules. The UI asks in a fixed way, and the API answers in a fixed way.</p>
</li>
<li><p>The <strong>database</strong> is where ClearPay writes everything down: every account, balance, and payment. PostgreSQL is the database program ClearPay uses.</p>
</li>
<li><p>A <strong>server</strong> is a computer that stays on all day and waits for requests. The API runs on one.</p>
</li>
</ul>
<p>A café works the same way. The menu is the UI. The waiter is the API. The kitchen and its records are the database. A customer never walks into the kitchen. The customer tells the waiter what they want.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/e734e16e-0bec-4f5e-b542-03b2cecc1980.png" alt="image demonstrating the flow of the application" style="display: block;" width="1536" height="1024" loading="lazy">

<p>This chapter runs all three on your laptop. Later chapters move the UI and the API to Google Cloud. The database stays on AWS.</p>
<h4 id="heading-steps">Steps</h4>
<p>Start Docker Desktop. Don't install Node or PostgreSQL for this chapter. They run inside the containers.</p>
<p>From the folder that holds <code>docker-compose.yml</code>:</p>
<pre><code class="language-bash">docker compose up --build
</code></pre>
<p>The first build takes a couple of minutes. This terminal keeps printing logs and doesn't give you a prompt back until you press Ctrl+C. Leave it running.</p>
<p>Wait until all three of these lines appear. The names on the left may start with your folder name:</p>
<pre><code class="language-plaintext">Container &lt;folder&gt;-postgres-1 Healthy
api-1       | {"message":"api_started", ...}
ui-1        |   ➜  Local:   http://localhost:5173/
</code></pre>
<p><code>Healthy</code> means Postgres is up. <code>api_started</code> means the API is up. The <code>localhost:5173</code> line means the UI is up.</p>
<p>Open a second terminal in the same folder. Create two demo accounts:</p>
<pre><code class="language-bash">docker compose exec api node db/seed.js
</code></pre>
<p>This runs the seed script directly. The API image has no npm, on purpose, because the security scan in Chapter 14 flags it.</p>
<p>Expected output:</p>
<pre><code class="language-plaintext">seeded: alice@clearpay.dev (password: password123)
seeded: bob@clearpay.dev (password: password123)
</code></pre>
<p>If you see <code>skip (already exists): alice@clearpay.dev</code>, the accounts exist from an earlier run. That's fine.</p>
<p><strong>This is what's now running:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/3dfe597d-2949-4ce8-beae-285d3a5a13f2.png" alt="Flow diagram of what we have running at this moment: Browser, React UI, Express API, and Postgres" style="display: block;" width="1223" height="1286" loading="lazy">

<h4 id="heading-check">Check</h4>
<p>In the second terminal:</p>
<pre><code class="language-bash">curl localhost:4000/health
</code></pre>
<p>Here's the expected output. Your <code>instance</code> value will be a container ID:</p>
<pre><code class="language-json">{"status":"ok","served_by":{"platform":"aws","version":"1.0.0","instance":"14b8d5d767ea","commit":"local","rollback_active":false}}
</code></pre>
<p><code>"status":"ok"</code> is the check. <code>served_by</code> names the platform that answered the request. It says <code>aws</code> on your laptop because <code>docker-compose.yml</code> sets <code>PLATFORM: aws</code>. Later, GKE answers with <code>gke</code>, and that one field tells you which cloud handled each request.</p>
<p>Now open <a href="http://localhost:5173">http://localhost:5173</a> and sign in. Seed already created Alice, so don't register her:</p>
<ul>
<li><p>Email: <code>alice@clearpay.dev</code></p>
</li>
<li><p>Password: <code>password123</code></p>
</li>
</ul>
<p>Send $20 to <code>bob@clearpay.dev</code>. The transfer appears under Recent activity as <code>Sent</code> with a short ID, for example <code>Sent · a1b2c3d4</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/fff6c8f3-be7d-4bb3-a525-403f424af320.png" alt="Image showing the UI of Our APP" style="display: block;" width="810" height="798" loading="lazy">

<p>Look at two places on this dashboard. You'll watch them for the rest of the book:</p>
<ol>
<li><p>Each line under Recent activity ends with <code>served by aws/1.0.0</code>. That's the platform and version of the API process that wrote the transfer. It's stored with the transfer in the database.</p>
</li>
<li><p>The black bar at the top says <code>All systems operational</code> when <code>/health/ready</code> succeeds. That means the API process is up and can reach Postgres. It says nothing about logins. A wrong password shows a red error on the form, while the bar keeps saying <code>All systems operational</code>.</p>
</li>
</ol>
<p>After Chapter 5, you open the same dashboard on the AWS address. Same login, same two places.</p>
<h4 id="heading-if-it-fails">If it Fails:</h4>
<ul>
<li><p><code>failed to connect to the docker API</code> or <code>docker.sock</code>: Docker Desktop isn't running. Start it, wait until it is ready, and run <code>docker compose up --build</code> again.</p>
</li>
<li><p><code>email already registered</code> on the Register page: Alice already exists. Go back to Sign in.</p>
</li>
<li><p><code>invalid credentials</code>: the password is the full string <code>password123</code>. If that still fails, run the seed command again. A <code>skip (already exists)</code> line means the account is there.</p>
</li>
<li><p><code>docker compose up</code> exits right away: run <code>docker compose logs api</code> in the second terminal. On a slow first boot the API can start before Postgres accepts connections. Run <code>docker compose up</code> again.</p>
</li>
<li><p>Port <code>4000</code> or <code>5173</code> already in use: another program on your machine holds that port. Stop it, or change the port mapping in <code>docker-compose.yml</code>.</p>
</li>
</ul>
<h4 id="heading-decision">Decision</h4>
<p>If the transfer went through, the application works. Continue.</p>
<p>If it didn't, fix it here, on the laptop. AWS and GKE are harder to debug. On this machine, the answer is one <code>docker compose logs</code> command away.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p><code>docker compose up</code> runs without errors</p>
</li>
<li><p>The seed reports two accounts, created or skipped</p>
</li>
<li><p>You sent $20 in the UI and saw it under Recent activity</p>
</li>
</ul>
<h3 id="heading-chapter-2-read-the-code-that-matters">Chapter 2. Read the Code That Matters</h3>
<p><em>Monday, 11:30 a.m.</em></p>
<p>The $20 went through. You're still looking at the transfer line when a chair rolls up next to yours. Daniel, the senior engineer at the next desk, has his coffee and no hurry.</p>
<p>Daniel looks over at your screen. "You don't need to memorize every line. Know which file does which job, so you can open it fast during the migration."</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>Every later chapter touches one of these four files. Five minutes now saves you a search under pressure later.</p>
<h4 id="heading-steps">Steps</h4>
<p>Open these files in this order:</p>
<ol>
<li><p><code>api/src/index.js</code> starts the API and connects every route: health checks, <code>/api/info</code>, login, and transactions.</p>
</li>
<li><p><code>api/src/servedBy.js</code> is 17 lines long. It reads <code>PLATFORM</code>, <code>APP_VERSION</code>, and <code>ROLLBACK_ACTIVE</code> from environment variables. The dashboard uses it to print <code>served by aws/1.0.0</code>.</p>
</li>
<li><p><code>api/src/routes/transactions.js</code> handles sending money. Find the <code>idempotency_key</code> check near the top. It runs before anything else.</p>
</li>
<li><p><code>api/src/db.js</code> connects the API to Postgres. Find <code>DB_POOL_SIZE || '10'</code>.</p>
</li>
</ol>
<p>Daniel stops you on the last file.</p>
<p>"A connection is an open line to the database. One API process can hold up to ten of those lines at the same time. Remember that number."</p>
<h4 id="heading-what-you-now-know">What You Now Know</h4>
<pre><code class="language-plaintext">index.js                 → starts the API and connects the routes
servedBy.js              → says which cloud answered (aws or gke)
routes/transactions.js   → sends money, checks the idempotency key first
db.js                    → talks to Postgres, up to 10 connections per process
</code></pre>
<p><code>servedBy.js</code> fills the dashboard line <code>served by aws/1.0.0</code>. That's which cloud answered, not who logged in.</p>
<p>Today there's one API process, so ten connections is the whole pool. On GKE, several copies of the API run at once. Kubernetes calls each copy a pod. Each pod gets its own pool of ten, and every pod shares the same database. Chapter 22 comes back to that math.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li>You can say, without looking, which file sets the connection limit and which file names the platform</li>
</ul>
<h3 id="heading-chapter-3-prove-a-retry-never-charges-twice">Chapter 3. Prove a Retry Never Charges Twice</h3>
<p><em>Monday, 2:10 p.m.</em></p>
<p>After lunch, you get a message from someone you haven't met yet. Amara maintains the payments API. Maya told her you have ClearPay running on your laptop, so she skips the greeting.</p>
<p>Amara: "Send $20 twice with the same key. Alice's balance should drop by $20, not $40."</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>Alice sends $20 to Bob. She taps Send, and the screen freezes. She doesn't know if the payment worked, so she taps Send again.</p>
<p>Without protection, the API sees two requests and takes $20 two times. Alice loses $40 for one payment.</p>
<p>The <code>idempotency_key</code> stops this. "Idempotent" means safe to repeat: doing it twice gives the same result as doing it once.</p>
<p>The key is a label the app puts on each payment, for example <code>round-a</code>.</p>
<ol>
<li><p>The first request arrives with the label <code>round-a</code>. The API takes $20 and saves the label.</p>
</li>
<li><p>The second request arrives with the same label. The API sees <code>round-a</code> again. It knows this is the same payment, so it takes no money. It sends back the first result.</p>
</li>
</ol>
<p>Same label means same payment.</p>
<p>This matters more during the migration. Sometimes the first tap goes to AWS and the retry goes to GKE. Those are two different servers, but they use one database. GKE looks in that database, finds <code>round-a</code>, and doesn't charge again. One shared database keeps the customer safe on both clouds.</p>
<p>Use <code>curl</code> for this chapter, not the Send money button. The UI gives every click a new key, so every click is a new transfer.</p>
<p>For a deeper walk-through of idempotency, see <a href="https://osomudeya.gumroad.com/l/payflow">PayFlow: A Production-Grade Fintech System for Learning Real DevOps</a>.</p>
<h4 id="heading-steps">Steps</h4>
<p>Stay in the second terminal, in the folder with <code>docker-compose.yml</code>. Run one command at a time and read the output before the next one.</p>
<p>Log in as Alice. This stores her login token in a shell variable called <code>TOKEN</code>. A token is proof that Alice signed in. Later commands send it with <code>$TOKEN</code>.</p>
<pre><code class="language-bash">TOKEN=$(curl -s -X POST localhost:4000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"alice@clearpay.dev","password":"password123"}' \
  | python3 -c "import sys, json; print(json.load(sys.stdin)['token'])")
</code></pre>
<p>This prints nothing when it works. Run <code>echo $TOKEN</code> to confirm. You should see a long string.</p>
<p>Then read Alice's account. Write down the <code>balance_cents</code> number.</p>
<pre><code class="language-bash">curl -s localhost:4000/api/accounts/me -H "Authorization: Bearer $TOKEN"
</code></pre>
<p>This is the expected shape. Your numbers and <code>id</code> will differ:</p>
<pre><code class="language-json">{"account":{"id":"...","balance_cents":"94000","currency":"USD"},"served_by":{"platform":"aws","version":"1.0.0","instance":"...","commit":"local","rollback_active":false}}
</code></pre>
<p><code>94000</code> means $940.00. If you haven't sent anything yet, you'll see <code>100000</code> ($1,000.00). If the command says <code>missing_token</code> or <code>invalid_token</code>, run step 1 again.</p>
<p>Now pick a label you haven't used yet:</p>
<pre><code class="language-bash">LABEL=round-a
</code></pre>
<p>Send $20 to Bob. <code>2000</code> is $20.00 in cents.</p>
<pre><code class="language-bash">curl -s -X POST localhost:4000/api/transactions \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"to_email\":\"bob@clearpay.dev\",\"amount_cents\":2000,\"idempotency_key\":\"$LABEL\"}"
</code></pre>
<p>Look for <code>"replayed"</code> in the reply:</p>
<ul>
<li><p><code>"replayed":false</code> means this send took $20. Continue.</p>
</li>
<li><p><code>"replayed":true</code> means this label was used before, probably in an earlier run. No money moved. Set <code>LABEL=round-b</code> and send again until you see <code>"replayed":false</code>.</p>
</li>
</ul>
<p>Send the exact same request again. Don't change <code>LABEL</code>.</p>
<pre><code class="language-bash">curl -s -X POST localhost:4000/api/transactions \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d "{\"to_email\":\"bob@clearpay.dev\",\"amount_cents\":2000,\"idempotency_key\":\"$LABEL\"}"
</code></pre>
<p>Now you should see <code>"replayed":true</code>, and the transaction <code>id</code> matches the first send.</p>
<p>Read the account again:</p>
<pre><code class="language-bash">curl -s localhost:4000/api/accounts/me -H "Authorization: Bearer $TOKEN"
</code></pre>
<h4 id="heading-check">Check</h4>
<p><code>balance_cents</code> is 2000 lower than the number you wrote down, not 4000 lower. If you started at <code>94000</code>, you now see <code>92000</code>.</p>
<h4 id="heading-if-it-fails">If it Fails:</h4>
<ul>
<li><p><code>echo $TOKEN</code> is empty: run the login command again.</p>
</li>
<li><p>The second send still shows <code>"replayed":false</code>: <code>LABEL</code> changed between the two sends. Run <code>echo $LABEL</code>, then send again.</p>
</li>
<li><p>Both sends showed <code>"replayed":true</code>: the balance won't change. Pick a new <code>LABEL</code> and repeat steps 3 and 4.</p>
</li>
</ul>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p>One send returned <code>"replayed":false</code> and the next returned <code>"replayed":true</code> for the same label</p>
</li>
<li><p><code>balance_cents</code> fell by 2000, not 4000</p>
</li>
</ul>
<h3 id="heading-chapter-4-build-the-aws-server-with-terraform">Chapter 4. Build the AWS Server With Terraform</h3>
<p><em>Tuesday, 9:15 a.m.</em></p>
<p>Maya stops at your desk on her way to a meeting. Yesterday you told her that ClearPay runs on your laptop and that a retried payment charges only once.</p>
<p>Maya: "Good. Now, localhost isn't a baseline. Get ClearPay onto AWS and prove a customer can complete a transaction there. Then we can talk about leaving AWS. Daniel will work with you on this one."</p>
<p>Daniel rolls his chair over from the next desk.</p>
<p>Daniel: "One rule before you start. Don't skip <code>terraform plan</code>."</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>This book starts from the setup many small companies run: one Linux server with no Docker on it.</p>
<p>systemd, the Linux service manager, starts the Node API when the server boots. nginx sits in front of it and handles HTTPS on port 443. PostgreSQL runs on Amazon RDS, the managed database service.</p>
<p>There's no AWS load balancer. A load balancer spreads traffic across several servers, and this setup has one. Adding one would add an hourly bill for no gain. The numbers are in <code>infra/aws/COST.md</code>.</p>
<p>The file <code>api/Dockerfile</code> exists, but only your laptop uses it. The AWS server never runs Docker.</p>
<p>Daniel: "The EC2 server won't have Docker. systemd starts Node on boot, next to nginx. That's the server you're inheriting."</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/30d53ba8-7337-4be6-8b45-3d37955315b2.png" alt="Architecture diagram showing ClearPay running on AWS. Users connect through an Elastic IP to nginx on port 443 inside an EC2 server. Nginx serves the frontend files and forwards API requests to a Node.js process managed by systemd on port 4000. The API connects to PostgreSQL 16 on Amazon RDS, which has no public endpoint." style="display: block;" width="1226" height="1283" loading="lazy">

<p>The diagram above shows how a customer request reaches ClearPay on AWS. The Elastic IP provides a fixed public address for the EC2 server. Nginx handles HTTPS traffic on port 443, serves the frontend files, and forwards API requests to the Node.js process managed by systemd on port 4000. The API reads and writes payment data in PostgreSQL on Amazon RDS, which has no public endpoint at this stage.</p>
<p>Two AWS terms appear in this chapter:</p>
<ul>
<li><p>A VPC is the private network your servers sit in. AWS gives every account a default one. This lab uses it instead of building its own.</p>
</li>
<li><p>A security group is AWS's firewall. It lists which ports may reach a server. This lab allows SSH from your IP only, HTTPS from anywhere, and Postgres only from the API server.</p>
</li>
</ul>
<h4 id="heading-steps">Steps</h4>
<p>First confirm your laptop is ready. Each of these must work:</p>
<pre><code class="language-bash">docker --version          # still needed later
aws sts get-caller-identity
terraform version         # 1.5 or newer
ssh-keygen -t ed25519 -f ~/.ssh/clearpay-deployer -N ""
</code></pre>
<p><code>aws sts get-caller-identity</code> prints your AWS account. If it errors, run <code>aws configure</code> with an access key from an IAM user that can create EC2, RDS, VPCs, and CloudWatch alarms. An admin user on a throwaway account also works.</p>
<p>From the repository root:</p>
<pre><code class="language-bash">cd infra/aws/terraform
cp terraform.tfvars.example terraform.tfvars
</code></pre>
<p>Open <code>terraform.tfvars</code> and change two values:</p>
<ul>
<li><p><code>ssh_allowed_cidr</code>: your public IP address followed by <code>/32</code>. Find your IP with <code>curl ifconfig.me</code>.</p>
</li>
<li><p><code>ssh_public_key_path</code>: the path to your key's <code>.pub</code> file.</p>
</li>
</ul>
<p>Then:</p>
<pre><code class="language-bash">terraform init
terraform plan
terraform apply
</code></pre>
<p>Read the plan before you type <code>yes</code>. This first run should only create things: EC2, RDS, and security groups. If the plan says it will destroy anything, stop and don't apply.</p>
<p>This is what Terraform builds. The server is empty until Chapter 5:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/02de98fe-808d-4a5a-8e96-aa9b4fcb5a18.png" alt="AWS infrastructure diagram showing an Elastic IP pointing to an empty EC2 Ubuntu server inside the default VPC. Its security group allows SSH from a specified IP address and ports 80 and 443 from anywhere. CloudWatch monitors the instance status. An RDS PostgreSQL 16 database accepts connections on port 5432 only from the EC2 server's security group." style="display: block;" width="1223" height="1286" loading="lazy">

<p>The diagram shows the AWS infrastructure Terraform creates: an EC2 Ubuntu server with an Elastic IP, security groups controlling SSH and HTTPS access, an RDS PostgreSQL database accessible only from the API server, and a CloudWatch status check. Both resources sit inside the default VPC. The EC2 server is still empty. Chapter 5 deploys ClearPay to it.</p>
<p><strong>A note on cost:</strong> the Elastic IP costs about $0.12 per day even while the server runs, because AWS bills every public IPv4 address (<a href="https://aws.amazon.com/blogs/aws/new-aws-public-ipv4-address-charge-public-ip-insights/">AWS public IPv4 charge</a>).</p>
<h4 id="heading-check">Check</h4>
<pre><code class="language-bash">terraform output instance_public_ip
</code></pre>
<p>This prints a public IPv4 address. That is how you reach the server. You do not need a domain, so Route 53 stays off (<code>manage_dns = false</code>).</p>
<h4 id="heading-if-it-fails">If it Fails:</h4>
<ul>
<li><p><code>Error: InvalidKeyPair.Duplicate</code>: a key pair named <code>clearpay-deployer</code> already exists in this AWS region from an earlier attempt. Delete it in the AWS console, or rename it in <code>infra/aws/terraform/ec2.tf</code>.</p>
</li>
<li><p><code>Error: UnauthorizedOperation</code>: your AWS user lacks permission for one resource type. The error names the operation. Add that one permission instead of reaching for full admin.</p>
</li>
</ul>
<h4 id="heading-decision">Decision</h4>
<p>The infrastructure exists. A server with nothing running on it isn't done yet. Continue to Chapter 5.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p><code>terraform apply</code> finished with no errors</p>
</li>
<li><p><code>terraform output instance_public_ip</code> prints a real IP</p>
</li>
</ul>
<h3 id="heading-chapter-5-deploy-clearpay-to-the-aws-server">Chapter 5. Deploy ClearPay to the AWS Server</h3>
<p><em>Tuesday, 1:40 p.m.</em></p>
<p>Terraform apply finished before lunch. AWS now shows a server and a database, but nothing is serving customers yet.</p>
<p>Terraform built an empty server and a database. Nothing serves customers yet.</p>
<p>You open a terminal to start the deploy. Daniel pulls his chair over and sits down next to you.</p>
<p>Daniel: "Terraform finishing only tells us the server exists. Let's get the app on it and send a real request."</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>An empty server isn't a migration source. Chapter 6 asks whether a real customer request works on AWS. That question has no answer until the login page and the API are up.</p>
<h4 id="heading-steps">Steps</h4>
<p>Start from the repository root. If you're still in <code>infra/aws/terraform</code>, run <code>cd ../../..</code> first.</p>
<p>Start by installing Node, nginx, and certbot on the server:</p>
<pre><code class="language-bash">cd infra/aws/deploy
INSTANCE_IP=$(terraform -chdir=../terraform output -raw instance_public_ip)
echo "$INSTANCE_IP"
# Write that IP down. A new terminal will not have INSTANCE_IP.

ssh -o StrictHostKeyChecking=accept-new \
  -i ~/.ssh/clearpay-deployer ubuntu@$INSTANCE_IP 'bash -s' &lt; bootstrap.sh
</code></pre>
<p>You should see <code>== ClearPay AWS bootstrap ==</code>, then a few minutes of <code>apt-get</code> output. <a href="http://bootstrap.sh">bootstrap.sh</a> is short. Read it.</p>
<p>The step worked when the output ends with these lines:</p>
<pre><code class="language-plaintext">== Bootstrap complete ==
Node: v20.20.2
Self-signed TLS is in /etc/letsencrypt/live/clearpay, use curl -k.
</code></pre>
<p>Your Node version may differ. Lines that start with <code>debconf: unable to initialize frontend</code> are harmless. They appear because the SSH session has no interactive screen. The rows of <code>+</code> and <code>*</code> come from OpenSSL creating the self-signed certificate.</p>
<p>If you see no output at all, SSH didn't connect. Your home IP may have changed since Chapter 4, so the security group still allows the old one. Check your IP with <code>curl -s ifconfig.me; echo</code>, put it plus <code>/32</code> in <code>terraform.tfvars</code> as <code>ssh_allowed_cidr</code>, run <code>terraform apply</code> in <code>infra/aws/terraform</code>, and retry.</p>
<p>Next, write the <code>.env</code> file the API needs and deploy the app. The database URL comes from Terraform. The JWT secret signs login tokens and is generated fresh:</p>
<pre><code class="language-bash">DATABASE_URL=$(terraform -chdir=../terraform output -raw database_url)

cat &gt; /tmp/clearpay.env &lt;&lt;EOF
DATABASE_URL=$DATABASE_URL
JWT_SECRET=$(openssl rand -hex 32)
PLATFORM=aws
APP_VERSION=1.0.0
GIT_COMMIT=$(git rev-parse --short HEAD 2&gt;/dev/null || echo local)
ROLLBACK_ACTIVE=false
PORT=4000
EOF

./deploy.sh "$INSTANCE_IP" ~/.ssh/clearpay-deployer /tmp/clearpay.env
rm /tmp/clearpay.env
</code></pre>
<p><code>deploy.sh</code> builds the React UI and copies it to <code>/opt/clearpay-ui</code>. It copies the API to <code>/opt/clearpay-api</code>, installs its packages, writes <code>.env</code>, installs the systemd service and the nginx config from <code>infra/aws/deploy/</code>, and restarts both.</p>
<p>Now load the database schema and seed Alice and Bob. These commands run on the server, not your laptop. RDS has no public endpoint yet (<code>publicly_accessible = false</code> in <code>infra/aws/terraform/rds.tf</code>), so your laptop can't reach it.</p>
<p>Your laptop can't reach the database directly. It uses the EC2 server as a middleman:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/fc6cf623-39f3-43f9-8c4b-82cc275a361f.png" alt="Two-part diagram showing database access rules. The top panel shows SSH access from a laptop to an EC2 server, which is allowed to connect to an RDS database within the same private AWS network. The bottom panel shows a direct connection from the laptop to RDS blocked by a red X." style="display: block;" width="1774" height="887" loading="lazy">

<p>The diagram above shows why your laptop can't connect directly to the private RDS database. SSH access to the EC2 server is allowed, and the server is allowed to connect to RDS within the AWS network. Direct access from your laptop to the database is blocked. You use the EC2 server as the entry point for database access.</p>
<p>Each command below starts with <code>ssh ... ubuntu@$INSTANCE_IP "..."</code>. That means: log in to the EC2 server and run the part in quotes there. So <code>psql</code> and the seed run on EC2, and EC2 talks to RDS for you. You see the result later in the browser: you log in as Alice, and EC2 reads her account from RDS.</p>
<pre><code class="language-bash">scp -i ~/.ssh/clearpay-deployer ../../../db/schema.sql \
  ubuntu@$INSTANCE_IP:/tmp/schema.sql

ssh -i ~/.ssh/clearpay-deployer ubuntu@$INSTANCE_IP \
  "sudo apt-get install -y postgresql-client -qq &amp;&amp; psql '$DATABASE_URL' -f /tmp/schema.sql"

ssh -i ~/.ssh/clearpay-deployer ubuntu@$INSTANCE_IP \
  "cd /opt/clearpay-api &amp;&amp; sudo -u clearpay npm run seed"
</code></pre>
<p>The seed is the same script from Chapter 1. This time it writes to RDS.</p>
<h4 id="heading-check">Check</h4>
<pre><code class="language-bash">ssh -i ~/.ssh/clearpay-deployer ubuntu@$INSTANCE_IP 'sudo systemctl is-active clearpay-api'
</code></pre>
<p>Expected output: <code>active</code>.</p>
<p>Then from your laptop:</p>
<pre><code class="language-bash">curl -k https://$INSTANCE_IP/health
</code></pre>
<p>Look for <code>"status":"ok"</code> and <code>"platform":"aws"</code>. The <code>-k</code> flag tells curl to accept the self-signed certificate that <code>bootstrap.sh</code> created.</p>
<p>Open <code>https://&lt;your instance IP&gt;</code> in a browser. Click through the "Not Secure" warning. That warning comes from the self-signed certificate. You should see the same ClearPay login as on your laptop. Sign in as Alice with <code>password123</code>. The dashboard shows her balance and <code>served by aws/1.0.0</code>.</p>
<p>Daniel: "The health check only tells us the app is running. Before we write down a baseline, I want to see a transfer go through on this IP."</p>
<p>Send $20 to Bob in the browser.</p>
<h4 id="heading-if-it-fails">If it Fails:</h4>
<ul>
<li><p><code>systemctl is-active</code> returns <code>failed</code>: SSH in and run <code>sudo journalctl -u clearpay-api -n 50</code>. The usual cause is a <code>DATABASE_URL</code> that doesn't match RDS. Compare it with <code>terraform output database_url</code>, character by character.</p>
</li>
<li><p><code>seed_failed</code> with <code>SELF_SIGNED_CERT_IN_CHAIN</code>: <code>psql</code> already worked, so the database is fine. Newer versions of the Node <code>pg</code> library treat <code>sslmode=require</code> as full certificate checking. Copy the current <code>api/src/pgConfig.js</code>, <code>api/src/db.js</code>, and <code>api/db/seed.js</code> onto the server, restart <code>clearpay-api</code>, and run only the seed command again. Don't run <code>schema.sql</code> again.</p>
</li>
<li><p>nginx returns <code>502 Bad Gateway</code>: nginx is running but the API behind it is not. Check the systemd status above first.</p>
</li>
<li><p>The browser shows <code>Cannot GET /</code>: nginx still has an old API-only config. Run <code>./deploy.sh "$INSTANCE_IP" ~/.ssh/clearpay-deployer</code> with no third argument. That keeps the <code>.env</code> already on the server. Don't seed again.</p>
</li>
<li><p><code>invalid credentials</code> for Alice: the browser probably filled in a saved password. Clear the box, type <code>password123</code>, and try again.</p>
</li>
</ul>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p><code>systemctl is-active clearpay-api</code> prints <code>active</code></p>
</li>
<li><p><code>curl -k https://$INSTANCE_IP/health</code> returns <code>"status":"ok"</code></p>
</li>
<li><p>You sent $20 in the browser on the AWS address</p>
</li>
</ul>
<h3 id="heading-chapter-6-record-the-aws-baseline">Chapter 6. Record the AWS Baseline</h3>
<p><em>Wednesday, 10:00 a.m.</em></p>
<p>You message Maya: "health is ok, and Alice signed in on the Elastic IP." Her reply comes back in under a minute.</p>
<p>Maya: "Good. Now run the five checks against that IP. Send me the terminal output, not a screenshot."</p>
<p>A second message follows: "Then load-test it and send me the p95. I can't compare GKE against 'it works'."</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>This chapter gives you two things. You'll use both for the rest of the book.</p>
<p><strong>1. A pass from the promotion gate.</strong> The promotion gate is a short script. It tests five things every payment app must do: the app is running, it can reach the database, a user can log in, a repeated payment charges only once, and the balance drops by the right amount. If all five pass, the app works.</p>
<p>In Chapter 15 you run the same script on GKE. If GKE passes the same test AWS passed, GKE does the job, too.</p>
<p><strong>2. A scorecard.</strong> The load test sends many requests at the same time, like many customers using the app at once. Then it writes down how fast the app answered and how many requests failed. That's how fast AWS is today. Later you run the same test on GKE and compare the two.</p>
<p>Think of timing a runner before they change shoes. Without the first time, the second time means nothing. This chapter records the first time.</p>
<p>The scripts in this chapter use Node on your laptop to read JSON. Install Node.js 20 or newer now if you haven't already.</p>
<h4 id="heading-steps">Steps</h4>
<p>Start from the repository root. If you're still in <code>infra/aws/deploy</code>, run <code>cd ../../..</code> first.</p>
<p>Run the promotion gate against AWS:</p>
<pre><code class="language-bash">export INSTANCE_IP=$(terraform -chdir=infra/aws/terraform output -raw instance_public_ip)

./scripts/promotion-gate.sh https://$INSTANCE_IP \
  alice@clearpay.dev password123 bob@clearpay.dev
</code></pre>
<p>Use <code>https://</code>. With <code>http://</code>, nginx redirects and the script looks like it hung.</p>
<p>The gate checks five things, in order:</p>
<ol>
<li><p>The process is up (<code>/health</code>).</p>
</li>
<li><p>It can reach the database (<code>/health/ready</code>).</p>
</li>
<li><p>Alice can log in.</p>
</li>
<li><p>A replayed payment key returns <code>replayed=true</code>.</p>
</li>
<li><p>Alice's balance drops once.</p>
</li>
</ol>
<p>Expected output:</p>
<pre><code class="language-plaintext">PASS: health endpoint returns ok
PASS: readiness endpoint confirms DB connectivity
PASS: login succeeds and returns a token
PASS: replaying an idempotency key returns replayed=true
PASS: balance debited exactly once (100 cents), not twice

5 passed, 0 failed

GATE PASSED: safe to promote to production.
</code></pre>
<p>The last line talks about production. There's no production to promote to yet. It's the same script you run on GKE later, where that line means what it says.</p>
<p>Now load-test AWS. The script logs in and hits <code>/api/accounts/me</code> and <code>/api/transactions</code>, which both query Postgres. Don't load-test <code>/health</code>. The API answers <code>/health</code> by itself, straight away. It never asks the database anything. A real customer request has to ask the database and wait for the answer, and that wait is most of the time:</p>
<pre><code class="language-plaintext">/health                  laptop → API → reply                    (fast, no database)
/api/accounts/me         laptop → API → database → API → reply   (the real work)
</code></pre>
<p>Load-testing <code>/health</code> times only the fast half. Think of asking a shop worker "are you open?" versus "do you have this shoe in size 42?" Only the second question makes them walk to the storeroom.</p>
<pre><code class="language-bash">./scripts/load-test.sh https://$INSTANCE_IP \
  alice@clearpay.dev password123 10 30
</code></pre>
<p>The last two numbers are concurrency (10 requests at once) and duration (30 seconds).</p>
<h4 id="heading-check">Check</h4>
<p>The script prints a scorecard. This is the one this lab printed:</p>
<pre><code class="language-plaintext">== DB load test: https://184.194.120.157  concurrency=10  duration=30s ==
Endpoints: GET /api/accounts/me  GET /api/transactions


ok=372  failed=0

Migration Scorecard, this run

p50 latency:          673 ms
p95 latency:          745 ms
p99 latency:          1326 ms
Throughput:           12.4 req/s
4xx rate:             0.0 %
5xx rate:             0.0 %
Transaction success:  100.0 %
</code></pre>
<p>Your scorecard will differ. Your IP address, request counts, and milliseconds depend on where you are. The load test runs on your laptop, so every request travels from your home to AWS in <code>us-east-1</code> and back.</p>
<p>A reader close to that region sees much lower numbers than a reader on another continent. Neither is wrong. What matters is that you compare GKE against your own scorecard, measured from the same place.</p>
<p>The run passes when you see <code>failed=0</code>, <code>5xx rate</code> at <code>0.0 %</code>, and <code>Transaction success</code> at <code>100.0 %</code>.</p>
<ul>
<li><p>p95 latency means 95 percent of requests finished faster than this number. It shows what slow customers feel, which an average hides.</p>
</li>
<li><p><code>4xx</code> means the client sent a bad request, such as a missing token.</p>
</li>
<li><p><code>5xx</code> means the server failed.</p>
</li>
</ul>
<p>You copy the whole scorecard into a notes file as printed, and send the same block to Maya. Her answer comes back fast: "Don't round it to look tidier. I will compare GKE to this page."</p>
<h4 id="heading-set-your-limits-now">Set Your Limits Now</h4>
<p>Write these two lines under the scorecard, with numbers:</p>
<pre><code class="language-plaintext">Outage:   5xx rate above 1 % for more than 2 minutes in a row
Failure:  p95 more than 3 times your AWS p95 for more than 5 minutes in a row
</code></pre>
<p>The failure limit uses your own AWS p95, because every reader's baseline is different. With this lab's 745 ms, the limit is about 2,235 ms. These are example limits. Pick your own, but pick them now, before GKE exists. A limit you set after you see the GKE numbers is a limit you bent to fit them.</p>
<p>Start the cost ledger with one line:</p>
<pre><code class="language-plaintext">Cost ledger, entry 1:  AWS about $1.25/day (infra/aws/COST.md)
</code></pre>
<h4 id="heading-if-it-fails">If it Fails:</h4>
<p>Stay here until all five checks pass. A problem you carry into the migration comes back later with two clouds involved, and is much harder to find.</p>
<ul>
<li><p>A check fails on readiness: the API can't reach RDS. Go back to Chapter 5's "If it fails" list.</p>
</li>
<li><p><code>node: command not found</code>: install Node.js on your laptop.</p>
</li>
</ul>
<h4 id="heading-decision">Decision</h4>
<p>The gate passed. This EC2 server is the baseline. Every GKE number in this book gets compared against this scorecard.</p>
<p>A minute later Maya adds one more line: "Good. Those are my numbers now."</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p>The gate printed <code>5 passed, 0 failed</code> against the AWS IP</p>
</li>
<li><p>The load test printed <code>failed=0</code>, and the scorecard is in your notes</p>
</li>
<li><p>Your outage and failure limits are written down with numbers</p>
</li>
</ul>
<h2 id="heading-part-2-how-to-plan-the-move">Part 2. How to Plan the Move</h2>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/23833564-68ec-469e-a404-cf2daca2d22c.png" alt="23833564-68ec-469e-a404-cf2daca2d22c" style="display: block;" width="2089" height="753" loading="lazy">

<p>Tell Maya the gate passed and send her the scorecard.</p>
<p>She writes back: "The next job is GKE. I need the API running there, and I need customers to keep sending money the whole time you move it. Leave Postgres on AWS. The database isn't in this assignment."</p>
<p>No cloud gets touched in this part. You read, map, and decide.</p>
<h3 id="heading-chapter-7-read-the-migration-assignment">Chapter 7. Read the Migration Assignment</h3>
<p><em>Wednesday, 4:00 p.m.</em></p>
<p>Maya forwards the assignment with one line on top: "Read all of it before you open Terraform."</p>
<h4 id="heading-the-assignment-migrate-the-clearpay-api-from-aws-ec2-to-gke">The Assignment: Migrate the ClearPay API from AWS EC2 to GKE.</h4>
<p>ClearPay runs on one EC2 server. It works, but it has no autoscaling, no staged rollout, and a deploy process that lives in one person's head. You ran that process by hand in Chapter 5, so you know what it lacks.</p>
<p>The business wants the API on Kubernetes: a staging environment, automated deploys, and room to grow. Again, the database isn't part of this assignment.</p>
<h4 id="heading-current-architecture">Current Architecture</h4>
<p>ClearPay lives in one AWS account, in the default VPC from Chapter 4: one EC2 server and one RDS database.</p>
<h4 id="heading-what-cant-change">What Can't Change</h4>
<ul>
<li><p>The database. Moving Postgres is a separate, later project.</p>
</li>
<li><p>The API contract. The same paths return the same JSON: <code>/health</code>, <code>/api/auth/login</code>, <code>/api/transactions</code>. A browser or script that works on AWS must work on GKE with no changes.</p>
</li>
<li><p>Customers see no lasting outage at any point.</p>
</li>
</ul>
<h4 id="heading-acceptance-criteria">Acceptance Criteria</h4>
<p>Done means this, in order:</p>
<ol>
<li><p>GKE serves all production traffic, and it stays that way. Errors and latency stay inside the limits you wrote in Chapter 6.</p>
</li>
<li><p>While AWS still exists, rollback to AWS still works.</p>
</li>
<li><p>Then you retire AWS, after GKE has proven itself.</p>
</li>
</ol>
<h4 id="heading-what-moves-and-what-stays">What moves and what stays:</h4>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/680819f1-1c81-49b3-9b04-4cfc2aec9cad.png" alt="Migration diagram showing the ClearPay UI and API moving from an AWS EC2 server to Google Kubernetes Engine (GKE) on GCP. GKE hosts staging and production. PostgreSQL remains on Amazon RDS in AWS and is shared by both environments." style="display: block;" width="1536" height="1024" loading="lazy">

<p>The diagram shows the migration from AWS to GCP. The ClearPay UI and API move from the EC2 server to Google Kubernetes Engine (GKE), where staging and production run. PostgreSQL remains on Amazon RDS in AWS, so both environments use the same database during the migration.</p>
<p>The whole book follows one path:</p>
<pre><code class="language-plaintext">AWS only  →  AWS and GKE together  →  GKE only, then EC2 gone
</code></pre>
<p>The middle step is where the work happens. Both clouds run the same app, both talk to the same database, and traffic moves between them in small steps.</p>
<h4 id="heading-how-traffic-will-move">How Traffic Will Move</h4>
<p>Today, customers type the Elastic IP and every request goes to AWS.</p>
<ol>
<li><p>You put a Google load balancer in front, and customers switch to its address once. They keep using that address for the rest of the migration.</p>
</li>
<li><p>The load balancer then sends some requests to AWS and the rest to GKE. It starts with <strong>100% AWS and 0% GKE</strong>.</p>
</li>
<li><p>You raise the GKE share by hand with <code>scripts/shift-traffic.sh</code>, a few steps at a time, checking numbers after each step.</p>
</li>
<li><p>If something goes wrong on GKE, you send <strong>100% of the traffic back to AWS</strong> with <code>shift-traffic.sh 100 0</code>. The rollback takes seconds.</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/e1df65a6-b164-403e-935b-f7bbd6500682.png" alt="Architecture diagram showing users connecting through one public address to the ClearPay UI and API running on AWS and Google Kubernetes Engine (GKE). Both environments connect to the same PostgreSQL database on Amazon RDS in AWS." style="display: block;" width="1536" height="1024" loading="lazy">

<p>The diagram above shows how customers reach ClearPay through one public address while the UI and API run on both AWS and GKE during migration. Both environments connect to the same PostgreSQL database on Amazon RDS in AWS. Keeping the database in one place avoids migrating payment data alongside the application.</p>
<p>Why not use DNS to split traffic? DNS is the lookup that turns a name into an IP address. Laptops and internet providers cache the answer.</p>
<p>After you change a DNS record, some customers keep going to the old place for minutes or hours. A rollback that takes an hour isn't a rollback. The load balancer changes the split in seconds.</p>
<h4 id="heading-rollback-requirement">Rollback Requirement</h4>
<p>At every stage of the shift, going back to 100 percent AWS must be fast and safe. Not a redeploy. Not a DNS change that takes an hour to spread. Chapter 20 tests exactly that, before the first shift.</p>
<h4 id="heading-what-zero-downtime-means-here">What "Zero Downtime" Means Here</h4>
<p>It doesn't mean zero failed requests, ever. No real system can promise that.</p>
<p>It means no customer sees a lasting outage. A few failed requests are allowed if they stay under the limits you wrote in Chapter 6.</p>
<h4 id="heading-why-the-api-moves-first">Why the API Moves First</h4>
<p>If the API and the database move together and something breaks, the fault could be in either one, or in how they interact. Moving only the API keeps the question small: can this API run on GKE without hurting customers?</p>
<p>That's an answerable question. "Can you move everything at once safely?" is much harder to answer, and much worse to debug live.</p>
<h4 id="heading-why-the-database-stays-on-aws">Why the Database Stays on AWS</h4>
<p>Chapter 13 shows what connecting across clouds involves. For now: PostgreSQL doesn't move in this project.</p>
<p>A future, separate project handles that, most likely with Google's Database Migration Service, continuous replication, and its own cutover. This project ends with the API on GKE and the database still on AWS. That's the plan, not an unfinished loose end.</p>
<h4 id="heading-why-both-clouds-must-run-at-the-same-time">Why Both Clouds Must Run at the Same Time</h4>
<p>The rollback plan depends on it. You send a little traffic to GKE and keep AWS ready. If GKE fails, AWS takes everything back. That only works while AWS still exists.</p>
<h4 id="heading-set-a-migration-budget-before-touching-gcp">Set a Migration Budget Before Touching GCP</h4>
<p><strong>Do this now, before Chapter 9.</strong> Write four numbers in your notes. They decide how much this move may cost you.</p>
<p>Plan your spending the way you plan money for a trip:</p>
<ol>
<li><p><strong>What AWS costs today.</strong> About $1.25 a day, from <code>infra/aws/COST.md</code>.</p>
</li>
<li><p><strong>What GCP will cost per day.</strong> You don't know yet. Write "TBD" (to be decided). Chapter 9 works it out. <code>infra/gcp/COST.md</code> puts it at about $1.60 to $5 a day.</p>
</li>
<li><p><strong>What the move costs while both clouds run.</strong> During the move you pay AWS and GCP at the same time. That is $1.25 plus the GCP daily cost, for every day of the move.</p>
</li>
<li><p><strong>Your budget.</strong> The most you will spend on the whole move. Pick it now, before you spend anything.</p>
</li>
</ol>
<p><strong>This lab's numbers:</strong></p>
<pre><code class="language-plaintext">Current AWS cost:   $1.25/day
Expected GCP cost:  about $3/day (Chapter 9 checks this)
Dual-run cost:      $1.25 + $3 = $4.25/day while both run
Migration budget:   5 days × $4.25 = about $21. Round up to $25.
</code></pre>
<p>Keep a running total as you go. Chapters 19 to 25 add the real dual-run days. Chapter 28 asks one question: did you stay under your budget? If the move took longer, for example because of a rollback, the total shows exactly what that cost.</p>
<h4 id="heading-migration-phases">Migration phases</h4>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/055183da-5e97-451b-a341-917bd5e1d198.png" alt="Flow diagram showing 12 migration phases in sequence: investigate, design, provision, containerize, deploy staging, promote to production, validate, shift traffic gradually, handle failures, complete the cutover, retire AWS, and review. Arrows connect the phases in order across three rows." style="display: block;" width="1983" height="793" loading="lazy">

<h4 id="heading-done-when">Done When:</h4>
<ul>
<li>You can explain to Maya, in two sentences, what moves, what stays, and how you undo a bad step</li>
</ul>
<h3 id="heading-chapter-8-map-what-clearpay-depends-on">Chapter 8. Map What ClearPay Depends On</h3>
<p><em>Thursday, 9:00 a.m.</em></p>
<p>Before anyone stands up GKE, the team needs a written map of what ClearPay depends on. This system has run by hand since Tuesday. Nothing on paper says what must keep working during the move.</p>
<p>Maya: "Before we stand up another cloud, write down what this system depends on."</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>A migration without a map moves the wrong thing, or misses something that breaks the day you switch. Answer each question below from the repository, not from memory.</p>
<h4 id="heading-1-is-the-app-ready-for-containers">1. Is the app ready for containers?</h4>
<p>Yes. <code>api/Dockerfile</code> has worked since Chapter 1. What changes is its role. On your laptop it was a convenience. On GKE it becomes the thing production runs.</p>
<h4 id="heading-2-where-do-the-secrets-live">2. Where do the secrets live?</h4>
<p>Open <code>infra/aws/deploy/systemd/clearpay-api.service</code>. It loads <code>EnvironmentFile=/opt/clearpay-api/.env</code>. That file isn't in Git. It sits on the EC2 server, where <code>deploy.sh</code> copied it in Chapter 5.</p>
<p>Every secret the API needs is in one plain text file on one server. Note that. Don't open it to print secrets. GKE will read the same values from Google Secret Manager instead.</p>
<h4 id="heading-3-what-outside-systems-does-it-call">3. What outside systems does it call?</h4>
<p>One: PostgreSQL. The connection stays inside the AWS default VPC, and RDS has no public endpoint. There's no third-party API. Confirm that by reading <code>api/src/</code>.</p>
<h4 id="heading-4-does-the-server-remember-users-between-requests">4. Does the server remember users between requests?</h4>
<p>No. Open <code>api/src/index.js</code> and <code>api/src/routes/transactions.js</code>. Each request carries a JWT (a signed login token), and all account data lives in Postgres. The server holds nothing in memory.</p>
<p>That's why AWS and GKE can split requests later. A user can log in on AWS and send money on GKE, because both clouds check the same token secret and read the same database.</p>
<h4 id="heading-5-can-a-retry-charge-twice">5. Can a retry charge twice?</h4>
<p>No. Chapter 3 proved the idempotency key works. It must keep working when a first try lands on AWS and the retry lands on GKE.</p>
<h4 id="heading-the-map">The Map</h4>
<p>Write these five answers in your notes:</p>
<pre><code class="language-plaintext">Container ready:   yes, api/Dockerfile
Secrets:           one .env file on the EC2 server
Outside systems:   PostgreSQL only
Server state:      none, JWT + database
Retry safety:      idempotency key, proven in Chapter 3
</code></pre>
<h4 id="heading-what-actually-needs-to-move">What Actually Needs to Move</h4>
<p>Only the UI and the API: off the EC2 server, onto GKE. Postgres stays on AWS.</p>
<p>Browsers and scripts still hit the same URLs and get the same JSON back. A retry still must not take money twice. Those rules don't change.</p>
<h4 id="heading-done-when"><strong>Done when</strong></h4>
<ul>
<li>All five answers are written down, each pointing at a file</li>
</ul>
<h3 id="heading-chapter-9-understand-the-gcp-design">Chapter 9. Understand the GCP Design</h3>
<p><em>Thursday, 2:30 p.m.</em></p>
<p>The Terraform for Google Cloud is already written. Somebody made two big decisions in it before you joined. Maya wants to know whether you can defend them. She writes in the team channel.</p>
<p>Maya: "Open <code>infra/gcp/COST.md</code> and <code>infra/gcp/terraform/03-gke</code>. Then tell me why this repo uses GKE Standard, not Autopilot. I need a reason I can defend."</p>
<p>Daniel, who sits at the next desk, has <code>infra/gcp/terraform/03-gke/main.tf</code> open. He points at a block named <code>primary</code>. It's the node pool: the group of virtual machines GKE runs the app on. This one holds one to three <code>e2-medium</code> machines.</p>
<p>Daniel: "If we keep these machines, how many copies of the API can run on them at once?"</p>
<p>This chapter doesn't run Terraform. The files are already written. You read the decisions in them.</p>
<h4 id="heading-standard-or-autopilot">Standard or Autopilot</h4>
<p>GKE is Google's managed Kubernetes. It runs in two modes:</p>
<ul>
<li><p><strong>Standard</strong>: you choose the virtual machines, grouped in a node pool. Google bills those machines whether the app is busy or idle.</p>
</li>
<li><p><strong>Autopilot</strong>: Google hides the machines. You say how much CPU and memory each container needs, and Google bills that.</p>
</li>
</ul>
<p>This repo uses Standard. The reason isn't cost. It keeps node pools, machine sizes, and autoscaling visible, and those are what this book teaches. Autopilot would hide them.</p>
<p>Work out both costs yourself. Standard bills the machines. One <code>e2-medium</code> running all day is about $1.44 in <code>infra/gcp/COST.md</code>, plus Cloud NAT.</p>
<p>Autopilot bills the CPU and memory each pod reserves. <code>k8s/base/deployment.yaml</code> reserves <code>250m</code> CPU (a quarter of one CPU core) and <code>256Mi</code> of memory per API pod. Using the Autopilot rates in <code>infra/gcp/COST.md</code>, three API pods cost roughly:</p>
<pre><code class="language-plaintext">0.25 vCPU × 3 × $0.0445/hour × 24  ≈  $0.80 / day
0.25 GiB  × 3 × $0.0049/hour × 24  ≈  $0.09 / day
</code></pre>
<p>That's about $0.89 a day for three API pods, before the UI pods. Autopilot can cost less than one Standard machine. The lab still uses Standard so you can see the node pool.</p>
<p>Tell Maya both numbers and the reason.</p>
<h4 id="heading-how-many-api-processes">How Many API Processes</h4>
<p>AWS runs one API process. On GKE, a HorizontalPodAutoscaler (HPA) adds or removes API pods based on CPU use. Each pod is one API process with its own pool of ten database connections.</p>
<p>This lab's cluster is three small <code>e2-medium</code> machines. The production HPA in <code>k8s/overlays/production/patch-hpa.yaml</code> runs between 1 and 3 API pods, because three machines of that size can't fit more alongside GKE's own system pods.</p>
<p>Answer Daniel: "One to three API pods on GKE, plus one on AWS while both run. Each can open ten connections."</p>
<p>A company with bigger machines would set a higher ceiling, such as 3 to 8 pods. Chapter 22 shows why that ceiling needs checking against the database limit before traffic moves.</p>
<h4 id="heading-a-designed-network">A Designed Network</h4>
<p>On AWS, this lab used the default VPC that AWS created for you. Nobody designed it.</p>
<p>On GCP, <code>infra/gcp/terraform/02-network</code> builds its own VPC with a named subnet and Cloud NAT. The cluster's machines get private addresses only. They reach the internet, and later the AWS database, through Cloud NAT, which gives all of them one shared public IP.</p>
<h4 id="heading-region">Region</h4>
<p><code>01-foundation/variables.tf</code> sets the region to <code>us-east4</code>, the Google region closest to AWS's <code>us-east-1</code>. Every database query from GKE crosses between the two clouds, so a short distance keeps that trip short. If your AWS setup isn't in <code>us-east-1</code>, change this before you build anything.</p>
<h4 id="heading-the-map">The Map</h4>
<p>The left side is what exists. The right side is what Part 3 builds.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/d52f2aab-d663-4081-a645-52871d564020.png" alt="Migration architecture diagram showing customers reaching a single public load balancer in GCP, which distributes traffic between an AWS EC2 server running nginx, the UI and API, and GKE in GCP running UI and API pods on private nodes. Cloud NAT provides outbound access from GKE. Both environments connect to the same PostgreSQL 16 database on AWS. Traffic shifts gradually from AWS to GKE." style="display: block;" width="1536" height="1024" loading="lazy">

<p>The diagram above maps the migration from AWS to GCP. Customers use one public address, managed by the GCP load balancer, which splits traffic between the existing AWS EC2 server and GKE. GKE runs the UI and API in private nodes, with Cloud NAT providing outbound internet access. Both environments connect to the same PostgreSQL database on AWS. Traffic shifts gradually to GKE while the database stays in place.</p>
<p>You'll meet three public IP addresses, in this order:</p>
<ol>
<li><p>The Elastic IP. That's the EC2 server. Customers use it today.</p>
</li>
<li><p>The GKE Gateway IP. The load balancer sends GKE's share of traffic to it. Customers never type it.</p>
</li>
<li><p>The load balancer IP from <code>08-edge</code>. Customers switch to this address once, and the traffic split happens behind it.</p>
</li>
</ol>
<h4 id="heading-if-you-need-to-pause-aws">If You Need to Pause AWS</h4>
<p>Part 3 doesn't need AWS running every minute. AWS is needed again in Chapter 11 (to copy the database URL into Secret Manager) and Chapter 13 (to open the database to GKE).</p>
<p>Don't stop the EC2 server and leave RDS running. RDS is most of the daily AWS bill, a stopped RDS restarts itself after seven days, and the Elastic IP bills either way.</p>
<p>If you continue within a day or two, leave AWS up. If you stop for several days, destroy it:</p>
<pre><code class="language-bash">cd infra/aws/terraform &amp;&amp; terraform destroy
</code></pre>
<p>To come back, run <code>terraform apply</code> in that folder and repeat Chapter 5. You get a new Elastic IP and a new <code>DATABASE_URL</code>. Your Chapter 6 scorecard still counts as the baseline.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p>You told Maya the Standard and Autopilot daily numbers and why the lab uses Standard</p>
</li>
<li><p>You told Daniel how many API processes can run, and how many connections each can open</p>
</li>
</ul>
<h2 id="heading-part-3-how-to-build-google-cloud-with-terraform">Part 3. How to Build Google Cloud With Terraform</h2>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/2c0327bb-ec77-4ed5-9dd4-f0ac575e150c.png" alt="Image describing what we have done, where we are and what next." style="display: block;" width="2089" height="753" loading="lazy">

<p>There's a working AWS app and a scorecard. Google Cloud isn't set up yet. There's no network, no cluster, and no registry until you build them.</p>
<p>The UI and the API will run on that cluster later. Postgres stays on AWS.<br>So the order is: build an empty place in GCP first, then put the app there, then teach the app how to reach RDS across the internet.</p>
<p>This part is only the empty place. Customers don't move yet.</p>
<h3 id="heading-chapter-10-set-up-gcloud-and-pick-your-project">Chapter 10. Set Up <code>gcloud</code> and Pick Your Project</h3>
<p><em>Friday, 8:45 a.m.</em></p>
<p>Google Cloud is a blank page. Before you build anything, your laptop needs to know which project to build in, and who you are.</p>
<p>Get <code>gcloud</code>, <code>kubectl</code>, and Terraform pointed at the right Google project, with billing on.</p>
<h4 id="heading-steps">Steps</h4>
<p><code>gcloud</code> is the Google Cloud command-line tool. Install it, then close the terminal and open a new one.</p>
<p>On a Mac:</p>
<pre><code class="language-bash">brew install --cask google-cloud-sdk
</code></pre>
<p>On Windows, download the installer from <a href="https://cloud.google.com/sdk/docs/install">Install the Google Cloud CLI</a>, tick the box that adds <code>gcloud</code> to PATH, and reopen Git Bash. On Linux, follow the Linux tab on the same page. <code>gcloud version</code> should print a version.</p>
<p>If <code>kubectl version --client</code> fails after <code>gcloud</code> works, install <code>kubectl</code> and the plugin it uses to log in to GKE:</p>
<pre><code class="language-bash">gcloud components install kubectl
gcloud components install gke-gcloud-auth-plugin
</code></pre>
<p>The plugin must be on your PATH. If a later <code>kubectl get nodes</code> says <code>gke-gcloud-auth-plugin not found</code>, run this and open a new terminal:</p>
<pre><code class="language-bash">echo 'export PATH="$(gcloud info --format="value(installation.sdk_root)")/bin:$PATH"' &gt;&gt; ~/.zshrc
</code></pre>
<p>That line is for zsh, the default Mac shell. On Linux with Bash, change <code>~/.zshrc</code> to <code>~/.bashrc</code>. On Windows, the installer usually handles PATH. Reopen Git Bash after the install.</p>
<p>Now log in and pick the project:</p>
<pre><code class="language-bash">gcloud auth login --update-adc
gcloud projects list
gcloud config set project YOUR_PROJECT_ID
gcloud billing projects describe YOUR_PROJECT_ID
terraform version
kubectl version --client
</code></pre>
<p><code>--update-adc</code> matters. Plain <code>gcloud auth login</code> only lets <code>gcloud</code> use your account. Terraform is a separate program that looks for a file called Application Default Credentials. The flag writes that file in the same browser login. On the Google page, click Allow and don't close the tab early.</p>
<h4 id="heading-which-id-to-use">Which ID to Use</h4>
<p><code>gcloud projects list</code> prints two columns that look alike:</p>
<ul>
<li><p><code>NAME</code> is a label, and many projects share the default label <code>My First Project</code>.</p>
</li>
<li><p><code>PROJECT_ID</code> is unique, for example <code>my-first-project-123456</code>.</p>
</li>
</ul>
<p>Copy the <code>PROJECT_ID</code>, never the name. Paste it wherever the commands say <code>YOUR_PROJECT_ID</code>.</p>
<p>If you have several projects, pick one with billing on. Run <code>gcloud billing projects describe</code> with each ID until the output includes <code>billingEnabled: true</code>.</p>
<p>You can also find the ID in the <a href="https://console.cloud.google.com">Google Cloud Console</a>: click the project name at the top of the page and read the ID column.</p>
<h4 id="heading-check">Check</h4>
<pre><code class="language-bash">gcloud config get-value project
</code></pre>
<p>This prints your Project ID. If it prints <code>[None]</code>, run the <code>gcloud config set project</code> command again.</p>
<h4 id="heading-if-it-fails">If it Fails:</h4>
<ul>
<li>Terraform later says <code>could not find default credentials</code>: you logged in without the flag. Run:</li>
</ul>
<pre><code class="language-bash">gcloud auth application-default login
</code></pre>
<ul>
<li>That command says <code>cloud-platform scope is required but not consented</code>: the browser closed before you clicked Allow. Run <code>gcloud auth login --update-adc</code> again and click Allow.</li>
</ul>
<p>Today is only a <code>gcloud</code> login. The name <code>api.clearpay.test</code> shows up later, when Stage 8 builds the load balancer. Skip it until then.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p><code>gcloud config get-value project</code> prints your Project ID</p>
</li>
<li><p><code>gcloud billing projects describe</code> shows <code>billingEnabled: true</code></p>
</li>
<li><p><code>kubectl version --client</code> prints a version</p>
</li>
</ul>
<p>Message Maya: "gcloud is logged in. The project is set. Billing is on. Ready to start GCP."</p>
<p>Maya: "Build one stage at a time. Tell me when each one is verified. If you run all eight at once and the third one breaks, you won't know which one to fix."</p>
<h3 id="heading-chapter-11-build-gcp-in-eight-terraform-stages">Chapter 11. Build GCP in Eight Terraform Stages</h3>
<p><em>Friday, 10:30 a.m., to Monday of week two</em></p>
<p>GCP is built in eight Terraform stages. Each stage creates one layer of the destination environment.</p>
<p>Run them in order. After each stage, verify the result before moving to the next.</p>
<p>Build the Google side of the migration: APIs, network, cluster, image registry, identities, secrets, error alerting, and the public load balancer.</p>
<h4 id="heading-why-eight-stages">Why Eight Stages?</h4>
<p>The Terraform lives in eight folders, <code>01-foundation</code> through <code>08-edge</code>. Each folder has its own state file and reads only the earlier stages it needs. If <code>03-gke</code> fails, the problem is in the cluster, not somewhere in one 1,500-line file.</p>
<p>This isn't "run Terraform eight times". The work is to build eight things and prove each one exists before the next one needs it.</p>
<p>State stays local on your laptop, which is fine for one person. A team would move each stage's state to a Google Cloud Storage bucket before a second person runs <code>apply</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/78d0dd12-38ee-4390-a0be-0eecab7f662e.png" alt="Diagram of eight Terraform stages inside a GCP project. Stage 1 enables APIs. Stage 2 creates the network and subnet. Stage 3 creates a private GKE cluster and Cloud NAT with a NAT IP. Stage 4 creates the image registry. Stage 5 configures identities. Stage 6 configures secrets. Stage 7 sets up error metrics and alerts. Stage 8 creates the public load balancer. The network provides internet access and later connectivity to AWS RDS." style="display: block;" width="1222" height="1287" loading="lazy">

<p>The diagram above shows how Terraform builds GCP infrastructure in eight stages. It enables the required APIs, creates the network and private GKE cluster with Cloud NAT, then sets up the image registry, identities, secrets, error monitoring, and public load balancer. Cloud NAT provides outbound connectivity for private nodes, including access to AWS RDS later in the migration.</p>
<h4 id="heading-how-every-stage-runs">How Every Stage Runs</h4>
<p>Each stage uses the same three commands inside its folder:</p>
<pre><code class="language-bash">terraform init
terraform plan
terraform apply
</code></pre>
<p>Read the plan before you apply. Every stage below lists the plan count to expect, for example <code>Plan: 7 to add, 0 to change, 0 to destroy</code>. If the count differs, or the plan wants to destroy something, stop and read it before typing <code>yes</code>.</p>
<p><code>terraform apply</code> asks <code>Do you want to perform these actions?</code>. Type <code>yes</code> and press Enter.</p>
<p>Folder paths below start from the repository root. If you're already inside <code>infra/gcp/terraform/0X-...</code>, use <code>cd ../0Y-...</code> to move to the next stage.</p>
<p>For the "In the console" checks: pick your project at the top of the page, and set the region to <code>us-east4</code> when a page has a region menu. An empty list often means the menu is still on <code>us-central1</code>.</p>
<h4 id="heading-stage-1-turn-on-the-google-apis-01-foundation">Stage 1: Turn on the Google APIs (<code>01-foundation</code>)</h4>
<p>Google Cloud services are off by default in a new project. This stage turns on ten of them: GKE, Artifact Registry, Secret Manager, IAM, logging, and others. If you skip it, <code>03-gke</code> fails later with a permission error that points nowhere useful.</p>
<pre><code class="language-bash">cd infra/gcp/terraform/01-foundation
cp terraform.tfvars.example terraform.tfvars
</code></pre>
<p>If <code>terraform.tfvars</code> already exists with your Project ID, skip the copy. A second copy overwrites your edit.</p>
<p>Open <code>terraform.tfvars</code> and replace <code>clearpay-migration-demo</code> with your Project ID. Then run:</p>
<pre><code class="language-bash">terraform init
terraform plan
terraform apply
</code></pre>
<p>Expected plan: <code>google_project_service</code> ten times, and no cluster.</p>
<pre><code class="language-plaintext">Plan: 10 to add, 0 to change, 0 to destroy.

Changes to Outputs:
  + project_id = "YOUR_PROJECT_ID"
  + region     = "us-east4"
</code></pre>
<p>If the plan prints <code>clearpay-migration-demo</code>, fix <code>terraform.tfvars</code> first. Secret Manager and Service Networking can sit on <code>Still creating</code> for about half a minute during apply.</p>
<p>Check:</p>
<pre><code class="language-bash">gcloud services list --enabled --filter='config.name:container.googleapis.com'
</code></pre>
<pre><code class="language-plaintext">NAME                      TITLE
container.googleapis.com  Kubernetes Engine API
</code></pre>
<p>If this prints nothing, the API isn't on for the project <code>gcloud</code> is looking at. Don't go to Stage 2 yet. Check these three causes, in order:</p>
<ol>
<li><p><code>gcloud</code> <strong>points at a different project.</strong> Run <code>gcloud config get-value project</code>. It must print the same Project ID you put in <code>terraform.tfvars</code>. If it doesn't, run <code>gcloud config set project YOUR_PROJECT_ID</code> and check again.</p>
</li>
<li><p><strong>The apply didn't finish.</strong> Run <code>terraform plan</code> in <code>01-foundation</code> again. A finished stage prints <code>No changes</code>. If the plan still wants to add resources, run <code>terraform apply</code> and type <code>yes</code>.</p>
</li>
<li><p><strong>Google hasn't caught up yet.</strong> A newly enabled API can take a minute to show in the list. Wait one minute and run the check again.</p>
</li>
</ol>
<p>Message Maya: "the first stage applied, and Kubernetes Engine API shows as enabled."</p>
<p>Maya: "Good. Network next. Nothing else."</p>
<h4 id="heading-stage-2-build-the-network-02-network">Stage 2: Build the Network (<code>02-network</code>)</h4>
<p>A VPC is a private network inside your Google project. Think of it as the neighborhood the cluster will live in, and the subnet as one street in it. This stage creates:</p>
<ul>
<li><p><code>clearpay-vpc</code>, the private network</p>
</li>
<li><p><code>clearpay-gke-subnet</code>, the address range the cluster's machines use, with two extra ranges named <code>pods</code> and <code>services</code> reserved for Stage 3</p>
</li>
<li><p><code>clearpay-router</code> and <code>clearpay-nat</code>, the Cloud NAT setup</p>
</li>
<li><p><code>clearpay-nat-ip</code>, the one public IP that all cluster machines share when they call out</p>
</li>
<li><p>two firewall rules so machines inside can talk to each other and Google's health checks can reach them</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/4934e0f5-ed7f-4de7-879d-9e04cec275e1.png" alt="The diagram shows the GCP network setup. Private machines run inside a subnet within the  and have no public IP addresses. Cloud NAT provides outbound internet access through the static IP . The network also supports connectivity to the AWS RDS database later in the migration." style="display: block;" width="1536" height="1024" loading="lazy">

<p>This folder has no <code>terraform.tfvars</code>. It reads your Project ID from Stage 1's state file.</p>
<pre><code class="language-bash">cd infra/gcp/terraform/02-network
</code></pre>
<p>Run:</p>
<pre><code class="language-bash">terraform init
terraform plan
terraform apply
</code></pre>
<p>Expected plan:</p>
<pre><code class="language-plaintext">Plan: 7 to add, 0 to change, 0 to destroy.
</code></pre>
<p>Expected end of apply:</p>
<pre><code class="language-plaintext">Apply complete! Resources: 7 added, 0 changed, 0 destroyed.

Outputs:

nat_ip_address = "x.x.x.x"
network_name = "clearpay-vpc"
subnet_name = "clearpay-gke-subnet"
</code></pre>
<p>Check:</p>
<pre><code class="language-bash">gcloud compute networks describe clearpay-vpc
terraform output nat_ip_address
</code></pre>
<p>Look for <code>name: clearpay-vpc</code>, a <code>subnetworks</code> line ending in <code>clearpay-gke-subnet</code>, and <code>autoCreateSubnetworks: false</code>. That last line means this network was designed, not generated.</p>
<p>Write the NAT IP down. Chapter 13 opens the AWS database to exactly this address.</p>
<p>In the console: <a href="https://console.cloud.google.com/networking/networks/list">VPC networks</a> shows <code>clearpay-vpc</code>. <a href="https://console.cloud.google.com/net-services/nat/list">Cloud NAT</a> shows <code>clearpay-nat</code> on <code>clearpay-router</code>. <a href="https://console.cloud.google.com/networking/addresses/list">IP addresses</a> shows <code>clearpay-nat-ip</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/fe7ffa07-ae26-4095-91a3-d9a62708c7e5.png" alt="image showing cloud NAT on gcp console" style="display: block;" width="778" height="354" loading="lazy">

<p>If the plan can't read <code>../01-foundation/terraform.tfstate</code>, Stage 1 isn't done.</p>
<p>Message Maya: "<code>clearpay-vpc</code> and Cloud NAT exist. The NAT IP is in your notes."</p>
<p>Daniel sees the NAT IP in your notes.</p>
<p>Daniel: "Every call our pods make to the outside world leaves from that one address. That includes calls to the database on AWS. Later we tell the AWS database to accept connections from this address and nothing else."</p>
<p>That happens in Chapter 13. Keep the NAT IP in your notes until then.</p>
<h4 id="heading-stage-3-create-the-gke-cluster-03-gke">Stage 3: Create the GKE Cluster (<code>03-gke</code>)</h4>
<p>This stage creates a Standard cluster named <code>clearpay</code> in zone <code>us-east4-a</code>, with a node pool named <code>primary</code> of one to three <code>e2-medium</code> machines. The machines have private IPs only and use Cloud NAT to reach out. The control plane, Google's managed API server for the cluster, has a public endpoint so <code>kubectl</code> on your laptop can reach it.</p>
<p>GKE first creates a default node pool, then Terraform deletes it and creates <code>primary</code>, which you control.</p>
<pre><code class="language-bash">cd infra/gcp/terraform/03-gke
</code></pre>
<p>Run:</p>
<pre><code class="language-bash">terraform init
terraform plan
terraform apply
</code></pre>
<p>Expected plan: the cluster and the <code>primary</code> node pool.</p>
<pre><code class="language-plaintext">Plan: 2 to add, 0 to change, 0 to destroy.
</code></pre>
<p>The apply sits on <code>Still creating</code> for seven to ten minutes. Don't press Ctrl+C.</p>
<pre><code class="language-plaintext">Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

cluster_name = "clearpay"
get_credentials_command = "gcloud container clusters get-credentials clearpay --zone us-east4-a --project YOUR_PROJECT_ID"
</code></pre>
<p><code>cluster_endpoint</code> prints <code>&lt;sensitive&gt;</code>. You don't need it.</p>
<p>Point <code>kubectl</code> at the new cluster:</p>
<pre><code class="language-bash">$(terraform output -raw get_credentials_command)
</code></pre>
<p>A good run prints <code>kubeconfig entry generated for clearpay</code>.</p>
<p>Check:</p>
<pre><code class="language-bash">kubectl get nodes
</code></pre>
<pre><code class="language-plaintext">NAME                                 STATUS   ROLES    AGE   VERSION
gke-clearpay-primary-........        Ready    &lt;none&gt;   2m    v1.xx
</code></pre>
<p><code>Ready</code> means the machine is on and joined the cluster. ClearPay isn't on it yet.</p>
<p>If the apply mentions <code>container.googleapis.com</code>, go back to Stage 1. If it mentions the subnet or the <code>pods</code> or <code>services</code> ranges, go back to Stage 2. Don't edit <code>03-gke</code> to hide those errors.</p>
<p>If <code>kubectl</code> says <code>gke-gcloud-auth-plugin not found</code>, the cluster is fine. Don't destroy it. Install the plugin and fix PATH for this terminal:</p>
<pre><code class="language-bash">gcloud components install gke-gcloud-auth-plugin
export PATH="$(gcloud info --format='value(installation.sdk_root)')/bin:$PATH"
kubectl get nodes
</code></pre>
<p>In the console: <a href="https://console.cloud.google.com/kubernetes/list/overview">Kubernetes clusters</a> shows <code>clearpay</code> running. <a href="https://console.cloud.google.com/compute/instances">VM instances</a> shows a <code>gke-clearpay-primary-</code> machine with an internal IP and no external IP.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/c4ad49c3-5313-48d9-906d-626ffa23be02.png" alt="image showing gke created in the console" style="display: block;" width="1120" height="788" loading="lazy">

<p>Message Maya: "the cluster is up. One node is Ready. Nothing of ours runs on it yet."</p>
<p>The apply took nine minutes. You watched every one of them.</p>
<h4 id="heading-stage-4-create-the-image-registry-04-registry">Stage 4: Create the Image Registry (<code>04-registry</code>)</h4>
<p>A container image is a packaged copy of the app. GKE downloads images from a registry. This stage creates a private Docker registry in Artifact Registry, named <code>clearpay</code>, in <code>us-east4</code>. It stays empty until the pipeline pushes to it in Chapter 14.</p>
<pre><code class="language-bash">cd infra/gcp/terraform/04-registry
</code></pre>
<p>Run:</p>
<pre><code class="language-bash">terraform init
terraform plan
terraform apply
</code></pre>
<p>The two resources are the registry and a rule that lets the cluster's machines pull images from it.</p>
<pre><code class="language-plaintext">Plan: 2 to add, 0 to change, 0 to destroy.
</code></pre>
<pre><code class="language-plaintext">Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

repository_url = "us-east4-docker.pkg.dev/YOUR_PROJECT_ID/clearpay"
</code></pre>
<p>Check:</p>
<pre><code class="language-bash">gcloud artifacts repositories describe clearpay --location=us-east4
</code></pre>
<p>Look for <code>format: DOCKER</code>, a <code>name</code> ending in <code>/repositories/clearpay</code>, and <code>Repository Size: 0.000MB</code>. An empty registry is correct today. You'll also see <code>SCANNING_DISABLED</code>. The pipeline scans images with Trivy instead, in Chapter 14.</p>
<p>Message Maya: "the image registry exists. It's empty."</p>
<h4 id="heading-stage-5-create-identities-with-no-key-files-05-identity">Stage 5: Create Identities with No Key Files (<code>05-identity</code>)</h4>
<p>Two things will need Google permissions later: the ClearPay pods on GKE, and GitHub Actions when it pushes images and deploys.</p>
<p>The old way is to download a service account key file. A key file is a password on disk. If it leaks, anyone can use it. This lab never creates one.</p>
<p>Instead, this stage creates three Google service accounts (identities with permissions) and lets named callers borrow them for short periods:</p>
<ul>
<li><p><code>clearpay-api-staging</code>, borrowed by pods in the <code>staging</code> namespace</p>
</li>
<li><p><code>clearpay-api-production</code>, borrowed by pods in the <code>production</code> namespace</p>
</li>
<li><p><code>clearpay-ci</code>, borrowed by GitHub Actions through a Workload Identity pool named <code>clearpay-github-pool</code></p>
</li>
</ul>
<p>Workload Identity is the Google feature that links a Kubernetes service account, or a GitHub repository, to a Google service account without a key.</p>
<p>Think of each service account as a badge. Nothing wears a badge today. Later, Google lets a named pod or a named repository wear one for a short time, and no key file ever exists.</p>
<p>Namespaces are separate areas inside one cluster. Staging and production share the cluster's machines but not their identities, so a staging pod can't read production's secrets. A real company would usually run separate clusters, often in separate projects. This lab uses one cluster to keep the bill small.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/9a167f0d-f157-4dd5-81dd-877eb25bc2d6.png" alt="Diagram showing one GKE cluster named  with separate staging and production namespaces. Staging uses the  service account, while production uses . GitHub Actions uses the separate  service account for CI/CD operations." style="display: block;" width="1536" height="1024" loading="lazy">

<pre><code class="language-bash">cd infra/gcp/terraform/05-identity
cp terraform.tfvars.example terraform.tfvars
</code></pre>
<p>The only value in the file is <code>github_repo</code>. If you already have your GitHub copy of this repository, put its <code>owner/name</code> there now, with no <code>.git</code> at the end. If not, leave <code>YOUR_GITHUB_USER/YOUR_REPO</code>. Chapter 14 tells you when to set it and apply this stage again.</p>
<p>Run:</p>
<pre><code class="language-bash">terraform init
terraform plan
terraform apply
</code></pre>
<p>Expected plan:</p>
<pre><code class="language-plaintext">Plan: 10 to add, 0 to change, 0 to destroy.
</code></pre>
<p>The ten are the three service accounts, the GitHub pool and its provider, and the permission links between them.</p>
<pre><code class="language-plaintext">Apply complete! Resources: 10 added, 0 changed, 0 destroyed.

Outputs:

ci_service_account_email = "clearpay-ci@YOUR_PROJECT_ID.iam.gserviceaccount.com"
service_account_emails = {
  "production" = "clearpay-api-production@YOUR_PROJECT_ID.iam.gserviceaccount.com"
  "staging" = "clearpay-api-staging@YOUR_PROJECT_ID.iam.gserviceaccount.com"
}
</code></pre>
<p>Check:</p>
<pre><code class="language-bash">gcloud iam service-accounts list --filter='email~clearpay-'
</code></pre>
<p>Look for three emails: <code>clearpay-api-staging</code>, <code>clearpay-api-production</code>, and <code>clearpay-ci</code>.</p>
<p>In the console: <a href="https://console.cloud.google.com/iam-admin/serviceaccounts">Service accounts</a> lists the three. <a href="https://console.cloud.google.com/iam-admin/workload-identity-pools">Workload Identity Federation</a> shows <code>clearpay-github-pool</code> with provider <code>clearpay-github-provider</code>.</p>
<p><strong>If apply fails</strong> with <code>Error 409: Requested entity already exists</code> on the pool, you ran this stage before. Google keeps a deleted pool for 30 days and blocks its name. Restore it with <code>gcloud iam workload-identity-pools undelete clearpay-github-pool --location=global</code>,<br>then run <code>terraform import google_iam_workload_identity_pool.github projects/YOUR_PROJECT_ID/locations/global/workloadIdentityPools/clearpay-github-pool</code> and apply again.</p>
<p>If the same error then names the provider, restore it with <code>gcloud iam workload-identity-pools providers undelete clearpay-github-provider --workload-identity-pool=clearpay-github-pool --location=global</code>,<br>then run <code>terraform import google_iam_workload_identity_pool_provider.github projects/YOUR_PROJECT_ID/locations/global/workloadIdentityPools/clearpay-github-pool/providers/clearpay-github-provider</code> and apply again.</p>
<p>Message Maya: "three service accounts exist. Nothing uses them yet."</p>
<p>Maya: "No key files?"</p>
<p>"None."</p>
<h4 id="heading-stage-6-create-the-secrets-06-secrets">Stage 6: Create the Secrets (<code>06-secrets</code>)</h4>
<p>ClearPay on GKE needs two secrets: the database URL and the JWT signing key. Both already exist on AWS. This stage creates four secrets in Secret Manager, one pair per namespace:</p>
<ul>
<li><p><code>clearpay-staging-jwt-secret</code></p>
</li>
<li><p><code>clearpay-staging-database-url</code></p>
</li>
<li><p><code>clearpay-production-jwt-secret</code></p>
</li>
<li><p><code>clearpay-production-database-url</code></p>
</li>
</ul>
<p>The staging service account can read only the staging pair. The production service account can read only the production pair.</p>
<p>Think of each secret as a locked drawer. Each badge from Stage 5 opens only its own drawers.</p>
<p>This lab puts the same values in both pairs. Both environments use the same RDS, and a login on AWS must still work when the next request lands on GKE. A real company would give each environment its own values.</p>
<p>Terraform creates the secrets with a placeholder value, <code>REPLACE_ME</code>. Terraform is allowed to create the secrets but not to invent their values. So the order is: apply first, then copy the real values in by hand.</p>
<pre><code class="language-bash">cd infra/gcp/terraform/06-secrets
</code></pre>
<p>Run:</p>
<pre><code class="language-bash">terraform init
terraform plan
terraform apply
</code></pre>
<p>Terraform creates fourteen resources:</p>
<ul>
<li><p>four secrets</p>
</li>
<li><p>four first versions</p>
</li>
<li><p>four access rules for the API service accounts</p>
</li>
<li><p>two rules that let the pipeline's <code>clearpay-ci</code> account read the staging pair</p>
</li>
</ul>
<pre><code class="language-plaintext">Plan: 14 to add, 0 to change, 0 to destroy.
</code></pre>
<pre><code class="language-plaintext">Apply complete! Resources: 14 added, 0 changed, 0 destroyed.
</code></pre>
<p>Now copy the AWS values in. AWS must be running.</p>
<p>Run these from the repository root, the top folder of the project, not from <code>06-secrets</code> and not from <code>infra/aws/terraform</code>. You're still in <code>06-secrets</code>, so go up four folders first:</p>
<pre><code class="language-bash">cd ../../../..
</code></pre>
<p><code>ls</code> should now show <code>api</code>, <code>infra</code>, <code>k8s</code>, and <code>scripts</code>.</p>
<p>You don't need to move into the AWS folder. <code>terraform -chdir=infra/aws/terraform</code> reads the AWS values from where you stand. Each command pipes a value straight into Secret Manager, so nothing prints on screen.</p>
<p>The database URL, once for each namespace:</p>
<pre><code class="language-bash">terraform -chdir=infra/aws/terraform output -raw database_url \
  | gcloud secrets versions add clearpay-staging-database-url --data-file=-
terraform -chdir=infra/aws/terraform output -raw database_url \
  | gcloud secrets versions add clearpay-production-database-url --data-file=-
</code></pre>
<p>A good run prints <code>Created version [2]</code> and doesn't print the URL.</p>
<p>The JWT secret, read from the <code>.env</code> file on the AWS server:</p>
<pre><code class="language-bash">INSTANCE_IP=$(terraform -chdir=infra/aws/terraform output -raw instance_public_ip)
ssh -i ~/.ssh/clearpay-deployer ubuntu@$INSTANCE_IP \
  "sudo grep '^JWT_SECRET=' /opt/clearpay-api/.env | cut -d= -f2-" \
  | gcloud secrets versions add clearpay-staging-jwt-secret --data-file=-
ssh -i ~/.ssh/clearpay-deployer ubuntu@$INSTANCE_IP \
  "sudo grep '^JWT_SECRET=' /opt/clearpay-api/.env | cut -d= -f2-" \
  | gcloud secrets versions add clearpay-production-jwt-secret --data-file=-
</code></pre>
<p>A later <code>terraform apply</code> in this folder won't put <code>REPLACE_ME</code> back. The Terraform file tells it to leave the value alone after the first apply.</p>
<p>Check:</p>
<pre><code class="language-bash">gcloud secrets list --filter='name~clearpay-'
</code></pre>
<p>Then prove no secret still holds the placeholder. This loop prints <code>ok</code> or <code>STILL_PLACEHOLDER</code> and never prints a secret:</p>
<pre><code class="language-bash">for s in clearpay-staging-database-url clearpay-staging-jwt-secret \
         clearpay-production-database-url clearpay-production-jwt-secret; do
  val=$(gcloud secrets versions access latest --secret="$s")
  if [ "$val" = "REPLACE_ME" ]; then echo "STILL_PLACEHOLDER $s"; else echo "ok $s"; fi
done
</code></pre>
<p>Look for four <code>ok</code> lines. If one says <code>STILL_PLACEHOLDER</code>, run its copy command again.</p>
<p>In the console: <a href="https://console.cloud.google.com/security/secret-manager">Secret Manager</a> lists the four secrets, each with version 2 as the latest. Don't click "Show secret value" on a shared screen.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/783c7aba-0a57-45bb-a629-5a76f4b942f7.png" alt="image showing list of secrets created on ur gcp console" style="display: block;" width="959" height="645" loading="lazy">

<p>Message Maya: "the four secrets hold the AWS values. Nothing was printed on screen or pasted into a chat."</p>
<h4 id="heading-stage-7-count-api-errors-07-observability">Stage 7: Count API Errors (<code>07-observability</code>)</h4>
<p>Every time something happens in the API, it writes a short note called a log line. A login writes one. A payment writes one. When something goes wrong, the note says <code>"level":"error"</code>. A failed payment writes this:</p>
<pre><code class="language-plaintext">{"level":"error","message":"transaction_failed","timestamp":"2026-09-29T10:15:02.000Z","error":"..."}
</code></pre>
<p>On GKE, Google Cloud collects every one of these notes. This stage builds two things on top of them:</p>
<ol>
<li><p><strong>A counter</strong>, named <code>clearpay-api-error-count</code>. It goes up by one each time an error note appears.</p>
</li>
<li><p><strong>An alarm</strong>, named <code>ClearPay API - elevated error rate</code>. It switches on when the counter goes up more than five times in five minutes. One error can be bad luck. Five in five minutes means something is broken.</p>
</li>
</ol>
<p>The alarm has no phone number or email attached yet. When it switches on, the Google Cloud console shows it, but nobody gets a message. You can add an email later in the console.</p>
<p>The counter stays at zero for now, because ClearPay doesn't run on GKE yet. Zero is the correct result today.</p>
<pre><code class="language-bash">cd infra/gcp/terraform/07-observability
</code></pre>
<p>Run:</p>
<pre><code class="language-bash">terraform init
terraform plan
terraform apply
</code></pre>
<p>Expected plan:</p>
<pre><code class="language-plaintext">Plan: 2 to add, 0 to change, 0 to destroy.
</code></pre>
<pre><code class="language-plaintext">Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Outputs:

log_metric_name = "clearpay-api-error-count"
</code></pre>
<p>If apply stops with <code>Error 404: Cannot find metric(s) that match type</code>, nothing is broken. Google needs a few minutes to register a brand-new counter before an alarm can watch it. The counter was created. Wait five minutes and run <code>terraform apply</code> again. This time the plan shows <code>1 to add</code>, only the alarm.</p>
<p>Check:</p>
<pre><code class="language-bash">gcloud logging metrics describe clearpay-api-error-count
</code></pre>
<p>Look for <code>name: clearpay-api-error-count</code> and a filter that mentions <code>clearpay-api</code> and <code>jsonPayload.level="error"</code>.</p>
<p>Message Maya: "the error counter exists and reads zero. No app runs on GKE yet."</p>
<p>Maya: "Last one. This is the address customers will use, so take your time."</p>
<h4 id="heading-stage-8-build-the-public-load-balancer-08-edge">Stage 8. Build the Public Load Balancer (<code>08-edge</code>)</h4>
<p>Stages 1 to 7 built the house. This stage builds the front door: the address customers keep for the rest of the migration. It's a Google global load balancer with a traffic dial behind it: a percentage to AWS, the rest to GKE. Today it sends 100 percent to AWS.</p>
<p>This stage creates:</p>
<ul>
<li><p><code>clearpay-api-ip</code>, the public address customers will use</p>
</li>
<li><p>a self-signed certificate so HTTPS works</p>
</li>
<li><p>the traffic split, set to AWS 100, GKE 0</p>
</li>
<li><p><code>clearpay-gke-edge-ip</code>, a second IP for GKE's side. The load balancer uses it. Customers never do.</p>
</li>
</ul>
<p>The diagram below shows how the GCP load balancer directs customer traffic between AWS and GKE using configurable traffic percentages. Customers connect through one public IP. AWS receives traffic through its Elastic IP, while GKE receives traffic through a separate edge IP that customers never access directly. Both environments connect to the same PostgreSQL database on AWS. The GKE cluster is empty at this stage.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/83038470-79f5-4488-be93-4e205dfbfeed.png" alt="Architecture diagram showing customers connecting to a GCP load balancer through one public IP. The load balancer splits traffic between an AWS EC2 server, reached through its Elastic IP, and a GKE cluster, reached through a separate GCP edge IP. Both environments connect to the same PostgreSQL database on AWS. The GKE cluster is empty at this stage." style="display: block;" width="1224" height="1285" loading="lazy">

<pre><code class="language-bash">cd infra/gcp/terraform/08-edge
cp terraform.tfvars.example terraform.tfvars
</code></pre>
<p>Skip the copy if the file exists. Put your Elastic IP in <code>aws_backend_ip</code>. Get it from a second terminal at the repository root:</p>
<pre><code class="language-bash">terraform -chdir=infra/aws/terraform output -raw instance_public_ip
</code></pre>
<p>Keep the weights at 100 and 0:</p>
<pre><code class="language-plaintext">aws_traffic_weight = 100
gke_traffic_weight = 0
</code></pre>
<p>Run:</p>
<pre><code class="language-bash">terraform init
terraform plan
terraform apply
</code></pre>
<p>The apply takes about two minutes.</p>
<pre><code class="language-plaintext">Plan: 17 to add, 0 to change, 0 to destroy.
</code></pre>
<pre><code class="language-plaintext">Apply complete! Resources: 17 added, 0 changed, 0 destroyed.

Outputs:

current_traffic_split = "AWS: 100% / GKE: 0%"
static_ip_address = "x.x.x.x"
gke_edge_ip_address = "y.y.y.y"
hosts_file_line = "x.x.x.x api.clearpay.test"
</code></pre>
<p>Write both IPs down. <code>static_ip_address</code> is the customer address. <code>gke_edge_ip_address</code> is not.</p>
<h4 id="heading-give-the-address-a-name">Give the Address a Name</h4>
<p>You don't own a domain, so you point the name <code>api.clearpay.test</code> at the load balancer in your computer's hosts file.</p>
<p>You're already in <code>08-edge</code> after the apply, so skip the <code>cd</code> line below. Run it only if you opened a new terminal at the repository root:</p>
<pre><code class="language-bash">cd infra/gcp/terraform/08-edge
LINE=$(terraform output -raw hosts_file_line)
echo "LINE is: [$LINE]"
</code></pre>
<p>If <code>LINE</code> is empty, stop. If it looks like <code>x.x.x.x api.clearpay.test</code>, add it to the hosts file.</p>
<p>On a Mac:</p>
<pre><code class="language-bash">echo "$LINE" | sudo tee -a /etc/hosts
sudo dscacheutil -flushcache
sudo killall -HUP mDNSResponder
</code></pre>
<p>On Linux, run only the first line. On Windows, add the same IP and <code>api.clearpay.test</code> to <code>C:\Windows\System32\drivers\etc\hosts</code> in an editor opened as Administrator.</p>
<p>Don't run <code>sudo cd</code>. <code>cd</code> is built into the shell, so <code>sudo</code> can't change your folder.</p>
<p>Confirm the line is there:</p>
<pre><code class="language-bash">grep api.clearpay.test /etc/hosts
</code></pre>
<p>The name ends in <code>.test</code> on purpose. <code>.test</code> is reserved for testing, so Chrome lets you click through a self-signed certificate warning. Don't change <code>lab_hostname</code> to a <code>.dev</code> name. Chrome forces strict HTTPS on every <code>.dev</code> site and hides the button to continue.</p>
<h4 id="heading-check">Check</h4>
<pre><code class="language-bash">curl -k https://api.clearpay.test/health
</code></pre>
<p>Expected output:</p>
<pre><code class="language-plaintext">{"status":"ok","served_by":{"platform":"aws","version":"1.0.0","instance":"ip-172-31-64-73","commit":"504e0cd","rollback_active":false}}
</code></pre>
<p>Your <code>instance</code> and <code>commit</code> values will differ. <code>"platform":"aws"</code> is the check. The request went to the new Google address, and the load balancer passed it to AWS, because the weights are 100 and 0.</p>
<p>A new Google load balancer can take a few minutes to start. If you don't see the JSON yet, you may see one of these first:</p>
<ul>
<li><p><code>Connection reset</code>. The load balancer is still starting. Wait a minute and run the same curl again.</p>
</li>
<li><p>A Google page titled <code>404 Not Found</code>. The load balancer answered but hasn't reached AWS yet. Confirm AWS itself is up:</p>
</li>
</ul>
<pre><code class="language-bash">curl -k https://YOUR_ELASTIC_IP/health
</code></pre>
<p>If AWS answers, wait a few minutes and try <code>api.clearpay.test</code> again. Stage 8 is done even if this is still slow. Chapter 20 checks it again.</p>
<p>When the JSON appears, open <a href="https://api.clearpay.test">https://api.clearpay.test</a> in a browser. In Chrome, click Advanced, then "Proceed to api.clearpay.test (unsafe)". You see the ClearPay login, served by AWS through the new address. If the browser says <code>NXDOMAIN</code>, the hosts line is missing or the cache wasn't flushed.</p>
<p><strong>In the console:</strong> <a href="https://console.cloud.google.com/networking/addresses/list">IP addresses</a> shows <code>clearpay-api-ip</code> and <code>clearpay-gke-edge-ip</code>. <a href="https://console.cloud.google.com/net-services/loadbalancing/list/loadBalancers">Load balancing</a> shows <code>clearpay-migration-forwarding-rule</code> with your Elastic IP as a backend.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/561ee96e-d795-4de1-a45a-d288f374c660.png" alt="image showing loadbalancer map on gcp" style="display: block;" width="1156" height="406" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/8f175baf-a8f5-498c-9a00-793da73e654d.png" alt="image showing IP addresses on gcp console" style="display: block;" width="1094" height="786" loading="lazy">

<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p>Stage 1: <code>container.googleapis.com</code> is enabled</p>
</li>
<li><p>Stage 2: <code>terraform output nat_ip_address</code> prints an IP, and you wrote it down</p>
</li>
<li><p>Stage 3: <code>kubectl get nodes</code> shows at least one <code>Ready</code> node</p>
</li>
<li><p>Stage 4: the <code>clearpay</code> registry exists</p>
</li>
<li><p>Stage 5: three <code>clearpay-</code> service accounts exist</p>
</li>
<li><p>Stage 6: the loop prints four <code>ok</code> lines</p>
</li>
<li><p>Stage 7: the error metric exists</p>
</li>
<li><p>Stage 8: <code>/etc/hosts</code> has <code>api.clearpay.test</code>, and weights are 100 / 0</p>
</li>
</ul>
<p>Tell Maya: "all eight stages are verified. Weights are 100 and 0. Nothing runs on GKE yet."</p>
<p>Daniel: "The cluster is empty. Next we put ClearPay on it."</p>
<h2 id="heading-part-4-how-to-get-clearpay-onto-gke-staging">Part 4. How to Get ClearPay Onto GKE Staging</h2>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/4a852432-a65f-4e7d-ad72-18f628051930.png" alt="4a852432-a65f-4e7d-ad72-18f628051930" style="display: block;" width="2048" height="738" loading="lazy">

<p>Maya stops by to look at the new address in your browser. She reads the dashboard line and smiles.</p>
<p>Maya: "Nice work on the front door. But look, it still says <code>aws</code>. Customers can reach the new address, and every request still lands on the old server. The cluster on GKE is empty, so nobody can send $20 there yet."</p>
<p>She points at your terminal.</p>
<p>Maya: "Next step is to get ClearPay onto that cluster. One thing I care about: let the pipeline do it, not a person typing commands. A pipeline does it the same way every time, and we can see exactly what it did."</p>
<p><code>https://api.clearpay.test</code> already works in your browser, so it's easy to think ClearPay is on GKE. It's not. That address is Google's new front door, the load balancer from Stage 8. With the weights at 100 and 0, the front door sends every request to AWS. The <code>served by aws/1.0.0</code> line on the dashboard proves it. The GKE cluster itself is still empty.</p>
<pre><code class="language-plaintext">Browser → api.clearpay.test (Google load balancer) → 100% → AWS EC2 → RDS
                                                  →   0% → GKE (empty)
</code></pre>
<p>In this part the pipeline puts ClearPay on GKE staging, and the promotion gate from Chapter 6 decides whether staging is good enough. Staging is a private copy of the app inside the cluster. No customer reaches it.</p>
<h3 id="heading-chapter-12-check-the-api-image">Chapter 12. Check the API Image</h3>
<p><em>Week two, Tuesday, 9:00 a.m.</em></p>
<p>The cluster is built and empty. Before anything goes on it, Daniel pulls the API Dockerfile up on your screen.</p>
<p>Daniel: "We're about to put this on a cluster. Show me the container doesn't run as root."</p>
<p>Prove the API image is small, holds no secrets, and does not run as <code>root</code>.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>A process that runs as <code>root</code> inside a container has more power than it needs if an attacker breaks in. Production images should run as a regular user.</p>
<h4 id="heading-steps">Steps</h4>
<p>Open <code>api/Dockerfile</code>. It has two stages:</p>
<ol>
<li><p>The first stage, <code>FROM node:22-slim AS deps</code>, installs only production packages with <code>npm install --omit=dev</code>.</p>
</li>
<li><p>The second stage starts again from <code>node:22-slim</code>. It applies Debian security updates and deletes npm and yarn, because the running API only needs <code>node src/index.js</code>. It creates <code>appuser</code> with user ID 1001 and copies in <code>node_modules</code>, <code>src</code>, and <code>db</code>. The last lines switch to <code>USER appuser</code>.</p>
</li>
</ol>
<p>Notice what the image doesn't contain: <code>DATABASE_URL</code> and <code>JWT_SECRET</code>. Those arrive at run time from Secret Manager.</p>
<p>Docker Desktop must be running. From the repository root:</p>
<pre><code class="language-bash">docker build -t clearpay-api:test ./api
docker run --rm clearpay-api:test whoami
</code></pre>
<h4 id="heading-check">Check</h4>
<p>The second command prints:</p>
<pre><code class="language-plaintext">appuser
</code></pre>
<h4 id="heading-if-it-fails">If it Fails:</h4>
<ul>
<li><p>It prints <code>root</code>: the <code>USER appuser</code> line is missing. Fix <code>api/Dockerfile</code> and run both commands again.</p>
</li>
<li><p>Docker can't connect: start Docker Desktop and retry. The cluster is fine.</p>
</li>
</ul>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><code>whoami</code> prints <code>appuser</code></li>
</ul>
<h3 id="heading-chapter-13-prepare-the-cluster-for-clearpay">Chapter 13. Prepare the Cluster for ClearPay</h3>
<p><em>Tuesday, 11:00 a.m.</em></p>
<p>The image check is done. You open GitHub, ready to run the pipeline. Daniel looks at your screen and shakes his head.</p>
<p>Daniel: "The first deploy will fail twice, for reasons that have nothing to do with the pipeline. Fix those first."</p>
<p>Before the first deploy, the cluster needs two things it doesn't have yet:</p>
<ol>
<li><p>A way for pods to read secrets from Secret Manager.</p>
</li>
<li><p>A network path to the database on AWS.</p>
</li>
</ol>
<p>Without the first, Kubernetes rejects the staging files. Without the second, the pods start but never become ready.</p>
<h4 id="heading-part-a-turn-on-the-secret-manager-add-ons">Part A. Turn on the Secret Manager Add-ons</h4>
<p>Daniel: "The pods should read the Secret Manager values from Stage 6. If they don't, write down why they still use a file."</p>
<p>The staging and production files in <code>k8s/overlays/</code> read the database URL and JWT secret from Secret Manager. They use two GKE add-ons:</p>
<ul>
<li><p>The Secret Manager CSI driver mounts secrets as files inside the pod.</p>
</li>
<li><p>Secret Sync copies them into a normal Kubernetes Secret named <code>clearpay-api-secrets</code>. The Deployment loads that Secret into the API's environment variables.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/60d4dd57-e960-441e-a86f-243c3b2b1b69.png" alt="Image showing secret manager flow" style="display: block;" width="1536" height="1024" loading="lazy"></li>
</ul>
<p>The pod proves who it is through Workload Identity, using the service account from Stage 5. No key file exists anywhere. The API code doesn't change. It still reads plain environment variables, as it does on AWS.</p>
<p>Turn on both add-ons. <code>gcloud</code> needs two separate commands, and each takes a few minutes:</p>
<pre><code class="language-bash">gcloud container clusters update clearpay --zone us-east4-a \
  --enable-secret-manager
gcloud container clusters update clearpay --zone us-east4-a \
  --enable-secret-sync
</code></pre>
<p>These run here, not in <code>03-gke</code>, so the cluster stage stays small and you see the moment secret access is added.</p>
<p>Leave <code>PROJECT_ID</code> as it is in <code>k8s/overlays/staging/secretproviderclass.yaml</code> and <code>patch-serviceaccount.yaml</code>. The pipeline replaces it with your real Project ID at deploy time. Don't type your ID into those files.</p>
<h4 id="heading-part-b-open-the-aws-database-to-gke">Part B. Open the AWS Database to GKE</h4>
<p>Don't describe this as "connecting GKE to the database". That phrasing suggests the database moves too, and it doesn't. This step lets GKE reach and use the real, unmoved AWS database, safely.</p>
<p>The cluster's machines have no public IP. Their outbound traffic leaves through Cloud NAT with one public address, the NAT IP from Stage 2. RDS will accept connections from exactly that address on port 5432, and the connection string already requires TLS (<code>sslmode=require</code>).</p>
<pre><code class="language-plaintext">GKE Pod  (clearpay-api)
   │
   ▼
Private node  (no public IP)
   │
   ▼
Cloud NAT  (clearpay-nat-ip)
   │
   │  public egress, one /32
   ▼
AWS security group  (additional_db_ingress_cidrs)
   │  port 5432 + SSL
   ▼
RDS PostgreSQL 16
</code></pre>
<p>This is one of three ways to connect two clouds:</p>
<table>
<thead>
<tr>
<th>Option</th>
<th>Setup</th>
<th>Ongoing</th>
<th>What it is</th>
</tr>
</thead>
<tbody><tr>
<td>Public endpoint + IP allowlist</td>
<td>A few Terraform variables</td>
<td>$0 extra (normal internet egress)</td>
<td>RDS gets a public IP. The security group allows only the Cloud NAT <code>/32</code>. TLS is required.</td>
</tr>
<tr>
<td>Cloud VPN (site-to-site)</td>
<td>Hours: gateways on both clouds</td>
<td>About $36 per month on GCP plus about $36 per month on AWS per tunnel (<a href="https://cloud.google.com/vpc/network-pricing#vpn">GCP VPN pricing</a>)</td>
<td>A private path. RDS has no public endpoint. Not in this repository.</td>
</tr>
<tr>
<td>Partner Cross-Cloud Interconnect for AWS</td>
<td>Real provisioning lead time</td>
<td>Fixed 10 or 100 Gbps tiers (<a href="https://cloud.google.com/network-connectivity/docs/interconnect/concepts/cross-cloud">Cross-Cloud Interconnect</a>)</td>
<td>A dedicated private link for heavy traffic. Not in this repository.</td>
</tr>
</tbody></table>
<p>To compare them for your own system:</p>
<pre><code class="language-plaintext">(expected monthly GB) × (price per GB on that path) + fixed monthly cost of that option
</code></pre>
<p>ClearPay sends small queries, so a VPN would cost more each month than the data is worth. This lab uses the allowlist. A busier system might choose differently.</p>
<p>Get the NAT IP:</p>
<pre><code class="language-bash">cd infra/gcp/terraform/02-network
terraform output nat_ip_address
</code></pre>
<p>Put it in <code>infra/aws/terraform/terraform.tfvars</code>:</p>
<pre><code class="language-plaintext">db_publicly_accessible      = true
additional_db_ingress_cidrs = ["&lt;the NAT IP&gt;/32"]
</code></pre>
<p>Then apply the AWS stack:</p>
<pre><code class="language-bash">cd infra/aws/terraform
terraform plan
terraform apply
</code></pre>
<p>The plan adds a new security group rule for the NAT IP and makes RDS publicly reachable. It must not remove the rule that lets the EC2 server reach RDS. Those are two separate rules in Terraform: <code>aws_security_group_rule.db_from_nat</code> for GKE and <code>aws_security_group_rule.db_from_api</code> for EC2.</p>
<h4 id="heading-check">Check</h4>
<p>AWS must still reach its database. From the repository root:</p>
<pre><code class="language-bash">export INSTANCE_IP=$(terraform -chdir=infra/aws/terraform output -raw instance_public_ip)
curl -k https://$INSTANCE_IP/health/ready
</code></pre>
<pre><code class="language-plaintext">{"status":"ready", ... "platform":"aws" ...}
</code></pre>
<p>If this prints <code>not_ready</code>, or the AWS login page says <code>internal error</code>, the database stopped accepting the EC2 server. Stop here and read the Terraform plan again. Fix AWS before you touch GKE, because AWS still serves every customer.</p>
<h4 id="heading-check-before-you-go-on">Check Before You Go On</h4>
<p>Chapter 14 pushes code and starts the pipeline. If any step above was skipped, the pipeline fails two chapters later with an error that doesn't say which step you missed. Run this check now instead. It prints <code>ok</code> or <code>MISSING</code> for each thing the pipeline needs. Run it from the repository root:</p>
<pre><code class="language-bash">export INSTANCE_IP=$(terraform -chdir=infra/aws/terraform output -raw instance_public_ip)
NAT_IP=$(terraform -chdir=infra/gcp/terraform/02-network output -raw nat_ip_address)
DB_HOST=$(terraform -chdir=infra/aws/terraform output -raw db_endpoint | cut -d: -f1)

grep -q '^db_publicly_accessible *= *true' infra/aws/terraform/terraform.tfvars \
  &amp;&amp; echo "ok   RDS public setting" \
  || echo "MISSING db_publicly_accessible = true in infra/aws/terraform/terraform.tfvars"
grep -q "$NAT_IP/32" infra/aws/terraform/terraform.tfvars \
  &amp;&amp; echo "ok   NAT IP in the allowlist" \
  || echo "MISSING $NAT_IP/32 in additional_db_ingress_cidrs"
kubectl get csidriver secrets-store-gke.csi.k8s.io &gt;/dev/null 2&gt;&amp;1 \
  &amp;&amp; echo "ok   Secret Manager driver" \
  || echo "MISSING Secret Manager driver: run the two gcloud commands in Part A"
kubectl get crd secretsyncs.secret-sync.gke.io &gt;/dev/null 2&gt;&amp;1 \
  &amp;&amp; echo "ok   Secret Sync" \
  || echo "MISSING Secret Sync: run the two gcloud commands in Part A"
curl -sk https://$INSTANCE_IP/health/ready | grep -q '"status":"ready"' \
  &amp;&amp; echo "ok   AWS still reaches its database" \
  || echo "MISSING AWS cannot reach its database: read the Terraform plan again"
</code></pre>
<p>The last check tests the real path from GKE to the database. It starts a small throwaway pod, asks the database port if it's accepting connections, and deletes the pod. The first run takes about a minute, because Kubernetes downloads the image:</p>
<pre><code class="language-bash">kubectl run dbcheck --rm -i --restart=Never --image=postgres:16 -- \
  pg_isready -h "$DB_HOST" -p 5432 -t 10 \
  &amp;&amp; echo "ok   GKE reaches the database" \
  || echo "MISSING GKE cannot reach the database"
</code></pre>
<p>You need six <code>ok</code> lines and no <code>MISSING</code>. Each <code>MISSING</code> line names the fix. Apply it, then run the check again. Don't start Chapter 14 until all six print <code>ok</code>.</p>
<p>If the last line says <code>MISSING</code> but the first two say <code>ok</code>, RDS may still be applying the change. Wait two minutes and run that command again.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p>Both <code>gcloud container clusters update</code> commands finished</p>
</li>
<li><p>RDS allows the NAT IP, and <code>curl -k https://$INSTANCE_IP/health/ready</code> still prints <code>ready</code></p>
</li>
<li><p>The check above printed six <code>ok</code> lines and no <code>MISSING</code></p>
</li>
</ul>
<h3 id="heading-chapter-14-build-the-cicd-pipeline">Chapter 14. Build the CI/CD Pipeline</h3>
<p><em>Tuesday, 3:00 p.m.</em></p>
<p>The cluster is ready for its first deploy. Maya reads the team channel and adds one rule.</p>
<p>Maya: "From now on, let's not run <code>kubectl apply</code> on staging by hand. The pipeline should build the image, scan it, push it, and deploy it, the same way every time."</p>
<p>The workflow file is already written: <code>.github/workflows/deploy.yml</code>. Your job is to understand it, connect it to Google Cloud, and watch it deploy staging.</p>
<h4 id="heading-why-a-pipeline">Why a Pipeline?</h4>
<p>When a person deploys by hand, each deploy is slightly different. Someone skips the scan because it's late. Someone deploys from a laptop with uncommitted changes. When something breaks, nobody can say what ran.</p>
<p>A pipeline removes those differences. Every image comes from a commit, passes the same scan, and reaches the cluster through the same steps. The promotion gate in Chapter 15 means something because it checks an image built that way.</p>
<h4 id="heading-how-the-pipeline-works">How the Pipeline Works</h4>
<p>The workflow has two jobs:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/75a5ecc9-a8b7-4458-ba10-e7d8ea1b33bd.png" alt="Image showing cicd flow" style="display: block;" width="1024" height="1536" loading="lazy">

<p>Open <code>.github/workflows/deploy.yml</code> in your <a href="https://github.com/Osomudeya/migrate-api-aws-gke-devops/blob/main/.github/workflows/deploy.yml">repository</a> and keep it open alongside this diagram. The diagram maps the workflow's two jobs and their sequence. The file contains the actual GitHub Actions configuration. Follow both together as you read through the triggers, permissions, image build and scan, staging deployment, and production promotion.</p>
<p>Read the file top to bottom with this map.</p>
<p><strong>Triggers:</strong> <code>on: push: branches: [main]</code> runs Job 1 on every push to <code>main</code>. <code>workflow_dispatch</code> adds a "Run workflow" button in GitHub with one checkbox, <code>promote_to_production</code>. Job 1 runs only when that box is off (<code>if: ${{ !inputs.promote_to_production }}</code>). Job 2 runs only when it's on. A push can never reach production by itself.</p>
<p><strong>Permissions:</strong> <code>id-token: write</code> lets the job ask GitHub for a short-lived OIDC token, a signed statement that says "this run belongs to repository X". Google checks that statement against the pool from Stage 5 and hands back temporary credentials for <code>clearpay-ci</code>. No key file sits in GitHub secrets, so there's no key to leak.</p>
<p><strong>Image tag:</strong> The tag is the short commit SHA from <code>git rev-parse --short HEAD</code>. Each image points to exactly one commit. The pipeline also pushes a moving tag, <code>staging-latest</code>, but deploys use the SHA.</p>
<p><strong>Scan:</strong> Trivy is an open-source scanner that checks the packages inside an image against lists of known vulnerabilities. Each known vulnerability has an ID like <code>CVE-2026-29786</code>. The workflow sets:</p>
<ul>
<li><p><code>severity: CRITICAL,HIGH</code>, so only serious findings count</p>
</li>
<li><p><code>ignore-unfixed: true</code>, so a finding with no patch available yet doesn't block the build</p>
</li>
<li><p><code>exit-code: "1"</code>, so any remaining finding fails the job</p>
</li>
</ul>
<p>Together they mean: fail if a serious vulnerability has a fix and this image doesn't have it. Nothing gets pushed if the scan fails.</p>
<p><strong>Push:</strong> The job tags each image with the full registry path, <code>us-east4-docker.pkg.dev/&lt;project&gt;/clearpay/api:&lt;sha&gt;</code>, and pushes it. <code>clearpay-ci</code> has <code>artifactregistry.writer</code> from Stage 5, so the push works.</p>
<p><strong>Deploy to staging:</strong> The step works inside <code>k8s/overlays/staging</code>:</p>
<ol>
<li><p>It writes the two staging secrets from Secret Manager into <code>secrets.staging.env</code> on the runner. <code>umask 077</code> makes the file readable only by the runner's user. The file is gitignored and dies with the runner.</p>
</li>
<li><p><code>sed</code> replaces the <code>PROJECT_ID</code> placeholder in <code>patch-serviceaccount.yaml</code> and <code>secretproviderclass.yaml</code> with your real Project ID.</p>
</li>
<li><p><code>kustomize edit set image</code> points the overlay at the image with this commit's SHA.</p>
</li>
<li><p><code>kubectl apply -k .</code> sends the overlay to the cluster.</p>
</li>
<li><p><code>kubectl rollout status</code> waits up to 180 seconds for the API and UI pods to become ready. If they never do, the job fails.</p>
</li>
</ol>
<p><code>clearpay-ci</code> has <code>container.developer</code>, which lets it deploy workloads but not change the cluster itself.</p>
<p><strong>Promote to production:</strong> Job 2 is the only way the pipeline touches production:</p>
<ul>
<li><p><code>environment: production</code> pauses the job until a named reviewer clicks Approve in GitHub.</p>
</li>
<li><p>It doesn't build anything. It reads the image string that staging is running right now and deploys that exact image to production.</p>
</li>
</ul>
<p>That rule is called "build once, promote the same artifact". The image that passed the Chapter 15 gate is byte for byte the image customers get. A rebuild could pull a newer package and ship something nobody tested.</p>
<p>Kustomize, used in both deploy steps, is the tool behind <code>kubectl apply -k</code>. It takes the shared files in <code>k8s/base/</code> and applies small environment-specific changes from <code>k8s/overlays/staging/</code> or <code>k8s/overlays/production/</code>, called overlays. Staging and production share one base and differ only where they must.</p>
<h4 id="heading-steps">Steps</h4>
<p>First, put this repository on GitHub under your own account, with <code>main</code> as the default branch.</p>
<p>Then tell Google which repository may use <code>clearpay-ci</code>. Open <code>infra/gcp/terraform/05-identity/terraform.tfvars</code> and set <code>github_repo</code> to your repository's <code>owner/name</code>, for example <code>Osomudeya/migrate-api-aws-gke-devops</code>. Don't paste the full <code>https://github.com/...</code> address, and no <code>.git</code> at the end. Apply Stage 5 again:</p>
<pre><code class="language-bash">cd infra/gcp/terraform/05-identity
terraform plan
terraform apply
</code></pre>
<p>The plan changes the pool's attribute condition and the <code>clearpay-ci</code> link. It doesn't touch the cluster. If you already set the real name in Chapter 11, the plan shows no changes.</p>
<p>On GitHub, open your repository and click Settings. In the left menu, click Secrets and variables, then Actions. Open the Variables tab and click New repository variable for each of these three. Use the Repository section, not Environment.</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><code>GCP_PROJECT_ID</code></td>
<td>your GCP Project ID</td>
</tr>
<tr>
<td><code>GCP_WORKLOAD_IDENTITY_PROVIDER</code></td>
<td>the output of <code>terraform -chdir=infra/gcp/terraform/05-identity output -raw github_actions_workload_identity_provider</code></td>
</tr>
<tr>
<td><code>GCP_CI_SERVICE_ACCOUNT</code></td>
<td>the output of <code>terraform -chdir=infra/gcp/terraform/05-identity output -raw ci_service_account_email</code></td>
</tr>
</tbody></table>
<p>These are variables, not secrets. None of them is a password. The pipeline needs no GitHub secrets at all.</p>
<p>When you copy the output of the two <code>terraform output -raw</code> commands, don't copy a <code>%</code> at the end. On a Mac, the terminal shows <code>%</code> when output has no line break after it. It's not part of the value. The provider value must end in <code>clearpay-github-provider</code>, and the service account must end in <code>.iam.gserviceaccount.com</code>.</p>
<p>Then, still in Settings, click Environments, then New environment. Name it <code>production</code> and click Configure environment. Tick Required reviewers, search for your own GitHub name, select it, and click Save protection rules.</p>
<p>Why: Job 2 is the only path to production. A required reviewer makes GitHub stop that job and wait until a person clicks Approve, so nothing reaches customers by accident. Job 2 waits for that approval in Chapter 19.</p>
<p>You'll see a Manage environment secrets button on this page. Don't click it. The <code>production</code> environment needs no secrets, because the pipeline gets its Google access through Workload Identity, not a stored password. If your repository already holds secrets from other projects, leave them where they are.</p>
<p>If GitHub doesn't offer <strong>Required reviewers</strong>, your repository may be private on a free plan. Make the repository public for this lab, or skip the reviewer. Without one, the production job runs as soon as you trigger it.</p>
<p>Next, check before you push. A pipeline that fails costs three minutes and gives a long log. A check costs ten seconds. First run the check from the end of Chapter 13 again. All six lines must print <code>ok</code>. Then check the repository name you gave Google:</p>
<pre><code class="language-bash">REPO=$(grep '^github_repo' infra/gcp/terraform/05-identity/terraform.tfvars | cut -d'"' -f2)
case "$REPO" in
  https*|*.git|YOUR_GITHUB_USER/*) echo "MISSING github_repo must be owner/name. It is: $REPO" ;;
  */*) echo "ok   github_repo $REPO" ;;
  *)   echo "MISSING github_repo must be owner/name. It is: $REPO" ;;
esac
</code></pre>
<p>It must print <code>ok</code> and your own <code>owner/name</code>.</p>
<p>Now, trigger the pipeline:</p>
<pre><code class="language-bash">git commit --allow-empty -m "trigger pipeline"
git push
</code></pre>
<h4 id="heading-check">Check</h4>
<p>Open the <strong>Actions</strong> tab and click the running workflow. Watch the steps of <code>build-scan-push</code> finish one after another, each with a check mark: build, <strong>Scan API image</strong>, <strong>Scan UI image</strong>, <strong>Authenticate to GCP</strong>, push, <strong>Deploy to staging</strong>.</p>
<p>If a step turns red, find it here. Each one ends by telling you to re-run the workflow:</p>
<table>
<thead>
<tr>
<th>Red step</th>
<th>Go to</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Scan API image</strong> or <strong>Scan UI image</strong></td>
<td>"If the scan fails"</td>
</tr>
<tr>
<td><strong>Authenticate to GCP</strong></td>
<td>"If Authenticate to GCP fails"</td>
</tr>
<tr>
<td><strong>Deploy to staging</strong></td>
<td>"If Deploy to staging fails"</td>
</tr>
</tbody></table>
<p>To re-run after a fix, open the <strong>Actions</strong> tab, click the failed run, and click <strong>Re-run all jobs</strong> at the top right. You don't need to push again.</p>
<p>The second job, <strong>promote-to-production</strong>, shows a gray circle with a slash. That's correct. GitHub skips it on every push. It only runs when you start it by hand in Chapter 19.</p>
<p>When <strong>Deploy to staging</strong> passes, check the cluster:</p>
<pre><code class="language-bash">kubectl -n staging get pods
</code></pre>
<p>The API and UI pods show <code>Running</code> with <code>READY 1/1</code>.</p>
<p>Staging has no public address, and the load balancer still sends every customer to AWS. Don't open a browser for staging. Chapter 15 reaches it through a private tunnel.</p>
<h4 id="heading-if-the-scan-fails">If the Scan Fails</h4>
<p>Never change <code>exit-code: "1"</code> to <code>"0"</code>. That turns the security gate off.</p>
<p>The Dockerfiles are already patched, so the scan should pass. A new security hole can turn it red later. Open the red job. Trivy prints a table. Find the <strong>Target</strong> column and match it:</p>
<table>
<thead>
<tr>
<th>Target column</th>
<th>What it means</th>
<th>What to do</th>
</tr>
</thead>
<tbody><tr>
<td>The operating system, like <code>debian 12.x</code>, with a version in the Fixed column</td>
<td>The base image is behind</td>
<td>Rebuild. If it stays red, move <code>FROM node:22-slim</code> to the newest <code>22.x-slim</code> tag on <a href="https://hub.docker.com/_/node/tags?name=slim">Docker Hub</a>.</td>
</tr>
<tr>
<td><code>usr/local/lib/node_modules/npm</code></td>
<td>npm ships the package</td>
<td>The Dockerfile deletes npm. Check that the delete line is still there.</td>
</tr>
<tr>
<td><code>app/node_modules</code> or <code>package-lock.json</code></td>
<td>One of ClearPay's packages is behind</td>
<td>Bump it in <code>api/package.json</code> or <code>ui/package.json</code></td>
</tr>
<tr>
<td>Alpine packages, in <strong>Scan UI image</strong>, with a Fixed version</td>
<td>The UI base image is behind</td>
<td>Add <code>RUN apk upgrade --no-cache</code> to the nginx stage of <code>ui/Dockerfile.prod</code></td>
</tr>
</tbody></table>
<p>After any Dockerfile change, rebuild locally and confirm the user is still <code>appuser</code>:</p>
<pre><code class="language-bash">docker build -t clearpay-api:test ./api
docker run --rm clearpay-api:test whoami
</code></pre>
<p>Commit, push to <code>main</code>, and watch Actions again.</p>
<h4 id="heading-if-authenticate-to-gcp-fails">If Authenticate to GCP Fails</h4>
<p>The error looks like this:</p>
<pre><code class="language-plaintext">The given credential is rejected by the attribute condition.
</code></pre>
<p>Google accepted the GitHub token and then rejected it. Stage 5 names a different repository, often the placeholder <code>YOUR_GITHUB_USER/YOUR_REPO</code>. The value must be exactly <code>owner/name</code>, like <code>Osomudeya/migrate-api-aws-gke-devops</code>. A full URL doesn't match, and neither does another person's repository. Repeat step 2 above with your exact <code>owner/name</code>. Then retry without changing the workflow:</p>
<pre><code class="language-bash">git commit --allow-empty -m "retry pipeline after WIF repo lock"
git push
</code></pre>
<h4 id="heading-if-deploy-to-staging-fails">If Deploy to Staging Fails</h4>
<p>Two messages mean the same thing: <code>error: timed out waiting for the condition</code> and <code>error: deployment "clearpay-api" exceeded its progress deadline</code>. The files applied, but the pods never became ready. Ask the cluster why:</p>
<pre><code class="language-bash">kubectl -n staging get pods
kubectl -n staging describe pod -l app=clearpay-api
</code></pre>
<p>Then match what you see. The first two rows appear in the job log. The rest appear in the pod output:</p>
<table>
<thead>
<tr>
<th>What you see</th>
<th>What it means</th>
<th>What to do</th>
</tr>
</thead>
<tbody><tr>
<td><code>no matches for kind "SecretProviderClass"</code> or <code>"SecretSync"</code></td>
<td>The Secret Manager add-ons are off</td>
<td>Run the two <code>gcloud</code> commands from Chapter 13, Part A. Re-run the workflow.</td>
</tr>
<tr>
<td><code>Permission denied</code> on <code>gcloud secrets versions access</code></td>
<td>The pipeline account can't read the staging secrets</td>
<td>Apply Stage 6 again (fix A below)</td>
</tr>
<tr>
<td><code>ImagePullBackOff</code> with <code>403 Forbidden</code></td>
<td>The cluster's machines can't read the registry</td>
<td>Apply Stage 4 again, then restart the pods (fix B below)</td>
</tr>
<tr>
<td><code>Insufficient cpu</code></td>
<td>The cluster is growing, or it's full</td>
<td>Run <code>kubectl get nodes</code>. Fewer than three machines: wait a few minutes. Three machines: scale staging to one API pod (fix C below).</td>
</tr>
<tr>
<td><code>FailedMount</code> with <code>driver ... not found</code>, or <code>secret "clearpay-api-secrets" not found</code></td>
<td>A new machine is still starting, or Secret Sync hasn't written the secret yet</td>
<td>Wait two minutes. The pod retries on its own. Check with <code>kubectl -n staging get secret clearpay-api-secrets</code>.</td>
</tr>
<tr>
<td><code>Running</code> but <code>READY 0/1</code>, with <code>Connection terminated due to connection timeout</code> or <code>Readiness probe failed</code></td>
<td>The pod can't reach PostgreSQL</td>
<td>Run the check at the end of Chapter 13. The <code>MISSING</code> line shows what's not done. Chapter 13, Part B is the usual cause. The pods become ready on their own at the next health check.</td>
</tr>
</tbody></table>
<p><strong>Fix A. Give the pipeline access to the staging secrets:</strong></p>
<pre><code class="language-bash">cd infra/gcp/terraform/06-secrets
terraform plan
terraform apply
</code></pre>
<p><strong>Fix B. Let the cluster read the registry.</strong> The machines pull images as the default Compute Engine service account, which needs <code>artifactregistry.reader</code>. The plan adds that one rule:</p>
<pre><code class="language-bash">cd infra/gcp/terraform/04-registry
terraform plan
terraform apply
</code></pre>
<p>Then restart the staging pods and wait:</p>
<pre><code class="language-bash">kubectl -n staging rollout restart deployment/clearpay-api deployment/clearpay-ui
kubectl -n staging rollout status deployment/clearpay-api --timeout=180s
kubectl -n staging rollout status deployment/clearpay-ui --timeout=180s
</code></pre>
<p><strong>Fix C. Run one API pod in staging:</strong> The small lab cluster can't fit two. Scale down, then re-run the workflow:</p>
<pre><code class="language-bash">kubectl -n staging scale deployment/clearpay-api --replicas=1
</code></pre>
<p>To read the API's logs without printing secrets:</p>
<pre><code class="language-bash">kubectl -n staging logs deployment/clearpay-api --tail=20
</code></pre>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p>You can explain what each of the two jobs in <code>deploy.yml</code> does</p>
</li>
<li><p>The <code>production</code> environment exists with you as required reviewer</p>
</li>
<li><p><strong>Deploy to staging</strong> passed</p>
</li>
<li><p><code>kubectl -n staging get pods</code> shows the API and UI <code>Running</code>, <code>READY 1/1</code></p>
</li>
</ul>
<p>The pipeline stops there. It doesn't run the promotion gate. You do, in Chapter 15.</p>
<h3 id="heading-chapter-15-check-staging-and-run-the-promotion-gate">Chapter 15. Check Staging and Run the Promotion Gate</h3>
<p><em>Week two, Wednesday, 10:00 a.m.</em></p>
<p>The pipeline finished without an error at 6:40 last night. You took a screenshot. Nobody at ClearPay cares about the screenshot.</p>
<p>Daniel stops at your desk and looks at the passing pipeline.</p>
<p>Daniel: "So GKE is healthy?"</p>
<p>You answer: "The pods are healthy. That doesn't prove a customer can send twenty dollars."</p>
<p>Maya has read the thread. She adds a line.</p>
<p>Maya: "Customers don't get this build until the promotion script passes. Running pods aren't enough for me."</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>Kubernetes reports a pod as ready when its health check answers. That says the process is up and can reach the database. It says nothing about logins, payments, or double charges. The promotion gate checks those.</p>
<p>The promotion gate is a script that you run on your laptop. The pipeline doesn't run it. It's also not the gray <strong>promote-to-production</strong> job you saw in GitHub. That job comes later, in Chapter 19, and only after this gate passes.</p>
<h4 id="heading-how-staging-runs">How Staging Runs</h4>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/36bb447a-2782-40a2-bf7b-588fb04f8dae.png" alt="Image showing the cluster architecture for gcp" style="display: block;" width="1536" height="1024" loading="lazy">

<pre><code class="language-plaintext">GKE cluster "clearpay"   zone us-east4-a
│
├── namespace staging
│     Service clearpay-api :80
│     └── API pods   (HPA 1–3)
│
└── namespace production   (created in Chapter 19)
      Gateway clearpay-gke-edge   ← clearpay-gke-edge-ip
      HTTPRoute → Service :80
      └── API pods   (HPA 1–3)
            each pod: DB_POOL_SIZE=10
</code></pre>
<p>Daniel looks at the same diagram.</p>
<p>"How many API processes can production run?"</p>
<p>"Up to three on this cluster. That's the HPA ceiling in <code>patch-hpa.yaml</code>."</p>
<p>"And how many database connections can each one open?"</p>
<p>"Ten. Same as <code>db.js</code> on Monday."</p>
<p>"So what could the database see?"</p>
<p>You stop. 30 from GKE. Plus 10 from AWS while both are live. On a cluster that could scale to 8 pods, it would be 80 from GKE alone.</p>
<p>"That's the number I asked you to remember," Daniel says.</p>
<p>Three files are worth opening now:</p>
<ul>
<li><p><code>k8s/base/deployment.yaml</code>: the health checks call <code>/health/ready</code>, and each API pod reserves <code>250m</code> CPU and <code>256Mi</code> memory.</p>
</li>
<li><p><code>k8s/base/networkpolicy.yaml</code>: limits which pods may talk to the API.</p>
</li>
<li><p><code>k8s/overlays/staging/patch-deployment.yaml</code>: staging's replica count and rollout settings.</p>
</li>
</ul>
<h4 id="heading-step-1-check-the-database-path-from-gke">Step 1. Check the Database Path from GKE</h4>
<p>Run a one-line Node script inside a staging API pod. It opens a database connection and prints <code>connected</code>:</p>
<pre><code class="language-bash">kubectl -n staging exec deployment/clearpay-api -- node -e \
  "require('./src/db').checkConnection().then(() =&gt; { console.log('connected'); process.exit(0); }).catch(e =&gt; { console.error(e); process.exit(1); })"
</code></pre>
<pre><code class="language-plaintext">connected
</code></pre>
<h4 id="heading-step-2-check-the-secrets-arrived">Step 2. Check the Secrets Arrived</h4>
<p>Kubernetes holds the synced Secret with two keys. <code>describe</code> lists key names and sizes, not values:</p>
<pre><code class="language-bash">kubectl -n staging get secret clearpay-api-secrets
kubectl -n staging describe secret clearpay-api-secrets
</code></pre>
<p>The API process has the value, checked without printing it:</p>
<pre><code class="language-bash">kubectl -n staging exec deployment/clearpay-api -- node -e 'if (!process.env.DATABASE_URL) process.exit(1); console.log("DATABASE_URL is set")'
</code></pre>
<pre><code class="language-plaintext">DATABASE_URL is set
</code></pre>
<p>The script is in single quotes on purpose. The default Mac shell, zsh, treats a <code>!</code> inside double quotes as a history command and stops with <code>zsh: event not found</code>.</p>
<p>The CSI mount shows the secret files by name:</p>
<pre><code class="language-bash">kubectl -n staging exec deployment/clearpay-api -- ls /mnt/secrets-store
</code></pre>
<p>Look for <code>DATABASE_URL</code> and <code>JWT_SECRET</code>.</p>
<h4 id="heading-step-3-run-the-promotion-gate">Step 3. Run the Promotion Gate</h4>
<p>Staging is private. <code>kubectl port-forward</code> opens a tunnel so <code>localhost:8080</code> on your laptop reaches the staging API. The <code>&amp;</code> keeps the tunnel running in the background. From the repository root:</p>
<pre><code class="language-bash">kubectl -n staging port-forward svc/clearpay-api 8080:80 &amp;
</code></pre>
<p>Wait for this line before you continue:</p>
<pre><code class="language-plaintext">Forwarding from 127.0.0.1:8080 -&gt; 4000
</code></pre>
<p>Amara is already on the thread: "Run the replay. I want to see one debit."</p>
<p>Run the same gate you ran against AWS in Chapter 6. The four arguments are the tunnel URL, Alice's email, her password, and Bob's email:</p>
<pre><code class="language-bash">./scripts/promotion-gate.sh http://localhost:8080 \
  alice@clearpay.dev password123 bob@clearpay.dev
</code></pre>
<pre><code class="language-plaintext">PASS: health endpoint returns ok
PASS: readiness endpoint confirms DB connectivity
PASS: login succeeds and returns a token
PASS: replaying an idempotency key returns replayed=true
PASS: balance debited exactly once (100 cents), not twice

5 passed, 0 failed

GATE PASSED: safe to promote to production.
</code></pre>
<p>Stop the tunnel with <code>kill %1</code>.</p>
<p>Alice exists on GKE without a new seed because GKE reads the same RDS database as AWS.</p>
<h4 id="heading-step-4-confirm-customers-are-still-on-aws">Step 4. Confirm Customers Are Still on AWS</h4>
<p>Staging has no public address:</p>
<pre><code class="language-bash">kubectl -n staging get svc clearpay-api
</code></pre>
<pre><code class="language-plaintext">NAME           TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
clearpay-api   ClusterIP   10.x.x.x       &lt;none&gt;        80/TCP    12m
</code></pre>
<p><code>ClusterIP</code> with <code>&lt;none&gt;</code> means only the cluster can reach it.</p>
<p>The customer address still answers from AWS:</p>
<pre><code class="language-bash">export INSTANCE_IP=$(terraform -chdir=infra/aws/terraform output -raw instance_public_ip)
curl -sk https://$INSTANCE_IP/health
</code></pre>
<pre><code class="language-plaintext">{"status":"ok","served_by":{"platform":"aws",...}}
</code></pre>
<p>If you see <code>gke</code> here, stop. If the curl fails, AWS is down, which is a different problem from GKE taking traffic.</p>
<p>So both clouds are running, but only AWS is open to customers. Staging runs on its own inside GKE. The gate isn't what keeps it running. It only tests it, through your tunnel, which is the only door in.</p>
<pre><code class="language-plaintext">Browser  →  api.clearpay.test  →  100 percent  →  AWS EC2  (serves everyone)

GKE staging  ←  your tunnel (port-forward)  ←  the gate and the load test
GKE production  (does not exist yet, Chapter 19)
</code></pre>
<p>Customers reach GKE only after Chapter 21, when the front door starts sending it real traffic.</p>
<h4 id="heading-if-it-fails">If it Fails</h4>
<p>Don't promote this build.</p>
<ul>
<li><p><code>Unexpected end of JSON input</code> or <code>Connection refused</code>: the script ran before the tunnel was listening. If <code>Forwarding from 127.0.0.1:8080</code> is already in the terminal, run only the script again. Don't start a second port-forward.</p>
</li>
<li><p>Health passes but ready fails: the pod can't reach PostgreSQL. Go back to Chapter 13, Part B.</p>
</li>
<li><p>Login fails: check the values you typed against the seed: <code>alice@clearpay.dev</code>, <code>password123</code>, <code>bob@clearpay.dev</code>.</p>
</li>
<li><p><code>checkConnection</code> prints a TLS or timeout error: same as ready failing. Chapter 13, Part B.</p>
</li>
<li><p>The <code>ls</code> shows no files, or the pod is stuck in <code>ContainerCreating</code>: run <code>kubectl -n staging describe pod &lt;pod-name&gt;</code> and look for a Secret Manager permission error. The annotation on the staging <code>clearpay-api</code> service account must match the staging email from Stage 5, character for character. That's the email Stage 6 allowed to read the secrets.</p>
</li>
</ul>
<p>Fix the cause, push through the pipeline if the code or files changed, and run the gate again.</p>
<h4 id="heading-decision">Decision</h4>
<p>Maya: "Are we ready?"</p>
<p>"The gate passed. Health, readiness, login, replay, and one debit. Amara's replay check passed. Customers are still on AWS."</p>
<p>Maya: "Then we can talk about production. Not before."</p>
<p>She looks at the terminal again. "Staging works. Now tell me whether we should keep sending GKE to that same Postgres over the internet."</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p><code>checkConnection</code> prints <code>connected</code> from a staging pod</p>
</li>
<li><p><code>DATABASE_URL is set</code>, and <code>/mnt/secrets-store</code> lists both secret names</p>
</li>
<li><p>The gate prints <code>5 passed, 0 failed</code> through the tunnel</p>
</li>
<li><p>Staging is <code>ClusterIP</code> with no external IP, and the Elastic IP still answers <code>aws</code></p>
</li>
</ul>
<p>This is the checkpoint. Staging passed its gate, and GKE reaches the real database.</p>
<p>Next you prove it's safe, then prove it under load, before any customer traffic moves.</p>
<h2 id="heading-part-5-how-to-make-gke-production-ready">Part 5. How to Make GKE Production-Ready</h2>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/c6f87779-a415-4b5c-b069-465f9be13f40.png" alt="Image describing what we have done, where we are and what next." style="display: block;" width="2089" height="753" loading="lazy">

<p>Staging works. The promotion gate proved five things: the app runs, it reaches the database, a user can log in, a repeated payment charges once, and the balance drops by the right amount. That's a good result. It's not the same as being safe for customers.</p>
<p>Think of a new car that passed its first test drive. It starts and it turns. Before anyone rides in it, you still ask three more questions. Are the doors locked? Can you see the dashboard? What happens on a steep hill, or when a tire goes flat?</p>
<p>This part asks the same three questions about ClearPay:</p>
<table>
<thead>
<tr>
<th>Question</th>
<th>Chapter</th>
<th>What you check</th>
</tr>
</thead>
<tbody><tr>
<td>Is anything exposed?</td>
<td>16</td>
<td>Who can read the secrets. Whether the database connection is encrypted. Whether each account has only the permissions it needs.</td>
</tr>
<tr>
<td>Can we see what's happening?</td>
<td>17 (optional)</td>
<td>A practice dashboard on your laptop that shows how fast the app answers and how many requests fail. It teaches the tools. During the traffic shift you watch with command-line checks.</td>
</tr>
<tr>
<td>What happens under pressure?</td>
<td>18</td>
<td>Many requests at once, like a busy afternoon. A pod that dies in the middle of work. A restart while customers are using the app.</td>
</tr>
</tbody></table>
<p>These questions come now, on purpose. Customers are still on AWS, so if something breaks in this part, nobody notices and nobody loses money. Fixing it here costs you an hour. Fixing it after customers arrive costs you their trust, and every fix happens while people are watching.</p>
<h3 id="heading-chapter-16-run-the-security-review">Chapter 16. Run the Security Review</h3>
<p><em>Wednesday, 2:00 p.m.</em></p>
<p>After lunch, Maya sends the next task. She has read the pipeline log.</p>
<p>Maya: "Before any customer touches GKE, I want a security check. I don't want to find a secret sitting in a file after launch."</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>Every earlier check asked "does it work?". This one asks "what's exposed?". For a payments API, a review after launch comes too late.</p>
<h4 id="heading-check-1-traffic-to-the-database-is-encrypted">Check 1. Traffic to the Database is Encrypted</h4>
<p>The database connection crosses the public internet between clouds. Confirm the live staging process requires TLS, without printing the URL:</p>
<pre><code class="language-bash">kubectl -n staging exec deployment/clearpay-api -- node -e "const u=process.env.DATABASE_URL||''; console.log(u.includes('sslmode=require') ? 'sslmode=require is set' : 'sslmode=require missing')"
</code></pre>
<pre><code class="language-plaintext">sslmode=require is set
</code></pre>
<h4 id="heading-check-2-secret-rotation-is-understood">Check 2. Secret Rotation is Understood</h4>
<p><code>api/src/routes/auth.js</code> issues login tokens that last <code>12h</code>. If you rotate <code>JWT_SECRET</code>, every token signed with the old one stops working, and users must log in again. For this lab that is acceptable. Write that sentence in your notes as the rotation policy. Don't automate rotation here.</p>
<h4 id="heading-check-3-audit-logs-exist">Check 3. Audit Logs Exist</h4>
<p>Google Cloud records who read secrets and who changed permissions, separate from the app's own logs. Confirm those audit entries exist:</p>
<pre><code class="language-bash">gcloud logging read 'protoPayload.serviceName=("secretmanager.googleapis.com" OR "iam.googleapis.com")' --limit=5 --freshness=7d --format='table(timestamp,protoPayload.methodName)'
</code></pre>
<p>Look for method names that mention <code>SecretManager</code> or <code>iam</code>. Seeing only <code>iam</code> names, such as <code>SetIAMPolicy</code>, is fine. That proves Google is recording permission changes. The timestamps will differ.</p>
<h4 id="heading-check-4-each-identity-has-only-what-it-needs">Check 4. Each Identity Has Only What it Needs</h4>
<p>Staging's account reads staging secrets. Production's account reads production secrets. The pipeline pushes images and deploys, and can't change the cluster.</p>
<pre><code class="language-bash">PROJECT_ID=$(gcloud config get-value project)
gcloud secrets get-iam-policy clearpay-staging-database-url
gcloud secrets get-iam-policy clearpay-production-database-url
gcloud projects get-iam-policy "$PROJECT_ID" --flatten='bindings[].members' --filter='bindings.members:serviceAccount:clearpay-ci@' --format='table(bindings.role)'
</code></pre>
<p>What to look for:</p>
<ul>
<li><p>The staging secret lists <code>clearpay-api-staging</code> and <code>clearpay-ci</code>. The pipeline reads the staging pair during deploys.</p>
</li>
<li><p>The production secret lists <code>clearpay-api-production</code> only.</p>
</li>
<li><p><code>clearpay-ci</code> has two project roles: <code>artifactregistry.writer</code> and <code>container.developer</code>.</p>
</li>
</ul>
<p>If a production secret lists a staging account, or the pipeline has a role like <code>owner</code> or <code>editor</code>, stop and fix it in Stage 5 or Stage 6.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li>All four checks are written in your notes with the output you saw, not "probably fine"</li>
</ul>
<h3 id="heading-chapter-17-watch-clearpay-with-prometheus-and-grafana-optional">Chapter 17. Watch ClearPay With Prometheus and Grafana (Optional)</h3>
<p><em>Thursday, 9:30 a.m.</em></p>
<p>The security review is done. Daniel reads your notes, then turns his chair toward you.</p>
<p>Daniel: "Once we start moving traffic, I want to watch a graph instead of guessing. What will you look at?"</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p><strong>Note that this chapter is optional.</strong> It doesn't touch AWS or GKE, and no later chapter needs it. Skip to Chapter 18 if you want. Do it if you want to learn how Prometheus and Grafana work.</p>
<p>The API exposes numbers at <code>/metrics</code>: request counts, latency, and database pool use. Prometheus collects those numbers every few seconds. Grafana draws them.</p>
<p>This chapter runs Prometheus and Grafana on your laptop with Docker Compose, pointed at the Chapter 1 API. It proves the dashboard works. It doesn't watch GKE or AWS. Those target files exist in <code>observability/targets/</code> as <code>.example</code> files, but this lab doesn't use them. During the traffic shift, the command-line checks in Chapters 21 and 22 are the source of truth.</p>
<p>Google Cloud has its own error counter from Stage 7. That's a Cloud Logging metric in GCP, separate from this Grafana.</p>
<pre><code class="language-plaintext">Now     localhost:4000  →  Prometheus  →  Grafana :3000
Later   AWS, GKE, load balancer  (observability/targets/*.example, optional)
</code></pre>
<h4 id="heading-steps">Steps</h4>
<p>Prometheus reads <code>host.docker.internal:4000</code>, which is the Chapter 1 API. If <code>curl http://localhost:4000/health</code> fails, start it:</p>
<pre><code class="language-bash">docker compose up -d api postgres
</code></pre>
<p>Then start the monitoring stack:</p>
<pre><code class="language-bash">cd observability
docker compose up
</code></pre>
<p>Open <a href="http://localhost:3000">http://localhost:3000</a>. Anonymous admin access is on for this laptop-only stack. Open the <strong>ClearPay - Migration Dashboard</strong>.</p>
<h4 id="heading-check">Check</h4>
<p>Send the local API a few requests:</p>
<pre><code class="language-bash">curl http://localhost:4000/health
</code></pre>
<p>The <strong>Request latency p95 by env</strong> panel shows a series named <code>local</code>. An empty panel means Prometheus can't reach port 4000.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li>The dashboard shows a <code>local</code> series</li>
</ul>
<h3 id="heading-chapter-18-load-test-and-break-staging-on-purpose">Chapter 18. Load-Test and Break Staging on Purpose</h3>
<p><em>Thursday, 1:00 p.m.</em></p>
<p>Staging passed its gate, and you feel done. Daniel puts your Chapter 6 scorecard and the gate results side by side on his screen.</p>
<p>Daniel: "The gate only tested one login. What happens when twenty requests arrive at once, or when a pod dies?"</p>
<p>Run the Chapter 6 load test against staging, put the two scorecards side by side, then kill a pod and restart the Deployment while load is running.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>Customers will hit GKE with many requests at once, and Kubernetes will restart pods during updates and machine repairs. You want to see both happen now, with no customer involved.</p>
<p>The system under test runs on the normal node pool from Stage 3. If you ever run the load generator in the cloud, a cheap Spot VM is a good fit for it, since it can be interrupted without harm. The system you measure should never run on Spot, because an interruption would ruin the numbers.</p>
<h4 id="heading-step-1-load-test-staging">Step 1. Load-Test Staging</h4>
<p>Use two terminals, both at the repository root.</p>
<p><strong>Terminal A</strong> holds the tunnel for this whole chapter. Leave it running:</p>
<pre><code class="language-bash">kubectl -n staging port-forward svc/clearpay-api 8080:80
</code></pre>
<p>Look for <code>Forwarding from 127.0.0.1:8080</code>.</p>
<p><strong>Terminal B</strong>: first confirm you reach GKE and that Postgres answers:</p>
<pre><code class="language-bash">curl -s http://localhost:8080/health
curl -s http://localhost:8080/health/ready
</code></pre>
<p><code>health</code> must include <code>"platform":"gke"</code>. <code>ready</code> must include <code>"status":"ready"</code>.</p>
<p>Then run the load test with 20 concurrent requests for 30 seconds:</p>
<pre><code class="language-bash">./scripts/load-test.sh http://localhost:8080 \
  alice@clearpay.dev password123 20 30
</code></pre>
<p>Copy the scorecard into your notes, next to the AWS one. This lab's numbers:</p>
<pre><code class="language-plaintext">                AWS (Chapter 6)   GKE staging (this chapter)
p95 latency:    745 ms            655 ms
Error rate:     0.0 %             0.0 %
Recovery time
after pod kill: n/a               (fill in after Step 3)
</code></pre>
<p>AWS ran with 10 concurrent requests and <code>ok=372</code>. GKE ran with 20 concurrent requests, <code>ok=686</code>, <code>failed=0</code>, and 22.9 requests per second.</p>
<p>GKE's p95 is a little lower than AWS's here. That might surprise you. Most of both numbers is the trip from your laptop to the cloud, and that trip is about the same size for both. Every database query on GKE also crosses from Google's <code>us-east4</code> to AWS's <code>us-east-1</code> through Cloud NAT, but that adds only a small part on top.</p>
<p>The two runs used different concurrency, 10 and 20, so treat the numbers as close, not as a race. The line that needs to stay clean is <code>failed=0</code>. Keep both latency numbers in mind. They come back at the end of the book.</p>
<h4 id="heading-step-2-read-the-served-by-lines">Step 2. Read the <code>served by</code> Lines</h4>
<p>Open the ClearPay dashboard on <code>api.clearpay.test</code> and look at Recent activity. Expect mostly <code>aws</code> lines and one or two <code>gke</code> lines. This lab looked like this:</p>
<pre><code class="language-plaintext">-$1.00  served by aws/1.0.0        ← a send you made in the browser
-$1.00  served by gke/0.0.0-unset  ← the promotion gate from Chapter 15
-$5.00  served by aws/1.0.0        ← an older send
</code></pre>
<p>Each line names the process that wrote that transfer. Both clouds write to the same database, and the UI reads every row. Why so few <code>gke</code> lines?</p>
<ul>
<li><p>Your browser sends go to AWS, because the front door still sends 100 percent of traffic there.</p>
</li>
<li><p>Only the promotion gate wrote through GKE, with a $1.00 payment through the tunnel. The load test only reads data, so it adds no lines.</p>
</li>
</ul>
<p>A <code>gke</code> line doesn't mean the load balancer sent your page to GKE. It means a GKE pod handled that transfer.</p>
<p>The two names come from environment variables. <code>aws/1.0.0</code> comes from the EC2 <code>.env</code> file. <code>gke/0.0.0-unset</code> comes from <code>k8s/base/deployment.yaml</code>, where <code>APP_VERSION</code> is a placeholder the pipeline doesn't change. The placeholder is expected, not a crash.</p>
<p>The black bar at the top shows the process answering right now. Recent activity shows history.</p>
<h4 id="heading-step-3-kill-a-pod-and-time-the-recovery">Step 3. Kill a Pod and Time the Recovery</h4>
<p>Delete one API pod and time how long Kubernetes takes to replace it:</p>
<pre><code class="language-bash">kubectl -n staging get pods -l app=clearpay-api
POD=$(kubectl -n staging get pod -l app=clearpay-api -o jsonpath='{.items[0].metadata.name}')
echo "deleting $POD"
START=$(date +%s)
kubectl -n staging delete pod "$POD"
kubectl -n staging wait --for=condition=Ready pod -l app=clearpay-api --timeout=120s
echo "recovery $(( $(date +%s) - START )) s"
kubectl -n staging get pods -l app=clearpay-api
</code></pre>
<p><code>kubectl delete pod</code> waits until the old pod is gone, including a 5-second <code>preStop</code> pause in <code>k8s/base/deployment.yaml</code>. That pause lets in-flight requests finish before the process stops. The <code>recovery</code> line is the time from the delete until the new pod is ready. An earlier run of this lab printed <code>42 s</code>. Yours will differ. Write your number in the table above.</p>
<p>If <code>wait</code> times out, run <code>kubectl -n staging get pods</code> and <code>kubectl -n staging describe pod</code> on the new pod name.</p>
<p>The Terminal A tunnel may drop when the pod it points at is deleted. If Terminal A exits, start the port-forward again before Step 4.</p>
<h4 id="heading-step-4-restart-the-deployment-under-load">Step 4. Restart the Deployment Under Load</h4>
<p>This step needs one change to <code>scripts/load-test.sh</code>. Without it, <code>failed=</code> reads 0 even when requests die. The script only counts requests that get an answer. When the pod restarts, the tunnel drops, requests get no answer, and the script stops that worker without counting a failure.</p>
<p>Open <code>scripts/load-test.sh</code> in your editor. Replace the whole <code>hit()</code> function with this one. Two things are new: <code>2&gt;/dev/null || true</code> on the curl line, and the <code>if</code> line at the end.</p>
<pre><code class="language-bash">hit() {
  local path="$1" out code secs
  out=$(curl "${CURL_OPTS[@]}" -o /dev/null -w '%{http_code} %{time_total}' \
    -H "Authorization: Bearer $TOKEN" \
    "$BASE_URL$path" 2&gt;/dev/null || true)
  code="${out%% *}"
  secs="${out##* }"
  echo "$code" &gt;&gt; "$CODES"
  node -e "process.stdout.write(String(Math.round(Number(process.argv[1]) * 1000)) + '\n')" "$secs" &gt;&gt; "$TIMES"
  if [ "$code" = "000" ]; then sleep 0.5; fi
}
</code></pre>
<p><code>000</code> is what curl reports when it got no answer. The short pause stops a failing worker from looping thousands of times a second.</p>
<p>Now run the load test in Terminal B. It runs for 40 seconds, so the restart fits inside it:</p>
<pre><code class="language-bash">./scripts/load-test.sh http://localhost:8080 \
  alice@clearpay.dev password123 5 40
</code></pre>
<p>Before you press Enter on that command, type the restart commands in a third terminal and leave them ready. Within three seconds of starting the load test, press Enter in the third terminal:</p>
<pre><code class="language-bash">kubectl -n staging rollout restart deployment/clearpay-api
kubectl -n staging rollout status deployment/clearpay-api
kubectl -n staging get events --sort-by=.lastTimestamp | tail
</code></pre>
<p>The events show old pods stopping and new pods starting, one at a time.</p>
<p>Staging lets one pod go down before its replacement starts (<code>maxUnavailable: 1</code> and <code>maxSurge: 0</code> in <code>k8s/overlays/staging/patch-deployment.yaml</code>). The small lab cluster has no room for an extra pod. So requests fail for a short time during this restart, and <code>failed=</code> above 0 is normal. Write down the count. Terminal A may print <code>lost connection to pod</code>. Stop it with Ctrl+C and start the tunnel again after the test.</p>
<p>Production is safer. Its base setting starts a new pod before an old one stops (<code>maxSurge: 1</code> and <code>maxUnavailable: 0</code>).</p>
<h4 id="heading-decision">Decision</h4>
<p>Pass: <code>failed=0</code> on the 30-second test, the replacement pod became ready after the delete, and <code>rollout status</code> finished. GKE's p95 being higher than AWS is still a pass on this path, as long as it stays under 3 times your AWS p95, the failure limit you wrote in Chapter 6.</p>
<p>Fail: 5xx errors in the 30-second test, a login <code>internal_error</code>, or a new pod that never becomes ready. Fix staging, push through the pipeline, and run this chapter again.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p>Both scorecards are in your notes side by side</p>
</li>
<li><p>Pod recovery is timed</p>
</li>
<li><p>The rolling restart finished while load was running, and you wrote down <code>failed=</code></p>
</li>
</ul>
<p>Stop the port-forward in Terminal A with Ctrl+C.</p>
<p>This is the checkpoint. GKE survived load and a pod kill. Every check so far happened with no customer in the path.</p>
<p>That changes now.</p>
<h2 id="heading-part-6-how-to-move-customers-to-gke">Part 6. How to Move Customers to GKE</h2>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ca5b0eba-5b2e-4999-bf7f-67c80bba426f.png" alt="Image describing what we have done, where we are and what next." style="display: block;" width="2089" height="753" loading="lazy">

<p>This is the part where customers can land on GKE. Both clouds can serve a request. Every step from here asks one question: what evidence lets you move more traffic?</p>
<h3 id="heading-chapter-19-deploy-production-and-the-gke-gateway">Chapter 19. Deploy Production and the GKE Gateway</h3>
<p><em>Week three, Monday, 9:00 a.m.</em></p>
<p>This is the week customers can land on GKE. Nobody says it out loud. The office is a little quieter than usual.</p>
<p>So far, GKE only holds staging. Staging is a private copy that you reach through a tunnel. Customers will use a separate copy called production. This chapter builds that copy, and it connects the address customers will call.</p>
<p>Two things are missing:</p>
<ol>
<li><p><strong>The production namespace.</strong> A namespace is a separate area inside the cluster. Staging has one. Production doesn't have one yet, so no production pods exist. You will create it and put the same image in it that passed the staging gate.</p>
</li>
<li><p><strong>Something behind the GKE edge IP.</strong> In Stage 8 you reserved a public address for GKE, called the edge IP. It works like a phone number you reserved but never connected: it rings, and nobody picks up. You will connect it to a Gateway. The Gateway answers each request and passes it to the production pods.</p>
</li>
</ol>
<p>When you finish, GKE can answer real requests. You still don't move any traffic. The front door keeps sending 100 percent of customers to AWS until Chapter 21.</p>
<h4 id="heading-why-this-matters">Why This Matters</h4>
<p>The load balancer can only send traffic to GKE if something on GKE answers. That something is a Gateway: a Kubernetes object that tells Google to build a load balancer in front of a Service. Here it listens on <code>clearpay-gke-edge-ip</code> and forwards to the production API and UI.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/34561bea-1e1e-4c3a-af90-0760e25ce0d3.png" alt="image showing the cloud architecture flow diagram" style="display: block;" width="1536" height="1024" loading="lazy">

<p>The <code>08-edge</code> load balancer reaches both clouds through Internet NEGs (network endpoint groups that point at an outside IP address). This type has no health checks. If a backend goes down, the load balancer keeps sending it traffic, and customers get 502 errors. Your protection is <code>synthetic-health.sh</code>, the decision gates, and <code>rollback.sh</code>.</p>
<h4 id="heading-step-1-turn-on-the-gateway-api">Step 1. Turn on the Gateway API</h4>
<p>The cluster from Stage 3 doesn't have it on:</p>
<pre><code class="language-bash">gcloud container clusters update clearpay --zone us-east4-a \
  --gateway-api=standard
</code></pre>
<p>Wait until it finishes, then:</p>
<pre><code class="language-bash">kubectl get gatewayclass
</code></pre>
<p>Look for <code>gke-l7-global-external-managed</code>. That's the class <code>k8s/gateway/gke-gateway.yaml</code> uses.</p>
<h4 id="heading-step-2-make-room-on-the-node-pool">Step 2. Make Room on the Node Pool</h4>
<p>The three <code>e2-medium</code> machines have about 940m of CPU each after GKE's own system pods. Staging and production together don't fit. Scale staging down to make room. Its images stay in Artifact Registry, and the gate result stays in your notes.</p>
<p>First write down the exact images staging runs. Production must run these same strings:</p>
<pre><code class="language-bash">kubectl -n staging get deploy clearpay-api \
  -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
kubectl -n staging get deploy clearpay-ui \
  -o jsonpath='{.spec.template.spec.containers[0].image}{"\n"}'
</code></pre>
<p>Then scale staging to zero:</p>
<pre><code class="language-bash">kubectl -n staging scale deploy/clearpay-api --replicas=0
kubectl -n staging scale deploy/clearpay-ui --replicas=0
</code></pre>
<p>Don't push to <code>main</code> during the traffic shift. A push redeploys staging at full size and takes the CPU back.</p>
<p>If production API pods stay <code>Pending</code> with <code>Insufficient cpu</code> later, staging is still holding CPU. Don't raise the node pool's maximum unless you accept the cost of another machine.</p>
<h4 id="heading-step-3-deploy-production">Step 3. Deploy Production</h4>
<p>Use the pipeline. It's the path Chapter 14 built for this moment.</p>
<p><strong>With the pipeline:</strong></p>
<ol>
<li><p>On GitHub, open Actions, then Build, scan, and deploy in the left list. Start from this workflow page, not from a finished run. If you're looking at a single run, click the back arrow next to its name. The <code>promote-to-production</code> job on that old run shows "This job was skipped", and it stays skipped. You need a new run.</p>
</li>
<li><p>Click Run workflow. Keep the branch on <code>main</code>, tick promote_to_production, and click Run workflow.</p>
</li>
<li><p>The <code>promote-to-production</code> job waits with "Waiting for review". Click Review deployments, tick <code>production</code>, and click Approve and deploy.</p>
</li>
<li><p>Watch Find the currently-deployed staging images and Deploy those exact images to production finish with a check mark.</p>
</li>
</ol>
<p>The job reads the image strings from the staging Deployment, even though staging now runs zero pods, and deploys those exact images.</p>
<p>By hand, if the pipeline isn't set up:</p>
<p>These are the same steps the job runs. <code>PROJECT_ID</code> in the files is a placeholder, so replace it the way the pipeline does. On a Mac, <code>sed -i</code> needs an empty <code>''</code> after it:</p>
<pre><code class="language-bash">cd k8s/overlays/production
PROJECT_ID=$(gcloud config get-value project)
sed -i '' "s/@PROJECT_ID.iam/@${PROJECT_ID}.iam/" patch-serviceaccount.yaml
sed -i '' "s#projects/PROJECT_ID/#projects/${PROJECT_ID}/#" secretproviderclass.yaml
</code></pre>
<p>On Linux, drop the <code>''</code> after <code>-i</code>.</p>
<p>Point the overlay at the two images you wrote down in Step 2. Never use <code>latest</code>:</p>
<pre><code class="language-bash">kustomize edit set image \
  REGION-docker.pkg.dev/PROJECT_ID/clearpay/api=STAGING_API_IMAGE
kustomize edit set image \
  REGION-docker.pkg.dev/PROJECT_ID/clearpay/ui=STAGING_UI_IMAGE
kubectl apply -k .
kubectl -n production rollout status deployment/clearpay-api --timeout=180s
kubectl -n production rollout status deployment/clearpay-ui --timeout=180s
</code></pre>
<p>Replace <code>STAGING_API_IMAGE</code> and <code>STAGING_UI_IMAGE</code> with the full strings from Step 2, in the form <code>us-east4-docker.pkg.dev/.../api:&lt;tag&gt;</code>.</p>
<p>Don't commit the files after <code>sed</code> and <code>kustomize edit</code>. They now contain your Project ID and a real tag. Run <code>git checkout k8s/overlays/production</code> from the repository root to undo them after the deploy.</p>
<h4 id="heading-check-production">Check Production</h4>
<p>Return to the repository root. Wait until the pods are ready:</p>
<pre><code class="language-bash">kubectl -n production get pods
</code></pre>
<p>API and UI pods show <code>Running</code> and <code>READY 1/1</code>. Then check the database from a production pod, not staging:</p>
<pre><code class="language-bash">kubectl -n production exec deployment/clearpay-api -- node -e \
  "require('./src/db').checkConnection().then(() =&gt; { console.log('connected'); process.exit(0); }).catch(e =&gt; { console.error(e); process.exit(1); })"
</code></pre>
<pre><code class="language-plaintext">connected
</code></pre>
<h4 id="heading-step-4-apply-the-gke-gateway">Step 4. Apply the GKE Gateway</h4>
<p>Stage 8 reserved <code>clearpay-gke-edge-ip</code> and the certificate <code>clearpay-gke-edge-cert</code>. Now give them something to serve:</p>
<pre><code class="language-bash">kubectl apply -f k8s/gateway/gke-gateway.yaml
kubectl apply -f k8s/gateway/httproute-production.yaml
kubectl apply -f k8s/gateway/healthcheck-api.yaml
kubectl -n production get gateway clearpay-gke-edge
</code></pre>
<p>Wait until <code>PROGRAMMED</code> shows <code>True</code>. That can take a few minutes while Google builds the load balancer.</p>
<p>The three files do three jobs:</p>
<ul>
<li><p><code>gke-gateway.yaml</code> creates the Gateway on the reserved IP with the certificate.</p>
</li>
<li><p><code>httproute-production.yaml</code> sends requests to the production Service.</p>
</li>
<li><p><code>healthcheck-api.yaml</code> tells Google to health-check <code>/health</code>. Without it, Google checks <code>/</code>, the API answers 404, and requests fail with <code>no healthy upstream</code>.</p>
</li>
</ul>
<h4 id="heading-check-the-gateway">Check the Gateway</h4>
<p>Call GKE directly through its edge IP. <code>--resolve</code> makes curl send the hostname on the certificate while connecting to the GKE IP:</p>
<pre><code class="language-bash">HOST=$(terraform -chdir=infra/gcp/terraform/08-edge output -raw public_hostname)
EDGE=$(terraform -chdir=infra/gcp/terraform/08-edge output -raw gke_edge_ip_address)
curl -k --resolve "$HOST:443:$EDGE" "https://$HOST/health"
</code></pre>
<p>Look for <code>"platform":"gke"</code>. If curl prints <code>no healthy upstream</code>, wait a minute and retry. The health check needs time to mark the pods healthy.</p>
<p>Without <code>--resolve</code>, the same hostname still goes to AWS, because weights are 100 / 0.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p><code>kubectl get gatewayclass</code> lists <code>gke-l7-global-external-managed</code></p>
</li>
<li><p>Production API and UI pods are <code>Running</code>, <code>READY 1/1</code>, with the staging image tags</p>
</li>
<li><p><code>checkConnection</code> prints <code>connected</code> from a production pod</p>
</li>
<li><p>The Gateway shows <code>PROGRAMMED</code> <code>True</code>, and the edge IP returns <code>"platform":"gke"</code></p>
</li>
</ul>
<h3 id="heading-chapter-20-walk-the-pre-cutover-checklist">Chapter 20. Walk the Pre-Cutover Checklist</h3>
<p><em>Monday, 3:00 p.m.</em></p>
<p>Production is up. Maya stops at your desk with her laptop under her arm.</p>
<p>Maya: "Before we move traffic, tell me exactly how we put customers back on AWS. Then show me it works."</p>
<h4 id="heading-why-the-rollback-drill-matters">Why the Rollback Drill Matters</h4>
<p><code>scripts/rollback.sh</code> does two things, in order:</p>
<ol>
<li><p>It runs <code>shift-traffic.sh 100 0</code>, which sends all traffic back to AWS in seconds.</p>
</li>
<li><p>It connects to the AWS server over SSH, sets <code>ROLLBACK_ACTIVE=true</code> in its <code>.env</code>, and restarts the API. The UI then shows the rollback banner.</p>
</li>
</ol>
<p>The dashboard has two different messages, on purpose:</p>
<ul>
<li><p><strong>The new-version message</strong> is a small pop-up in the bottom right corner. It says "Noticed a change: this request ran on gke/... (previously aws/...)" and fades after five seconds. It appears when a different version answers.</p>
</li>
<li><p><strong>The rollback banner</strong> is a red-tinted strip across the top of the dashboard. It says "Rolled back to AWS, traffic has been restored while the GKE issue is investigated." It stays until you clear the flag.</p>
</li>
</ul>
<p>Anyone looking at the app can tell "a new version shipped" apart from "something broke and we recovered".</p>
<p>The first time you run a rollback shouldn't be during a real incident, with customers failing, hoping the SSH key works. Run it now while nothing is wrong. Weights are already 100 / 0, so the traffic step changes nothing. The SSH step and the banner are what you test.</p>
<h4 id="heading-the-checklist">The Checklist</h4>
<p>Tick each line with a command you ran, not from memory. Use your own Elastic IP and hostname.</p>
<p>Only four lines need a command now: <strong>GKE healthy</strong>, <strong>Rollback tested</strong>, <strong>AWS healthy</strong>, and <strong>Synthetic test running</strong>. The other five use results you already have, so read them and tick them. Chapters 21 and 22 don't repeat this checklist. They reuse only the synthetic test, which you leave running in its own terminal.</p>
<pre><code class="language-plaintext">Pre-Cutover Checklist

[ ] GKE healthy
    kubectl -n production get pods
    (all Running, READY)

[ ] Database healthy
    the production checkConnection from Chapter 19 prints connected

[ ] Monitoring up (optional, only if you did Chapter 17)
    Grafana at http://localhost:3000 loads.
    It does not watch GKE. Do not block on an empty GKE series.

[ ] Rollback tested
    YOUR_ELASTIC_IP is your AWS server's public IP. Get it with:
    terraform -chdir=infra/aws/terraform output -raw instance_public_ip
    ./scripts/rollback.sh YOUR_ELASTIC_IP ~/.ssh/clearpay-deployer
    Open https://YOUR_ELASTIC_IP, log in, and look for the red strip
    at the top of the dashboard ("Rolled back to AWS ..."). Then clear it:
    ./scripts/clear-rollback-flag.sh YOUR_ELASTIC_IP ~/.ssh/clearpay-deployer

[ ] AWS healthy
    ./scripts/promotion-gate.sh https://YOUR_ELASTIC_IP \
      alice@clearpay.dev password123 bob@clearpay.dev
    5 passed, 0 failed

[ ] Synthetic test running
    ./scripts/synthetic-health.sh https://YOUR_PUBLIC_HOSTNAME
    Leave it running in its own terminal for the whole shift.
    YOUR_PUBLIC_HOSTNAME is api.clearpay.test. From the repository root, confirm with:
    terraform -chdir=infra/gcp/terraform/08-edge output -raw public_hostname
    It prints one line per second: time, status code, latency.

[ ] Limits confirmed
    Chapter 6 limits, plus the Chapter 18 table:
    AWS p95 745 ms, GKE staging p95 655 ms, both 0.0 % errors, plus your pod recovery time from Chapter 18

[ ] Security review passed
    Chapter 16, all four checks written down

[ ] Cost ledger current
    infra/aws/COST.md and infra/gcp/COST.md, both clouds now billing
</code></pre>
<h4 id="heading-what-the-rollback-drill-prints">What the Rollback Drill Prints</h4>
<p>The traffic weights are already 100 / 0, so step 1 changes nothing. Step 2 is the real test. It ends like this:</p>
<pre><code class="language-plaintext">== Traffic split updated ==
"AWS: 100% / GKE: 0%"

== Step 2: flagging AWS as the active rollback target ==
AWS ROLLBACK_ACTIVE=true, service restarted.

== Rollback complete ==
</code></pre>
<p>The last lines of the script name old chapter numbers. Ignore them. The steps for a real rollback are in Chapter 23.</p>
<p>If step 2 stops with <code>ssh: connect to host ... port 22: Operation timed out</code>, your home IP has changed and the AWS firewall still allows the old one. Get your IP with <code>curl -s ifconfig.me; echo</code>. Put it plus <code>/32</code> in <code>ssh_allowed_cidr</code> in <code>infra/aws/terraform/terraform.tfvars</code>. Run <code>terraform apply</code> in <code>infra/aws/terraform</code>, then run the script again.</p>
<h4 id="heading-decision">Decision</h4>
<p>Every line ticked, and the GKE edge IP returns <code>"platform":"gke"</code>: go to Chapter 21.</p>
<p>Anything unticked: stop here. Don't run <code>./scripts/shift-traffic.sh 99 1</code> while the GKE edge IP is empty. One percent of customers would hit an address with nothing behind it.</p>
<p>Tell Maya: "Rollback is one script. It moves traffic in seconds and shows a banner. I ran it five minutes ago."</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li>Every checklist line is ticked with output you saw</li>
</ul>
<h3 id="heading-chapter-21-send-1-percent-of-traffic-to-gke">Chapter 21. Send 1 Percent of Traffic to GKE</h3>
<p><em>Tuesday, 10:00 a.m.</em></p>
<p>The whole team is on one call. Chris has the support inbox open on his second screen. Amara has the transactions table open on hers. Daniel sits next to you with the load balancer page open.</p>
<p>Maya: "One percent to GKE. Watch the errors and the transfers. If they look wrong, send everything back."</p>
<h4 id="heading-why-1-percent">Why 1 Percent?</h4>
<p>One percent of requests. A request is one message from a browser to the API: a login, a balance check, a payment. Out of every 100 requests, about 99 go to AWS and about 1 goes to GKE. The load balancer picks for each request, not for each customer. So one customer can land on both clouds in a single visit.</p>
<p>One percent is enough to find a broken contract, such as a different JSON shape, a missing route, or a login that fails on one side. It's far too small to test capacity. Chapter 22 handles capacity.</p>
<h4 id="heading-steps">Steps</h4>
<pre><code class="language-bash">./scripts/shift-traffic.sh 99 1
</code></pre>
<p>The script re-applies <code>08-edge</code> with new weights on the load balancer's URL map. There's no DNS change and no deploy.</p>
<p>Now copy the same weights into <code>infra/gcp/terraform/08-edge/terraform.tfvars</code>:</p>
<pre><code class="language-plaintext">aws_traffic_weight = 99
gke_traffic_weight = 1
</code></pre>
<p>The script passes the weights as command-line values. The next plain <code>terraform apply</code> in <code>08-edge</code> reads the file. If the file still says 100 and 0, that apply would quietly move traffic back.</p>
<p>Confirm the file and the live state match:</p>
<pre><code class="language-bash">grep traffic_weight infra/gcp/terraform/08-edge/terraform.tfvars
terraform -chdir=infra/gcp/terraform/08-edge output current_traffic_split
</code></pre>
<p>You should see <code>aws_traffic_weight = 99</code>, <code>gke_traffic_weight = 1</code>, and <code>"AWS: 99% / GKE: 1%"</code>. If the file still says 100 and 0, fix it now.</p>
<h4 id="heading-check-did-any-request-reach-gke">Check: Did Any Request Reach GKE?</h4>
<p>At 1 percent, almost every request still goes to AWS. Forty requests in a row that all say <code>aws</code> is normal. Loop until one says <code>gke</code>:</p>
<pre><code class="language-bash">HOST=$(terraform -chdir=infra/gcp/terraform/08-edge output -raw public_hostname)
IP=$(terraform -chdir=infra/gcp/terraform/08-edge output -raw static_ip_address)
while true; do
  curl -sk --resolve "$HOST:443:$IP" "https://$HOST/health"
  echo
  sleep 0.2
done
</code></pre>
<p>Stop with Ctrl+C when you see <code>"platform":"gke"</code>. One run in this lab took 200 requests: 197 <code>aws</code> and 3 <code>gke</code>.</p>
<p>To confirm GKE is still answering without waiting on chance, call its edge IP directly. This isn't a customer request:</p>
<pre><code class="language-bash">HOST=$(terraform -chdir=infra/gcp/terraform/08-edge output -raw public_hostname)
EDGE=$(terraform -chdir=infra/gcp/terraform/08-edge output -raw gke_edge_ip_address)
curl -sk --resolve "$HOST:443:$EDGE" "https://$HOST/health"
</code></pre>
<p>Look for <code>"status":"ok"</code> and <code>"platform":"gke"</code>. This lab printed:</p>
<pre><code class="language-plaintext">{"status":"ok","served_by":{"platform":"gke","version":"0.0.0-unset","instance":"clearpay-api-6f9448c857-mj2j6","commit":"unset","rollback_active":false}}
</code></pre>
<p>Your <code>instance</code> will differ. If <code>platform</code> says <code>aws</code>, or the curl fails, GKE isn't answering. Don't raise the weight.</p>
<h4 id="heading-check-does-a-login-work-through-the-public-address">Check: Does a Login Work Through the Public Address?</h4>
<p>From now on, one customer can land on AWS for one request and on GKE for the next. Both must accept the same request and give the same reply. If they differ, you want to find out now, at 1 percent, not later at 50 percent. Send a login through the public address:</p>
<pre><code class="language-bash">HOST=$(terraform -chdir=infra/gcp/terraform/08-edge output -raw public_hostname)
IP=$(terraform -chdir=infra/gcp/terraform/08-edge output -raw static_ip_address)
curl -sk --resolve "$HOST:443:$IP" \
  -H 'Content-Type: application/json' \
  -d '{"email":"alice@clearpay.dev","password":"password123"}' \
  "https://$HOST/api/auth/login"
</code></pre>
<p>Look for <code>"token"</code> and <code>"user"</code> in the reply. If you see <code>internal_error</code>, stop and don't raise the weight.</p>
<h4 id="heading-check-use-the-app-like-a-customer">Check: Use the App Like a Customer</h4>
<p>Open <a href="https://api.clearpay.test">https://api.clearpay.test</a> and click through the certificate warning. Log in, check the balance, and send a small amount. Each new line under Recent activity says <code>served by aws/1.0.0</code> or <code>served by gke/0.0.0-unset</code>. Only the newest line tells you about this request. Older lines keep the stamp from when they were written.</p>
<p>The black bar at the top will say AWS almost every time at 1 percent. Watch new activity lines and the loop, not the bar.</p>
<p>Glance at the <code>synthetic-health.sh</code> terminal. Every line should show status <code>200</code>.</p>
<h4 id="heading-decision">Decision</h4>
<ul>
<li><p>The loop showed <code>gke</code> at least once, login returns a token, and synthetic health shows only <code>200</code>: go to Chapter 22.</p>
</li>
<li><p>Anything else: stop and investigate. Don't raise the weight. If customers are affected, run <code>rollback.sh</code> and go to Chapter 23.</p>
</li>
</ul>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p>Weights are 99 / 1 in both the file and <code>current_traffic_split</code></p>
</li>
<li><p>The loop printed <code>"platform":"gke"</code> at least once</p>
</li>
<li><p>Login through the public address returns a token</p>
</li>
</ul>
<h3 id="heading-chapter-22-raise-traffic-to-100-percent">Chapter 22. Raise Traffic to 100 Percent</h3>
<p><em>Tuesday, 11:30 a.m., to Wednesday afternoon</em></p>
<p>One percent worked. That means 1 out of every 100 requests went to GKE. Now you turn the dial up, a little at a time. Nothing does it for you. You raise it by hand, and you look at the numbers before every step.</p>
<p>Check the same three things each time:</p>
<ul>
<li><p><strong>The scorecard:</strong> how fast the app answers, and how many requests fail.</p>
</li>
<li><p><strong>The replica count:</strong> how many copies of the API are running.</p>
</li>
<li><p><strong>The connection math:</strong> whether the database still has room for every connection.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/66432691-1878-4296-b6b4-634dab51bb3e.png" alt="Image showing the traffic migration split" style="display: block;" width="1536" height="1024" loading="lazy">

<pre><code class="language-plaintext">99 / 1     AWS ████████████████████░    GKE ░
90 / 10    AWS ██████████████████░░     GKE ██
75 / 25    AWS ███████████████░░░░░     GKE █████
50 / 50    AWS ██████████░░░░░░░░░░     GKE ██████████
25 / 75    AWS █████░░░░░░░░░░░░░░░     GKE ███████████████
10 / 90    AWS ██░░░░░░░░░░░░░░░░░░     GKE ██████████████████
 0 / 100   AWS                          GKE ████████████████████
</code></pre>
<h4 id="heading-before-50-50-do-the-connection-math">Before 50 / 50: Do the Connection Math</h4>
<p>Daniel, who is on the call, cuts in before you go to the halfway mark. "How many database connections can hit RDS if GKE scales all the way up while AWS is still live?"</p>
<p>Each API process can open up to <code>DB_POOL_SIZE</code> connections, ten by default. The pool opens them as load needs them, so the number stays low when traffic is light. At full load, every process can hold its full ten. The database has a hard limit, <code>max_connections</code>. When the total passes it, new connections are refused, requests wait, and customers see timeouts.</p>
<p>Get your database's limit. This runs <code>psql</code> on the AWS server, which already has it from Chapter 5:</p>
<pre><code class="language-bash">export INSTANCE_IP=$(terraform -chdir=infra/aws/terraform output -raw instance_public_ip)
DATABASE_URL=$(terraform -chdir=infra/aws/terraform output -raw database_url)
ssh -i ~/.ssh/clearpay-deployer ubuntu@$INSTANCE_IP "psql '$DATABASE_URL' -c 'SHOW max_connections;'"
</code></pre>
<p>Get GKE's ceiling from <code>maxReplicas</code> in <code>k8s/overlays/production/patch-hpa.yaml</code>. Then fill in this table with your numbers:</p>
<pre><code class="language-plaintext">RDS max_connections:          ___
AWS, 1 process × 10:          10
GKE, maxReplicas × 10:        ___
Worst case, both at once:     10 + ___ = ___
</code></pre>
<p>This lab's filled-in table, so you can check yours:</p>
<pre><code class="language-plaintext">RDS max_connections:          81
AWS, 1 process × 10:          10
GKE, maxReplicas × 10:        30    (3 pods × 10)
Worst case, both at once:     10 + 30 = 40
</code></pre>
<p>40 is under 81, so there's room to raise the traffic. Your <code>max_connections</code> may differ if your RDS instance is a different size. What matters is that your worst case stays below it.</p>
<p>Now run the same math for a bigger setup. A company with larger machines might run the HPA from 3 to 8 pods: 10 + 80 = 90 connections, against a small RDS instance that allows about 87.</p>
<p>Neither setting is wrong on its own. The pool size was set in the API code, and the pod ceiling was set in a Kubernetes file, by different people at different times. They only collide when both clouds run at full load at the same time, which is the exact situation a gradual migration creates.</p>
<p>If your worst case is above your <code>max_connections</code>, stop and read the fix options in Chapter 23 before you raise the weight.</p>
<h4 id="heading-steps">Steps</h4>
<p>Every stage has the same four moves. Do all four, then repeat with the next pair of numbers.</p>
<ol>
<li><p><strong>Shift:</strong> Run <code>./scripts/shift-traffic.sh</code> with the new pair. The first number is the share for AWS, and the second is the share for GKE.</p>
</li>
<li><p><strong>Save:</strong> Put the same two numbers in <code>terraform.tfvars</code> in <code>08-edge</code>.</p>
</li>
<li><p><strong>Check:</strong> Run the six checks in the next section. No single script does all of them. You use a few commands, plus two scripts: <code>synthetic-health.sh</code>, which is already running, and <code>load-test.sh</code>.</p>
</li>
<li><p><strong>Decide:</strong> Every check passes: go to the next stage. Any check fails: stop.</p>
</li>
</ol>
<p>The stages, in order:</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>Command</th>
<th>AWS gets</th>
<th>GKE gets</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><code>./scripts/shift-traffic.sh 90 10</code></td>
<td>90 %</td>
<td>10 %</td>
</tr>
<tr>
<td>2</td>
<td><code>./scripts/shift-traffic.sh 75 25</code></td>
<td>75 %</td>
<td>25 %</td>
</tr>
<tr>
<td>3</td>
<td><code>./scripts/shift-traffic.sh 50 50</code></td>
<td>50 %</td>
<td>50 %</td>
</tr>
<tr>
<td>4</td>
<td><code>./scripts/shift-traffic.sh 25 75</code></td>
<td>25 %</td>
<td>75 %</td>
</tr>
<tr>
<td>5</td>
<td><code>./scripts/shift-traffic.sh 10 90</code></td>
<td>10 %</td>
<td>90 %</td>
</tr>
<tr>
<td>6</td>
<td><code>./scripts/shift-traffic.sh 0 100</code></td>
<td>0 %</td>
<td>100 %</td>
</tr>
</tbody></table>
<p>Run one row at a time, and never run them all together.</p>
<h4 id="heading-checks-at-every-stage">Checks at Every Stage</h4>
<p><strong>1. The dial moved.</strong> The script's printout is Terraform's setting, not proof. Confirm the file matches:</p>
<pre><code class="language-bash">grep traffic_weight infra/gcp/terraform/08-edge/terraform.tfvars
terraform -chdir=infra/gcp/terraform/08-edge output current_traffic_split
</code></pre>
<p><strong>2. Real requests follow the dial.</strong> Wait three minutes after the shift, then count who answers:</p>
<pre><code class="language-bash">HOST=$(terraform -chdir=infra/gcp/terraform/08-edge output -raw public_hostname)
IP=$(terraform -chdir=infra/gcp/terraform/08-edge output -raw static_ip_address)

for i in $(seq 1 40); do
  curl -sk --resolve "$HOST:443:$IP" "https://$HOST/health"
  echo
done | tee /tmp/clearpay-health.txt

grep -c '"platform":"aws"' /tmp/clearpay-health.txt
grep -c '"platform":"gke"' /tmp/clearpay-health.txt
</code></pre>
<p>The GKE count should rise as its weight rises. Forty requests are a rough sample, not a percentage proof. Google also takes a few minutes to apply a new split everywhere, so counting too early shows the old split. This lab saw:</p>
<pre><code class="language-plaintext">90 / 10   80 requests:  75 aws,  5 gke
50 / 50   40 requests:  34 aws,  6 gke
25 / 75   40 requests:  17 aws, 23 gke
10 / 90   40 requests:   8 aws, 31 gke
 0 / 100  40 requests, 1 minute later:    3 aws, 37 gke
 0 / 100  40 requests, 90 seconds later:  0 aws, 40 gke
</code></pre>
<p>If the counts still don't follow the dial after ten minutes, ask Google which split it holds:</p>
<pre><code class="language-bash">gcloud compute url-maps describe clearpay-migration-map | grep -B2 -A2 weight
</code></pre>
<p>At 0 / 100, the <code>clearpay-gke-backend</code> line shows <code>weight: 100</code>, and the AWS line shows no weight at all, because Google leaves out a zero. If the weights differ from your file, run <code>terraform apply</code> again in <code>08-edge</code>.</p>
<p><strong>3. GKE is healthy.</strong></p>
<pre><code class="language-bash">kubectl -n production get pods -l app=clearpay-api
kubectl -n production get hpa clearpay-api
</code></pre>
<p>Pods are <code>Running</code> and ready. The HPA line shows current CPU against the 70 percent target, and the current replica count. This lab stayed at 1 replica and 4 to 7 percent CPU through 25 / 75.</p>
<p><strong>4. Logins still work.</strong> Run the Chapter 21 login curl again at every stage. A contract problem can appear only under a certain mix of traffic. Five failures in a row means stop, not raise.</p>
<p><strong>5. Errors stay inside your limits.</strong> The <code>synthetic-health.sh</code> terminal should show only <code>200</code>. If a line shows anything else, stop and don't raise.</p>
<p><strong>6. The UI.</strong> Log in on <a href="https://api.clearpay.test">https://api.clearpay.test</a>, send money, and read the newest <code>served by</code> line.</p>
<h4 id="heading-the-decision-at-every-stage">The Decision at Every Stage</h4>
<p>Maya asks the same question each time: "Are we ready for the next step?"</p>
<p>Answer with evidence: "The dial matches the file and GKE's share of requests rose. Pods are healthy, login works, and synthetic health is all 200. The worst-case connection count fits under the database limit."</p>
<p>If any check fails, don't raise. If customers are affected, go to Chapter 23 now.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p>Weights are 0 / 100 in the file and in <code>current_traffic_split</code></p>
</li>
<li><p>40 requests in a row return <code>"platform":"gke"</code></p>
</li>
<li><p>The connection math is written down with your own numbers</p>
</li>
<li><p>Synthetic health showed no sustained errors during any stage</p>
</li>
</ul>
<h3 id="heading-chapter-23-when-the-numbers-go-bad">Chapter 23. When the Numbers Go Bad</h3>
<p><em>Wednesday, 4:12 p.m.</em></p>
<p>The last shift is done. Every customer is now on GKE, and the health check keeps printing 200, which means all is well. The support inbox has been quiet for the past hour. Amara checks the transactions table one more time.</p>
<p>"It looks boring," she says. Which is the best thing she could say.</p>
<p>You wait for someone to call it a successful migration.</p>
<p>Daniel is still looking at the deployment. He counts the API copies running in GKE, then looks at the database metrics.</p>
<p><em>"Can I tell you about the worst afternoon at my last job?"</em></p>
<p>Chris turns his chair around.</p>
<p>Daniel leans back.</p>
<p>"It was another migration for a different company, same basic problem though. We were moving customers from one cloud to another, and we'd reached about half of them by Friday afternoon. Traffic started climbing, so the new environment began adding more copies of the API."</p>
<p>He pauses for a moment.</p>
<p>"The old environment was still running too. It was serving the customers we hadn't moved yet."</p>
<p>You look at the deployment on the screen.</p>
<p>"So both environments were using the database?" "Yes. And every copy of the API needed database connections."</p>
<p>He points at the database on the diagram.</p>
<p>"That was where we got into trouble." Daniel reaches for a pen.</p>
<p>"Think about a restaurant with a fixed number of seats. The restaurant has enough seats for a certain number of people, but the number of seats doesn't change because more people arrive."</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/c89a67f5-b226-4401-8a4f-186edacddddb.png" alt="Daniel describing his situation to the team" style="display: block;" width="1536" height="1024" loading="lazy">

<p>He draws two groups on the whiteboard.</p>
<p>"AWS was one group. GKE was the other. Both groups were using the same restaurant."</p>
<p>He added another group beside GKE.</p>
<p>"Then traffic increased. GKE added more copies of the API, and each new copy needed database connections. The old AWS copies were still holding their connections too."</p>
<p>He puts the pen down.</p>
<p>"The database had a limit. We kept increasing the number of connections going into it."</p>
<p>"What happened when you reached the limit?" you ask.</p>
<p>"Requests started waiting for connections. Then they started timing out."</p>
<p>He looks at you.</p>
<p>"The application itself wasn't obviously broken. If the pods were running and health checks were passing, the problem was capacity. We had more demand for database connections than the database could provide."</p>
<p>Chris looks back at the deployment.</p>
<p>"So you were watching the new environment, but the database was dealing with both environments."</p>
<p>"Exactly."</p>
<p>Daniel looks at the diagram again.</p>
<p>"That was the part we missed. We calculated what the new environment needed. We didn't calculate what the database needed while both environments were running."</p>
<p>He sits back down.</p>
<p>"We eventually moved the traffic back to the old environment and recovered. But the incident stayed with me."</p>
<p>You look at the GKE deployment. "What would you do differently now?"</p>
<p>"I'd work through the numbers before moving traffic."</p>
<p>Daniel turns his laptop toward you.</p>
<p>"Your lab is safe. You ran 3 copies against a database limit of 81 connections, and the workload never came close to exhausting it."</p>
<p>He opens another set of notes.</p>
<p>"My incident was different. We had up to 8 copies, and the database allowed 87 connections."</p>
<p>He looks at you. "So let's use those numbers."</p>
<p>You pull your chair closer. Daniel continues.</p>
<p>"I'm going to give you the events in the order they happened. You're on call. Don't assume you know the ending. Tell me what you would check and what you think is happening as each event comes in."</p>
<p>He opens a terminal.</p>
<p>The drill begins with 8 API copies available across the 2 environments and a database connection limit of 87.</p>
<p>This time, the numbers are going to matter.</p>
<p><strong>Do you run anything in this chapter?</strong> Read it once, like a story with a lesson. Your lab had no incident, so there's nothing to fix. If you want proof that you're in the clear, run the two commands in Step 2 and compare them with "Your own cluster". If the logs show no errors and the HPA is at 1 replica, you're done. Go on to Chapter 24.</p>
<h4 id="heading-what-it-looks-like">What it Looks Like</h4>
<p>The first few minutes after the 50 / 50 shift look normal. Then the response times begin to creep up.</p>
<p>Amara notices it first in the team channel.</p>
<p>"A couple of transfers just timed out."</p>
<p>Daniel opens the production dashboard. The API has scaled to seven replicas, and the p95 latency is climbing.</p>
<p>Chris posts two customer reports about slow transfers.</p>
<p>Daniel looks at the graph again. "Is this affecting every request?"</p>
<p>"Not yet," Chris says. "A few transfers so far."</p>
<p>Amara checks the payment records. "Let's make sure those timeouts didn't result in duplicate charges."</p>
<p>Daniel looks at the database metrics. "Could be the database."</p>
<p>You don't know yet.</p>
<p>Nothing in the logs says, "The database is out of connections." The first signal is simply that requests are taking longer than they were a few minutes ago.</p>
<p>No error message says "the database is out of connections". All you see first is the slowest responses (p95 latency) getting slower. The status bar in the app (ui/src/components/StatusRibbon.jsx) switches to "Degraded. Investigating" only after several health checks fail in a row, so one slow request doesn't trigger it.</p>
<h4 id="heading-what-you-know">What You Know</h4>
<p>Traffic is on both clouds. Latency is worse than the AWS baseline. The status bar flipped after several failed checks, not one blip.</p>
<h4 id="heading-what-you-dont-know-yet">What You Don't Know Yet</h4>
<p>Whether this is the load balancer, the pods, the database, or a bad deploy. So don't assume. Gather info first.</p>
<h4 id="heading-step-1-stop-shifting">Step 1. Stop Shifting</h4>
<p>Don't run <code>shift-traffic.sh</code> toward GKE again until you understand what happened. Write down the time you noticed.</p>
<p>Record what you see before you do anything else: the actual error messages, the actual replica count, and the actual timestamp.</p>
<p>Every hour spent diagnosing at a stalled percentage is also an hour of dual-run cost. Add it to the cost ledger. Don't let it drop out of the running total because everyone's attention is elsewhere.</p>
<h4 id="heading-step-2-gather-evidence">Step 2. Gather Evidence</h4>
<p>Read the API's errors:</p>
<pre><code class="language-bash">kubectl -n production logs deployment/clearpay-api --tail=100 | grep error
</code></pre>
<p>Look for words about the database: connection errors, "too many connections", or a timeout while waiting for a client from the pool. A 5xx error with no database words points somewhere else, such as the Gateway or a bad deploy.</p>
<p>Check how many pods Kubernetes is running:</p>
<pre><code class="language-bash">kubectl -n production get hpa clearpay-api
</code></pre>
<p>Compare <code>REPLICAS</code> with <code>maxReplicas</code>. A climbing count means load went up. That's a clue, not yet a cause.</p>
<p>Then fill in the Chapter 22 table with the live numbers:</p>
<pre><code class="language-plaintext">RDS max_connections:          87
AWS, 1 process × 10:          10
GKE, 7 replicas × 10:         70
Right now:                    80, close to the limit
GKE at maxReplicas 8:         80, worst case 90, over the limit
</code></pre>
<h4 id="heading-your-own-cluster-at-50-50">Your Own Cluster at 50 / 50</h4>
<p>Run the same two commands on your cluster before you assume you're in Daniel's story. This lab at AWS 50 / GKE 50 showed:</p>
<pre><code class="language-plaintext">HPA:       cpu 4%/70%, MINPODS 1, MAXPODS 3, REPLICAS 1
pods:      1 Running
logs:      no error lines in the last 100
RDS:       max_connections=81
           two sessions on database clearpay
status:    not Degraded
</code></pre>
<p>Worst case here is up to 40, under 81. Don't run <code>rollback.sh</code> unless your numbers are worse than your Chapter 6 limits.</p>
<p>If your HPA is still at 1 and the logs are quiet, write that down. That's the evidence. Stay with it.</p>
<h4 id="heading-how-the-failure-builds">How the Failure Builds</h4>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/5d5bc823-4b2a-40c0-8060-7adb2376a8b6.png" alt="Image diagram describing how the failure builds" style="display: block;" width="1024" height="1536" loading="lazy">

<p>AWS alone would never hit this. GKE alone would never hit this. It happens only while both run at full load, and the migration plan keeps both running on purpose.</p>
<p>Tell Daniel: "Replica count climbed. The pool math puts us near the database limit. This is capacity, not Kubernetes."</p>
<h4 id="heading-step-3-roll-back">Step 3. Roll Back</h4>
<p>Maya: "What do you recommend?"</p>
<p>Compare the numbers with the limits you wrote in Chapter 6. Don't invent a new limit now.</p>
<p>"Put customers back on AWS. Errors are past our limit, customers are affected, and rollback is tested."</p>
<p>Maya: "Okay. Do it."</p>
<pre><code class="language-bash">./scripts/rollback.sh &lt;aws-instance-ip&gt; ~/.ssh/clearpay-deployer
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/e996fa61-cf03-4f11-abaf-1e0e57e9622a.png" alt="Image demonstrating instant rollback" style="display: block;" width="1536" height="1024" loading="lazy">

<p>The diagram above shows two actions performed by <code>rollback.sh</code>. First, <code>shift-traffic.sh</code> <code>100 0</code> sends all customer traffic back to AWS and removes traffic from GKE. Second, the script connects to the AWS EC2 server over SSH, sets <code>ROLLBACK_ACTIVE=true</code> in the API's environment file, and restarts the API service. The API then reports AWS as the active platform and enables the distinct “Rolled back to AWS” banner in the ClearPay UI. These steps restore traffic and make the rollback visible to the operator.</p>
<p>Set <code>terraform.tfvars</code> in <code>08-edge</code> back to 100 and 0 so a later apply doesn't undo the rollback.</p>
<h4 id="heading-step-4-confirm-the-rollback-worked">Step 4. Confirm the Rollback Worked</h4>
<pre><code class="language-bash">curl -k https://api.clearpay.test/api/info
</code></pre>
<p><code>platform</code> reads <code>aws</code> and <code>rollback_active</code> reads <code>true</code>. Refresh the UI: the red "Rolled back to AWS" banner appears, and the status bar goes back to <code>All systems operational</code> once enough health checks pass.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/da9f3303-1efa-4d13-9fee-e058ee67bbaf.png" alt="ClearPay dashboard showing a red banner stating that traffic has been restored to AWS while the GKE issue is investigated. The status bar reads “All systems operational.” The page displays an account balance of $965.00, a form to send money, and recent transactions marked as served by AWS." style="display: block;" width="772" height="724" loading="lazy">

<p>Then check each of these from evidence, not assumptions:</p>
<ul>
<li><p>Requests stopped failing (<a href="http://synthetic-health.sh">synthetic-health.sh</a> shows 200 again)</p>
</li>
<li><p>Latency is back near the Chapter 6 AWS numbers</p>
</li>
<li><p>The database is consistent: a transfer from just before the rollback shows exactly one debit, not zero and not two</p>
</li>
</ul>
<p>Amara: "I don't care that traffic is back on AWS. Did the transfer happen once?"</p>
<p>Answer from the database, through the transaction history, not from the load balancer. The idempotency key from Chapter 3 is what makes a retried transfer safe here, even when the first try went to GKE and the retry went to AWS.</p>
<p>Write down one number for the incident review: the minutes from "noticed" to "traffic back on AWS".</p>
<h4 id="heading-step-5-fix-the-cause">Step 5. Fix the Cause</h4>
<p>Daniel: "What will you change so that number can't happen again?"</p>
<p>There are four real options, each with a cost:</p>
<ul>
<li><p><strong>Lower</strong> <code>DB_POOL_SIZE</code> <strong>per pod:</strong> Fewer connections per process. Cheap and fast. Each pod can serve fewer database queries at the same time.</p>
</li>
<li><p><strong>Lower the HPA's</strong> <code>maxReplicas</code> in <code>k8s/overlays/production/patch-hpa.yaml</code>. Caps the total. GKE can no longer scale as far under a real traffic spike.</p>
</li>
<li><p><strong>Raise RDS</strong> <code>max_connections</code> with a custom parameter group. Each connection uses database memory, and a small instance like <code>db.t3.micro</code> can fail to start if you set it too high. Check AWS's guidance for your instance size first.</p>
</li>
<li><p><strong>Put a connection pooler in front of Postgres</strong>, such as PgBouncer or RDS Proxy. Many app connections share a few database connections. The best long-term fix, and the most work to set up.</p>
</li>
</ul>
<p>This book doesn't pick one for you. Write down which one you chose and what you gave up.</p>
<p>For the smallest real change, lower the pool size. The math with 6 per pod: 8 × 6 = 48 from GKE, plus 10 from AWS, is 58. That fits under 87 even at the full ceiling.</p>
<p>In an emergency you can set it live:</p>
<pre><code class="language-bash">kubectl -n production set env deployment/clearpay-api DB_POOL_SIZE=6
</code></pre>
<p>A live change leaves no record, and the next deploy erases it. Make the real change through the pipeline: set <code>DB_POOL_SIZE</code> in <code>k8s/base/deployment.yaml</code>, commit, and push to <code>main</code>. The pipeline builds, scans, and deploys staging. Load-test staging hard enough to push the HPA to its ceiling, using Chapter 18's steps with higher concurrency. A fix that only passes under light load proves nothing. Then run the promotion gate, and promote with the Chapter 19 pipeline job.</p>
<h4 id="heading-step-6-resume">Step 6. Resume</h4>
<p>Maya: "Are we ready for 10 percent?"</p>
<p>"Yes. The API is healthy, synthetic checks are clean, errors are within the limit, the pool math holds at the ceiling, and rollback is tested."</p>
<p>Daniel: "Agreed."</p>
<p>Maya: "Your call."</p>
<p>Clear the rollback flag first:</p>
<pre><code class="language-bash">./scripts/clear-rollback-flag.sh &lt;aws-instance-ip&gt; ~/.ssh/clearpay-deployer
</code></pre>
<p>Refresh the UI. The rollback banner is gone. Leaving the flag on would keep the banner showing after recovery, which trains people to ignore it.</p>
<p>Then repeat Chapters 21 and 22 from the start:</p>
<pre><code class="language-bash">./scripts/shift-traffic.sh 99 1
# observe, compare, record, decide

./scripts/shift-traffic.sh 90 10
# observe, compare, record, decide

./scripts/shift-traffic.sh 75 25
./scripts/shift-traffic.sh 50 50
./scripts/shift-traffic.sh 25 75
./scripts/shift-traffic.sh 10 90
./scripts/shift-traffic.sh 0 100
</code></pre>
<p>Watch the HPA and the error lines closely at the stage where the incident happened last time. That stage is where the fix proves itself.</p>
<p>Add a line to the cost ledger. A second pass through the shift means more hours with both clouds billing. Keep that cost visible instead of folding it into one "migration cost" number.</p>
<h4 id="heading-the-skills-behind-this-chapter">The Skills Behind This Chapter</h4>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/e0c6ec10-3f2b-4689-93a6-80539e82d727.png" alt="The incident response flow starts by observing latency, the status bar, and error logs. You form a hypothesis that AWS and GKE together are exhausting PostgreSQL connections, then test the combined replica count and connection pool sizes against the database limit. You record the live numbers as evidence. If customers are affected, stop shifting traffic and roll back. Fix the cause through the deployment pipeline and test the fix at maximum expected load. Resume the migration only after the evidence supports it." style="display: block;" width="1024" height="1536" loading="lazy">

<p>The diagram above shows the incident response process from observation to recovery. Start by checking latency, the status bar, and error logs. Form a hypothesis, test it against the database connection limit, and use live measurements as evidence. If customers are affected, stop shifting traffic and roll back. Fix the cause through the pipeline, test under maximum expected load, and resume the migration only when the evidence supports it.</p>
<p>An incident isn't a hunt for the answer. It's the work of reducing uncertainty until you can make a safe decision. If customers are failing, the safe decision is to stop and roll back. If they aren't, the safe decision is to keep watching. Don't roll back because a number looks odd, and don't keep shifting because the pods look healthy.</p>
<p>This is the checkpoint. GKE is serving 100 percent.</p>
<p>AWS is a priced safety net now, not a leftover you forgot.</p>
<h2 id="heading-part-7-how-to-finish-the-migration">Part 7. How to Finish the Migration</h2>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/048f42c0-5eda-4d41-b518-2110ec08f856.png" alt="Image describing what we have done, where we are and what next." style="display: block;" width="2048" height="738" loading="lazy">

<p>GKE serves every customer. AWS still runs, and still bills, as a safety net. This part turns the cutover into evidence, puts a date on the safety net, and removes what you no longer need.</p>
<h3 id="heading-chapter-24-prove-the-cutover-with-numbers">Chapter 24. Prove the Cutover With Numbers</h3>
<p><em>Thursday, 9:00 a.m.</em></p>
<p>Maya has a meeting with the company leaders at ten. She stops at your desk with her laptop open to an empty page.</p>
<p>Maya: "The leaders weren't on any of our calls, and they'll ask one thing: did customers notice the move? I need a short summary I can put on the screen. How many requests went through, how many failed, how fast the app answers compared with AWS, and whether anyone saw an outage. Can you have it before ten?"</p>
<p>You nod. Every number she asked for comes from two scripts you already know.</p>
<h4 id="heading-steps">Steps</h4>
<p>Run the two checks that produce hard numbers against the public address, now at 100 percent GKE:</p>
<pre><code class="language-bash">HOST=$(terraform -chdir=infra/gcp/terraform/08-edge output -raw public_hostname)
./scripts/promotion-gate.sh "https://$HOST" \
  alice@clearpay.dev password123 bob@clearpay.dev
</code></pre>
<p>Then run <code>load-test.sh</code> against <code>https://$HOST</code> with the Chapter 6 settings, and copy the scorecard. Add the counts from your Chapter 22 loops and the <code>synthetic-health.sh</code> log.</p>
<h4 id="heading-the-evidence">The Evidence</h4>
<p>Below is this lab's table. Yours will have different numbers:</p>
<pre><code class="language-plaintext">Production requests (counted checks, not a full dashboard total):
  40 /health one minute after 0 / 100:  3 aws, 37 gke
  40 /health ninety seconds later:      0 aws, 40 gke
  load-test.sh 10 × 20s:                ok=310  failed=0
  promotion-gate.sh:                    5 passed, 0 failed
Successful requests:                 310 + 40 + 5 in those runs
Failed requests:                     0
5xx errors:                          0.0 %
p95 latency (GKE, 100%):             647 ms
  Chapter 6 AWS baseline was 164 ms.
  Chapter 18 GKE staging (port-forward) was 1157 ms.
Sustained customer-visible outage:   0 seconds
</code></pre>
<p>Local Prometheus didn't watch GKE, so this table uses counted checks. Don't invent a dashboard total you never measured.</p>
<p>Check the table against the limits you wrote in Chapter 6, before GKE existed:</p>
<ul>
<li><p>Outage limit: no stretch of 5xx errors above your limit. This lab had zero failed requests.</p>
</li>
<li><p>Failure limit: p95 under your ceiling. This lab's 647 ms is under the example ceiling of 1500 ms, so it passes.</p>
</li>
</ul>
<p>Passing isn't the whole story. 647 ms is about four times the AWS 164 ms. Customers never saw an outage, but every request got slower. Write that down plainly instead of hiding it. The last section of this book explains why.</p>
<h4 id="heading-why-the-table-doesnt-say-zero-failed-requests-ever">Why the Table Doesn't Say "Zero failed requests, ever"</h4>
<p>No real system running for hours, across traffic shifts and a possible rollback, can honestly promise that. Claiming it would weaken the one claim that matters: no sustained outage a customer could see.</p>
<p>A few failed requests inside the limit you set in advance is the honest version of "this went well". The standard is controlled and measured, not perfect.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p>Every line in your table holds a number you measured</p>
</li>
<li><p>Each line is checked against your Chapter 6 limits, including any that failed</p>
</li>
</ul>
<h3 id="heading-chapter-25-run-on-gke-and-set-a-date-for-aws">Chapter 25. Run on GKE and Set a Date for AWS</h3>
<p><em>Friday, 10:00 a.m.</em></p>
<p>Maya comes back from her meeting with a question from the leaders.</p>
<p>Maya: "So what changed for the customer?"</p>
<p>You look at the numbers. Traffic is 100 percent GKE. Transfers succeed. Synthetic checks all pass. PostgreSQL is still on AWS. The EC2 server serves nobody.</p>
<p>"The address stayed the same and nobody saw an outage."</p>
<p>Maya: "Then this system is yours now. And I want a calendar date for turning off the old server."</p>
<h4 id="heading-step-1-watch-it-run">Step 1. Watch it Run</h4>
<p>Let production run for a real window before you commit to anything. For a real company that means days, not the minutes after the last shift.</p>
<p>This lab's 100 percent window lasted minutes, with a 5/5 gate and <code>failed=0</code>. Write that down. A weekend lab isn't a week of production.</p>
<h4 id="heading-step-2-check-the-alert">Step 2. Check the Alert</h4>
<p>Stage 7 created the alert policy <code>ClearPay API - elevated error rate</code>, which fires on more than five error lines in five minutes. It has no notification channel, so nobody gets told when it fires. An alert that reaches nobody isn't an alert.</p>
<p>For a real system, add a notification channel in Cloud Monitoring and trigger a test on staging, never by sending errors to customers. For this lab, write down the gap: the policy exists, and nobody gets paged.</p>
<h4 id="heading-step-3-review-capacity">Step 3. Review Capacity</h4>
<p>Look at the HPA over the window:</p>
<pre><code class="language-bash">kubectl -n production get hpa clearpay-api
</code></pre>
<p>This lab stayed at 1 replica and 4 to 7 percent CPU, even at 15.5 requests per second during the 100 percent load test. One pod was enough. Three <code>e2-medium</code> machines for one API pod is more than this traffic needs.</p>
<p>Google offers Committed Use Discounts (CUDs): a lower price in exchange for promising to use a set amount of machine capacity for one or three years.</p>
<p>Commit only after you have a stable usage history. A commitment based on the architecture you planned, instead of the usage you measured, is a guess with a contract attached. This lab bought none, because a few hours of GKE isn't a usage history.</p>
<h4 id="heading-step-4-write-the-handover">Step 4. Write the Handover</h4>
<p>Write this for someone who wasn't part of the migration. This lab's version:</p>
<pre><code class="language-plaintext">Logs:       kubectl -n production logs deployment/clearpay-api
Dashboard:  http://localhost:3000 if observability/docker-compose.yml
            is up. GKE series may be empty.
Alert:      Cloud Monitoring "ClearPay API - elevated error rate"
            No notification channel.
Rollback:   scripts/rollback.sh, only while EC2 still exists.
Hostname:   terraform output public_hostname
            terraform output static_ip_address
            (yours, not a hostname copied from this book)
</code></pre>
<h4 id="heading-step-5-put-a-date-on-aws">Step 5. Put a Date on AWS</h4>
<p><code>scripts/rollback.sh</code> works only while the EC2 server exists, and the server bills every day it exists. Decide how long it stays, write down why, and price it:</p>
<pre><code class="language-plaintext">AWS daily cost:              ~$1.25/day (infra/aws/COST.md)
Retention window:            ___ days
Decommission date:           ___
Total safety-margin cost:    ___ days × $1.25 = $___
</code></pre>
<p>This lab used 0 extra days and retired AWS compute the same day 100 percent GKE was proven, because it's a teardown lab.</p>
<p>A real company might keep AWS for one or two weeks. A shorter window saves money. A longer one buys more confidence. The mistake is leaving it open as "keep it around just in case", which costs money every day and never gets revisited.</p>
<p>Write the decommission rules before the date arrives, so the decision doesn't get made under deadline pressure:</p>
<ul>
<li><p>a set number of days at 100 percent GKE with no rollback</p>
</li>
<li><p>no alert fired for a GKE-caused problem in that window</p>
</li>
<li><p>a named person signs off</p>
</li>
</ul>
<p><strong>This lab's rules:</strong> weights 0 / 100, gate 5/5, load test <code>failed=0</code>, HPA at 1, no rollback.</p>
<p>Last, check that nothing else still points at the Elastic IP directly: bookmarks, hardcoded URLs in other systems, or outside uptime monitors set up before the load balancer existed.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p>You watched production for a written, real time window</p>
</li>
<li><p>The alert gap is written down</p>
</li>
<li><p>Capacity is reviewed and the CUD decision is written down</p>
</li>
<li><p>The handover note exists</p>
</li>
<li><p>The retention window, decommission date, cost, and rules are written down</p>
</li>
</ul>
<h3 id="heading-chapter-26-retire-the-aws-server">Chapter 26. Retire the AWS Server</h3>
<p><em>Friday, 3:00 p.m.</em></p>
<p>The date has arrived, and the rules from Chapter 25 are met. Remove the EC2 server and keep the database. It was never part of this migration.</p>
<h4 id="heading-steps">Steps</h4>
<p>Perform your final check, from the repository root. GKE must pass the gate on the public address:</p>
<pre><code class="language-bash">HOST=$(terraform -chdir=infra/gcp/terraform/08-edge output -raw public_hostname)
./scripts/promotion-gate.sh "https://$HOST" \
  alice@clearpay.dev password123 bob@clearpay.dev
</code></pre>
<p>Then confirm that Terraform tracks the server you mean to remove:</p>
<pre><code class="language-bash">cd infra/aws/terraform
terraform state show aws_instance.api   # confirm you have the right resource
</code></pre>
<p>Next, return to the repository root with <code>cd ../../..</code>, then remove the AWS compute resources only:</p>
<pre><code class="language-bash">./scripts/teardown-aws-compute.sh
# Pass --yes to add -auto-approve after you have read a plan once.
</code></pre>
<p>After this step, rollback to AWS is gone. That's the point of the decision you made in Chapter 25.</p>
<h4 id="heading-read-the-destroy-plan">Read the Destroy Plan</h4>
<p>The script destroys only these:</p>
<ul>
<li><p>the EC2 instance</p>
</li>
<li><p>the Elastic IP</p>
</li>
<li><p>the security group rule that let the API reach the database</p>
</li>
<li><p>the API security group</p>
</li>
<li><p>the SSH key pair</p>
</li>
<li><p>the CloudWatch alarm</p>
</li>
</ul>
<p>RDS stays.</p>
<p>The database rule is its own Terraform resource, <code>aws_security_group_rule.db_from_api</code>, and the script removes it first. If that rule lived inside the database security group, AWS would refuse to delete the API security group while the database still referenced it.</p>
<p>A full <code>terraform destroy</code> in <code>infra/aws/terraform</code> would also delete PostgreSQL. Don't use it here.</p>
<h4 id="heading-clean-up-credentials">Clean Up Credentials</h4>
<ul>
<li><p>The SSH key pair in AWS is gone with the script. Delete the local private key <code>~/.ssh/clearpay-deployer</code> if you used it only for this server.</p>
</li>
<li><p>Remove anything else that pointed at the old server, such as saved hosts or scripts with the old IP.</p>
</li>
<li><p>Remove any CI/CD secrets or variables that referenced the old server.</p>
</li>
</ul>
<h4 id="heading-update-the-diagram">Update the Diagram</h4>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/7e4308e9-0c42-4246-a2f5-9c550c9fb26b.png" alt="updated diagram of of our architecture" style="display: block;" width="1222" height="1287" loading="lazy">

<p>The dual-cloud diagram from Chapter 7 is now wrong. The new picture is GKE alone behind the load balancer, with the database still on AWS, as Chapter 7 said the project would end.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p>The gate passed on the public address before the teardown</p>
</li>
<li><p>AWS compute is destroyed, and RDS still exists</p>
</li>
<li><p>The local deploy key is removed or kept on purpose</p>
</li>
<li><p>Your architecture diagram matches what runs</p>
</li>
</ul>
<h3 id="heading-chapter-27-tear-down-gcp-and-the-database">Chapter 27. Tear Down GCP and the Database</h3>
<p><em>Friday, 5:30 p.m.</em></p>
<p>The office is emptying out. Two clouds are still billing you by the hour.</p>
<p>This is a lab. Once your notes are complete, remove everything that still bills: the Gateway load balancer, the GKE machines, Cloud NAT, the registry, and the RDS database.</p>
<p>Before tearing anything down, complete the migration record in Chapter 28. It asks you to document the final architecture, migration results, performance numbers, and total cost. If you still need evidence from the running system, collect it now. Once you destroy the infrastructure, those live measurements will no longer be available.</p>
<h4 id="heading-steps">Steps</h4>
<p>From the repository root:</p>
<pre><code class="language-bash">./scripts/teardown-gcp.sh
# Pass --yes to skip the prompt and -auto-approve each stage.
</code></pre>
<p><strong>The script:</strong></p>
<ol>
<li><p>Deletes the Gateway and the production and staging workloads. Kubernetes creates Google load balancer pieces for the Gateway, and Terraform doesn't know about them. Deleting the Gateway first lets Google remove them. Skip this and Terraform can get stuck on resources it didn't create.</p>
</li>
<li><p>Runs <code>terraform destroy</code> in reverse stage order: <code>08-edge</code> → <code>07-observability</code> → <code>06-secrets</code> → <code>05-identity</code> → <code>04-registry</code> → <code>03-gke</code> → <code>02-network</code> → <code>01-foundation</code>. Later stages depend on earlier ones, so they come down first.</p>
</li>
</ol>
<p>Type <code>yes</code> at the script's prompt, then read and confirm each Terraform plan.</p>
<p>Namespaces can sit in <code>Terminating</code> for a few minutes. The script waits up to 180 seconds for each delete, then moves on. Destroying the cluster removes them anyway.</p>
<p>Then remove the database. The migration ended with it on AWS on purpose, but a lab shouldn't keep paying about $0.80 per day for it:</p>
<pre><code class="language-bash">cd infra/aws/terraform
terraform destroy
</code></pre>
<p>Last, delete the <code>api.clearpay.test</code> line from <code>/etc/hosts</code>.</p>
<p>The book never created or deletes your GCP project. Don't delete a project unless it held only this lab.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p><code>kubectl get ns</code> no longer shows <code>staging</code> or <code>production</code>, or the cluster is gone</p>
</li>
<li><p>Every <code>infra/gcp/terraform/0*-*</code> stage is destroyed</p>
</li>
<li><p><code>terraform destroy</code> finished in <code>infra/aws/terraform</code></p>
</li>
<li><p>GCP and AWS billing show nothing from this lab still running</p>
</li>
</ul>
<p>This is the checkpoint. The work is over.</p>
<p>Write down what actually happened, including the bill, before you forget.</p>
<h3 id="heading-chapter-28-write-the-migration-record">Chapter 28. Write the Migration Record</h3>
<p><em>Week four, Monday, 9:00 a.m.</em></p>
<p>Write down what happened, the architecture, the bill, and what you would change, before the details fade.</p>
<h4 id="heading-part-1-the-architecture-record">Part 1. The Architecture Record</h4>
<p><strong>Before:</strong></p>
<pre><code class="language-plaintext">User → nginx (TLS) → systemd-managed Node process → RDS Postgres
</code></pre>
<p>One EC2 server, one database, a default network nobody designed, secrets in a plain <code>.env</code> file, no autoscaling, and a deploy process run by hand.</p>
<p><strong>During the migration:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/1b0d833a-1077-4d01-9ab3-c259aaf5be8a.png" alt="Architecture diagram during the migration period" style="display: block;" width="1536" height="1024" loading="lazy">

<p><strong>After:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/2dcd551e-b47e-4390-a5d4-2cf4a1368581.png" alt="architecture diagram after the migration" style="display: block;" width="1536" height="1024" loading="lazy">

<p>Then one short paragraph for each model:</p>
<ul>
<li><p><strong>Traffic</strong>: weighted backends on a global external Application Load Balancer, changed by <code>shift-traffic.sh</code>. Not DNS, because cached DNS answers would have made rollback slow.</p>
</li>
<li><p><strong>Deploys</strong>: GitHub Actions builds, scans, and deploys every commit to staging. Production gets the exact staging image, only after the promotion gate and a reviewer's approval.</p>
</li>
<li><p><strong>Database</strong>: never moved. The same RDS instance from Chapter 4 stayed the single source of truth the whole time.</p>
</li>
<li><p><strong>Security</strong>: Workload Identity for both the pods and the pipeline. No downloaded service account key existed at any point. Secrets come from Secret Manager.</p>
</li>
<li><p><strong>Observability</strong>: the API's own JSON logs and <code>/metrics</code> endpoint, the same on AWS and GKE, plus a log-based error metric in Cloud Logging.</p>
</li>
<li><p><strong>Rollback</strong>: one script that moves the traffic weight and sets a flag on AWS, tested before the first shift.</p>
</li>
</ul>
<h4 id="heading-part-2-the-postmortem">Part 2. The Postmortem</h4>
<p>The four of you go through the notes together. Maya opens with the question everyone half expects.</p>
<p>Maya: "Was Kubernetes the problem?"</p>
<p>"Not really. It did what we told it to do. The thing to watch was the database. Every copy of the API can open ten connections, GKE can run several copies, and AWS was still serving customers at the same time. Add those up and you get close to the database's limit.</p>
<p>On this small cluster we topped out at forty connections against eighty-one, so nothing broke. On a cluster that scales to eight copies, the same sum goes over the limit."</p>
<p>Daniel nods. "That's what I missed at my last company. All the numbers were there, and nobody put them side by side. You did it before any traffic moved."</p>
<p>Amara: "And the payments? Was every transfer charged once?"</p>
<p>"Yes. The idempotency key from Chapter 3 held through every shift. We even replayed a payment at 100 percent GKE, and there was no second charge."</p>
<p>Maya closes her laptop. "So what would we do differently next time?"</p>
<p>Write these sections:</p>
<p><strong>What worked:</strong></p>
<ul>
<li><p>Idempotency keys, built in before GKE existed, made it safe for a customer's retry to land on a different cloud than the first try.</p>
</li>
<li><p>The staged Terraform meant each failure pointed at one stage.</p>
</li>
<li><p>The pipeline meant production ran the exact image that passed the gate.</p>
</li>
<li><p>Rollback was tested in Chapter 20, before it was ever needed.</p>
</li>
</ul>
<p><strong>What went wrong in this lab:</strong></p>
<ul>
<li><p>Local Prometheus never watched GKE, so Chapter 24 used counted checks instead of a dashboard total.</p>
</li>
<li><p>The alert policy has no notification channel.</p>
</li>
<li><p>The small node pool couldn't run the pod counts a real company would use, so the connection-limit risk was checked on paper, not seen live.</p>
</li>
<li><p>GKE's p95 was about four times AWS's.</p>
</li>
</ul>
<p><strong>The root cause:</strong> Two reasonable settings, <code>DB_POOL_SIZE=10</code> and the HPA's <code>maxReplicas</code>, were chosen in different places. Nobody checked their product against the database limit until the migration ran both clouds at once. The fix isn't complicated once you find it. Finding it takes treating Chapter 23 as a real investigation instead of skipping ahead to an answer.</p>
<p><strong>Decisions and their trade-offs:</strong></p>
<ul>
<li><p>GKE Standard over Autopilot: full control of node pools and autoscaling, at the cost of having to understand and size them. This lab paid for three mostly idle machines. Autopilot might have hidden this connection-limit risk inside its own scheduling, for better or worse.</p>
</li>
<li><p>Two environments instead of five: less to build and pay for, at the cost of staging sharing the production database. Fine at this scale, not for every company.</p>
</li>
<li><p>A public database endpoint with an IP allowlist instead of a VPN: no setup time and no monthly VPN bill. The cost is that RDS has a public endpoint, even if it accepts only one IP and requires TLS.</p>
</li>
<li><p>Leaving the database on AWS: a simpler, safer migration, at the cost of every query crossing between clouds.</p>
</li>
</ul>
<p>So what would change in a second migration? Before any traffic moves, compute the worst-case connection count across every environment that shares the database. Compare it with the real limit. Make it a checklist item, not a live discovery.</p>
<h4 id="heading-part-3-the-cost-postmortem">Part 3. The Cost Postmortem</h4>
<p>This lab's version:</p>
<p><strong>Budget:</strong> about $25 for five days of GCP usage.</p>
<p><strong>Actual dual-run:</strong> about $4.30 total, $1.25 AWS + $3.05 GCP. The GCP $3.05 was covered by free-trial credit, so the actual GCP charge was $0.</p>
<p>The migration stayed on schedule. No incident, rollback, or second shift was needed.</p>
<p><strong>The cluster was over-provisioned:</strong> three GKE nodes supported one API pod, with CPU at 4–7%.</p>
<p>Standard GKE was chosen for the teaching value. Autopilot would have been cheaper for this workload.</p>
<p>No CUD was purchased.</p>
<p><strong>Conclusion:</strong> The migration stayed well within budget, but the GKE setup used more capacity than the workload needed.</p>
<p>Fill in your own numbers from your cloud bills. This book can't do that part for you, and a guessed number would be the same false precision this whole project avoided.</p>
<h4 id="heading-done-when">Done When:</h4>
<ul>
<li><p>The before, during, and after diagrams match what ran</p>
</li>
<li><p>The postmortem states the root cause clearly</p>
</li>
<li><p>The cost postmortem holds your real numbers</p>
</li>
</ul>
<h2 id="heading-what-comes-after-this-project">What Comes After This Project</h2>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/f6161aec-ff05-4e2b-8a21-6b9c108ea6bc.png" alt="Image describing what we have done, where we are and what next." style="display: block;" width="2048" height="738" loading="lazy">

<h3 id="heading-what-question-this-project-leaves-open">What Question This Project Leaves Open</h3>
<p>Chapter 13 flagged it and moved on, on purpose: at what point does keeping PostgreSQL in AWS cost more, in risk and in engineering time, than migrating it? The cross-cloud trip, the dependency on two providers, and the connection-pool pressure from Chapter 23 all sit on the same side of that scale.</p>
<p>Put two numbers from your notes side by side:</p>
<pre><code class="language-plaintext">AWS, Chapter 6:             p95 164 ms
GKE at 100%, Chapter 24:    p95 647 ms
</code></pre>
<p>The API moved and customers never saw an outage. But every request got about four times slower.</p>
<p>The code didn't change. The load test did not change. What changed is distance. On AWS, the API and the database sat in the same data center. On GKE, every query leaves Google's <code>us-east4</code>, goes out through Cloud NAT, crosses the internet to AWS's <code>us-east-1</code>, and comes back. A single transfer runs several queries, so it pays that trip several times. The Google load balancer adds one more hop in front.</p>
<p>Maya reads the table. "Why is it slower?"</p>
<p>"The database stayed on AWS. Every query crosses between clouds."</p>
<p>"Was that a mistake?"</p>
<p>"No. Chapter 7 made the call to move the API alone, because moving two things at once would have made any failure much harder to trace. A problem could have come from the API, the network, or the database move itself. But the call has a price, and now we can measure it."</p>
<p>Maya: "Then that's the next project."</p>
<p>This project doesn't answer the open question. It was never supposed to. The next project moves PostgreSQL to Google Cloud, most likely with Google's Database Migration Service, which copies data continuously so the final switch takes minutes instead of hours. It will use the same method as this book: a baseline first, a written limit, small steps, a tested rollback, and evidence at every stage. When it's done, run the same load test and compare the same two numbers.</p>
<p>Again, that's a separate project, with its own book. This one ends here: ClearPay's API on GKE, its database still on AWS, exactly as Chapter 7 said it would.</p>
<h2 id="heading-what-you-should-have-at-the-end">What You Should Have at the End</h2>
<ul>
<li><p>Working fintech API and minimal customer UI (<code>api/</code>, <code>ui/</code>)</p>
</li>
<li><p>AWS deployment (<code>infra/aws/</code>)</p>
</li>
<li><p>GCP architecture, staged Terraform (<code>infra/gcp/terraform/</code>)</p>
</li>
<li><p>Docker images and a CI/CD pipeline that builds, scans, deploys staging, and promotes the tested image (<code>.github/workflows/deploy.yml</code>)</p>
</li>
<li><p>Kubernetes manifests, two-environment Kustomize overlays (<code>k8s/base/</code>, <code>k8s/overlays/</code>)</p>
</li>
<li><p>Database connectivity, validated and never migrated (Chapter 13)</p>
</li>
<li><p>Secret management, upgraded from a <code>.env</code> file to Secret Manager (<code>k8s/overlays/*/secretproviderclass.yaml</code>, <code>secretsync.yaml</code>)</p>
</li>
<li><p>A GKE Gateway behind a weighted load balancer (<code>k8s/gateway/</code>, <code>infra/gcp/terraform/08-edge/</code>)</p>
</li>
<li><p>Observability dashboard on your laptop, if you did the optional Chapter 17 (<code>observability/</code>)</p>
</li>
<li><p>Load-testing, promotion-gate, and synthetic health scripts (<code>scripts/</code>)</p>
</li>
<li><p>Migration scorecard (Chapter 6, Chapter 18, Chapter 24)</p>
</li>
<li><p>Cost ledger, start to finish (Chapter 6 through Chapter 28)</p>
</li>
<li><p>Traffic-shifting procedure (<code>scripts/shift-traffic.sh</code>)</p>
</li>
<li><p>Rollback procedure (<code>scripts/rollback.sh</code>, <code>scripts/clear-rollback-flag.sh</code>)</p>
</li>
<li><p>Architecture diagrams (Chapters 4, 7, 9, 11, 13, 14, 19, 28)</p>
</li>
<li><p>Migration runbook (this book)</p>
</li>
<li><p>Technical postmortem (Chapter 28)</p>
</li>
<li><p>Cost postmortem (Chapter 28)</p>
</li>
</ul>
<p>The ClearPay API moved from one AWS EC2 server to GKE. PostgreSQL is still on AWS, on purpose. Customers kept one address while you shifted traffic, practiced a rollback, and finished the cutover.</p>
<p>Maya asked what changed for the customer. The address didn't. The balance didn't. Nobody saw an outage. Now you understand why that was the hard part, and why 647 milliseconds is the next one.</p>
<p>The lab covered how to prove each stage, how to read evidence, and how to undo a bad shift. Destroy anything you're still paying for.</p>
<p>Subscribe to my <a href="https://osomudeya.kit.com/23db7ca59f">newsletter</a> for new technical articles, project walkthroughs, and upcoming releases.</p>
<h2 id="heading-how-to-discuss-this-project-in-an-interview"><strong>How to Discuss This Project in an Interview</strong></h2>
<p>I worked on a cloud migration project for a payments application called ClearPay. The application was running on AWS, and the goal was to move it to Google Cloud without disrupting access to the service.</p>
<p>The main challenge was making that move safely. Instead of switching everything at once, I designed a controlled migration where traffic could be moved gradually to the new environment. If something went wrong, I could redirect traffic back to the original system. I also kept the database on AWS to reduce the scope of the migration and avoid introducing unnecessary risk.</p>
<p>I set up the deployment process, security checks, and verification steps needed to confirm that the application was working correctly before completing the switch.</p>
<p>I then tested the final migration. The tests completed without failed requests, and the application didn't produce server errors during the final checks. I also identified a performance difference between the old and new environments that would need further investigation before considering the migration fully optimized.</p>
<p>The project demonstrates that I can plan and execute a cloud migration, manage the risks involved, verify the outcome, and identify issues that still need attention rather than simply declaring success because the application is running.</p>
<p><strong>Interview tip:</strong> Be prepared to explain why you used weighted traffic routing, how you validated the application during cutover, what triggered a rollback, and why PostgreSQL remained on AWS.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build Ephemeral PR Preview Environments on GKE with GitHub Actions, Ingress, and Automatic TLS ]]>
                </title>
                <description>
                    <![CDATA[ Every team that ships software eventually builds a staging environment. And every team that builds a staging environment eventually discovers the same problem. Staging is shared. One environment, many ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-ephemeral-pr-preview-environments-on-gke-with-github-actions-ingress-automatic-tls/</link>
                <guid isPermaLink="false">6ad0d6b41b1aaeb205d2d198</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ gke ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Terraform ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ephemeral ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Amina Lawal ]]>
                </dc:creator>
                <pubDate>Thu, 15 Oct 2026 13:35:48 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8d8182a7-81dd-4bb8-bd62-f9e3c4175532.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every team that ships software eventually builds a staging environment. And every team that builds a staging environment eventually discovers the same problem.</p>
<p>Staging is shared. One environment, many pull requests. Someone deploys a half-finished feature to test it. Someone else deploys on top of that. A third person runs a database migration. By Thursday afternoon, nobody can tell which version of which branch is actually running, and the sentence "it works on staging" has stopped carrying any information at all.</p>
<p>The workarounds are familiar: a Slack channel where people claim staging like a meeting room, a rotating deploy schedule, or a second staging environment, then a third, each one drifting further from production configuration than the last.</p>
<p>There's a better pattern, and platform teams at companies like Vercel, Netlify, and Shopify have made it standard: <strong>give every pull request its own environment</strong>.</p>
<p>Open a PR, and a fresh, isolated copy of your application deploys automatically. Push another commit, and it updates. Merge or close the PR, and the whole thing deletes itself. No queue, no claiming, no cleanup, and no drift.</p>
<p>That's what we're building in this tutorial. Here's what it looks like from the reviewer's side. A bot comment appearing on the pull request with a live, working URL</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/366bb671-d2cb-41e5-859b-aa2242c9dd77.png" alt="A GitHub pull request thread showing an automated bot comment titled &quot;Preview environment is ready&quot;, containing a live HTTPS URL, the commit SHA, and the namespace name, with all checks passing below it." style="display: block;" width="2366" height="1434" loading="lazy">

<h2 id="heading-what-youll-build">What You'll Build</h2>
<p>By the end of this handbook, you'll have a working system where:</p>
<ul>
<li><p>Opening a pull request triggers a GitHub Actions workflow that builds a container image tagged specifically for that PR</p>
</li>
<li><p>The image is pushed to Google Artifact Registry</p>
</li>
<li><p>A dedicated Kubernetes namespace (<code>pr-42</code>, <code>pr-43</code>, and so on) is created inside a GKE cluster, with resource quotas applied before anything deploys</p>
</li>
<li><p>The application deploys into that namespace, completely isolated from every other PR's environment</p>
</li>
<li><p>A shared Ingress controller routes <code>pr-42.preview.yourdomain.com</code> to that namespace, with a valid TLS certificate issued automatically</p>
</li>
<li><p>A bot comments the live URL on the pull request, updating the same comment on every push</p>
</li>
<li><p>Pushing new commits redeploys the environment with the updated code</p>
</li>
<li><p>Closing or merging the PR deletes the namespace and everything inside it</p>
</li>
<li><p>A scheduled job catches any environment the teardown missed</p>
</li>
</ul>
<p>You'll also build the underlying infrastructure with Terraform, with three key design decisions, each with its own section.</p>
<ol>
<li><p><strong>No service account keys:</strong> GitHub Actions authenticates to Google Cloud through Workload Identity Federation. Short-lived OIDC tokens verified against a repository claim. Service account JSON keys sitting in GitHub secrets are one of the most common security problems in CI/CD pipelines today, and you don't need one.</p>
</li>
<li><p><strong>One load balancer, not one per PR:</strong> A single shared Ingress controller routes every environment by hostname, and one wildcard DNS record covers every hostname that will ever exist. The naïve version provisions a cloud load balancer per environment, which gets expensive quickly.</p>
</li>
<li><p><strong>Cleanup that assumes failure:</strong> Teardown runs when a PR closes. But a scheduled reaper also deletes namespaces whose PR is gone or that have outlived a maximum age, because even 95% effective automated cleanup still leaks, and leaks show up on the bill.</p>
</li>
</ol>
<p>Here's the architecture we're working toward:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/1a414785-4e82-4e8f-ba58-1e02a093677c.png" alt="Architecture diagram of the complete system. GitHub Actions builds and deploys to a GKE cluster through Workload Identity Federation and Artifact Registry, while browsers reach per-pull-request namespaces through wildcard DNS, a shared Google Cloud load balancer, and an NGINX Ingress controller with cert-manager handling TLS." style="display: block;" width="2136" height="1424" loading="lazy">

<p>The diagram traces two separate paths through the same system.</p>
<p>The solid blue path is automation. A pull request triggers GitHub Actions, which authenticates to Google Cloud through Workload Identity Federation, pushes a PR-tagged image to Artifact Registry, and deploys into a dedicated namespace inside the GKE cluster.</p>
<p>Each namespace (<code>pr-42</code>, <code>pr-43</code>, and so on) holds its own Deployment, Service, and Pod, completely isolated from every other one.</p>
<p>Two more automated pieces sit alongside it: a bot that posts and updates the preview URL on the pull request, and a scheduled reaper that finds and deletes orphaned namespaces.</p>
<p>The dashed green path is public traffic. A browser request for a preview URL resolves through a single wildcard DNS record to one Google Cloud load balancer, which hands it to a shared NGINX Ingress controller. That controller reads the request's <code>Host</code> header and routes it to the right namespace. cert-manager runs beside it, requesting certificates from Let's Encrypt so each environment gets its own valid TLS certificate.</p>
<p>The important detail is what <em>doesn't</em> multiply. One load balancer, one DNS record, and one Ingress controller serve every environment, no matter how many pull requests are open.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-youll-build">What You'll Build</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-step-1-set-up-the-project-structure">Step 1: Set Up the Project Structure</a></p>
</li>
<li><p><a href="#heading-step-2-write-the-application">Step 2: Write the Application</a></p>
</li>
<li><p><a href="#heading-step-3-write-the-dockerfile">Step 3: Write the Dockerfile</a></p>
</li>
<li><p><a href="#heading-step-4-provision-the-infrastructure-with-terraform">Step 4: Provision the Infrastructure with Terraform</a></p>
</li>
<li><p><a href="#heading-step-5-set-up-keyless-authentication-with-workload-identity-federation">Step 5: Set Up Keyless Authentication with Workload Identity Federation</a></p>
</li>
<li><p><a href="#heading-step-6-apply-the-terraform-configuration">Step 6: Apply the Terraform Configuration</a></p>
</li>
<li><p><a href="#heading-step-7-prove-the-lifecycle-manually">Step 7: Prove the Lifecycle Manually</a></p>
</li>
<li><p><a href="#heading-step-8-write-the-kubernetes-manifests-with-kustomize">Step 8: Write the Kubernetes Manifests with Kustomize</a></p>
</li>
<li><p><a href="#heading-step-9-build-the-spin-up-workflow">Step 9: Build the Spin-Up Workflow</a></p>
</li>
<li><p><a href="#heading-step-10-build-the-teardown-workflow">Step 10: Build the Teardown Workflow</a></p>
</li>
<li><p><a href="#heading-step-11-open-a-pull-request-and-watch-it-work">Step 11: Open a Pull Request and Watch It Work</a></p>
</li>
<li><p><a href="#heading-step-12-reserve-a-static-ip-address">Step 12: Reserve a Static IP Address</a></p>
</li>
<li><p><a href="#heading-step-13-install-the-ingress-controller">Step 13: Install the Ingress Controller</a></p>
</li>
<li><p><a href="#heading-step-14-point-wildcard-dns-at-the-load-balancer">Step 14: Point Wildcard DNS at the Load Balancer</a></p>
</li>
<li><p><a href="#heading-step-15-install-cert-manager-and-create-a-clusterissuer">Step 15: Install cert-manager and Create a ClusterIssuer</a></p>
</li>
<li><p><a href="#heading-step-16-add-an-ingress-to-the-base-manifests">Step 16: Add an Ingress to the Base Manifests</a></p>
</li>
<li><p><a href="#heading-step-17-patch-the-hostname-per-pull-request">Step 17: Patch the Hostname Per Pull Request</a></p>
</li>
<li><p><a href="#heading-step-18-add-guardrails-with-resourcequota-and-limitrange">Step 18: Add Guardrails with ResourceQuota and LimitRange</a></p>
</li>
<li><p><a href="#heading-step-19-update-the-deploy-workflow">Step 19: Update the Deploy Workflow</a></p>
</li>
<li><p><a href="#heading-step-20-comment-the-url-on-the-pull-request">Step 20: Comment the URL on the Pull Request</a></p>
</li>
<li><p><a href="#heading-step-21-build-the-reaper">Step 21: Build the Reaper</a></p>
</li>
<li><p><a href="#heading-step-22-test-the-whole-thing">Step 22: Test the Whole Thing</a></p>
</li>
<li><p><a href="#heading-what-this-costs">What This Costs</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>. Running this tutorial costs roughly $5–10 depending on how long you leave the cluster up. Don't skip the cleanup section at the end.</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>Terraform</strong> version 1.5 or later. Check with <code>terraform version</code>.</p>
</li>
<li><p><code>kubectl</code> installed. This is the CLI for interacting with Kubernetes clusters.</p>
</li>
<li><p><strong>Docker</strong> installed locally, for testing the build before you hand it to CI.</p>
</li>
<li><p><strong>A GitHub repository</strong> that you own. This needs to be a repository you can push branches to and open pull requests against, since that's the trigger for everything we build.</p>
</li>
<li><p><strong>Basic familiarity with Docker</strong> (images, layers, Dockerfile), <strong>Kubernetes</strong> (pods, deployments, services, namespaces), and <strong>GitHub Actions</strong> (workflows, jobs, steps). You don't need to be an expert, but you should know what these things are.</p>
</li>
<li><p><strong>Helm</strong> version 3, for installing the Ingress controller and cert-manager. Check with <code>helm version</code>.</p>
</li>
<li><p><strong>A domain name you control</strong>, with the ability to add DNS records. It doesn't matter where it's registered. You'll dedicate one subdomain to previews, like <code>preview.yourdomain.com</code>, and every environment lives beneath it.</p>
</li>
</ul>
<p>A note on the domain requirement: this is the part of the project that can't be faked. Let's Encrypt needs to verify that you control the domain before it will issue a certificate, and it verifies this by making a real HTTP request from the public internet. A <code>.local</code> domain, an entry in <code>/etc/hosts</code>, or a domain you don't own won't work.</p>
<p>If you don't have a spare domain, a <code>.dev</code> or <code>.xyz</code> costs a few pounds a year and is worth having for exactly this kind of project.</p>
<p>One more thing worth bringing your attention to early: you'll also need the <code>gke-gcloud-auth-plugin</code>. Recent versions of <code>gcloud</code> prompt you to install it the first time you connect to a GKE cluster. You can install it ahead of time with:</p>
<pre><code class="language-bash">gcloud components install gke-gcloud-auth-plugin
</code></pre>
<h2 id="heading-step-1-set-up-the-project-structure">Step 1: Set Up the Project Structure</h2>
<p>Let's lay out the directories first, so you have a clear mental model of where everything lives.</p>
<pre><code class="language-bash">mkdir -p pr-preview-envs/{app,terraform,k8s/base,.github/workflows}
cd pr-preview-envs
git init
</code></pre>
<p>Here's what each directory holds:</p>
<ul>
<li><p><code>app/</code>: the Go application and its Dockerfile</p>
</li>
<li><p><code>terraform/</code>: infrastructure as code: the GKE cluster, Artifact Registry, and Workload Identity Federation setup</p>
</li>
<li><p><code>k8s/base/</code>: the base Kubernetes manifests that every preview environment is built from</p>
</li>
<li><p><code>.github/workflows/</code>: the two GitHub Actions workflows that create and destroy environments</p>
</li>
</ul>
<p>Connect this to a GitHub repository now, because the Workload Identity Federation setup in Step 5 needs to know your repository's full name (<code>owner/repo</code>):</p>
<pre><code class="language-bash">git remote add origin https://github.com/YOUR_USERNAME/pr-preview-envs.git
</code></pre>
<h2 id="heading-step-2-write-the-application">Step 2: Write the Application</h2>
<p>We need something to deploy. We'll use <strong>Go</strong>, for three reasons that matter to this project specifically.</p>
<p>First, Go compiles to a single statically-linked binary. There's no runtime to install and no interpreter, which means very small container images, and small images mean fast pushes and fast pulls, which matters a lot when you're building an image on <em>every single commit to every open PR</em>.</p>
<p>Second, Go's <code>-ldflags -X</code> mechanism lets us inject values into the binary at build time. We'll use this to bake the PR number and Git commit SHA directly into the application, so the running app can tell us exactly which build it is.</p>
<p>And lastly, build times are fast, and CI minutes aren't free.</p>
<p>Create <code>app/main.go</code>:</p>
<pre><code class="language-go">package main

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

// These values are empty at compile time and get injected by the
// linker via -ldflags -X during the Docker build. See the Dockerfile.
var (
	prNumber = "local"
	gitSha   = "dev"
)

func handler(w http.ResponseWriter, r *http.Request) {
	// Kubernetes injects these via the Downward API — see deployment.yaml
	namespace := os.Getenv("POD_NAMESPACE")
	podName := os.Getenv("POD_NAME")

	if namespace == "" {
		namespace = "not-in-kubernetes"
	}
	if podName == "" {
		podName, _ = os.Hostname()
	}

	fmt.Fprintf(w, "Preview environment is live\n\n")
	fmt.Fprintf(w, "PR number  : %s\n", prNumber)
	fmt.Fprintf(w, "Git SHA    : %s\n", gitSha)
	fmt.Fprintf(w, "Namespace  : %s\n", namespace)
	fmt.Fprintf(w, "Pod name   : %s\n", podName)
}

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>Initialize the Go module from inside <code>app/</code>:</p>
<pre><code class="language-bash">cd app
go mod init preview-app
</code></pre>
<p>Let's walk through what this code does and why each piece is here.</p>
<p><code>var prNumber = "local"</code> <strong>and</strong> <code>var gitSha = "dev"</code> are package-level string variables with default values. The defaults are what you'll see if you run the binary locally without going through the Docker build.</p>
<p>During the CI build, the Go linker overwrites them with the real PR number and commit SHA. This is the single most useful debugging feature in the whole project: when you hit a preview environment, the app itself tells you exactly which commit it was built from. There's no guessing.</p>
<p><code>os.Getenv("POD_NAMESPACE")</code> <strong>and</strong> <code>os.Getenv("POD_NAME")</code> read environment variables that Kubernetes injects into the container using something called the <strong>Downward API</strong>. We'll configure that in the deployment manifest in Step 8.</p>
<p>The Downward API lets a pod read metadata about itself (its own name, namespace, and labels) without needing any Kubernetes API credentials. It's the cleanest way for an app to know where it's running.</p>
<p><strong>The</strong> <code>handler</code> <strong>function</strong> is registered on the root path <code>/</code>. It writes the PR number, commit SHA, namespace, and pod name to the response. It's four pieces of information that together answer "what exactly am I looking at right now?"</p>
<p><strong>The</strong> <code>healthz</code> <strong>function</strong> is registered on <code>/healthz</code>, and returns HTTP 200 with the text <code>ok</code>. Kubernetes uses this endpoint for liveness and readiness probes, which we'll wire up in the deployment manifest.</p>
<p><code>http.ListenAndServe(":8080", nil)</code> starts the server on port 8080. If it fails to start, it prints the error and exits with a non-zero code, so Kubernetes knows the container is broken and can restart it.</p>
<p>Test it locally before moving on:</p>
<pre><code class="language-bash">go run main.go
</code></pre>
<p>In a second terminal:</p>
<pre><code class="language-bash">curl http://localhost:8080
</code></pre>
<p>You should see <code>PR number : local</code> and <code>Git SHA : dev</code>, since you're running outside the Docker build. That's the expected local behaviour.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/1ee23b56-dcd8-48f1-a0c8-5c1e3a908d2c.png" alt="Terminal output from curl showing the application reporting PR number &quot;local&quot; and Git SHA &quot;dev&quot; when run outside the Docker build" style="display: block;" width="2744" height="1256" loading="lazy">

<h2 id="heading-step-3-write-the-dockerfile">Step 3: Write the Dockerfile</h2>
<p>Create <code>app/Dockerfile</code>:</p>
<pre><code class="language-dockerfile"># -----------------------------------------------------------
# Stage 1: Build
# -----------------------------------------------------------
FROM golang:1.27-alpine AS builder
#Ensure the version matches what is in your go.mod file

# These are injected by the CI pipeline at build time
ARG PR_NUMBER=local
ARG GIT_SHA=dev

WORKDIR /app

# Copy go.mod first so Docker can cache the dependency layer
COPY go.mod .
RUN go mod download

COPY main.go .

# -w -s strips debug symbols (~30% smaller binary)
# -X overwrites package-level string variables at link time
RUN CGO_ENABLED=0 go build \
    -ldflags="-w -s -X main.prNumber=${PR_NUMBER} -X main.gitSha=${GIT_SHA}" \
    -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 are a few things happening here worth understanding properly.</p>
<h3 id="heading-1-the-ldflags-x-injection">1. The <code>-ldflags -X</code> Injection</h3>
<pre><code class="language-plaintext">-X main.prNumber=${PR_NUMBER}
</code></pre>
<p>The <code>-X</code> linker flag sets the value of a package-level string variable at link time. The syntax is <code>-X importpath.name=value</code>. Since our variables live in <code>package main</code>, the import path is just <code>main</code>.</p>
<p>This only works on <code>string</code> variables that are already declared in the source. You can't create new variables this way, and you can't inject into an <code>int</code> or a <code>bool</code>. That's why <code>prNumber</code> is a string in <code>main.go</code> even though it holds a number.</p>
<p>Why do this instead of just passing an environment variable through the deployment manifest? Because build metadata belongs to the <em>image</em>, not the <em>deployment</em>. If you pass it as an env var, someone can deploy image <code>abc123</code> with an env var claiming it's commit <code>def456</code>, and the app will happily lie to you. Baking it into the binary means the image can only ever report what it actually is.</p>
<h3 id="heading-the-cgoenabled0-flag">The <code>CGO_ENABLED=0</code> Flag</h3>
<p>By default, Go can link against C libraries. When it does, the resulting binary depends on the system's C library being present at runtime. Alpine uses <code>musl</code> instead of the more common <code>glibc</code>, and that mismatch is a classic source of "works in the builder stage, crashes in the runtime stage" errors.</p>
<p>Setting <code>CGO_ENABLED=0</code> forces a pure-Go, fully static binary with no external library dependencies. It will run on any Linux system, including a <code>scratch</code> image with nothing in it at all.</p>
<h3 id="heading-copy-gomod-before-copy-maingo"><code>COPY go.mod .</code> Before <code>COPY main.go .</code></h3>
<p>Docker builds images layer by layer and caches each layer. By copying only <code>go.mod</code> first and running <code>go mod download</code> before copying the source code, we create a cached dependency layer.</p>
<p>On subsequent builds (and remember, this pipeline builds on <em>every commit to every open PR</em>), Docker skips the dependency download entirely as long as <code>go.mod</code> hasn't changed. On a real project with dozens of dependencies, this is the difference between a 20-second build and a 3-minute build, multiplied by every push your team makes.</p>
<h3 id="heading-the-non-root-user">The Non-Root User</h3>
<pre><code class="language-dockerfile">RUN addgroup -S appgroup &amp;&amp; adduser -S appuser -G appgroup
USER appuser
</code></pre>
<p>Containers run as root by default. If an attacker finds a vulnerability in your application, running as root inside the container gives them significantly more room to escalate. Creating an unprivileged user and switching to it limits the blast radius. It costs you two lines, and there's no reason not to do it.</p>
<p>Build and test it locally:</p>
<pre><code class="language-bash">docker build --build-arg PR_NUMBER=99 --build-arg GIT_SHA=abc1234 -t preview-app:test .
docker run -p 8080:8080 preview-app:test
</code></pre>
<p>Then <code>curl http://localhost:8080</code> in another terminal. You should now see <code>PR number : 99</code> and <code>Git SHA : abc1234</code>. The injection works.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/bce5bd63-02fc-4ee6-9631-34f810fad546.png" alt="Terminal output from curl showing the application reporting PR number 99 and Git SHA abc1234, injected at build time by the Docker build arguments" style="display: block;" width="2744" height="1256" loading="lazy">

<h2 id="heading-step-4-provision-the-infrastructure-with-terraform">Step 4: Provision the Infrastructure with Terraform</h2>
<p>Now we'll build the cloud infrastructure. We're using Terraform rather than a series of <code>gcloud</code> commands for a reason that becomes obvious the moment something goes wrong: with Terraform, <code>terraform destroy</code> cleans up everything you created, in the right order, with no orphaned resources quietly billing you.</p>
<p>Create <code>terraform/variables.tf</code>:</p>
<pre><code class="language-hcl">variable "project_id" {
  description = "GCP project ID"
  type        = string
}

variable "region" {
  description = "GCP region"
  type        = string
  default     = "europe-west2"
}

variable "zone" {
  description = "GCP zone. A zonal cluster is cheaper than a regional one."
  type        = string
  default     = "europe-west2-a"
}

variable "cluster_name" {
  description = "Name of the GKE cluster"
  type        = string
  default     = "pr-preview-cluster"
}

variable "github_repo" {
  description = "GitHub repository in owner/repo format, e.g. amina/pr-preview-envs"
  type        = string
}

variable "machine_type" {
  description = "Node machine type"
  type        = string
  default     = "e2-standard-2"
}

variable "node_count" {
  description = "Number of nodes in the pool"
  type        = number
  default     = 2
}
</code></pre>
<p>Now create <code>terraform/main.tf</code>:</p>
<pre><code class="language-hcl">terraform {
  required_version = "&gt;= 1.5"
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~&gt; 5.0"
    }
  }
}

provider "google" {
  project = var.project_id
  region  = var.region
}

# Look up the project so we can reference its numeric project number,
# which Workload Identity Federation requires (it doesn't accept the
# human-readable project ID).
data "google_project" "current" {}

# -----------------------------------------------------------
# Enable required APIs
# -----------------------------------------------------------
resource "google_project_service" "apis" {
  for_each = toset([
    "container.googleapis.com",
    "artifactregistry.googleapis.com",
    "iamcredentials.googleapis.com",
    "sts.googleapis.com",
  ])

  service            = each.value
  disable_on_destroy = false
}

# -----------------------------------------------------------
# Artifact Registry — where preview images live
# -----------------------------------------------------------
resource "google_artifact_registry_repository" "preview" {
  location      = var.region
  repository_id = "pr-preview"
  description   = "Container images for PR preview environments"
  format        = "DOCKER"

  depends_on = [google_project_service.apis]
}

# -----------------------------------------------------------
# GKE cluster
# -----------------------------------------------------------
resource "google_container_cluster" "primary" {
  name     = var.cluster_name
  location = var.zone

  # Create the cluster with a throwaway default pool, then remove it
  # and manage nodes through a separate node pool resource. This is the
  # standard pattern — it lets you resize or upgrade the node pool
  # without recreating the cluster control plane.
  remove_default_node_pool = true
  initial_node_count       = 1

  networking_mode = "VPC_NATIVE"
  ip_allocation_policy {}

  # Set to true before you use this for anything real.
  deletion_protection = false

  depends_on = [google_project_service.apis]
}

resource "google_container_node_pool" "primary_nodes" {
  name       = "${var.cluster_name}-pool"
  location   = var.zone
  cluster    = google_container_cluster.primary.name
  node_count = var.node_count

  node_config {
    machine_type = var.machine_type
    disk_size_gb = 50

    oauth_scopes = [
      "https://www.googleapis.com/auth/cloud-platform",
    ]

    labels = {
      purpose = "pr-preview-envs"
    }
  }

  management {
    auto_repair  = true
    auto_upgrade = true
  }
}
</code></pre>
<p>There are a few things that're worth calling out here.</p>
<p><code>data "google_project" "current"</code> isn't creating anything. It's a data source, which reads existing information from your provider. We need it because Workload Identity Federation identifies your project by its <strong>numeric project number</strong>, not the project ID you type in the console. Rather than making you look it up manually and paste it in, Terraform fetches it for you.</p>
<p><code>for_each</code> <strong>on</strong> <code>google_project_service</code>: instead of writing four nearly identical resource blocks to enable four APIs, <code>for_each</code> iterates over a set and creates one resource per item. <code>disable_on_destroy = false</code> means <code>terraform destroy</code> won't turn these APIs off, which is what you want, because other things in your project might be using them.</p>
<p><code>remove_default_node_pool = true</code>: GKE insists on creating a default node pool when you create a cluster. Setting this immediately deletes it, and we define our own node pool as a separate resource. The benefit is that node pool changes (resizing, changing machine type, upgrading) don't force a change to the cluster resource itself.</p>
<p><code>node_count = 2</code> <strong>with</strong> <code>e2-standard-2</code>: two nodes with 2 vCPU and 8GB each. Preview environments are small, but you'll have several running at once, plus GKE's own system pods. Two nodes give you comfortable headroom. If you plan to keep more than about eight preview environments alive simultaneously, bump this up.</p>
<p><code>deletion_protection = false</code>: GKE added this as a guard against accidentally destroying production clusters. We're disabling it because this is a tutorial cluster you'll delete at the end. If you adapt this for real use, set it to <code>true</code>.</p>
<h2 id="heading-step-5-set-up-keyless-authentication-with-workload-identity-federation">Step 5: Set Up Keyless Authentication with Workload Identity Federation</h2>
<p>This is the section that matters most, so let's take it slowly.</p>
<h3 id="heading-the-problem-with-service-account-keys">The Problem With Service Account Keys</h3>
<p>GitHub Actions needs permission to push images to Artifact Registry and deploy to your GKE cluster. The way most tutorials handle this is:</p>
<ol>
<li><p>Create a service account in GCP</p>
</li>
<li><p>Generate a JSON key for it</p>
</li>
<li><p>Paste that JSON into a GitHub secret called <code>GCP_SA_KEY</code></p>
</li>
<li><p>Load it in the workflow</p>
</li>
</ol>
<p>This works, but it's also a security problem, and here's why:</p>
<p>First, <strong>the key never expires.</strong> A service account JSON key is a long-lived credential. It's valid until someone explicitly revokes it, which in practice means it's valid forever, because nobody remembers to rotate them.</p>
<p>Second, <strong>the key is a bearer token.</strong> Anyone who obtains that JSON file <em>is</em> your service account. No second factor, IP restriction, or expiry. Copy the file, and use it from anywhere in the world.</p>
<p>Third, <strong>the blast radius is enormous.</strong> GitHub secrets are exposed to workflows. A compromised third-party action, a malicious dependency in your build, or a workflow modified in a pull request can exfiltrate that secret. Once it's out, an attacker has persistent access to your cloud project.</p>
<p>This isn't theoretical. Leaked service account keys are one of the most common root causes in cloud security incidents.</p>
<h3 id="heading-how-workload-identity-federation-fixes-it">How Workload Identity Federation Fixes It</h3>
<p><strong>Workload Identity Federation</strong> removes the long-lived key entirely. Here's the flow:</p>
<ol>
<li><p>GitHub Actions generates a short-lived <strong>OIDC token</strong> for the running workflow. This token is signed by GitHub and contains verified claims: which repository is running, which branch, which workflow, and which actor.</p>
</li>
<li><p>Your workflow presents that token to Google Cloud's Security Token Service.</p>
</li>
<li><p>Google checks the token's signature against GitHub's public keys, and checks the claims against rules <strong>you</strong> configured, specifically that the token came from your repository and no other.</p>
</li>
<li><p>If everything matches, Google issues a short-lived access token, typically valid for about an hour.</p>
</li>
<li><p>The workflow uses that token, and it expires shortly after.</p>
</li>
</ol>
<p>There's no secret stored anywhere. Nothing to leak, rotate, or revoke. If someone forks your repository and runs the same workflow, the OIDC token they generate will carry <em>their</em> repository name in its claims, Google's check will fail, and they get nothing.</p>
<p>Add this to <code>terraform/main.tf</code>:</p>
<pre><code class="language-hcl"># -----------------------------------------------------------
# Service account that GitHub Actions will impersonate
# -----------------------------------------------------------
resource "google_service_account" "github_actions" {
  account_id   = "github-actions-preview"
  display_name = "GitHub Actions — PR Preview Environments"
}

# Permission to push images to Artifact Registry
resource "google_project_iam_member" "artifact_writer" {
  project = var.project_id
  role    = "roles/artifactregistry.writer"
  member  = "serviceAccount:${google_service_account.github_actions.email}"
}

# Permission to create namespaces and deploy workloads in GKE.
# container.developer grants full access to Kubernetes API objects,
# but NOT permission to modify the cluster itself.
resource "google_project_iam_member" "gke_developer" {
  project = var.project_id
  role    = "roles/container.developer"
  member  = "serviceAccount:${google_service_account.github_actions.email}"
}

# -----------------------------------------------------------
# Workload Identity Federation
# -----------------------------------------------------------
resource "google_iam_workload_identity_pool" "github" {
  workload_identity_pool_id = "github-pool"
  display_name              = "GitHub Actions Pool"
  description               = "Identity pool for GitHub Actions OIDC"

  depends_on = [google_project_service.apis]
}

resource "google_iam_workload_identity_pool_provider" "github" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.github.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-provider"
  display_name                       = "GitHub OIDC Provider"

  # Map claims from the GitHub OIDC token to Google attributes
  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.actor"      = "assertion.actor"
    "attribute.repository" = "assertion.repository"
  }

  # CRITICAL: without this condition, ANY GitHub repository on the
  # internet could authenticate against your pool.
  attribute_condition = "assertion.repository == '${var.github_repo}'"

  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}

# Allow tokens from your specific repo to impersonate the service account
resource "google_service_account_iam_member" "github_impersonation" {
  service_account_id = google_service_account.github_actions.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/projects/${data.google_project.current.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.github.workload_identity_pool_id}/attribute.repository/${var.github_repo}"
}
</code></pre>
<p>Let's break down the parts that trip people up.</p>
<p><code>attribute_mapping</code>: GitHub's OIDC token contains a set of claims. This block tells Google which claims to read and what to call them internally. <code>assertion.sub</code> is the token's subject (something like <code>repo:owner/name:ref:refs/heads/main</code>). <code>assertion.repository</code> is the full repository name. <code>google.subject</code> is required, while the others are optional attributes you can then write rules against.</p>
<p><code>attribute_condition</code>: read this line twice, because getting it wrong is a serious vulnerability:</p>
<pre><code class="language-hcl">attribute_condition = "assertion.repository == '${var.github_repo}'"
</code></pre>
<p>Without this condition, your identity pool trusts <em>the GitHub OIDC issuer</em>, which means it trusts every repository on GitHub. Anyone who knows your pool's resource path (which isn't secret and appears in workflow files) could authenticate and impersonate your service account.</p>
<p>This condition narrows the trust to exactly one repository. Google now requires an attribute condition when you map <code>assertion.repository</code>, precisely because so many people got this wrong.</p>
<p><code>principalSet://...</code>: this is the identity being granted the <code>workloadIdentityUser</code> role. Read it right to left: any principal in the <code>github-pool</code> whose <code>attribute.repository</code> equals your repo may impersonate this service account. Note that it uses <code>data.google_project.current.number</code>, which is the numeric project number, not the project ID. Using the project ID here produces an error that doesn't clearly explain what's wrong, and it's one of the most common mistakes with WIF.</p>
<p><strong>Why</strong> <code>roles/container.developer</code> <strong>and not</strong> <code>roles/container.admin</code><strong>?</strong> <code>container.developer</code> grants full access to Kubernetes API objects inside clusters: creating namespaces, applying deployments, deleting resources. <code>container.admin</code> additionally allows modifying the clusters themselves: resizing node pools, upgrading, deleting the cluster entirely.</p>
<p>Our workflow never needs to touch the cluster's own configuration, so it shouldn't have permission to. This is least privilege in practice: grant exactly what the job requires and nothing beyond it.</p>
<p>Now create <code>terraform/outputs.tf</code>:</p>
<pre><code class="language-hcl">output "cluster_name" {
  value = google_container_cluster.primary.name
}

output "cluster_zone" {
  value = var.zone
}

output "artifact_registry_url" {
  description = "Base URL for pushing images"
  value       = "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.preview.repository_id}"
}

output "workload_identity_provider" {
  description = "Paste this into the GitHub Actions workflow"
  value       = "projects/${data.google_project.current.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.github.workload_identity_pool_id}/providers/${google_iam_workload_identity_pool_provider.github.workload_identity_pool_provider_id}"
}

output "service_account_email" {
  description = "Paste this into the GitHub Actions workflow"
  value       = google_service_account.github_actions.email
}

output "get_credentials_command" {
  value = "gcloud container clusters get-credentials ${google_container_cluster.primary.name} --zone ${var.zone} --project ${var.project_id}"
}
</code></pre>
<p>Those outputs aren't decoration. <code>workload_identity_provider</code> and <code>service_account_email</code> are the two values you'll paste into your GitHub Actions workflow, and both are tedious to construct by hand.</p>
<h2 id="heading-step-6-apply-the-terraform-configuration">Step 6: Apply the Terraform Configuration</h2>
<p>From inside the <code>terraform/</code> directory:</p>
<pre><code class="language-bash">terraform init
</code></pre>
<p>This downloads the Google provider and sets up the backend. You should see "Terraform has been successfully initialized!"</p>
<p>Now preview what will be created:</p>
<pre><code class="language-bash">terraform plan \
  -var="project_id=YOUR_PROJECT_ID" \
  -var="github_repo=YOUR_USERNAME/pr-preview-envs"
</code></pre>
<p>Read the plan output. Terraform tells you exactly what it intends to create before it creates anything. You should see roughly a dozen resources to add and none to change or destroy.</p>
<p>If that looks right:</p>
<pre><code class="language-bash">terraform apply \
  -var="project_id=YOUR_PROJECT_ID" \
  -var="github_repo=YOUR_USERNAME/pr-preview-envs"
</code></pre>
<p>Type <code>yes</code> when prompted. Cluster creation takes about five minutes.</p>
<p><strong>Tip:</strong> typing those <code>-var</code> flags repeatedly gets old fast. Create a file called <code>terraform.tfvars</code> containing <code>project_id = "your-project-id"</code> and <code>github_repo = "you/pr-preview-envs"</code>, and Terraform picks it up automatically. Add it to <code>.gitignore</code>.</p>
<p>When it finishes, Terraform prints your outputs. <strong>Save these</strong>, as you'll need them in Step 9:</p>
<pre><code class="language-plaintext">artifact_registry_url = "europe-west2-docker.pkg.dev/your-project/pr-preview"
service_account_email = "github-actions-preview@your-project.iam.gserviceaccount.com"
workload_identity_provider = "projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-provider"
</code></pre>
<p>You can print them again any time with <code>terraform output</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/23a9ff48-3691-41c5-876f-83cfdf226909.png" alt="Output of Terraform apply showing credentials needed" style="display: block;" width="2744" height="1454" loading="lazy">

<p>Connect <code>kubectl</code> to the new cluster using the command from the <code>get_credentials_command</code> output:</p>
<pre><code class="language-bash">gcloud container clusters get-credentials pr-preview-cluster \
  --zone europe-west2-a \
  --project YOUR_PROJECT_ID
</code></pre>
<p>Verify:</p>
<pre><code class="language-bash">kubectl get nodes
</code></pre>
<p>You should see two nodes in <code>Ready</code> state.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/cbfcf36b-9e9a-4d38-8165-3c1327e3b0f6.png" alt="Output of kubectl get nodes showing two GKE nodes in Ready status" style="display: block;" width="2744" height="416" loading="lazy">

<h2 id="heading-step-7-prove-the-lifecycle-manually">Step 7: Prove the Lifecycle Manually</h2>
<p>Before automating anything, do it by hand once.</p>
<p>This isn't busywork. Automation is fundamentally just a script running commands you could have typed. If you don't know what the commands are, you can't debug the automation when it fails...and it will fail, usually at 11 pm before a demo. Ten minutes here saves hours later.</p>
<p>Create a namespace:</p>
<pre><code class="language-bash">kubectl create namespace pr-test
</code></pre>
<p>Deploy a placeholder into it:</p>
<pre><code class="language-bash">kubectl create deployment hello \
  --image=nginx:1.27-alpine \
  --namespace=pr-test
</code></pre>
<p>Check it:</p>
<pre><code class="language-bash">kubectl get pods --namespace=pr-test
</code></pre>
<p>Now the important part – prove the isolation:</p>
<pre><code class="language-bash">kubectl get pods --all-namespaces | grep hello
</code></pre>
<p>The pod exists only in <code>pr-test</code>. Nothing in <code>default</code> or any other namespace knows about it. That's the property the entire project is built on: a namespace is a boundary, and everything inside one PR's namespace is invisible to every other.</p>
<p>Now tear it down:</p>
<pre><code class="language-bash">kubectl delete namespace pr-test
</code></pre>
<p>Watch what happens:</p>
<pre><code class="language-bash">kubectl get namespace pr-test
</code></pre>
<p>For a few seconds, you'll see <code>Terminating</code>, then the namespace disappears entirely. Deleting a namespace deletes every resource inside it: deployments, pods, services, config maps, secrets, everything. One command, complete cleanup.</p>
<p>That single property is why namespace-per-PR is such a good fit for preview environments. Teardown isn't a careful sequence of deletions you have to get right. It's one command that can't leave anything behind.</p>
<h2 id="heading-step-8-write-the-kubernetes-manifests-with-kustomize">Step 8: Write the Kubernetes Manifests with Kustomize</h2>
<p>Every preview environment deploys the same application. The only differences between <code>pr-42</code> and <code>pr-43</code> are the namespace and the image tag.</p>
<p>This is exactly the problem <strong>Kustomize</strong> solves. You write the manifests once as a <strong>base</strong>, then apply small, declarative changes on top of it (an <strong>overlay</strong>) without templating, string substitution, or <code>sed</code> scripts mangling your YAML.</p>
<p>Kustomize is built into <code>kubectl</code> (that's the <code>-k</code> flag), so there's nothing extra to install.</p>
<p>Create <code>k8s/base/deployment.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: apps/v1
kind: Deployment
metadata:
  name: preview-app
  labels:
    app: preview-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: preview-app
  template:
    metadata:
      labels:
        app: preview-app
    spec:
      containers:
        - name: preview-app
          # This is a placeholder. The overlay replaces it with the
          # real registry path and PR-specific tag.
          image: preview-app
          ports:
            - containerPort: 8080
          env:
            # Downward API: the pod learns its own identity
            - name: POD_NAMESPACE
              valueFrom:
                fieldRef:
                  fieldPath: metadata.namespace
            - name: POD_NAME
              valueFrom:
                fieldRef:
                  fieldPath: metadata.name
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080
            initialDelaySeconds: 2
            periodSeconds: 5
          resources:
            requests:
              cpu: "50m"
              memory: "64Mi"
            limits:
              cpu: "200m"
              memory: "128Mi"
</code></pre>
<p>Create <code>k8s/base/service.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Service
metadata:
  name: preview-app
spec:
  selector:
    app: preview-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
  type: ClusterIP
</code></pre>
<p>Create <code>k8s/base/kustomization.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - deployment.yaml
  - service.yaml
</code></pre>
<p>There are some deliberate choices here.</p>
<p><code>replicas: 1</code>: production runs multiple replicas for availability. A preview environment doesn't need high availability. One replica per environment, multiplied across every open PR, keeps your node count and your bill sane.</p>
<p><code>image: preview-app</code>: a bare placeholder with no registry and no tag. It would never pull as-is. Kustomize's image transformer matches on this name and replaces it wholesale, so the base manifest never needs to know your registry URL.</p>
<p><strong>The Downward API</strong> <code>env</code> <strong>block</strong>: this is where <code>POD_NAMESPACE</code> and <code>POD_NAME</code> come from. <code>fieldRef</code> reads a field from the pod's own spec at runtime and exposes it as an environment variable. No API calls, credentials, or service account needed.</p>
<p><code>type: ClusterIP</code>: internal-only. Each <code>LoadBalancer</code> service provisions a real Google Cloud load balancer with a real hourly cost and a public IP. Doing that per PR would be expensive and wasteful. We'll reach the app with <code>kubectl port-forward</code> for now, and in Step 13 a single shared Ingress controller takes over, serving every environment through one load balancer.</p>
<p><strong>Resource requests and limits</strong>: <code>requests</code> reserve capacity so the scheduler knows how much room a pod needs. <code>limits</code> cap what it can consume. Without limits, one runaway preview environment could starve every other one on the node. With many short-lived environments coming and going, this isn't optional.</p>
<p>Test the base renders correctly:</p>
<pre><code class="language-bash">kubectl kustomize k8s/base
</code></pre>
<p>That prints the rendered YAML without applying it. This is useful for debugging.</p>
<h2 id="heading-step-9-build-the-spin-up-workflow">Step 9: Build the Spin-Up Workflow</h2>
<p>Now we connect everything.</p>
<p>Create <code>.github/workflows/preview-deploy.yml</code>:</p>
<pre><code class="language-yaml">name: Deploy PR Preview

on:
  pull_request:
    types: [opened, synchronize, reopened]

# If someone pushes three commits quickly, cancel the older runs.
# Without this, three deploys race each other into the same namespace
# and whichever finishes last wins — which may not be the newest commit.
concurrency:
  group: preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true

permissions:
  contents: read
  id-token: write   # REQUIRED for Workload Identity Federation
  pull-requests: write

env:
  PROJECT_ID: YOUR_PROJECT_ID
  REGION: europe-west2
  CLUSTER_NAME: pr-preview-cluster
  CLUSTER_ZONE: europe-west2-a
  REPOSITORY: pr-preview
  IMAGE_NAME: preview-app

jobs:
  deploy:
    name: Build and deploy preview
    runs-on: ubuntu-latest

    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set environment variables
        run: |
          PR_NUMBER=${{ github.event.pull_request.number }}
          SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7)

          echo "PR_NUMBER=${PR_NUMBER}"   &gt;&gt; $GITHUB_ENV
          echo "SHORT_SHA=${SHORT_SHA}"   &gt;&gt; $GITHUB_ENV
          echo "NAMESPACE=pr-${PR_NUMBER}" &gt;&gt; $GITHUB_ENV
          echo "IMAGE_TAG=pr-${PR_NUMBER}-${SHORT_SHA}" &gt;&gt; $GITHUB_ENV
          echo "IMAGE_BASE=${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}" &gt;&gt; $GITHUB_ENV

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v2

      - name: Configure Docker for Artifact Registry
        run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet

      - name: Build and push image
        run: |
          docker build \
            --build-arg PR_NUMBER="${PR_NUMBER}" \
            --build-arg GIT_SHA="${SHORT_SHA}" \
            -t "${IMAGE_BASE}:${IMAGE_TAG}" \
            ./app

          docker push "${IMAGE_BASE}:${IMAGE_TAG}"

      - name: Get GKE credentials
        uses: google-github-actions/get-gke-credentials@v2
        with:
          cluster_name: ${{ env.CLUSTER_NAME }}
          location: ${{ env.CLUSTER_ZONE }}

      - name: Create namespace
        run: |
          # --dry-run=client | apply makes this idempotent.
          # Running it on an existing namespace is a no-op, not an error.
          kubectl create namespace "${NAMESPACE}" \
            --dry-run=client -o yaml | kubectl apply -f -

      - name: Generate Kustomize overlay
        run: |
          mkdir -p k8s/overlays/"${NAMESPACE}"
          cat &gt; k8s/overlays/"${NAMESPACE}"/kustomization.yaml &lt;&lt;EOF
          apiVersion: kustomize.config.k8s.io/v1beta1
          kind: Kustomization

          namespace: ${NAMESPACE}

          resources:
            - ../../base

          images:
            - name: preview-app
              newName: ${IMAGE_BASE}
              newTag: ${IMAGE_TAG}
          EOF

          echo "--- Rendered manifest ---"
          kubectl kustomize k8s/overlays/"${NAMESPACE}"

      - name: Deploy
        run: kubectl apply -k k8s/overlays/"${NAMESPACE}"

      - name: Wait for rollout
        run: |
          kubectl rollout status deployment/preview-app \
            --namespace="${NAMESPACE}" \
            --timeout=180s

      - name: Show deployment details
        run: |
          echo "Namespace: ${NAMESPACE}"
          kubectl get pods,svc --namespace="${NAMESPACE}"
</code></pre>
<h3 id="heading-add-the-two-secrets">Add the Two Secrets</h3>
<p>Go to your repository on GitHub and go to <strong>Settings</strong>, <strong>Secrets and variables</strong>, <strong>Actions</strong>, and <strong>New repository secret</strong>. Add:</p>
<table>
<thead>
<tr>
<th>Secret name</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><code>WIF_PROVIDER</code></td>
<td>The <code>workload_identity_provider</code> output from Terraform</td>
</tr>
<tr>
<td><code>WIF_SERVICE_ACCOUNT</code></td>
<td>The <code>service_account_email</code> output from Terraform</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/3badb7bc-a162-4db7-ae18-5c93b492c5cc.png" alt="GitHub repository secrets page listing WIF_PROVIDER and WIF_SERVICE_ACCOUNT" style="display: block;" width="2380" height="1446" loading="lazy">

<p>Neither of these is a credential. The provider path and service account email are identifiers, not secrets. They grant nothing on their own, because the OIDC token and the attribute condition are what actually control access. Storing them as secrets is just tidiness. Update <code>PROJECT_ID</code> and the region values in the workflow's <code>env:</code> block to match your setup.</p>
<h3 id="heading-understanding-the-workflow">Understanding the Workflow</h3>
<p><code>on: pull_request: types: [opened, synchronize, reopened]</code>: these are three distinct triggers.</p>
<ul>
<li><p><code>opened</code> fires when the PR is first created.</p>
</li>
<li><p><code>synchronize</code> fires on every subsequent push to the branch, which is what keeps the environment current.</p>
</li>
<li><p><code>reopened</code> handles a PR that was closed and then reopened. Without it, that PR would have a torn-down environment and nothing to recreate it.</p>
</li>
</ul>
<p><code>concurrency</code>: this one saves real debugging time. Push three commits in quick succession, and you get three workflow runs targeting the same namespace. They race. The one that finishes last wins, and that's not necessarily the newest code. A fast early build can overtake a slow later one, leaving your preview environment silently running stale code while the PR shows green. Grouping by PR number with <code>cancel-in-progress: true</code> means only the newest run survives.</p>
<p><code>permissions: id-token: write</code>: without this line, nothing else works. It's what allows the workflow to request an OIDC token from GitHub. Leave it out and <code>google-github-actions/auth</code> fails with an error about being unable to get an ID token. This is the single most common WIF setup mistake.</p>
<p><code>$GITHUB_ENV</code>: writing <code>KEY=value</code> to this file makes the variable available in all <em>subsequent</em> steps. Not the current one, as a variable you write in a step can't be read later in that same step. It's why the setup happens in its own step at the top.</p>
<p><code>github.event.pull_request.head.sha</code>: note this rather than <code>github.sha</code>. On <code>pull_request</code> events, GitHub checks out a <em>merge commit</em>. This is a temporary commit combining your branch with the base branch. <code>github.sha</code> refers to that merge commit, which doesn't exist in your repository's history and will confuse anyone who tries to look it up. <code>head.sha</code> is the actual last commit on the PR branch, which is what a developer expects to see.</p>
<p><code>--dry-run=client -o yaml | kubectl apply -f -</code> is a small trick worth stealing. <code>kubectl create namespace</code> fails if the namespace already exists, which would break every push after the first. This pipeline generates the namespace YAML without contacting the cluster, then pipes it to<code>apply</code>, which creates it if it's missing and does nothing if it isn't. Idempotent by construction.</p>
<p><strong>The heredoc overlay</strong>: instead of committing an overlay per PR (impossible, as you don't know PR numbers in advance), the workflow writes one at runtime and throws it away when the runner is destroyed. It's ephemeral configuration for an ephemeral environment.</p>
<p><code>kubectl rollout status</code> is what makes the workflow honest. Without it, the job succeeds the moment <code>kubectl apply</code> is accepted by the API server, which happens before a single pod has started. A crash-looping image would still show a green check. <code>rollout status</code> blocks until pods are actually ready, or fails after 180 seconds. Green means working.</p>
<h3 id="heading-a-note-on-forked-pull-requests">A Note on Forked Pull Requests</h3>
<p>Something is worth knowing before you deploy this anywhere public: pull requests from forks do <strong>not</strong> receive <code>id-token: write</code> permission, by design. GitHub blocks it so a stranger can't open a PR that steals your cloud credentials.</p>
<p>For a private repository or a personal project, this doesn't affect you. For a public repository accepting outside contributions, you'd need a <code>workflow_run</code>-based pattern where a privileged workflow runs from the base repository after the untrusted build completes. That's beyond this handbook's scope, but if you're deploying this on an open-source project, don't skip it.</p>
<h2 id="heading-step-10-build-the-teardown-workflow">Step 10: Build the Teardown Workflow</h2>
<p>Spin-up is only half the value. An environment that never disappears is just a slow leak in your cloud bill.</p>
<p>Create <code>.github/workflows/preview-teardown.yml</code>:</p>
<pre><code class="language-yaml">name: Tear Down PR Preview

on:
  pull_request:
    types: [closed]

permissions:
  contents: read
  id-token: write

env:
  PROJECT_ID: YOUR_PROJECT_ID
  CLUSTER_NAME: pr-preview-cluster
  CLUSTER_ZONE: europe-west2-a

jobs:
  teardown:
    name: Delete preview namespace
    runs-on: ubuntu-latest

    steps:
      - name: Set namespace
        run: echo "NAMESPACE=pr-${{ github.event.pull_request.number }}" &gt;&gt; $GITHUB_ENV

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}

      - name: Get GKE credentials
        uses: google-github-actions/get-gke-credentials@v2
        with:
          cluster_name: ${{ env.CLUSTER_NAME }}
          location: ${{ env.CLUSTER_ZONE }}

      - name: Delete namespace
        run: |
          echo "Deleting namespace ${NAMESPACE}"
          kubectl delete namespace "${NAMESPACE}" \
            --ignore-not-found=true \
            --wait=false

      - name: Confirm
        run: |
          echo "Preview environment for PR #${{ github.event.pull_request.number }} torn down."
</code></pre>
<p>A few details here matter more than they might seem.</p>
<p>First, <code>types: [closed]</code> <strong>covers merges too.</strong> GitHub doesn't have a separate <code>merged</code> event type. Merging a pull request fires <code>closed</code> with the payload field <code>merged: true</code>. So this single trigger handles both "merged successfully" and "abandoned and closed." Both should tear down the environment, so we don't need to distinguish between them.</p>
<p>Next, <code>--ignore-not-found=true</code>: if the namespace doesn't exist (perhaps the deploy failed, or someone deleted it manually), <code>kubectl delete</code> would normally exit with an error and mark the workflow red. That's a false alarm: the desired end state is "namespace doesn't exist," and it doesn't. This flag makes the step succeed when there's nothing to do.</p>
<p>Finally, <code>--wait=false</code>: namespace deletion is asynchronous. Kubernetes marks it<code>Terminating</code>, then works through deleting every resource inside it. Waiting for that to complete could hold your CI runner for a minute or more, for no benefit. Fire the delete and let the cluster finish on its own time.</p>
<h2 id="heading-step-11-open-a-pull-request-and-watch-it-work">Step 11: Open a Pull Request and Watch It Work</h2>
<p>Commit everything and push:</p>
<pre><code class="language-bash">git add .
git commit -m "Add PR preview environment infrastructure"
git push -u origin main
</code></pre>
<p>Now create a branch and make a visible change:</p>
<pre><code class="language-bash">git checkout -b test-preview
</code></pre>
<p>Edit <code>app/main.go</code>. Change the first line of the handler output:</p>
<pre><code class="language-go">fmt.Fprintf(w, "Preview environment is live — testing the pipeline\n\n")
</code></pre>
<p>Commit and push:</p>
<pre><code class="language-bash">git add .
git commit -m "Test preview environment"
git push -u origin test-preview
</code></pre>
<p>Open a pull request on GitHub, then go to the <strong>Actions</strong> tab. You should see "Deploy PR Preview" running.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/4da5dcda-506a-4a9d-8413-5c5cd32af0c1.png" alt="GitHub Actions summary for a pull request showing the Deploy PR Preview workflow completed successfully" style="display: block;" width="2380" height="1446" loading="lazy">

<p>Watch the steps in order: authentication succeeds without a key, the image builds and pushes, the namespace is created, Kustomize renders the overlay (you'll see the fully resolved manifest printed), the deployment applies, and <code>rollout status</code> waits until pods are ready.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/3f0ae123-82fb-447e-9a5a-db1e87589340.png" alt="Expanded GitHub Actions log showing the deploy, wait for rollout, and show deployment details steps completing successfully" style="display: block;" width="2380" height="1446" loading="lazy">

<p>Once it's green, verify from your terminal:</p>
<pre><code class="language-shell">kubectl get namespaces | grep pr-
</code></pre>
<p>You should see your <code>pr-1</code> (or whatever number your PR got).</p>
<pre><code class="language-shell">kubectl get pods --namespace=pr-1
</code></pre>
<p>Now reach the application:</p>
<pre><code class="language-shell">kubectl port-forward --namespace=pr-1 service/preview-app 8080:80
</code></pre>
<p>In a second terminal:</p>
<pre><code class="language-shell">curl http://localhost:8080
</code></pre>
<p>You should see:</p>
<pre><code class="language-shell">Preview environment is live — testing the pipeline

PR number  : 1
Git SHA    : a1b2c3d
Namespace  : pr-1
Pod name   : preview-app-7d9f8b6c4-xk2mp
</code></pre>
<p>The application is reporting the PR number and commit SHA that were baked into its binary at build time, and the namespace it discovered through the Downward API. Every one of those values was resolved by a different part of the pipeline, and they all agree. That's your proof the whole chain works.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/17b0712a-0fa3-47be-8078-67663239acfe.png" alt="Terminal output from curl showing the preview environment reporting PR number 1, its commit SHA, namespace pr-1, and the pod name" style="display: block;" width="1266" height="1012" loading="lazy">

<p>Now push another commit to the same branch and watch the environment update in place.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/2ca76645-9fe0-491d-8dae-05789a6b5498.png" alt="GitHub Actions log showing the Deploy PR Preview workflow running again after a second commit was pushed to the same branch" style="display: block;" width="2372" height="1012" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/426cc240-5ee7-487b-9fe4-dc955b06ac31.png" alt="Terminal showing kubectl port-forward running against namespace pr-1 alongside the curl command used to reach it" style="display: block;" width="1870" height="554" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/a66c098b-b972-4282-8f84-6f4ab49fc34b.png" alt="Terminal output from curl showing the updated greeting text and new commit SHA after the second push, confirming the environment redeployed in place" style="display: block;" width="1870" height="646" loading="lazy">

<p>Then close the pull request and watch the teardown workflow fire.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/bbcacedc-2ade-4945-b849-fd6be8931819.png" alt="GitHub Actions teardown workflow triggered and in progress" style="display: block;" width="2378" height="864" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/4b5fb004-ebb6-424c-a535-caa9f8037265.png" alt="GitHub Actions teardown workflow completing" style="display: block;" width="2378" height="1438" loading="lazy">

<pre><code class="language-bash">kubectl get namespace pr-1
</code></pre>
<p><code>Terminating</code>, then gone.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/8d373dd3-f627-400c-aad2-9a845b549ce5.png" alt="Terminal output showing kubectl get namespace returning &quot;namespaces pr-1 not found&quot;" style="display: block;" width="2378" height="158" loading="lazy">

<h3 id="heading-what-you-have-and-whats-still-missing">What You Have, and What's Still Missing</h3>
<p>Stop and take stock, because the hard infrastructure work is done. A pull request now produces a real, isolated environment with no manual steps, and closing it removes every trace. That's the whole lifecycle, automated.</p>
<p>But look again at how you reached it:</p>
<pre><code class="language-plaintext">kubectl port-forward --namespace=pr-1 service/preview-app 8080:80
</code></pre>
<p>That requires <code>kubectl</code> installed, credentials for the cluster, permission to use them, and knowledge of which namespace maps to which PR. This is fine for the person who built the pipeline, but it's useless for the designer checking a spacing change, the product manager reviewing a copy edit, or the QA engineer clicking through a flow before sign-off.</p>
<p>The whole point of a preview environment is that <em><strong>anyone</strong></em> can look at the change. An environment only engineers can reach isn't really a preview environment. It's a debugging tool.</p>
<p>The rest of this tutorial closes that gap, and then makes the result safe to leave running. First, we'll set up a public HTTPS URL for every environment. Then we'll build the guardrails and go through the cleanup that stop it becoming a quiet line item on your cloud bill.</p>
<h2 id="heading-step-12-reserve-a-static-ip-address">Step 12: Reserve a Static IP Address</h2>
<p>Before installing anything, you'll need to reserve an external IP address.</p>
<p>This matters because when you install the Ingress controller, it creates a Kubernetes Service of type <code>LoadBalancer</code>, and Google Cloud provisions a real load balancer with a public IP. By default, that IP is <strong>ephemeral</strong>. If the Service is deleted and recreated, you get a different one.</p>
<p>Your wildcard DNS record points at that IP. If it changes, every preview environment breaks at once, and you won't find out until someone tells you the link is dead. Reserving the IP first means it belongs to your project, not to the load balancer, and it survives anything you do to the cluster.</p>
<p>Add this to <code>terraform/main.tf</code>:</p>
<pre><code class="language-hcl"># -----------------------------------------------------------
# Static external IP for the Ingress controller
# -----------------------------------------------------------
resource "google_compute_address" "ingress" {
  name         = "pr-preview-ingress-ip"
  region       = var.region
  address_type = "EXTERNAL"

  depends_on = [google_project_service.apis]
}
</code></pre>
<p>And add the output to <code>terraform/outputs.tf</code>:</p>
<pre><code class="language-hcl">output "ingress_ip" {
  description = "Point your wildcard DNS record at this address"
  value       = google_compute_address.ingress.address
}
</code></pre>
<p>You'll also want <code>compute.googleapis.com</code> in the APIs list if it isn't there already. Update the <code>for_each</code> set in your <code>google_project_service</code> resource:</p>
<pre><code class="language-hcl">  for_each = toset([
    "container.googleapis.com",
    "artifactregistry.googleapis.com",
    "iamcredentials.googleapis.com",
    "sts.googleapis.com",
    "compute.googleapis.com",
  ])
</code></pre>
<p>Apply it:</p>
<pre><code class="language-bash">cd terraform
terraform apply \
  -var="project_id=YOUR_PROJECT_ID" \
  -var="github_repo=YOUR_USERNAME/pr-preview-envs"
</code></pre>
<p>Note the IP that comes back:</p>
<pre><code class="language-bash">terraform output ingress_ip
</code></pre>
<p>Write it down. You'll use it twice in the next two steps.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/a9b0586d-3b1e-4fe2-9eb4-53087b70a45a.png" alt="terraform output ingress_ip showing the reserved address" style="display: block;" width="2378" height="1056" loading="lazy">

<p><strong>Important:</strong> this must be a <strong>regional</strong> address in the same region as your cluster, which is what <code>region = var.region</code> gives you. Google Cloud also offers global addresses, used by global HTTP(S) load balancers.</p>
<p>The Service type <code>LoadBalancer</code> that ingress-nginx creates is a regional load balancer, and it can't bind a global address. Mixing these up produces a Service stuck in <code>Pending</code> forever with an error that doesn't clearly explain why.</p>
<h2 id="heading-step-13-install-the-ingress-controller">Step 13: Install the Ingress Controller</h2>
<p>An <strong>Ingress</strong> is a Kubernetes resource that describes HTTP routing rules: "requests for this hostname go to that Service." But the Ingress resource is only a description. Something has to actually read those rules and route traffic, and that something is an <strong>Ingress controller</strong>.</p>
<p>We'll use <strong>ingress-nginx</strong>, which is the community-maintained controller built on NGINX. It runs as pods in your cluster, watches the Kubernetes API for Ingress resources, and rewrites its own NGINX configuration whenever one changes.</p>
<p>The architectural point that makes this work for preview environments is that <strong>one controller serves every namespace</strong>. Each preview environment creates its own Ingress resource. The controller picks it up, adds a routing rule for that hostname, and sends traffic to the right Service. The controller doesn't care how many environments exist.</p>
<p>Contrast this with what we'd otherwise do. If each preview environment used a <code>LoadBalancer</code> Service, every PR would provision its own Google Cloud load balancer, each with an hourly charge and its own IP address that DNS would need to know about. Ten open PRs would mean ten load balancers. With a shared Ingress controller, ten open PRs mean one.</p>
<p>Install it with Helm:</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.loadBalancerIP="YOUR_STATIC_IP" \
  --set controller.service.externalTrafficPolicy=Local
</code></pre>
<p>Replace <code>YOUR_STATIC_IP</code> with the address from Step 12.</p>
<p>Let's break down the flags before we go on:</p>
<p><code>--namespace ingress-nginx --create-namespace</code>: the controller gets its own namespace, separate from any preview environment. It's shared infrastructure, and it should never be caught up in a preview environment's teardown.</p>
<p><code>controller.service.loadBalancerIP</code>: tells GKE to bind the load balancer to the specific address you reserved, rather than allocating a new one. This is the line that makes your DNS record stable.</p>
<p><code>controller.service.externalTrafficPolicy=Local</code>: preserves the client's real source IP. Without it, Kubernetes may route a request through an intermediate node, and by the time it reaches NGINX the source IP has been rewritten to a node's internal address. Your access logs would show cluster IPs instead of real visitors.</p>
<p>The tradeoff is that traffic only lands on nodes actually running a controller pod, which is fine here.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/7813b917-231e-44fc-9d5b-9501c1745145.png" alt="Terminal output from helm install showing the ingress-nginx chart deployed successfully into the ingress-nginx namespace" style="display: block;" width="2768" height="1420" loading="lazy">

<p>Watch for the external IP to be assigned:</p>
<pre><code class="language-bash">kubectl get service ingress-nginx-controller \
  --namespace ingress-nginx \
  --watch
</code></pre>
<p>It shows <code>&lt;pending&gt;</code> for a minute or two while Google provisions the load balancer, then displays your static IP from Step 12. Press Ctrl+C once it appears.</p>
<p>Verify that the controller is healthy:</p>
<pre><code class="language-bash">kubectl get pods --namespace ingress-nginx
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/f38b8e9c-d7e6-4c18-b5ac-927aef24118f.png" alt="Output of kubectl get service and kubectl get pods showing the ingress-nginx controller running with the reserved static external IP assigned" style="display: block;" width="2768" height="1420" loading="lazy">

<h2 id="heading-step-14-point-wildcard-dns-at-the-load-balancer">Step 14: Point Wildcard DNS at the Load Balancer</h2>
<p>This step takes about two minutes and is the piece that makes the whole pattern scale.</p>
<p>Go to wherever your domain's DNS is managed and create a single <strong>A record</strong>:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Type</td>
<td><code>A</code></td>
</tr>
<tr>
<td>Name / Host</td>
<td><code>*.preview</code></td>
</tr>
<tr>
<td>Value</td>
<td>Your static IP from Step 1</td>
</tr>
<tr>
<td>TTL</td>
<td>300 (or the lowest your provider allows)</td>
</tr>
</tbody></table>
<p>That's it. That's the entire DNS configuration for the project.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/461ad5af-b55e-4ec4-912a-0fac144a4db5.png" alt="DNS provider interface showing a wildcard A record for *.preview pointing to the static IP address of the ingress load balancer" style="display: block;" width="1892" height="1172" loading="lazy">

<p>Here's why one record is enough. A wildcard DNS record matches <strong>any</strong> label in that position. <code>*.preview.yourdomain.com</code> resolves <code>pr-1.preview.yourdomain.com</code>, <code>pr-42.preview.yourdomain.com</code>, and <code>pr-99999.preview.yourdomain.com</code>, all to the same IP, all without anyone creating a record for them.</p>
<p>This is worth pausing on, because it's the design decision that keeps the system simple. The obvious approach would be to have the CI pipeline create a DNS record when a PR opens and delete it when the PR closes. That means DNS API credentials in your pipeline, another thing to fail, another thing to leak, and another source of orphaned resources.</p>
<p>The wildcard approach eliminates that entire category of work: DNS is configured once, manually, and never touched again.</p>
<p>Routing to the right environment doesn't happen in DNS. Every hostname resolves to the same load balancer, and the Ingress controller reads the <code>Host</code> header of each incoming request to decide which namespace's Service should handle it.</p>
<p>Verify the record has propagated:</p>
<pre><code class="language-bash">dig +short anything-at-all.preview.yourdomain.com
</code></pre>
<p>You should get your static IP back. Note that the subdomain doesn't need to exist yet. That's the whole point of a wildcard.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/a11681bf-5899-47b5-9731-f45096915f02.png" alt="Terminal output from dig returning the ingress static IP for a subdomain that was never explicitly created, confirming the wildcard record resolves" style="display: block;" width="1892" height="152" loading="lazy">

<p>If nothing comes back, wait a few minutes and try again. DNS propagation is usually fast with a low TTL, but it isn't instant.</p>
<h2 id="heading-step-15-install-cert-manager-and-create-a-clusterissuer">Step 15: Install cert-manager and Create a ClusterIssuer</h2>
<p>Now for HTTPS.</p>
<p><strong>cert-manager</strong> is a Kubernetes controller that automates the full certificate lifecycle. It watches for Ingress resources annotated with an issuer, requests a certificate from a certificate authority, proves you control the domain, stores the certificate in a Kubernetes Secret, and renews it before it expires. Ther eare no cron jobs, manual renewal, or expired-certificate incident at 3 am.</p>
<p>Install it:</p>
<pre><code class="language-bash">helm repo add jetstack https://charts.jetstack.io
helm repo update

helm install cert-manager jetstack/cert-manager \
  --namespace cert-manager \
  --create-namespace \
  --version v1.21.2 \
  --set crds.enabled=true
</code></pre>
<p><code>--set crds.enabled=true</code> Installs cert-manager's Custom Resource Definitions: <code>Certificate</code>, <code>Issuer</code>, <code>ClusterIssuer</code>, <code>CertificateRequest</code> and others. These extend the Kubernetes API with new resource types. Without them, cert-manager has nothing to watch for. (If you're following an older tutorial that uses <code>installCRDs=true</code>, that flag was renamed in v1.15.)</p>
<p>Check it's running:</p>
<pre><code class="language-bash">kubectl get pods --namespace cert-manager
</code></pre>
<p>You should see three pods: the controller, the webhook, and cainjector.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/dad07949-4123-4854-861e-5d96e19a4591.png" alt="Output of kubectl get pods showing the cert-manager controller, webhook, and cainjector pods running" style="display: block;" width="1892" height="264" loading="lazy">

<h3 id="heading-create-the-clusterissuer">Create the ClusterIssuer</h3>
<p>An <strong>Issuer</strong> tells cert-manager where to get certificates from. A <strong>ClusterIssuer</strong> is the same thing with cluster-wide scope, which is what we need. A namespaced Issuer would have to be recreated in every single preview namespace.</p>
<p>Create <code>k8s/cluster/letsencrypt-staging.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    # Let's Encrypt's staging environment. Certificates from this
    # server are NOT trusted by browsers, but the rate limits are
    # far more generous. Use this while debugging.
    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>
<p>And <code>k8s/cluster/letsencrypt-prod.yaml</code>:</p>
<pre><code class="language-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>
<p>Apply both:</p>
<pre><code class="language-bash">kubectl apply -f k8s/cluster/letsencrypt-staging.yaml
kubectl apply -f k8s/cluster/letsencrypt-prod.yaml
</code></pre>
<p>Confirm they're ready:</p>
<pre><code class="language-bash">kubectl get clusterissuer
</code></pre>
<p>Both should show <code>READY: True</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/e76271ef-0b0c-471f-b826-d5f476bc33f2.png" alt="Output of kubectl get clusterissuer showing both letsencrypt-staging and letsencrypt-prod with READY status True" style="display: block;" width="1892" height="342" loading="lazy">

<h3 id="heading-why-two-issuers">Why Two Issuers?</h3>
<p>This isn't redundancy. It's how you avoid a frustrating afternoon.</p>
<p>Let's Encrypt enforces strict rate limits on its production environment. The one that matters here is <strong>50 certificates per registered domain per week</strong>. There's also a duplicate-certificate limit of 5 per week for the identical set of hostnames.</p>
<p>While you're setting this up, you'll likely misconfigure something. The Ingress annotation will be wrong, or the HTTP-01 challenge won't route correctly, or DNS won't have propagated. Each failed attempt burns against your quota, and once you hit the limit you're locked out for a week with no appeal.</p>
<p>The staging issuer has far looser limits. Its certificates are signed by an untrusted root, so browsers show a warning, but that warning <em>is itself proof the pipeline works</em>. If you see a certificate warning naming "Let's Encrypt Staging," everything functioned correctly, and the only remaining step is switching issuers.</p>
<p>Use <code>letsencrypt-staging</code> until the flow works end to end, then switch to <code>letsencrypt-prod</code>.</p>
<h3 id="heading-how-the-http-01-challenge-works">How the HTTP-01 Challenge Works</h3>
<p>This is worth understanding, because it's what breaks when things go wrong.</p>
<p>When cert-manager requests a certificate for <code>pr-42.preview.yourdomain.com</code>, Let's Encrypt needs proof you control that hostname. With the HTTP-01 solver, the sequence is:</p>
<ol>
<li><p>Let's Encrypt gives cert-manager a random token.</p>
</li>
<li><p>cert-manager creates a temporary pod, Service, and Ingress rule that serves that token at <code>http://pr-42.preview.yourdomain.com/.well-known/acme-challenge/&lt;token&gt;</code>.</p>
</li>
<li><p>Let's Encrypt makes a real HTTP request to that URL from the public internet.</p>
</li>
<li><p>If the token matches, domain control is proven and the certificate is issued.</p>
</li>
<li><p>cert-manager deletes the temporary resources and stores the certificate in a Secret.</p>
</li>
</ol>
<p>Three things must be true for this to work, and they're the three things to check when it doesn't: DNS must resolve the hostname to your load balancer, port 80 must be reachable from the internet (don't redirect everything to HTTPS at the load balancer level. The challenge is plain HTTP), and the Ingress controller must be routing correctly.</p>
<h3 id="heading-a-note-on-scale">A Note on Scale</h3>
<p>HTTP-01 issues one certificate per hostname, and every preview environment has a unique hostname. That's one certificate per pull request.</p>
<p>Against Let's Encrypt's 50-per-week limit, that's comfortable for a solo project or a small team. An active team opening more than 50 PRs a week would hit the ceiling.</p>
<p>The production answer is to issue a <strong>single wildcard certificate</strong> for <code>*.preview.yourdomain.com</code> and share it across every namespace. Wildcards can't be issued over HTTP-01. They require the <strong>DNS-01</strong> challenge, where cert-manager proves control by creating a TXT record instead of serving a file.</p>
<p>With Cloud DNS, cert-manager can do this automatically using a Google service account. You'd then replicate the resulting Secret into each preview namespace with a tool like <a href="https://github.com/emberstack/kubernetes-reflector">reflector</a>.</p>
<p>That's more moving parts than this handbook needs, but it's the right answer once your PR volume justifies it, and it's worth knowing that limit exists before you hit it.</p>
<h2 id="heading-step-16-add-an-ingress-to-the-base-manifests">Step 16: Add an Ingress to the Base Manifests</h2>
<p>Now we'll give the application a route.</p>
<p>Create <code>k8s/base/ingress.yaml</code>:</p>
<pre><code class="language-yaml">apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: preview-app
  annotations:
    # Tells cert-manager to request a certificate for the hosts
    # listed under spec.tls, using this ClusterIssuer.
    cert-manager.io/cluster-issuer: letsencrypt-staging
    # Redirect HTTP to HTTPS once the certificate exists.
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - preview-host-placeholder
      secretName: preview-app-tls
  rules:
    - host: preview-host-placeholder
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: preview-app
                port:
                  number: 80
</code></pre>
<p>Add it to <code>k8s/base/kustomization.yaml</code>:</p>
<p>You created this file back in Step 8 with two resources. Add the Ingress to it:</p>
<pre><code class="language-yaml">apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
  - deployment.yaml
  - service.yaml
  - ingress.yaml
</code></pre>
<p>Here are the pieces that matter:</p>
<p><code>cert-manager.io/cluster-issuer</code>: this single annotation is what triggers the entire certificate workflow. cert-manager watches for it, reads the hostnames from <code>spec.tls</code>, and does everything else. Note it's set to <code>letsencrypt-staging</code> for now. We'll switch to production once the flow is proven.</p>
<p><code>ingressClassName: nginx</code>: identifies which controller should handle this Ingress. A cluster can run several controllers simultaneously, and this field prevents them from fighting over the same resource. Older tutorials use the <code>kubernetes.io/ingress.class</code> annotation, which is deprecated.</p>
<p><code>secretName: preview-app-tls</code>: where cert-manager stores the issued certificate. It's created automatically in the same namespace as the Ingress, so each preview environment has its own.</p>
<p><code>preview-host-placeholder</code>: deliberately not a valid hostname. It appears twice: once in <code>spec.tls.hosts</code> and once in <code>spec.rules.host</code>, and both get replaced per PR in the next step. Using something that obviously isn't a real domain means that if the patch ever fails, you get an immediate visible error rather than a subtle misroute.</p>
<p><code>pathType: Prefix</code> <strong>with</strong> <code>path: /</code>: matches every path on the host. Preview environments serve the whole application, not a subpath.</p>
<h2 id="heading-step-17-patch-the-hostname-per-pull-request">Step 17: Patch the Hostname Per Pull Request</h2>
<p>Each environment needs its own hostname substituted into two places in the Ingress. Kustomize handles this with a <strong>JSON 6902 patch</strong>. A precise operation that targets an exact path within a resource.</p>
<p>In Step 9, the workflow generated an overlay that set the namespace and image tag. We extend that generated overlay with a patch block. Here's what the complete generated <code>kustomization.yaml</code> looks like for PR 42:</p>
<pre><code class="language-yaml">apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

namespace: pr-42

resources:
  - ../../base

images:
  - name: preview-app
    newName: europe-west2-docker.pkg.dev/your-project/pr-preview/preview-app
    newTag: pr-42-a1b2c3d

patches:
  - target:
      kind: Ingress
      name: preview-app
    patch: |-
      - op: replace
        path: /spec/rules/0/host
        value: pr-42.preview.yourdomain.com
      - op: replace
        path: /spec/tls/0/hosts/0
        value: pr-42.preview.yourdomain.com
</code></pre>
<p>The patch syntax reads literally: in the resource matching <code>kind: Ingress, name: preview-app</code>, replace the value at <code>/spec/rules/0/host</code>, and replace the value at <code>/spec/tls/0/hosts/0</code>.</p>
<p>The <code>0</code> indices are array positions. <code>/spec/rules/0</code> is the first (and only) rule. <code>/spec/tls/0/hosts/0</code> is the first hostname in the first TLS block. If you later add a second hostname per environment, you'd add another operation targeting index <code>1</code>.</p>
<p>Why a JSON 6902 patch rather than a strategic merge patch? Because strategic merge on an array of objects is ambiguous. Kubernetes has to guess whether you mean "replace this list" or "merge into an existing entry," and the answer depends on per-field merge keys. For replacing a single value at a known position, an explicit <code>op: replace</code> says exactly what you mean and fails loudly if the path doesn't exist.</p>
<p>The workflow generates this file at runtime, so you never commit a per-PR overlay. We'll write that step in Step 19.</p>
<h2 id="heading-step-18-add-guardrails-with-resourcequota-and-limitrange">Step 18: Add Guardrails with ResourceQuota and LimitRange</h2>
<p>Before we wire up the workflow, let's stop a preview environment from being able to take down the cluster.</p>
<p>Right now nothing prevents a pull request from deploying a manifest requesting 32 CPUs, or scaling to 200 replicas, or creating a dozen <code>LoadBalancer</code> Services. Someone doesn't need bad intent for this to happen. A bad merge, a copied manifest, or a typo in a replica count does it just as effectively.</p>
<p>Two Kubernetes resources solve this, and they work together.</p>
<p>Create <code>k8s/cluster/namespace-guardrails.yaml</code> as a template:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: ResourceQuota
metadata:
  name: preview-quota
spec:
  hard:
    requests.cpu: "1"
    requests.memory: 1Gi
    limits.cpu: "2"
    limits.memory: 2Gi
    pods: "10"
    services: "5"
    services.loadbalancers: "0"
    persistentvolumeclaims: "2"
---
apiVersion: v1
kind: LimitRange
metadata:
  name: preview-limits
spec:
  limits:
    - type: Container
      # Applied to containers that don't specify their own
      defaultRequest:
        cpu: 50m
        memory: 64Mi
      default:
        cpu: 200m
        memory: 128Mi
      # Hard ceiling — a container asking for more is rejected
      max:
        cpu: "1"
        memory: 512Mi
</code></pre>
<h3 id="heading-what-each-one-does">What Each One Does</h3>
<p>A <strong>ResourceQuota</strong> caps the total consumption of an entire namespace. Add up every pod's requests and limits. The sum can't exceed these numbers. A request that would push the namespace over its quota is rejected by the API server at admission time, before anything is scheduled.</p>
<p>The <code>services.loadbalancers: "0"</code> line is worth highlighting. It makes it structurally impossible for a preview environment to provision a Google Cloud load balancer, even if someone commits a manifest with <code>type: LoadBalancer</code>. That's a direct financial guardrail: the most expensive mistake this system could make is now unrepresentable.</p>
<p>A <strong>LimitRange</strong> operates on individual containers rather than the namespace total. It sets defaults for containers that don't specify resources, and a hard maximum that no single container may exceed.</p>
<h3 id="heading-why-they-must-be-deployed-together">Why They Must Be Deployed Together</h3>
<p>Once a ResourceQuota that constrains <code>requests.cpu</code> exists in a namespace, <strong>every container in that namespace must declare resource requests</strong>. A pod without them is rejected outright, because Kubernetes can't verify it fits within the quota if it doesn't know what it's asking for.</p>
<p>That means adding a ResourceQuota on its own will break any manifest that omits resource specs, and most manifests found in the wild do omit them.</p>
<p>The LimitRange fixes this by supplying defaults. A container with no resource spec gets the <code>defaultRequest</code> and <code>default</code> values injected automatically at admission time, which satisfies the quota's requirement.</p>
<p>Deploy one without the other, and you get confusing failures. Deploy both and the system is forgiving of incomplete manifests while still being strictly bounded.</p>
<h3 id="heading-sizing-the-numbers">Sizing the Numbers</h3>
<p>The values above suit a small application like ours. To adjust them, work backwards from your node capacity.</p>
<p>Two <code>e2-standard-2</code> nodes give you 4 vCPU and 16GB total, minus roughly 15% that GKE reserves for system components. Call it 3.4 usable vCPU. With <code>requests.cpu: "1"</code> per namespace, three preview environments could theoretically fill the cluster.</p>
<p>Our application requests only 50m CPU, so in practice you'll fit far more than three. But the quota is a ceiling on what a namespace <em>may</em> request, and you should set it well below "one PR can consume everything."</p>
<p>If you expect ten concurrent environments, size the per-namespace quota at roughly a tenth of usable capacity, or add nodes.</p>
<h2 id="heading-step-19-update-the-deploy-workflow">Step 19: Update the Deploy Workflow</h2>
<p>Here's the complete <code>.github/workflows/preview-deploy.yml</code>, with everything added in this section marked <code># NEW</code>. Replace your Step 9 version with this one.</p>
<pre><code class="language-yaml">name: Deploy PR Preview

on:
  pull_request:
    types: [opened, synchronize, reopened]

concurrency:
  group: preview-${{ github.event.pull_request.number }}
  cancel-in-progress: true

permissions:
  contents: read
  id-token: write
  pull-requests: write

env:
  PROJECT_ID: YOUR_PROJECT_ID
  REGION: europe-west2
  CLUSTER_NAME: pr-preview-cluster
  CLUSTER_ZONE: europe-west2-a
  REPOSITORY: pr-preview
  IMAGE_NAME: preview-app
  # NEW: the base domain for all preview environments
  PREVIEW_DOMAIN: preview.yourdomain.com
  # NEW: switch to letsencrypt-prod once the flow is proven
  CLUSTER_ISSUER: letsencrypt-staging

jobs:
  deploy:
    name: Build and deploy preview
    runs-on: ubuntu-latest

    steps:
      - name: Check out code
        uses: actions/checkout@v4

      - name: Set environment variables
        run: |
          PR_NUMBER=${{ github.event.pull_request.number }}
          SHORT_SHA=$(echo "${{ github.event.pull_request.head.sha }}" | cut -c1-7)

          echo "PR_NUMBER=${PR_NUMBER}"    &gt;&gt; $GITHUB_ENV
          echo "SHORT_SHA=${SHORT_SHA}"    &gt;&gt; $GITHUB_ENV
          echo "NAMESPACE=pr-${PR_NUMBER}" &gt;&gt; $GITHUB_ENV
          echo "IMAGE_TAG=pr-${PR_NUMBER}-${SHORT_SHA}" &gt;&gt; $GITHUB_ENV
          echo "IMAGE_BASE=${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}" &gt;&gt; $GITHUB_ENV
          # NEW: the hostname this environment will be reachable at
          echo "PREVIEW_HOST=pr-${PR_NUMBER}.${{ env.PREVIEW_DOMAIN }}" &gt;&gt; $GITHUB_ENV

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}

      - name: Set up gcloud
        uses: google-github-actions/setup-gcloud@v2

      - name: Configure Docker for Artifact Registry
        run: gcloud auth configure-docker ${{ env.REGION }}-docker.pkg.dev --quiet

      - name: Build and push image
        run: |
          docker build \
            --build-arg PR_NUMBER="${PR_NUMBER}" \
            --build-arg GIT_SHA="${SHORT_SHA}" \
            -t "${IMAGE_BASE}:${IMAGE_TAG}" \
            ./app

          docker push "${IMAGE_BASE}:${IMAGE_TAG}"

      - name: Get GKE credentials
        uses: google-github-actions/get-gke-credentials@v2
        with:
          cluster_name: ${{ env.CLUSTER_NAME }}
          location: ${{ env.CLUSTER_ZONE }}

      - name: Create namespace
        run: |
          kubectl create namespace "${NAMESPACE}" \
            --dry-run=client -o yaml | kubectl apply -f -

          # NEW: label it so the reaper knows this namespace is ours
          kubectl label namespace "${NAMESPACE}" \
            app.kubernetes.io/managed-by=pr-preview \
            preview.pr="${PR_NUMBER}" \
            --overwrite

      # NEW: apply the quota and limits before anything deploys
      - name: Apply namespace guardrails
        run: |
          kubectl apply -f k8s/cluster/namespace-guardrails.yaml \
            --namespace="${NAMESPACE}"

      - name: Generate Kustomize overlay
        run: |
          mkdir -p k8s/overlays/"${NAMESPACE}"
          cat &gt; k8s/overlays/"${NAMESPACE}"/kustomization.yaml &lt;&lt;EOF
          apiVersion: kustomize.config.k8s.io/v1beta1
          kind: Kustomization

          namespace: ${NAMESPACE}

          resources:
            - ../../base

          images:
            - name: preview-app
              newName: ${IMAGE_BASE}
              newTag: ${IMAGE_TAG}

          patches:
            - target:
                kind: Ingress
                name: preview-app
              patch: |-
                - op: replace
                  path: /spec/rules/0/host
                  value: ${PREVIEW_HOST}
                - op: replace
                  path: /spec/tls/0/hosts/0
                  value: ${PREVIEW_HOST}
                - op: replace
                  path: /metadata/annotations/cert-manager.io~1cluster-issuer
                  value: ${CLUSTER_ISSUER}
          EOF

          echo "--- Rendered manifest ---"
          kubectl kustomize k8s/overlays/"${NAMESPACE}"

      - name: Deploy
        run: kubectl apply -k k8s/overlays/"${NAMESPACE}"

      - name: Wait for rollout
        run: |
          kubectl rollout status deployment/preview-app \
            --namespace="${NAMESPACE}" \
            --timeout=180s

      # NEW: wait for the TLS certificate to be issued
      - name: Wait for certificate
        continue-on-error: true
        run: |
          echo "Waiting for TLS certificate for ${PREVIEW_HOST}..."
          kubectl wait certificate/preview-app-tls \
            --namespace="${NAMESPACE}" \
            --for=condition=Ready \
            --timeout=180s

      - name: Show deployment details
        run: |
          echo "Preview URL: https://${PREVIEW_HOST}"
          kubectl get pods,svc,ingress,certificate --namespace="${NAMESPACE}"
</code></pre>
<p>There are a few things to understand about the new steps.</p>
<p><strong>The</strong> <code>~1</code> <strong>in the annotation patch path:</strong> this looks like a typo and isn't:</p>
<pre><code class="language-plaintext">path: /metadata/annotations/cert-manager.io~1cluster-issuer
</code></pre>
<p>JSON Pointer paths use <code>/</code> as a separator, so a literal <code>/</code> inside a key has to be escaped. The escape sequence is <code>~1</code>. (And <code>~</code> itself escapes to <code>~0</code>.) Since the annotation key is<code>cert-manager.io/cluster-issuer</code>, the pointer becomes <code>cert-manager.io~1cluster-issuer</code>. Get this wrong, and the patch silently targets a path that doesn't exist.</p>
<p><strong>Namespace labels:</strong> The <code>app.kubernetes.io/managed-by=pr-preview</code> label is what makes the reaper in Step 21 safe. A cleanup job that deletes namespaces matching a name pattern is one bad regex away from deleting something important. A job that only ever touches namespaces carrying a label it applied itself has a much smaller worst case.</p>
<p><strong>Guardrails before deploy:</strong> The ResourceQuota must exist before the application deploys, not after. Applying it afterwards means the first deployment escapes it entirely, and you'd only find out when something exceeded a limit that was never enforced.</p>
<p><code>continue-on-error: true</code> <strong>on the certificate wait:</strong> This is deliberate. Certificate issuance depends on Let's Encrypt, an external service with rate limits and occasional slowness. If issuance is delayed, the environment is still deployed and still works over HTTP.</p>
<p>So failing the entire workflow would be wrong. We note the problem and continue. The PR comment in the next step reflects whether the certificate is actually ready.</p>
<h2 id="heading-step-20-comment-the-url-on-the-pull-request">Step 20: Comment the URL on the Pull Request</h2>
<p>The environment is live. Now it's time to tell people about it.</p>
<p>Add this step to the end of the deploy job:</p>
<pre><code class="language-yaml">      - name: Comment preview URL on PR
        uses: actions/github-script@v7
        with:
          script: |
            const host = process.env.PREVIEW_HOST;
            const sha = process.env.SHORT_SHA;
            const ns = process.env.NAMESPACE;

            // A hidden marker so we can find our own comment later
            const marker = '&lt;!-- pr-preview-environment --&gt;';

            const body = [
              marker,
              '### Preview environment is ready',
              '',
              `**https://${host}**`,
              '',
              `Commit \`${sha}\` · Namespace \`${ns}\``,
              '',
              '&lt;sub&gt;This environment updates on every push and is deleted when the PR closes.&lt;/sub&gt;',
            ].join('\n');

            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });

            const existing = comments.find(c =&gt; c.body.includes(marker));

            if (existing) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: existing.id,
                body,
              });
            } else {
              await github.rest.issues.createComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                issue_number: context.issue.number,
                body,
              });
            }
</code></pre>
<p>So why this approach?</p>
<p><code>actions/github-script</code> is maintained by GitHub itself and gives you an authenticated Octokit client with no setup. There are third-party actions that post PR comments in fewer lines, but every third-party action in a workflow with write permissions is a supply chain dependency.</p>
<p>Given the effort we spent in Step 5 removing a long-lived credential, adding an unaudited action that can write to your repository would be an odd trade.</p>
<p><strong>The hidden marker comment</strong> is the mechanism that makes this a <em>sticky</em> comment. <code>&lt;!-- pr-preview-environment --&gt;</code> renders as nothing in GitHub's markdown, but it's present in the comment body. On each run, we list the PR's comments, look for one containing the marker, and update it if found.</p>
<p>Without this, a PR with fifteen commits gets fifteen near-identical bot comments, and the thread becomes unreadable. Reviewers start ignoring bot comments entirely, which defeats the purpose. One comment that stays current is the difference between a useful tool and noise.</p>
<p><code>process.env.PREVIEW_HOST</code>: values written to <code>$GITHUB_ENV</code> in earlier steps are available as environment variables in the script. This is cleaner than interpolating <code>${{ }}</code> expressions directly into JavaScript, which can break in surprising ways if a value contains quotes or backticks.</p>
<p><code>context.issue.number</code> works on <code>pull_request</code> events because GitHub treats pull requests as a specialised kind of issue. It's why we use the <code>issues</code> API to comment on a PR.</p>
<p>The step needs <code>pull-requests: write</code>, which the workflow already declares.</p>
<h2 id="heading-step-21-build-the-reaper">Step 21: Build the Reaper</h2>
<p>Here's the uncomfortable truth about the teardown workflow from Part 1: it will eventually fail to run.</p>
<p>The failure modes are mundane. Someone closes a PR while GitHub Actions is having an incident. A workflow file gets edited on a branch in a way that breaks the teardown job. Someone deletes a branch without closing its PR. A repository is archived with PRs still open. GitHub's event delivery hiccups.</p>
<p>None of these are common. All of them leave a namespace running indefinitely, consuming cluster capacity and (if it happens enough) real money. Cleanup that works 95% of the time is still a leak.</p>
<p>The fix is a <strong>reaper</strong>: a scheduled job that periodically compares what's running against what should be running, and deletes the difference.</p>
<p>Create <code>.github/workflows/preview-reaper.yml</code>:</p>
<pre><code class="language-yaml">name: Reap Orphaned Preview Environments

on:
  schedule:
    # 03:00 UTC daily
    - cron: '0 3 * * *'
  # Allow manual runs from the Actions tab
  workflow_dispatch:

permissions:
  contents: read
  id-token: write
  pull-requests: read

env:
  PROJECT_ID: YOUR_PROJECT_ID
  CLUSTER_NAME: pr-preview-cluster
  CLUSTER_ZONE: europe-west2-a
  # Delete environments older than this even if the PR is still open
  MAX_AGE_DAYS: 7

jobs:
  reap:
    name: Delete orphaned namespaces
    runs-on: ubuntu-latest

    steps:
      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
          service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }}

      - name: Get GKE credentials
        uses: google-github-actions/get-gke-credentials@v2
        with:
          cluster_name: ${{ env.CLUSTER_NAME }}
          location: ${{ env.CLUSTER_ZONE }}

      - name: Reap
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          set -euo pipefail

          echo "Fetching open pull requests..."
          OPEN_PRS=$(gh pr list \
            --repo "${{ github.repository }}" \
            --state open \
            --limit 500 \
            --json number \
            --jq '.[].number')

          echo "Open PRs: ${OPEN_PRS:-none}"
          echo ""

          # Only consider namespaces we created ourselves.
          NAMESPACES=$(kubectl get namespaces \
            -l app.kubernetes.io/managed-by=pr-preview \
            -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.metadata.labels.preview\.pr}{"\t"}{.metadata.creationTimestamp}{"\n"}{end}')

          if [ -z "${NAMESPACES}" ]; then
            echo "No preview namespaces found. Nothing to do."
            exit 0
          fi

          NOW=$(date -u +%s)
          MAX_AGE_SECONDS=$(( MAX_AGE_DAYS * 86400 ))
          DELETED=0

          while IFS=$'\t' read -r NS PR_NUM CREATED; do
            [ -z "${NS}" ] &amp;&amp; continue

            CREATED_EPOCH=$(date -u -d "${CREATED}" +%s)
            AGE=$(( NOW - CREATED_EPOCH ))
            AGE_DAYS=$(( AGE / 86400 ))

            REASON=""

            # Rule 1: the PR is no longer open
            if ! echo "${OPEN_PRS}" | grep -qx "${PR_NUM}"; then
              REASON="PR #${PR_NUM} is not open"
            # Rule 2: the environment has outlived MAX_AGE_DAYS
            elif [ "${AGE}" -gt "${MAX_AGE_SECONDS}" ]; then
              REASON="environment is ${AGE_DAYS} days old (max ${MAX_AGE_DAYS})"
            fi

            if [ -n "${REASON}" ]; then
              echo "DELETING ${NS} — ${REASON}"
              kubectl delete namespace "${NS}" \
                --ignore-not-found=true \
                --wait=false
              DELETED=$(( DELETED + 1 ))
            else
              echo "KEEPING  ${NS} — PR #${PR_NUM} open, ${AGE_DAYS}d old"
            fi
          done &lt;&lt;&lt; "${NAMESPACES}"

          echo ""
          echo "Reaped ${DELETED} namespace(s)."
</code></pre>
<h3 id="heading-where-does-githubtoken-come-from">Where does <code>GITHUB_TOKEN</code> come from?</h3>
<p>Unlike <code>WIF_PROVIDER</code> and <code>WIF_SERVICE_ACCOUNT</code>, which you created in Step 6, <code>GITHUB_TOKEN</code> <strong>isn't something you set up</strong>. You won't find it on the secrets page, and you shouldn't add it there.</p>
<p>GitHub mints a fresh installation token at the start of every workflow run, exposes it as <code>secrets.GITHUB_TOKEN</code>, and revokes it the moment the job finishes. It's scoped to the repository running the workflow and nothing else.</p>
<h3 id="heading-understanding-the-reaper">Understanding the Reaper</h3>
<p>The reaper runs from GitHub Actions, not inside the cluster. A CronJob in Kubernetes would need a GitHub token stored as a cluster Secret to query open PRs. That's a long-lived credential sitting in the cluster, which is exactly what we avoided in Step 5.</p>
<p>A scheduled workflow already has <code>GITHUB_TOKEN</code> (scoped, automatic, short-lived) and already authenticates to GCP through Workload Identity Federation. No new secrets.</p>
<p>The label selector is the safety mechanism. The query is <code>-l app.kubernetes.io/managed-by=pr-preview</code>, not a name pattern like <code>pr-*</code>. This is a deliberate choice: a name-pattern match would happily delete a production namespace someone named <code>pr-routing-service</code>. The reaper can only ever see namespaces that the deploy workflow explicitly labelled, which means the set of things it could possibly delete is the set of things it created.</p>
<p>Be careful when you adapt this. Any cleanup automation should operate on a positive allowlist it controls, never a pattern that could match something it didn't create.</p>
<p>We also have two deletion rules, for two different problems. Rule 1 catches the actual orphan. A namespace whose PR is closed but whose teardown never ran. Rule 2 catches the stale environment: a PR that's been open for three weeks, hasn't been touched in two, and is quietly holding cluster capacity. Seven days is a reasonable default. If someone needs their environment back, they just push a commit, and it redeploys in a couple of minutes.</p>
<p><code>set -euo pipefail</code>: <code>-e</code> exits on any command failure, <code>-u</code> errors on undefined variables, and <code>-o pipefail</code> makes a pipeline fail if <em>any</em> stage fails rather than only the last. In a script whose job is deleting things, silent failure is the worst outcome. If something goes wrong, stop immediately.</p>
<p><code>grep -qx</code>: the <code>-x</code> flag requires the whole line to match. Without it, PR <code>4</code> would match against open PR <code>42</code> and the reaper would spare an orphaned namespace forever. It's a small flag, but a real bug.</p>
<p><code>workflow_dispatch</code> lets you run the reaper manually from the Actions tab. Do that the first few times and read the output before trusting the schedule. The log prints <code>KEEPING</code> or <code>DELETING</code> with a reason for every namespace, so you can verify the logic matches your expectations before it runs unattended.</p>
<h2 id="heading-step-22-test-the-whole-thing">Step 22: Test the Whole Thing</h2>
<p>Commit everything and push:</p>
<pre><code class="language-bash">git add .
git commit -m "Add Ingress, TLS, PR comments, guardrails, and reaper"
git push
</code></pre>
<p>Create a branch, make a visible change, and open a pull request:</p>
<pre><code class="language-bash">git checkout -b test-preview-urls
# edit app/main.go — change the greeting text
git add .
git commit -m "Test preview URLs"
git push -u origin test-preview-urls
</code></pre>
<p>Open the PR and watch the Actions tab.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/5c13299f-c72c-4073-8ab0-2f552749cee5.png" alt="The deploy workflow running with the new certificate and comment step" style="display: block;" width="2366" height="1434" loading="lazy">

<p>When it finishes, the bot comment should appear with your URL. Open it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/652f2033-7672-4f32-9a53-e2837c97bab2.png" alt="The bot comment showing the new URL" style="display: block;" width="2366" height="1434" loading="lazy">

<p>Because you're still on the staging issuer, your browser will show a certificate warning. Something like "Your connection is not private," with an issuer of "Let's Encrypt Staging."</p>
<p><strong>This is the success condition.</strong> It means DNS resolved, the load balancer routed, the Ingress matched the hostname, the HTTP-01 challenge completed, and a certificate was issued. Everything worked. The certificate simply comes from an untrusted test authority.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/4e0e252f-66e4-47fa-a0cf-31a0c491a73a.png" alt="Browser security warning for the preview environment URL, showing the certificate was issued by Let's Encrypt Staging rather than a trusted authority" style="display: block;" width="2366" height="1434" loading="lazy">

<p>Click Advanced, pass through the warning, and confirm the application responds with the right PR number and namespace.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/094f0273-1a55-4d84-ac42-b3dca2b8ce58.png" alt="The preview environment loading in the browser, showing the application's output with the correct PR number, commit SHA, and namespace" style="display: block;" width="2366" height="954" loading="lazy">

<p>Check the certificate from the command line:</p>
<pre><code class="language-bash">kubectl get certificate --namespace=pr-2 
#Ensure you use your own pr-number
</code></pre>
<p>You want <code>READY: True</code>. If it's <code>False</code>, inspect what's happening:</p>
<pre><code class="language-bash">kubectl describe certificate preview-app-tls --namespace=pr-2
kubectl get challenges --all-namespaces
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/d27a623d-f1fc-49bd-a1eb-69d277a17631.png" alt="Checking the certificate from the command line" style="display: block;" width="2366" height="1208" loading="lazy">

<p>A pending challenge that never resolves almost always means DNS or port 80 reachability. Confirm with:</p>
<pre><code class="language-bash">curl -I http://pr-2.preview.yourdomain.com/.well-known/acme-challenge/test
</code></pre>
<p>You want any HTTP response from NGINX. A 404 is fine, as it proves that the routing works. A connection timeout means the request isn't reaching your cluster.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/5b539f11-bc2b-46e8-853d-7f8b9d783b8e.png" alt="Confirming DNS reachability" style="display: block;" width="2366" height="358" loading="lazy">

<h3 id="heading-switch-to-production-certificates">Switch to Production Certificates</h3>
<p>Once the staging flow works end to end, change one line in the workflow's <code>env:</code> block:</p>
<pre><code class="language-yaml">  CLUSTER_ISSUER: letsencrypt-prod
</code></pre>
<p>Make this change <strong>on your existing PR branch</strong> and push it. You don't need to close the pull request or merge to <code>main</code> first. You can also change the message in your main.go file to confirm everything works.</p>
<p>Push a new commit to the PR. The environment redeploys, cert-manager requests a real certificate, and this time the browser shows a padlock.</p>
<p>Open the URL in a <strong>private browsing window</strong> to check. Your browser cached an exception for the staging certificate against that hostname, and a normal tab may show a trusted-looking page that's really just a remembered override and still shows <code>not secure</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/54b5979c-f3d9-4dca-a63d-970e4e66e219.png" alt="The preview environment loading over HTTPS with a valid certificate" style="display: block;" width="1046" height="758" loading="lazy">

<h3 id="heading-verify-the-guardrails">Verify the Guardrails</h3>
<p>Confirm the quota is active:</p>
<pre><code class="language-bash">kubectl describe resourcequota preview-quota --namespace=pr-2
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/9d50f2e6-9802-424e-8be7-7955ba8dcd92.png" alt="Output of kubectl describe resourcequota showing the preview-quota in namespace pr-1 with used and hard limits listed for CPU, memory, pods, services, and load balancers" style="display: block;" width="1820" height="532" loading="lazy">

<p>You'll see used versus hard limits for each resource. Then try to break it:</p>
<pre><code class="language-bash">kubectl scale deployment/preview-app --replicas=50 --namespace=pr-2
kubectl get events --namespace=pr-2 | grep -i quota
</code></pre>
<p>The deployment will scale partway and then stop, with events explaining that it exceeded the quota. That's the guardrail doing its job: the namespace is bounded, and the rest of the cluster is unaffected.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/13872121-d7a9-49e5-b85f-34a629afc77a.png" alt="Terminal output showing the deployment scaled only partway to 50 replicas, with Kubernetes events reporting that the request exceeded the namespace quota" style="display: block;" width="2728" height="854" loading="lazy">

<h3 id="heading-verify-teardown-and-the-reaper">Verify Teardown and the Reaper</h3>
<p>Close the pull request. The teardown workflow fires and the namespace goes to <code>Terminating</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/336470b2-df0d-4f14-bab3-6f17f38ad4f3.png" alt="Github Actions teardown workflow job completed" style="display: block;" width="2342" height="1434" loading="lazy">

<p>Then test the reaper on a namespace it <em>shouldn't</em> touch. Create one manually without the label:</p>
<pre><code class="language-bash">kubectl create namespace pr-9999
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/ac2c98c2-f8fc-4cbb-b5e9-8cae679fd4cf.png" alt="Terminal showing kubectl create namespace pr-9999, creating a namespace without the pr-preview management label" style="display: block;" width="2342" height="140" loading="lazy">

<p>Run the reaper manually from the Actions tab.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/4941adde-9be6-4870-8742-47920ffa8f97.png" alt="GitHub Actions interface showing the Reap Orphaned Preview Environments workflow with the Run workflow button available through workflow_dispatch" style="display: block;" width="2402" height="1196" loading="lazy">

<p>It should ignore <code>pr-9999</code> entirely, because that namespace carries no <code>app.kubernetes.io/managed-by=pr-preview</code> label. The log won't even mention it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5f97fb446ea7602886a16070/a16298d1-c63a-45f3-b117-a35ba79fc9e9.png" alt="GitHub Actions log for the reaper showing it found no preview namespaces to act on, having ignored the unlabelled pr-9999 namespace entirely" style="display: block;" width="2402" height="1196" loading="lazy">

<p>Clean it up yourself:</p>
<pre><code class="language-bash">kubectl delete namespace pr-9999
</code></pre>
<p>That test matters more than it looks. You've just verified that your automated deletion job can't touch things it didn't create.</p>
<h2 id="heading-what-this-costs">What This Costs</h2>
<p>We should be concrete about this, since cost control is half the argument for this pattern.</p>
<p>Running continuously in <code>europe-west2</code>, here are the approximate monthly figures:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Approximate cost</th>
</tr>
</thead>
<tbody><tr>
<td>GKE control plane</td>
<td>~$74/month (one zonal cluster, free tier covers one cluster on some accounts)</td>
</tr>
<tr>
<td>2 × <code>e2-standard-2</code> nodes</td>
<td>~$97/month</td>
</tr>
<tr>
<td>Regional load balancer</td>
<td>~$18/month + data processing</td>
</tr>
<tr>
<td>Static IP (in use)</td>
<td>Free while attached</td>
</tr>
<tr>
<td>Artifact Registry storage</td>
<td>~$0.10/GB/month</td>
</tr>
<tr>
<td>Let's Encrypt certificates</td>
<td>Free</td>
</tr>
<tr>
<td>GitHub Actions (public repo)</td>
<td>Free</td>
</tr>
</tbody></table>
<p>Call it roughly $190/month for the always-on infrastructure, regardless of how many preview environments you run.</p>
<p>The number that matters is what <em>doesn't</em> scale with PR count. One load balancer serves every environment. One DNS record covers every hostname. Certificates are free. The marginal cost of the eleventh preview environment is only the node capacity its pods consume, a few pence a day for an application this size.</p>
<p>Compare that with the naïve approach of a <code>LoadBalancer</code> Service per environment: ten open PRs would add roughly $180/month in load balancers alone, and that's before anyone forgets to clean one up. The <code>services.loadbalancers: "0"</code> line in the ResourceQuota exists specifically to make that mistake impossible.</p>
<p>There are two ways to cut the standing cost for a personal project. Use <strong>spot nodes</strong>, add <code>spot = true</code> to the <code>node_config</code> block in Terraform for roughly a 60–70% discount on node hours. Preview environments are the ideal spot workload: if a node is reclaimed, pods reschedule, and the worst case is a preview environment briefly restarting. Or scale the node pool to zero overnight with a scheduled workflow, since nobody reviews PRs at 3 am.</p>
<h2 id="heading-cleanup">Cleanup</h2>
<p>When you're finished:</p>
<pre><code class="language-bash"># Delete every preview namespace
kubectl delete namespaces -l app.kubernetes.io/managed-by=pr-preview

# Remove the shared infrastructure
helm uninstall cert-manager --namespace cert-manager
helm uninstall ingress-nginx --namespace ingress-nginx
kubectl delete namespace cert-manager ingress-nginx
</code></pre>
<p>Uninstall the Helm releases <em>before</em> running <code>terraform destroy</code>. The Ingress controller's Service owns a Google Cloud load balancer that Terraform doesn't know about, because Kubernetes created it rather than Terraform. Destroying the cluster first can orphan that load balancer, leaving it billing quietly in a project with nothing else in it.</p>
<h3 id="heading-if-you-plan-to-rebuild-this-later">If You Plan to Rebuild This Later</h3>
<p><strong>Don't run a full</strong> <code>terraform destroy</code><strong>.</strong> Tear down only the expensive parts:</p>
<pre><code class="language-bash">cd terraform 
terraform destroy 
-var="project_id=YOUR_PROJECT_ID" 
-var="github_repo=YOUR_USERNAME/pr-preview-envs"
</code></pre>
<p>Here's why. When you delete a Workload Identity Pool, Google doesn't remove it immediately. It marks it <code>DELETED</code> and reserves the ID for 30 days. Terraform forgets about it, but Google hasn't. Recreating the stack within that window fails with:</p>
<pre><code class="language-plaintext">Error: Error creating WorkloadIdentityPool: googleapi: Error 409: Requested entity already exists
</code></pre>
<p>Almost all of your cost is the cluster and its nodes. The identity pool, provider, service account, IAM bindings, static IP, and Artifact Registry repository cost nothing or pennies to leave in place. Rebuilding later is then just <code>terraform apply</code>, and your <code>WIF_PROVIDER</code> and <code>WIF_SERVICE_ACCOUNT</code> secrets in GitHub stay valid. No need to update them.</p>
<p>If you want to keep the cluster but stop paying for idle nodes, scale the node pool to zero instead:</p>
<pre><code class="language-bash">gcloud container clusters resize pr-preview-cluster
--node-pool pr-preview-cluster-pool
--num-nodes 0
--zone europe-west2-a
</code></pre>
<p>You still pay for the GKE control plane, so this reduces cost rather than eliminating it.</p>
<p>If you're done for good:</p>
<pre><code class="language-bash">cd terraform
terraform destroy
-var="project_id=YOUR_PROJECT_ID"
-var="github_repo=YOUR_USERNAME/pr-preview-envs" 
</code></pre>
<p>Then confirm nothing was left behind:</p>
<pre><code class="language-bash">gcloud compute forwarding-rules list
gcloud compute addresses list
</code></pre>
<p>Both should be empty, or contain only resources belonging to other projects of yours. Don't forget to remove the wildcard DNS record too.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've built something that most engineering teams either don't have or pay a vendor for.</p>
<p>You wrote a Go application that reports its own build metadata, injected at compile time through linker flags, so a running environment can always tell you exactly which commit it came from. You wrapped it in a multi-stage Dockerfile that produces a small, statically linked, non-root image</p>
<p>You provisioned a GKE cluster and an Artifact Registry repository with Terraform, which means the whole environment is reproducible and destroyable with a single command. You set up Workload Identity Federation so GitHub Actions authenticates to Google Cloud with short-lived OIDC tokens instead of a long-lived service account key, scoped to exactly one repository by an attribute condition.</p>
<p>You wrote base Kubernetes manifests once and used Kustomize overlays, generated at runtime, to deploy the same application into a different namespace with a different image tag and a different hostname for every pull request. You put a single shared Ingress controller in front of every environment and gave it a stable static IP, so one load balancer serves any number of previews. You pointed one wildcard DNS record at it, which means a new environment needs no DNS work at all: routing happens on the <code>Host</code> header inside the cluster. You added cert-manager so every environment gets a valid, auto-renewing TLS certificate, and made the pipeline announce itself with a sticky PR comment that updates in place.</p>
<p>You bounded each namespace with a ResourceQuota and LimitRange, including one line that makes the most expensive possible mistake structurally impossible. And you built a reaper that catches what the happy path misses, operating only on namespaces it can prove it created.</p>
<p>The result is that an isolated, production-shaped, publicly reachable environment is the <strong>default</strong> for every change, not something a developer has to request, claim in a Slack channel, or remember to clean up.</p>
<h3 id="heading-where-to-go-from-here">Where to Go From Here</h3>
<p>There are a few directions you can explore. Here they are, roughly in order of how much they'd improve a real team's experience:</p>
<ol>
<li><p><strong>Seed data for each environment.</strong> The biggest limitation of what we built is that the application is stateless. Real applications need a database. The common patterns are a per-namespace ephemeral database seeded from a sanitised snapshot, or a shared database with per-environment schema isolation. This is the single hardest problem in preview environments and where most of the remaining engineering effort goes.</p>
</li>
<li><p><strong>Run your test suite against the preview.</strong> Once the environment has a real URL, end-to-end tests can run against it in CI, a far stronger signal than tests against mocks.</p>
</li>
<li><p><strong>Wildcard certificates via DNS-01.</strong> As covered in Step 15, this removes the Let's Encrypt rate limit ceiling once your PR volume approaches it.</p>
</li>
<li><p><strong>Move to GitOps.</strong> Rather than the workflow running <code>kubectl apply</code> directly, have it commit the rendered manifests to a repository that Argo CD or Flux reconciles. The cluster's state then has a git history, and your CI pipeline no longer needs cluster write access at all.</p>
</li>
<li><p><strong>Preview environment metrics.</strong> Hook the namespaces into the cost visibility tooling you'd use elsewhere, and you can answer "what do preview environments actually cost us per month" with a number rather than a shrug.</p>
</li>
</ol>
<h2 id="heading-project-file-structure">Project File Structure</h2>
<pre><code class="language-plaintext">pr-preview-envs/
├── app/
│   ├── main.go                      — Go HTTP server reporting build metadata
│   ├── go.mod                       — Go module definition
│   └── Dockerfile                   — Multi-stage build with ldflags injection
├── terraform/
│   ├── main.tf                      — GKE, Artifact Registry, WIF, static IP
│   ├── variables.tf                 — Input variables
│   └── outputs.tf                   — Cluster, WIF, and ingress IP values
├── k8s/
│   ├── base/
│   │   ├── deployment.yaml          — Deployment with probes and Downward API
│   │   ├── service.yaml             — ClusterIP Service
│   │   ├── ingress.yaml             — Ingress with cert-manager annotation
│   │   └── kustomization.yaml       — Base Kustomize config
│   └── cluster/
│       ├── letsencrypt-staging.yaml — Staging ClusterIssuer
│       ├── letsencrypt-prod.yaml    — Production ClusterIssuer
│       └── namespace-guardrails.yaml — ResourceQuota and LimitRange
└── .github/
    └── workflows/
        ├── preview-deploy.yml       — Build, deploy, certificate, PR comment
        ├── preview-teardown.yml     — Delete namespace on PR close
        └── preview-reaper.yml       — Scheduled orphan cleanup
</code></pre>
<p>All source files for this tutorial are available in the companion GitHub repository: <a href="https://github.com/Amiynarh/pr-preview-envs"><code>https://github.com/Amiynarh/pr-preview-envs</code></a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Debug a Stuck Kubernetes Rollout with a Hands-On Lab ]]>
                </title>
                <description>
                    <![CDATA[ You update a Deployment, run kubectl rollout status, and wait. The command times out. But when you send a request to the Service, it still answers. So did the update finish? And if it didn't, which Po ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-debug-a-stuck-kubernetes-rollout-with-a-hands-on-lab/</link>
                <guid isPermaLink="false">6abe3dc913d662f7af15bf03</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ debugging ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Praneeth Kodumagulla ]]>
                </dc:creator>
                <pubDate>Thu, 01 Oct 2026 11:02:33 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/3864c1c8-994c-4f98-9833-47a9bb34e15d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You update a Deployment, run <code>kubectl rollout status</code>, and wait. The command times out. But when you send a request to the Service, it still answers. So did the update finish? And if it didn't, which Pods are serving your traffic?</p>
<p>You'll investigate that situation in a disposable Kubernetes lab. Starting from a healthy application, you'll introduce three failures: an image that can't be pulled, a readiness probe that never passes, and a Pod that can't be scheduled. You'll identify the blocked revision, inspect its evidence, and verify recovery before moving on.</p>
<p>In the recorded image failure, the deployment reported both of these conditions. This table is derived from its JSON snapshot:</p>
<table>
<thead>
<tr>
<th>Condition</th>
<th>Status</th>
<th>Reason</th>
</tr>
</thead>
<tbody><tr>
<td>Available</td>
<td>True</td>
<td>MinimumReplicasAvailable</td>
</tr>
<tr>
<td>Progressing</td>
<td>False</td>
<td>ProgressDeadlineExceeded</td>
</tr>
</tbody></table>
<p>Those conditions answer different questions. The following experiments show why an application can still answer requests while its update is stuck.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-how-to-set-up-the-lab">How to Set Up the Lab</a></p>
</li>
<li><p><a href="#heading-how-to-establish-a-healthy-rollout">How to Establish a Healthy Rollout</a></p>
</li>
<li><p><a href="#heading-how-to-diagnose-an-image-pull-failure">How to Diagnose an Image-Pull Failure</a></p>
</li>
<li><p><a href="#heading-how-to-distinguish-two-timeouts">How to Distinguish Two Timeouts</a></p>
</li>
<li><p><a href="#heading-how-to-restore-the-healthy-configuration">How to Restore the Healthy Configuration</a></p>
</li>
<li><p><a href="#heading-how-to-diagnose-failed-readiness">How to Diagnose Failed Readiness</a></p>
</li>
<li><p><a href="#heading-how-to-diagnose-a-placement-constraint">How to Diagnose a Placement Constraint</a></p>
</li>
<li><p><a href="#heading-how-to-clean-up">How to Clean Up</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-how-to-set-up-the-lab">How to Set Up the Lab</h2>
<p>You should be familiar with containers, Deployments, ReplicaSets, Pods, Services, and basic <code>kubectl</code> commands. The lab uses kind to run a single Kubernetes node inside a Docker container.</p>
<p>The recorded environment was:</p>
<table>
<thead>
<tr>
<th>Component</th>
<th>Version or configuration</th>
</tr>
</thead>
<tbody><tr>
<td>Host</td>
<td>macOS 15.7.4, Apple silicon, 16 GiB RAM</td>
</tr>
<tr>
<td>Colima</td>
<td>0.10.3, Docker runtime, 4 CPUs, about 5.77 GiB visible to Docker</td>
</tr>
<tr>
<td>Docker</td>
<td>Client 29.4.3, server 29.2.1</td>
</tr>
<tr>
<td>kind</td>
<td>0.33.0</td>
</tr>
<tr>
<td>kubectl</td>
<td>1.36.0</td>
</tr>
<tr>
<td>Kubernetes server</td>
<td>1.37.0</td>
</tr>
</tbody></table>
<p>The companion scripts require macOS ARM64 with Colima and kind 0.33.0. I didn't test Linux, Windows, and Intel machines. These memory figures describe the test machine, they're not minimum requirements.</p>
<p>You should already have Docker CLI, Colima, kind, and kubectl installed, with Colima running and internet access to Docker Hub.</p>
<p>Download the <a href="https://github.com/praneethhere/fcc-rollout-lab/">companion code here</a>. Extract it into a fresh directory and open that directory in Terminal. Start Bash, which is the shell I'm using, then run setup:</p>
<pre><code class="language-bash">bash
bash setup.sh
</code></pre>
<p>Setup creates the <code>fcc-rollout-lab</code> cluster and <code>rollout-lab</code> namespace. It writes credentials to <code>./kubeconfig</code>, mounts the application source through an immutable ConfigMap, and creates the Deployment, Service, and an <code>http-client</code> Pod. No custom image build is needed.</p>
<p>It then verifies v1 and v2, saving snapshots and HTTP responses in an evidence archive. Setup refuses to overwrite an existing lab cluster or local kubeconfig. The node image and Python image are pinned by digest in the source.</p>
<p>After setup succeeds, define this function from the companion directory:</p>
<pre><code class="language-bash">LAB_ROOT="$PWD"
k() {
  kubectl --kubeconfig "$LAB_ROOT/kubeconfig" \
    --context kind-fcc-rollout-lab \
    --namespace rollout-lab "$@"
}
</code></pre>
<p>The companion also includes <code>failures.sh</code>, an automated reproduction of the cases and checks. Follow the commands below for the manual walkthrough. The automated runner is an alternative way to reproduce the sequence.</p>
<p>Here, <code>k</code> is a Bash function that runs <code>kubectl</code> with this lab’s kubeconfig, context, and namespace already selected. For example, <code>k get pods</code> lists Pods in the lab. Run the definition above once in this Bash terminal, and keep using the same terminal and companion directory throughout the walkthrough.</p>
<h2 id="heading-how-to-establish-a-healthy-rollout">How to Establish a Healthy Rollout</h2>
<p>Before you break anything, establish that the rollout path and Service work. Setup performs a v1-to-v2 update by changing only the <code>APP_VERSION</code> environment variable in the Pod template.</p>
<p>The application is a small Python HTTP server. <code>GET /</code> returns its configured version and Pod name, and <code>/ready</code> returns HTTP 200. Every other path returns HTTP 404. The Pod name comes from <code>metadata.name</code> through the downward API.</p>
<p>Here's the routing logic from <code>app/server.py</code>:</p>
<pre><code class="language-python">if self.path == "/":
    status = 200
    payload = {
        "version": os.environ["APP_VERSION"],
        "pod": os.environ.get("POD_NAME", "local-test"),
    }
elif self.path == "/ready":
    status = 200
    payload = {"ready": True}
else:
    status = 404
    payload = {"error": "unknown path"}
</code></pre>
<p>The readiness probe checks <code>/ready</code> on port 8080. These settings come from the Deployment's <code>spec</code> in <code>manifests/deployment.yaml</code>. This is an excerpt, not a standalone manifest:</p>
<pre><code class="language-yaml">replicas: 2
minReadySeconds: 5
progressDeadlineSeconds: 180
strategy:
  type: RollingUpdate
  rollingUpdate:
    maxUnavailable: 0
    maxSurge: 1
</code></pre>
<p>With these <a href="https://kubernetes.io/docs/concepts/workloads/controllers/deployment/">rollout settings</a>, the controller can add one replacement Pod. It waits for replacement availability before reducing the two healthy old replicas. A new Pod must remain Ready for five seconds to count as available. The progress deadline is 180 seconds.</p>
<p><code>maxUnavailable: 0</code> constrains this rollout. It doesn't protect the old Pods from unrelated failures. Terminating Pods can also remain visible beyond the active replica counts. These settings are teaching choices, not a production availability guarantee.</p>
<p>The recorded controls each completed successfully and returned ten correct Service responses. This table is derived from the two Deployment snapshots:</p>
<table>
<thead>
<tr>
<th>Control</th>
<th>Revision</th>
<th>Updated</th>
<th>Ready</th>
<th>Available</th>
<th>Correct HTTP responses</th>
</tr>
</thead>
<tbody><tr>
<td>v1</td>
<td>1</td>
<td>2</td>
<td>2</td>
<td>2</td>
<td>10/10</td>
</tr>
<tr>
<td>v2</td>
<td>2</td>
<td>2</td>
<td>2</td>
<td>2</td>
<td>10/10</td>
</tr>
</tbody></table>
<p>For example, the decoded response body of one recorded v2 request was:</p>
<pre><code class="language-json">{"version": "v2", "pod": "rollout-demo-5b4b45f68f-qrntz"}
</code></pre>
<p>Your Pod names will differ. Check your current state:</p>
<pre><code class="language-bash">k get deployment rollout-demo -o json
k get pods -l app=rollout-demo -o wide
</code></pre>
<p>Before continuing, wait until exactly two application Pods remain, both Ready and neither terminating. The Deployment should have two updated, ready, and available replicas, with <code>status.observedGeneration</code> matching <code>metadata.generation</code>.</p>
<p>All subsequent failures leave <code>APP_VERSION=v2</code>. That makes the response's Pod name essential: the version alone can't distinguish the previous revision from the new one.</p>
<h2 id="heading-how-to-diagnose-an-image-pull-failure">How to Diagnose an Image-Pull Failure</h2>
<p>The first failure changes only the image reference to a deliberately missing tag in the public Python repository:</p>
<pre><code class="language-bash">k apply -f failure-lab/manifests/image.json
k rollout status deployment/rollout-demo --timeout=10s
</code></pre>
<p>The short watch in the recorded run exited with code 1 and this error:</p>
<pre><code class="language-text">error: timed out waiting for the condition
</code></pre>
<p>This is expected for the exercise. Authentication, connection, or other errors require a different diagnosis. Don't treat every nonzero exit as the intended result.</p>
<h3 id="heading-find-the-blocked-revision">Find the Blocked Revision</h3>
<p>Inspect the Deployment and its related objects:</p>
<pre><code class="language-bash">k get deployment rollout-demo
k get deployment rollout-demo -o json
k describe deployment rollout-demo
k get replicasets -l app=rollout-demo -o wide
k get pods -l app=rollout-demo -o wide
</code></pre>
<p>Compare <code>metadata.generation</code> with <code>status.observedGeneration</code>. If the latter is lower, the controller hasn't yet observed the current desired state. Recheck before interpreting its rollout status.</p>
<p>The recorded fault snapshot contained three replicas: one updated, two ready, and two available. A healthy-looking ready count can therefore come entirely from the previous revision.</p>
<p><code>kubectl describe deployment</code> identifies the <code>NewReplicaSet</code>. Inspect that ReplicaSet and the unready Pod it owns. Replace both values below with names from your output:</p>
<pre><code class="language-bash">NEW_RS='replace-with-the-new-replicaset-name'
BAD_POD='replace-with-its-unready-pod-name'
k get replicaset "$NEW_RS" -o json
k get pod "$BAD_POD" -o json
k describe pod "$BAD_POD"
</code></pre>
<p>In the ReplicaSet's <code>metadata.ownerReferences</code>, the entry with <code>controller: true</code> must name the Deployment's UID. In the Pod's owner references, the controller UID must match that ReplicaSet's UID. The new ReplicaSet's template matches the Deployment's current template apart from the generated template-hash label. Its revision annotation matches the Deployment's revision.</p>
<p>Labels narrow your search. The UID ownership chain establishes which objects belong together. See the <a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/owners-dependents/">owner-reference documentation</a> for the underlying relationship.</p>
<h3 id="heading-read-the-failure-message">Read the Failure Message</h3>
<p>Get Events for this exact Pod instance:</p>
<pre><code class="language-bash">POD_UID="$(
  k get pod "$BAD_POD" -o jsonpath='{.metadata.uid}'
)"
k get events --field-selector "involvedObject.uid=$POD_UID" \
  --sort-by=.metadata.creationTimestamp -o yaml
</code></pre>
<p>The image Pod was scheduled: <code>PodScheduled=True</code>, with <code>nodeName</code> set. Its container was waiting with <code>ErrImagePull</code>, followed by <code>ImagePullBackOff</code> between retries. The Pod phase remained <code>Pending</code>. The registry error ended with this exact text:</p>
<pre><code class="language-text">docker.io/library/python:fcc-rollout-missing-20260928: not found
</code></pre>
<p>That message identifies the missing tag. Image-pull errors can also result from registry credentials, throttling, DNS, or network problems. Diagnose the Event message rather than assuming every <code>ImagePullBackOff</code> has the same cause.</p>
<p>Also distinguish the kubectl STATUS display from the Pod's phase. <code>ImagePullBackOff</code> is a container waiting reason, while <code>Pending</code> is the phase. The <a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/">Pod lifecycle reference</a> explains the distinction.</p>
<h3 id="heading-check-which-pods-serve-requests">Check Which Pods Serve Requests</h3>
<p>Inspect the Service's EndpointSlices and send requests through the Service from inside the cluster:</p>
<pre><code class="language-bash">k get endpointslices -l kubernetes.io/service-name=rollout-demo -o json
k exec --pod-running-timeout=30s --request-timeout=60s http-client -- \
  python -u /app/probe.py --url http://rollout-demo:8080/ \
  --expected-version v2 --count 10 --interval 0.2
</code></pre>
<p>The probe checks the status and version and records the responding Pod's name. Compare those names with the previous healthy ReplicaSet's Pods. Compare the EndpointSlice <code>targetRef.uid</code> values with their UIDs too.</p>
<p>In the recorded run, all ten responses came from the two previous-revision Pods. Their EndpointSlice entries were ready. The blocked image Pod was also listed, but its endpoint had <code>ready: false</code> and <code>serving: false</code>.</p>
<p>The new Pod couldn't become available, so the controller retained both healthy old replicas. The Service samples confirmed that those Pods answered. Ten successful requests establish what happened during those samples. They don't prove uninterrupted service between measurements.</p>
<h2 id="heading-how-to-distinguish-two-timeouts">How to Distinguish Two Timeouts</h2>
<p>The first timeout came from your terminal. <code>kubectl rollout status --timeout=10s</code> stopped waiting after its client-side timeout. It didn't cancel the update or restore the previous configuration. The <a href="https://kubernetes.io/docs/reference/kubectl/generated/kubectl_rollout/kubectl_rollout_status/">command reference</a> describes that watch timeout.</p>
<p>The second belongs to the Deployment controller. Wait for its deadline reason, then inspect the full conditions:</p>
<pre><code class="language-bash">k wait --for=jsonpath='{.status.conditions[?(@.type=="Progressing")].reason}'=ProgressDeadlineExceeded \
  deployment/rollout-demo --timeout=240s
k get deployment rollout-demo -o json
</code></pre>
<p>The 240-second timeout bounds this separate wait. Confirm that <code>Progressing</code> is <code>False</code> with reason <code>ProgressDeadlineExceeded</code>. The <a href="https://kubernetes.io/docs/reference/kubectl/generated/kubectl_wait/">kubectl wait reference</a> documents JSONPath waits.</p>
<p>In the original recording, the Progressing condition's last-update time changed from <code>04:57:14Z</code> to <code>05:00:15Z</code>: 181 seconds, with a configured deadline of 180. Controller timing isn't a promise of an exact wall-clock interval.</p>
<p><code>Available=True</code> remained because the old Pods still supplied the required availability. <code>Progressing=False</code> reported the stalled update. Conversely, <code>Progressing=True</code> can also remain after a successful rollout, so always read its reason and the replica counts.</p>
<p>The <a href="https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#failed-deployment">Deployment controller reports the deadline without automatically rolling back</a>. The missing image was still the desired image in the deadline snapshot.</p>
<p>Repeat the Service check now. All ten recorded requests after the deadline also came from the old Pods. Only the image case waits for the controller deadline in this tutorial.</p>
<h2 id="heading-how-to-restore-the-healthy-configuration">How to Restore the Healthy Configuration</h2>
<p>Recover now, before introducing another failure. Keep <code>BAD_POD</code> set to the failed Pod you just inspected:</p>
<pre><code class="language-bash">k apply -f failure-lab/manifests/healthy.json
k rollout status deployment/rollout-demo --timeout=300s
k wait --for=delete "pod/$BAD_POD" --timeout=120s
k get deployment rollout-demo -o json
k get replicasets -l app=rollout-demo -o wide
k get pods -l app=rollout-demo -o wide
k get endpointslices -l kubernetes.io/service-name=rollout-demo -o json
k exec --pod-running-timeout=30s --request-timeout=60s http-client -- \
  python -u /app/probe.py --url http://rollout-demo:8080/ \
  --expected-version v2 --count 10 --interval 0.2
</code></pre>
<p>Check the whole recovery, not just the rollout command's success. The desired spec should match <code>healthy.json</code>. The controller must have observed it, with replicas, updated replicas, ready replicas, and available replicas all equal to two.</p>
<p>Exactly two application Pods should remain, both owned by the current ReplicaSet, Ready, and not terminating. Their UIDs should be the ready Service endpoints, and the HTTP samples should return v2 from those Pod names.</p>
<p>In the original experiment, recovery reused the existing healthy ReplicaSet and the same two Pods. Their template already matched the restored configuration. The failed ReplicaSet scaled to zero.</p>
<p>Revision annotations changed even though those healthy Pods remained. These values are from the recorded run:</p>
<table>
<thead>
<tr>
<th>Case</th>
<th>Before failure</th>
<th>Failed revision</th>
<th>After recovery</th>
</tr>
</thead>
<tbody><tr>
<td>Image</td>
<td>2</td>
<td>3</td>
<td>4</td>
</tr>
<tr>
<td>Readiness</td>
<td>4</td>
<td>5</td>
<td>6</td>
</tr>
<tr>
<td>Scheduling</td>
<td>6</td>
<td>7</td>
<td>8</td>
</tr>
</tbody></table>
<p>Your numbers can differ. Restore the known healthy specification rather than relying on these example revision numbers.</p>
<h2 id="heading-how-to-diagnose-failed-readiness">How to Diagnose Failed Readiness</h2>
<p>After verifying recovery, apply the second failure:</p>
<pre><code class="language-bash">k apply -f failure-lab/manifests/readiness.json
k rollout status deployment/rollout-demo --timeout=10s
</code></pre>
<p>This changes only <code>readinessProbe.httpGet.path</code>, from <code>/ready</code> to <code>/not-ready</code>. The short watch times out again.</p>
<p>Repeat the inspection sequence from the image case. Identify the new ReplicaSet, reset <code>NEW_RS</code> and <code>BAD_POD</code> to this case's names, and query Events using the new Pod UID. This time the container runs, but the Pod isn't Ready.</p>
<p>The following values are derived from the recorded Pod snapshot:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Phase</td>
<td>Running</td>
</tr>
<tr>
<td>PodScheduled</td>
<td>True</td>
</tr>
<tr>
<td>Ready</td>
<td>False</td>
</tr>
<tr>
<td>Container restart count</td>
<td>0</td>
</tr>
</tbody></table>
<p>The Pod's Event message explains why:</p>
<pre><code class="language-text">Readiness probe failed: HTTP probe failed with statuscode: 404
</code></pre>
<p>The app has no handler for <code>/not-ready</code>. HTTP probes accept responses from 200 through 399, so this 404 fails the readiness check. A failing readiness probe leaves the container running while marking it unready and continuing to probe. See the <a href="https://kubernetes.io/docs/concepts/workloads/pods/probes/">probe documentation</a>.</p>
<p>The recording also had brief startup <code>connection refused</code> Events. Those alone wouldn't establish this fault: they can occur before a healthy server begins listening. The later 404 demonstrates that this server answered the request. Combined with the single changed field and the known routes, it identifies the wrong probe path.</p>
<p>The failed readiness check didn't restart the container. The captured restart count was zero. This case wasn't a crash loop.</p>
<p>Repeat the EndpointSlice and Service checks. The new Pod remained listed in the recorded EndpointSlice with <code>ready: false</code>, <code>serving: false</code>, and <code>terminating: false</code>. The two old Pods had ready endpoints, and all ten responses came from them.</p>
<p>This Service doesn't enable <code>publishNotReadyAddresses</code>. Endpoint readiness and termination settings matter, so avoid generalizing this observation to every Service configuration. The <a href="https://kubernetes.io/docs/concepts/services-networking/endpoint-slices/">EndpointSlice documentation</a> describes those conditions and exceptions.</p>
<p>For related background, freeCodeCamp's <a href="https://www.freecodecamp.org/news/kubernetes-self-healing-explained/">Kubernetes self-healing tutorial</a> also explores readiness. Here, the failed probe explains why a new rollout can't finish.</p>
<p>Run the complete recovery sequence again, using this readiness Pod as <code>BAD_POD</code>. Verify healthy v2 before proceeding.</p>
<h2 id="heading-how-to-diagnose-a-placement-constraint">How to Diagnose a Placement Constraint</h2>
<p>The third failure adds this selector to the Pod template:</p>
<pre><code class="language-yaml">nodeSelector:
  rollout-lab.example.com/placement: no-matching-node
</code></pre>
<p>Check that it matches no node, then apply it:</p>
<pre><code class="language-bash">k get nodes -l rollout-lab.example.com/placement=no-matching-node
k apply -f failure-lab/manifests/scheduling.json
k rollout status deployment/rollout-demo --timeout=10s
</code></pre>
<p>If the first command lists a node, the intended unmatched-selector scenario doesn't hold. Inspect the label configuration before continuing.</p>
<p>Repeat the ownership and Event checks, updating <code>NEW_RS</code>, <code>BAD_POD</code>, and <code>POD_UID</code> for this case. The new Pod is <code>Pending</code>, just as in the image failure. But <code>Pending</code> covers both waiting for scheduling and waiting for container setup, including image downloads.</p>
<p>These are the recorded differences:</p>
<table>
<thead>
<tr>
<th>Evidence</th>
<th>Image failure</th>
<th>Scheduling failure</th>
</tr>
</thead>
<tbody><tr>
<td>PodScheduled</td>
<td>True</td>
<td>False, reason Unschedulable</td>
</tr>
<tr>
<td>nodeName</td>
<td>Lab node assigned</td>
<td>Absent</td>
</tr>
<tr>
<td>containerStatuses</td>
<td>Waiting container</td>
<td>Absent</td>
</tr>
<tr>
<td>Diagnostic Event</td>
<td>Image pull failed</td>
<td>FailedScheduling</td>
</tr>
</tbody></table>
<p>The scheduling Event said:</p>
<pre><code class="language-text">0/1 nodes are available: 1 node(s) didn't match Pod's node affinity/selector. preemption: 0/1 nodes are available: 1 Preemption is not helpful for scheduling.
</code></pre>
<p>No node satisfied the selector, so the Pod was never assigned to a kubelet. Image retrieval had not begun. Kubernetes requires nodes to match every specified <code>nodeSelector</code> label, as the <a href="https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/">node-selection documentation</a> explains.</p>
<p>Repeat the Service checks. This unscheduled Pod had no Pod IP or EndpointSlice entry. The old Pods supplied all ten recorded responses.</p>
<p>Apply the complete recovery sequence once more, using this scheduling Pod as <code>BAD_POD</code>. Confirm healthy v2 and correct Service responses. The experiment tests a selector mismatch, not resource exhaustion or every possible scheduling failure.</p>
<h2 id="heading-how-to-clean-up">How to Clean Up</h2>
<p>After saving your evidence, delete the disposable lab:</p>
<pre><code class="language-bash">bash cleanup.sh
</code></pre>
<p>The script selects Colima's Docker context and deletes <code>fcc-rollout-lab</code>, passing the lab's kubeconfig explicitly. It records the result and checks that other kind cluster names remain unchanged. This removes the lab workloads.</p>
<p>Evidence archives remain in the local companion directory. To run the tutorial again, use a fresh extraction after cleanup; setup intentionally refuses to reuse the old kubeconfig.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Three different faults stopped the new revision while the previous revision continued answering the recorded requests:</p>
<table>
<thead>
<tr>
<th>Case</th>
<th>Where it stopped</th>
<th>Strongest evidence</th>
<th>Successful fault-state samples</th>
</tr>
</thead>
<tbody><tr>
<td>Image</td>
<td>Container image retrieval</td>
<td>Scheduled Pod; missing-tag error</td>
<td>20/20 across two batches</td>
</tr>
<tr>
<td>Readiness</td>
<td>Readiness check</td>
<td>Running Pod; HTTP 404; Ready=False</td>
<td>10/10</td>
</tr>
<tr>
<td>Scheduling</td>
<td>Node selection</td>
<td>PodScheduled=False; selector mismatch</td>
<td>10/10</td>
</tr>
</tbody></table>
<p>For your next stalled rollout, follow ownership from the Deployment to its ReplicaSet and Pod. Check whether the Pod was scheduled, whether the container started, and whether it became Ready. Use its conditions and Events to locate the failure, then use Service responses to establish which Pods actually answered.</p>
<p>Finally, distinguish a client watch timeout from a controller progress deadline, and verify the full recovery before drawing conclusions. These results came from a single-node local lab and finite HTTP samples. They don't establish production behavior or continuous availability.</p>
 ]]>
                </content:encoded>
            </item>
        
            <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>
        
    </channel>
</rss>
