<?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[ Cloud Computing - 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[ Cloud Computing - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Tue, 10 Nov 2026 21:32:43 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/cloud-computing/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ Cloud Service Models Explained: IaaS vs PaaS vs SaaS ]]>
                </title>
                <description>
                    <![CDATA[ You likely use cloud services every day. When you check Gmail, join a Zoom meeting, or message your team on Slack, you use cloud-hosted software. But you don't manage the servers it runs on or maintai ]]>
                </description>
                <link>https://www.freecodecamp.org/news/cloud-service-models-explained-iaas-vs-paas-vs-saas/</link>
                <guid isPermaLink="false">6ae368ce7483e3ea0090cfe2</guid>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Engineering  ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Chinwendu Enyinna ]]>
                </dc:creator>
                <pubDate>Thu, 29 Oct 2026 15:39:58 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e9e06980-6916-4ded-9079-e007e85ff32b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You likely use cloud services every day. When you check Gmail, join a Zoom meeting, or message your team on Slack, you use cloud-hosted software.</p>
<p>But you don't manage the servers it runs on or maintain the underlying infrastructure. You simply sign in and get to work. This is called <strong>Software as a Service (SaaS)</strong>, and many people use it for years without knowing the term.</p>
<p>SaaS is just one of three major cloud service models. The other two, <strong>Infrastructure as a Service (IaaS)</strong> and <strong>Platform as a Service (PaaS)</strong>, give you different levels of control over the infrastructure and software you build. Understanding how these models differ will help you make sense of cloud engineering concepts and choose the right approach for your projects.</p>
<p>In this article, we'll break down IaaS, PaaS, and SaaS, explain what you manage and what the cloud provider handles in each model, and learn how to choose between them for a real project.</p>
<h3 id="heading-what-youll-learn">What You'll&nbsp;Learn</h3>
<p>By the end of this article, you'll be able to:</p>
<ul>
<li>Explain what IaaS, PaaS, and SaaS are and what each one manages for you</li>
<li>Use the shared responsibility model to see where your job starts and ends</li>
<li>Recognise real products in each category</li>
<li>Choose the right model for a given project</li>
</ul>
<h3 id="heading-table-of-contents">Table of&nbsp;Contents</h3>
<ul>
<li><a href="#heading-prerequisites">Prerequisites</a></li>
<li><a href="#heading-what-is-cloud-computing">What Is Cloud Computing?</a></li>
<li><a href="#heading-the-shared-responsibility-model">The Shared Responsibility Model</a></li>
<li><a href="#heading-infrastructure-as-a-service-iaas">Infrastructure as a Service (IaaS)</a></li>
<li><a href="#heading-platform-as-a-service-paas">Platform as a Service (PaaS)</a></li>
<li><a href="#heading-software-as-a-service-saas">Software as a Service (SaaS)</a></li>
<li><a href="#heading-beyond-the-big-three-caas-faas-and-serverless">Beyond the Big Three: CaaS, FaaS, and Serverless</a></li>
<li><a href="#heading-how-to-choose-the-right-service-model">How to Choose the Right Service Model</a></li>
<li><a href="#heading-conclusion">Conclusion</a></li>
</ul>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>You don't need any cloud experience to follow this article. If you know what a computer, a server, and the internet are, you have everything you need.</p>
<p>If you're new to the cloud, read my article <a href="https://chinwendu.medium.com/what-is-the-cloud-and-cloud-computing-49c1260862b5">What is the Cloud and Cloud Computing?</a> first. It explains what the cloud is and how it differs from running your own hardware, and this article builds on that foundation.</p>
<h2 id="heading-what-is-cloud-computing">What Is Cloud Computing?</h2>
<p>Cloud computing is a model that lets you use computing resources, such as servers, storage, and networking, on demand over the internet. YOu pay only for what you use.</p>
<p>These resources sit in data centres that are owned and run by cloud service providers, or CSPs for short. The best-known CSPs are Amazon Web Services (AWS), Microsoft Azure, and Google Cloud.</p>
<p>This is just a short recap to make sure you understand the key concepts. My article above covers it in full if you want or need more info.</p>
<h3 id="heading-what-does-on-premises-mean">What Does On-Premises Mean?</h3>
<p>Before the cloud, most organisations ran their systems on-premises. On-premises means the hardware sits in a building the organisation owns or rents. This could be a server room in their office, with its own staff looking after it.</p>
<p>In this setup, you're responsible for everything. This includes buying servers, setting up the network, installing and updating software, and keeping the room powered and cool.</p>
<p>For example, if a small business wanted to launch an online shop, it would need to buy a server, install the software, and pay someone to keep it running. If the shop grew faster than expected, it would need to buy more hardware.</p>
<h3 id="heading-what-changes-when-you-move-to-the-cloud">What Changes When You Move to the&nbsp;Cloud?</h3>
<p>The cloud moves some of that work to the provider. The physical servers still exist, but someone else owns and maintains them, and you rent what you need.</p>
<p>What differs from one cloud service to the next is <strong>how much of the work the provider takes on for you</strong>. The cloud service models describe this, and there are three main ones:</p>
<ul>
<li><strong>Infrastructure as a Service (or IaaS):</strong> Here the provider gives you virtual servers, storage, and networking. Then you manage everything you put on them.</li>
<li><strong>Platform as a Service (or PaaS):</strong> Here, the provider also manages the operating system and the tools your code needs to run. You bring your application and your data.</li>
<li><strong>Software as a Service (or SaaS)</strong>: In this model, the provider gives you a finished application. You sign in, use it, and manage your users and data.</li>
</ul>
<p>So a cloud service model describes how the work is split between you, the customer, and the provider, that is which parts of the system you manage and which parts the provider manages for you.</p>
<h2 id="heading-the-shared-responsibility-model">The Shared Responsibility Model</h2>
<p>The shared responsibility model defines which parts of a cloud system the provider manages and which parts you, as the customer, manage. To understand it, you first need to see the parts that make up a running application.</p>
<h3 id="heading-the-layers-of-a-computing-stack">The Layers of a Computing Stack</h3>
<p>A computing stack is the set of layers that work together to run an application. Each layer depends on the ones beneath it. From the bottom up, we have:</p>
<ul>
<li>Networking: the physical cables, switches, and routers that connect servers to each other and to the internet.</li>
<li>Storage: the physical drives where files and databases are saved.</li>
<li>Servers: the physical computers that provide processing power (CPU) and memory (RAM).</li>
<li>Virtualisation: software that splits one physical server into several virtual machines, or VMs for short. A VM behaves like a separate computer even though it shares hardware with other VMs.</li>
<li>Operating system: the base software, such as Linux or Windows, that manages the hardware and lets other programs run.</li>
<li>Middleware: software that connects your application to the outside world and to other services, such as a web server that receives requests from browsers.</li>
<li>Runtime: the environment your code needs to run, such as Node.js for JavaScript or the Python interpreter for Python.</li>
<li>Application: the software itself, such as an online shop or an email client.</li>
<li>Data: the information the application stores and processes, such as customer records, orders, and emails.</li>
</ul>
<h3 id="heading-who-manages-what-in-each-model">Who Manages What in Each&nbsp;Model</h3>
<p>AWS describes the split as security “of” the cloud and security “in” the cloud. The provider manages the security of the cloud. This covers the data centres, physical hardware, physical network, and the virtualisation layer.</p>
<p>But security <strong>in</strong> the cloud is the customer's job. It covers what you put into the cloud and how you configure it. This includes your data, your users and their permissions, and the settings of the services you use.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6181007bb368d91070197eba/f1fdda02-613c-447b-9a9e-4cdc8706c969.png" alt="Overview of the cloud service models showing who manages what" style="display: block;" width="1187" height="1068" loading="lazy">

<h2 id="heading-infrastructure-as-a-service-iaas">Infrastructure as a Service&nbsp;(IaaS)</h2>
<p>Infrastructure as a Service is a cloud model where the provider gives you the basic building blocks of computing. This includes virtual servers, storage, and networking.</p>
<h3 id="heading-how-iaas-works">How IaaS&nbsp;Works</h3>
<p>With IaaS, you request a virtual machine from the provider. You choose how many CPUs and how much memory it has, which operating system it runs, and which network it connects to. Within minutes, the VM is running, and you can connect to it.</p>
<p>From that point, the server is yours to manage. You install your software, apply operating system updates, configure the firewall, and keep your application running.</p>
<p>Before IaaS, getting a new server meant ordering hardware and waiting weeks for installation. With IaaS, the same server is ready in minutes, and you can delete it when you no longer need it.</p>
<p>Just remember: <strong>IaaS removes the hardware burden, but it doesn't remove the server burden.</strong></p>
<p>With IaaS, you usually pay for each hour or second a server is running, plus the storage you keep and outbound data transfer.</p>
<h3 id="heading-real-world-examples-include">Real-World Examples include:</h3>
<ul>
<li>Amazon EC2 (Elastic Compute Cloud) on AWS</li>
<li>Google Compute Engine on Google Cloud</li>
<li>Azure Virtual Machines on Microsoft Azure</li>
</ul>
<p>Each of these gives you virtual machines. They work alongside other IaaS building blocks, such as virtual disks (Amazon EBS), storage for files and backups (Amazon S3), and private networks (Amazon VPC).</p>
<h3 id="heading-when-to-use-iaas">When to Use&nbsp;IaaS</h3>
<p>IaaS is the right choice when you need control over the operating system and everything on it, especially when:</p>
<ul>
<li>You need a specific operating system version or software that a managed platform doesn't support.</li>
<li>You need to meet security or compliance rules that require you to configure and audit the server yourself.</li>
<li>You're moving an existing application from your own servers to the cloud without rewriting it. This is often called “lift and shift”.</li>
<li>You need development and test environments that you create and delete often.</li>
</ul>
<h3 id="heading-tradeoffs">Tradeoffs</h3>
<p>As with any system, there are some tradeoffs you should be aware of before selecting this model.</p>
<p>First, you manage the operating system. You handle patching, security updates, and backups, and a missed update can create a security gap.</p>
<p>You also need to plan for scaling. The provider gives you tools to add servers automatically, but you have to set them up and test them.</p>
<p>In addition, you may need to acquire some new skills. Running servers well requires knowledge of Linux or Windows administration, networking, and security.</p>
<p>And you also pay for idle time. A server that runs all night with no traffic still costs money.</p>
<h2 id="heading-platform-as-a-service-paas">Platform as a Service&nbsp;(PaaS)</h2>
<p>Platform as a Service is a cloud model where the provider gives you a ready environment to run your code. The provider manages the servers, operating system, middleware, and runtime. The customer brings the application code and data.</p>
<h3 id="heading-how-paas-works">How PaaS&nbsp;Works</h3>
<p>With IaaS, you still have to prepare a server before your application can run. This includes updating the operating system, setting up a web server, installing the right language version, and keeping it all maintained.</p>
<p>PaaS removes this burden. With PaaS, you tell the platform which language your application uses, then send your code, usually by pushing to Git or uploading a package. The platform builds your application, runs it on managed servers, and gives you a web address. When traffic grows, it can run more copies of your application based on rules you set.</p>
<p>You still decide how much memory your application gets, which settings it uses, and who can deploy it. You just no longer have to log in to servers to patch and maintain them.</p>
<p>With PaaS, you usually pay for the resources your application uses, such as the size and number of running instances, billed by the hour or month.</p>
<h3 id="heading-real-world-examples-include">Real-World Examples include:</h3>
<ul>
<li>Heroku</li>
<li>AWS Elastic Beanstalk</li>
<li>Google App Engine</li>
<li>Azure App Service</li>
</ul>
<p>There are some products that sit between categories. For example, Firebase from Google is often called a PaaS, but it's more accurately a Backend as a Service, or BaaS. A BaaS gives you ready-made backend features, such as a database, user authentication flow, and file storage, that your app calls directly. You don't deploy a whole application to it the way you would on Heroku, for example.</p>
<p>Another is Amazon RDS (Relational Database Service), which is a managed database. AWS runs the database software and backups, while you manage the tables, data, and access. It works like PaaS, but for databases.</p>
<h3 id="heading-when-to-use-paas">When to Use&nbsp;PaaS</h3>
<ul>
<li>You want to launch a web application or API quickly and focus on code.</li>
<li>Your team is small and has nobody dedicated to managing servers.</li>
<li>Your application uses a common language the platform supports, such as Node.js, Python, Java, or&nbsp;.NET.</li>
<li>You want deployments, scaling, and monitoring without building them yourself.</li>
</ul>
<h3 id="heading-tradeoffs">Tradeoffs</h3>
<p>The first tradeoff here is that you have less control. You can't install just any software you want or change operating system settings. Also, if the platform doesn't support something, you have to work around it.</p>
<p>You also risk vendor lock-in. Vendor lock-in means that moving to another provider becomes hard because your application depends on features only one platform offers.</p>
<p>Also, you may pay more at scale. As traffic grows, PaaS can cost more than running the same workload on servers you manage because you pay for the convenience.</p>
<p>And you need to work within platform limits, such as maximum request times or supported language versions.</p>
<p>Finally, you still own your code’s security, including the third-party libraries it uses (and all the risks that come with that).</p>
<h2 id="heading-software-as-a-service-saas">Software as a Service&nbsp;(SaaS)</h2>
<p>Software as a Service is a cloud model where the provider delivers a complete application over the internet. You access it through a web browser or an app, and the provider manages everything needed to keep it running.</p>
<h3 id="heading-how-saas-works">How SaaS&nbsp;Works</h3>
<p>Before SaaS, using business software meant buying a license, installing it on every computer, and updating each one for every new version. For example, if an organisation wanted its own email system, it would need its own email server, someone to run it, and a backup plan.</p>
<p>This is different with SaaS. The provider runs the application on its own infrastructure, and every customer uses the same centrally updated version. You create an account, add your users, and start working. New features and security fixes arrive without you installing anything.</p>
<p>Your work moves from running software to configuring it. You decide who gets an account, what each person can access, and how files are shared.</p>
<p>With SaaS, you usually pay per user per month or per year, often in tiers with more features at higher levels.</p>
<h3 id="heading-real-world-examples-include">Real-World Examples include:</h3>
<ul>
<li>Gmail and Google Workspace, for email, documents, and file storage</li>
<li>Microsoft 365, for Outlook, Word, Excel, and Teams</li>
<li>Slack, for team messaging</li>
<li>Zoom, for video meetings</li>
<li>Dropbox, for file storage and sharing</li>
<li>Salesforce, for managing customers and sales</li>
</ul>
<h3 id="heading-when-to-use-saas">When to Use&nbsp;SaaS</h3>
<ul>
<li>The software solves a common problem that many organisations share, such as email, documents, chat, or accounting.</li>
<li>You don't want to build or run the software yourself.</li>
<li>Your team needs access from anywhere, on any device.</li>
<li>You need the tool working this week, not in six months.</li>
</ul>
<p>For example, say a non-governmental organisation with twenty staff who need professional email, shared documents, and video calls. It has no IT team. With Google Workspace or Microsoft 365, one administrator can set up accounts for every staff member in a day, while the provider handles the servers, updates, and spam filtering.</p>
<h3 id="heading-tradeoffs">Tradeoffs</h3>
<p>With this option, you have the least control. You get the features the provider builds, and if you need something it doesn't offer, you wait or look elsewhere.</p>
<p>Also, your data lives with the provider. You depend on their security, so you need to know where your data is stored and how you can export it if you leave.</p>
<p>Your costs could also easily grow with your team.</p>
<p>Further, you depend on your internet connection. When connectivity is unreliable, a browser-based tool can become unavailable when you need it most.</p>
<p>And you have limited customisation options. Connecting the tool to your other systems depends on what the provider allows.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6181007bb368d91070197eba/3bc84cee-d5c3-4bb5-9dd4-4f9c8072b474.png" alt="Comparison of IaaS, PaaS, and SaaS by control, management effort, cost model, and flexibility." style="display: block;" width="1300" height="834" loading="lazy">

<h2 id="heading-beyond-the-big-three-caas-faas-and-serverless">Beyond the Big Three: CaaS, FaaS, and Serverless</h2>
<p>IaaS, PaaS, and SaaS are the main models, but there are also options like CaaS, FaaS, and serverless. Let’s look at what each means and how it works.</p>
<h3 id="heading-containers-as-a-service-caas">Containers as a Service&nbsp;(CaaS)</h3>
<p>A container packages your application with everything it needs to run, including its runtime, libraries, and settings. Because everything is inside the package, a container runs the same way on your laptop as it does on a server in the cloud. Docker is the most common tool for building containers.</p>
<p>Containers as a Service, or CaaS, is a model where the provider runs and manages containers for you. You give it your container images, and it starts them, restarts them when they fail, and spreads them across servers. The software that makes these decisions is called a container orchestrator, and Kubernetes is the most widely used one.</p>
<p>CaaS sits between IaaS and PaaS. You have more control than on a PaaS, because you decide exactly what goes into each container, but the provider handles much of the work of running them.</p>
<p>Some common examples include:</p>
<ul>
<li>Amazon ECS (Elastic Container Service), which is AWS’s own orchestrator</li>
<li>Amazon EKS (Elastic Kubernetes Service), which is managed Kubernetes on AWS</li>
<li>Google Kubernetes Engine (GKE), which is managed Kubernetes on Google Cloud</li>
</ul>
<p>With ECS and EKS, you may still manage the virtual machines your containers run on, unless you use AWS Fargate, which runs containers without you managing any servers.</p>
<h3 id="heading-functions-as-a-service-faas">Functions as a Service&nbsp;(FaaS)</h3>
<p>Functions as a Service, or FaaS, is a model where you upload small pieces of code called functions, and the provider runs each one only when an event triggers it. An event is something that happens, such as a web request arriving, a file being uploaded, or a scheduled time being reached.</p>
<p>You don't keep a server running while you wait for work. When the event happens, the provider starts your function, runs it, and stops it. You pay per request and for the compute time each run uses, so you pay nothing for compute when nothing happens.</p>
<p>For example, say that an online shop needs to create a small thumbnail every time a seller uploads a product photo. With IaaS, you keep a server running all day to watch for uploads, even at 3 a.m. when nobody is uploading. With FaaS, the upload itself triggers a function that creates the thumbnail, and you pay only for the seconds it runs.</p>
<p>Some common examples include:</p>
<ul>
<li>AWS Lambda</li>
<li>Azure Functions</li>
<li>Google Cloud Run functions, previously called Google Cloud Functions</li>
</ul>
<p>FaaS has trade-offs, too. Each run has a time limit, and an idle function can take a moment longer to start. This is called a cold start.</p>
<h3 id="heading-where-serverless-fits">Where Serverless Fits</h3>
<p>Serverless describes cloud services where you never set up or manage servers, the service scales automatically with demand, and you pay only for what you use rather than for idle capacity.</p>
<p>Serverless isn't a fourth model on the stack. It describes how a service is operated and billed, not which layers you manage. FaaS is serverless, but so are many other managed services, such as Amazon S3 for file storage, Amazon DynamoDB for databases, and AWS Fargate for containers.</p>
<p>Google Cloud Run functions, which are CaaS, also fit into the serverless category.</p>
<h2 id="heading-how-to-choose-the-right-service-model">How to Choose the Right Service&nbsp;Model</h2>
<p>No single model is best for every project. The right choice depends on what you're building, who's building it, and how much control you need.</p>
<p>To help you decide, you can work through these questions in order:</p>
<ul>
<li>Does software that does this job already exist? If yes, start with SaaS.</li>
<li>Are you building your own application in a common language? If yes, look at PaaS first, or FaaS for small tasks that run in response to events.</li>
<li>Is your application already packaged in containers? If yes, CaaS or a serverless container platform may suit you.</li>
<li>Do you need control over the operating system, custom software, or a strict compliance setup? If yes, choose IaaS.</li>
<li>Is your traffic steady or unpredictable? Unpredictable traffic often suits serverless pay-per-use pricing.</li>
</ul>
<p>Start with the most managed model that meets your needs, and move down the stack only when you have a clear reason.</p>
<p>Most production systems combine multiple cloud service models, each serving a different purpose.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>Each service model draws the line between your work and the provider’s at a different point on the stack. IaaS gives you virtual infrastructure, and you manage the operating system and everything above it. PaaS gives you a platform, and you manage your code and data. SaaS gives you a finished application, and you manage your users, data, and settings.</p>
<p>CaaS and FaaS sit between these models, and serverless describes how you operate and pay. In all of them, you (the customer) remain responsible for your data and who can access it.<br><br>Thank you for reading!</p>
 ]]>
                </content:encoded>
            </item>
        
            <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[ AWS Cloud Cost Monitoring, Alerting, and Optimization: A Guide for Devs ]]>
                </title>
                <description>
                    <![CDATA[ There's a common conversation that happens in engineering teams every month. Someone forwards a screenshot of the AWS bill. The number is higher than last month. Everyone nods and agrees it should be  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/aws-cloud-cost-monitoring-alerting-and-optimization-a-guide-for-devs/</link>
                <guid isPermaLink="false">6a9ee93ad24de200149086c9</guid>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ monitoring ]]>
                    </category>
                
                    <category>
                        <![CDATA[ optimization ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ayobami Adejumo ]]>
                </dc:creator>
                <pubDate>Mon, 07 Sep 2026 16:41:30 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/01c5b407-d719-404c-a207-495ae6dcaa53.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>There's a common conversation that happens in engineering teams every month. Someone forwards a screenshot of the AWS bill. The number is higher than last month. Everyone nods and agrees it should be lower. Nothing specific gets decided, and the cycle repeats.</p>
<p>This guide is designed to end that cycle by replacing it with something more useful: a systematic, service-by-service approach to knowing exactly what you're spending, why you're spending it, catching problems before they become invoices, and reducing costs without guesswork.</p>
<p>It's organised as a reference you can return to. Each part is complete on its own: you can go straight to the RDS section if that's your current problem, or follow the guide start to finish if you're building a FinOps practice from scratch. Every command is runnable, and every script is deployable.</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-monitoring-know-where-every-dollar-goes">Part 1: Monitoring — Know Where Every Dollar Goes</a></p>
</li>
<li><p><a href="#heading-part-2-alerting-catch-spikes-before-they-become-invoices">Part 2: Alerting — Catch Spikes Before They Become Invoices</a></p>
</li>
<li><p><a href="#heading-part-3-optimisation-by-service">Part 3: Optimisation by Service</a></p>
</li>
<li><p><a href="#heading-part-4-the-30-day-optimisation-sprint">Part 4: The 30-Day Optimisation Sprint</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>How to set up Cost and Usage Report querying with Athena, the foundation of all serious cost analysis</p>
</li>
<li><p>The five dashboards every engineering team needs, built with queries you can run today</p>
</li>
<li><p>A three-tier alerting strategy that catches cost spikes without creating alert fatigue</p>
</li>
<li><p>Service-specific optimisation playbooks for EC2, S3, Lambda, RDS, DynamoDB, and data transfer</p>
</li>
<li><p>A concrete 30-day sprint that produces measurable savings in the first month</p>
</li>
</ul>
<p>Let's build this from the ground up.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before following this guide, you should have:</p>
<p><strong>Knowledge:</strong></p>
<ul>
<li><p>Working familiarity with AWS services: EC2, S3, RDS, Lambda, and VPC</p>
</li>
<li><p>Comfort reading Python and SQL</p>
</li>
<li><p>Basic understanding of how IAM policies and roles work</p>
</li>
</ul>
<p><strong>Access:</strong></p>
<ul>
<li><p>AWS account with billing access. The IAM user or role you work with needs <code>ce:GetCostAndUsage</code>, <code>ec2:Describe*</code>, <code>rds:Describe*</code>, and <code>s3:GetBucketLifecycleConfiguration</code> permissions.</p>
</li>
<li><p>AWS CLI v2 configured</p>
</li>
<li><p>Athena access (for the CUR queries in Part 1)</p>
</li>
</ul>
<p><strong>Setup:</strong></p>
<ul>
<li>Enable Cost Explorer if it isn't already. It's free and required for most commands in this guide:</li>
</ul>
<pre><code class="language-bash">aws ce enable-cost-explorer --region us-east-1
</code></pre>
<ul>
<li>The Cost and Usage Report (CUR) should be configured and exporting to an S3 bucket. If it isn't yet, the <a href="https://docs.aws.amazon.com/cur/latest/userguide/cur-create.html">AWS CUR setup guide</a> walks through the process. Give the report 24 hours after setup to generate the first data file.</li>
</ul>
<h2 id="heading-part-1-monitoring-know-where-every-dollar-goes">Part 1: Monitoring — Know Where Every Dollar Goes</h2>
<h3 id="heading-11-the-cost-and-usage-report-your-source-of-truth">1.1 The Cost and Usage Report — Your Source of Truth</h3>
<p>Cost Explorer shows you service-level totals. It's useful for trends, but it isn't sufficient for root cause analysis. When you need to know which specific resource is responsible for a $12,000/month line item, you need the Cost and Usage Report queried through Athena.</p>
<p>Create the Athena table over your CUR data:</p>
<pre><code class="language-sql">-- Run this once in Athena after your CUR starts generating data
-- Replace 'your-cur-bucket' and 'your-prefix' with your actual values

CREATE EXTERNAL TABLE IF NOT EXISTS cur_database.billing (
    bill_billing_period_start_date  STRING,
    bill_payer_account_id           STRING,
    line_item_usage_start_date      STRING,
    line_item_resource_id           STRING,
    line_item_usage_type            STRING,
    line_item_usage_amount          DOUBLE,
    line_item_unblended_cost        DOUBLE,
    product_servicecode             STRING,
    product_instance_type           STRING,
    product_region                  STRING,
    resource_tags_user_environment  STRING,
    resource_tags_user_team         STRING,
    resource_tags_user_service      STRING,
    resource_tags_user_owner        STRING
)
PARTITIONED BY (year STRING, month STRING)
ROW FORMAT DELIMITED
FIELDS TERMINATED BY ','
LOCATION 's3://your-cur-bucket/your-prefix/'
TBLPROPERTIES ('skip.header.line.count'='1');

MSCK REPAIR TABLE cur_database.billing;
</code></pre>
<p>Here are the three queries you should run on day one:</p>
<pre><code class="language-sql">-- Query 1: Top 20 resources by cost this month
-- Run this first. It tells you where to focus.
SELECT
    line_item_resource_id,
    product_servicecode,
    resource_tags_user_team     AS team,
    resource_tags_user_service  AS service,
    SUM(line_item_unblended_cost) AS total_cost_usd
FROM cur_database.billing
WHERE line_item_usage_start_date &gt;= DATE_FORMAT(
    DATE_TRUNC('month', CURRENT_DATE), '%Y-%m-%d'
)
  AND line_item_unblended_cost &gt; 0
GROUP BY 1, 2, 3, 4
ORDER BY total_cost_usd DESC
LIMIT 20;
</code></pre>
<pre><code class="language-sql">-- Query 2: Week-over-week cost growth by service
-- Identifies which services are growing fastest — these need investigation
WITH weekly AS (
    SELECT
        DATE_TRUNC('week', CAST(line_item_usage_start_date AS DATE)) AS week,
        product_servicecode,
        SUM(line_item_unblended_cost) AS cost
    FROM cur_database.billing
    WHERE line_item_usage_start_date &gt;=
          DATE_FORMAT(DATE_ADD('day', -42, CURRENT_DATE), '%Y-%m-%d')
    GROUP BY 1, 2
)
SELECT
    curr.product_servicecode AS service,
    ROUND(prev.cost, 2)      AS prev_week_cost,
    ROUND(curr.cost, 2)      AS curr_week_cost,
    ROUND(
        100.0 * (curr.cost - prev.cost) / NULLIF(prev.cost, 0),
        1
    ) AS pct_change
FROM weekly curr
JOIN weekly prev
  ON curr.product_servicecode = prev.product_servicecode
  AND curr.week = DATE_ADD('week', 1, prev.week)
WHERE curr.week = DATE_TRUNC('week', CURRENT_DATE)
  AND ABS((curr.cost - prev.cost) / NULLIF(prev.cost, 0)) &gt; 0.20
ORDER BY pct_change DESC;
</code></pre>
<pre><code class="language-sql">-- Query 3: Resources running with no usage (candidates for shutdown)
-- Finds resources that incurred cost but had zero usage quantity
-- in the past 7 days — strong signal for idle or orphaned resources
SELECT
    line_item_resource_id,
    product_servicecode,
    resource_tags_user_owner    AS owner,
    resource_tags_user_team     AS team,
    SUM(line_item_unblended_cost) AS cost_past_7_days
FROM cur_database.billing
WHERE line_item_usage_start_date &gt;=
      DATE_FORMAT(DATE_ADD('day', -7, CURRENT_DATE), '%Y-%m-%d')
  AND line_item_usage_amount = 0
  AND line_item_unblended_cost &gt; 5
GROUP BY 1, 2, 3, 4
ORDER BY cost_past_7_days DESC
LIMIT 30;
</code></pre>
<h3 id="heading-12-the-five-essential-cost-dashboards">1.2 The Five Essential Cost Dashboards</h3>
<p>These five views cover the monitoring needs of most engineering teams. Each is built from queries you can run immediately, no third-party tool required.</p>
<h4 id="heading-dashboard-1-executive-summary-for-weekly-leadership-updates">Dashboard 1: Executive Summary (for weekly leadership updates)</h4>
<pre><code class="language-python"># executive_summary.py
import boto3
from datetime import datetime, timedelta

ce = boto3.client('ce')


def weekly_summary():
    today     = datetime.now()
    start_mtd = today.replace(day=1).strftime('%Y-%m-%d')
    today_str = today.strftime('%Y-%m-%d')

    # Month-to-date spend
    mtd = ce.get_cost_and_usage(
        TimePeriod={'Start': start_mtd, 'End': today_str},
        Granularity='MONTHLY',
        Metrics=['UnblendedCost']
    )
    mtd_spend = float(
        mtd['ResultsByTime'][0]['Total']['UnblendedCost']['Amount']
    )

    # End-of-month forecast
    forecast = ce.get_cost_forecast(
        TimePeriod={
            'Start': today_str,
            'End':   (today.replace(day=28) + timedelta(days=4)).replace(day=1).strftime('%Y-%m-%d'),
        },
        Metric='UNBLENDED_COST',
        Granularity='MONTHLY'
    )
    eom_forecast = float(forecast['Total']['Amount']) + mtd_spend

    # Top 5 services
    by_service = ce.get_cost_and_usage(
        TimePeriod={'Start': start_mtd, 'End': today_str},
        Granularity='MONTHLY',
        Metrics=['UnblendedCost'],
        GroupBy=[{'Type': 'DIMENSION', 'Key': 'SERVICE'}]
    )
    services = sorted(
        [
            (g['Keys'][0], float(g['Metrics']['UnblendedCost']['Amount']))
            for g in by_service['ResultsByTime'][0]['Groups']
        ],
        key=lambda x: x[1],
        reverse=True
    )[:5]

    print(f"\n{'─'*48}")
    print(f"  AWS Cost Summary — {today.strftime('%B %Y')}")
    print(f"{'─'*48}")
    print(f"  Month-to-date:     ${mtd_spend:&gt;12,.2f}")
    print(f"  End-of-month est:  ${eom_forecast:&gt;12,.2f}")
    print(f"\n  Top 5 Services:")
    for name, cost in services:
        short = name.replace('Amazon ', '').replace('AWS ', '')
        print(f"    {short:&lt;32} ${cost:&gt;9,.2f}")
    print(f"{'─'*48}\n")


weekly_summary()
</code></pre>
<h4 id="heading-dashboard-2-team-cost-breakdown-for-engineering-leads">Dashboard 2: Team Cost Breakdown (for engineering leads)</h4>
<pre><code class="language-python"># team_breakdown.py
import boto3
from datetime import datetime

ce = boto3.client('ce')


def team_breakdown():
    start = datetime.now().replace(day=1).strftime('%Y-%m-%d')
    end   = datetime.now().strftime('%Y-%m-%d')

    response = ce.get_cost_and_usage(
        TimePeriod={'Start': start, 'End': end},
        Granularity='MONTHLY',
        Metrics=['UnblendedCost'],
        GroupBy=[
            {'Type': 'TAG',       'Key': 'Team'},
            {'Type': 'DIMENSION', 'Key': 'SERVICE'},
        ]
    )

    by_team = {}
    for group in response['ResultsByTime'][0].get('Groups', []):
        team_raw = group['Keys'][0]
        team     = team_raw.replace('Team$', '') if team_raw else 'untagged'
        service  = group['Keys'][1]
        cost     = float(group['Metrics']['UnblendedCost']['Amount'])

        if team not in by_team:
            by_team[team] = {'total': 0.0, 'by_service': {}}
        by_team[team]['total'] += cost
        by_team[team]['by_service'][service] = (
            by_team[team]['by_service'].get(service, 0.0) + cost
        )

    total_bill = sum(d['total'] for d in by_team.values())

    print(f"\n{'─'*58}")
    print(f"  Team Cost Breakdown — MTD {datetime.now().strftime('%Y-%m-%d')}")
    print(f"  Total: ${total_bill:,.2f}")
    print(f"{'─'*58}")

    for team, data in sorted(by_team.items(), key=lambda x: x[1]['total'], reverse=True):
        pct = (data['total'] / total_bill * 100) if total_bill else 0
        print(f"\n  {team:&lt;20}  ${data['total']:&gt;10,.2f}  ({pct:.1f}%)")
        top3 = sorted(data['by_service'].items(), key=lambda x: x[1], reverse=True)[:3]
        for svc, cost in top3:
            short = svc.replace('Amazon ', '').replace('AWS ', '')
            print(f"    └─ {short:&lt;30} ${cost:&gt;8,.2f}")

    print()


team_breakdown()
</code></pre>
<h4 id="heading-dashboard-3-waste-detection-for-weekly-cleanup-reviews">Dashboard 3: Waste Detection (for weekly cleanup reviews):</h4>
<pre><code class="language-python"># waste_detector.py
import boto3
from datetime import datetime, timezone, timedelta

ec2  = boto3.client('ec2')
elbv2 = boto3.client('elbv2')
cw   = boto3.client('cloudwatch')


def detect_waste():
    report = {'items': [], 'total_monthly_waste': 0.0}

    # Unattached EBS volumes
    for vol in ec2.describe_volumes(
        Filters=[{'Name': 'status', 'Values': ['available']}]
    )['Volumes']:
        age  = (datetime.now(timezone.utc) - vol['CreateTime']).days
        cost = round(vol['Size'] * 0.08, 2)
        tags = {t['Key']: t['Value'] for t in vol.get('Tags', [])}
        report['items'].append({
            'type':         'Unattached EBS Volume',
            'id':           vol['VolumeId'],
            'detail':       f"{vol['Size']}GB — {age} days old",
            'owner':        tags.get('Owner', '—'),
            'monthly_cost': cost,
        })
        report['total_monthly_waste'] += cost

    # Unassociated Elastic IPs
    for addr in ec2.describe_addresses()['Addresses']:
        if 'AssociationId' not in addr:
            report['items'].append({
                'type':         'Unassociated Elastic IP',
                'id':           addr.get('AllocationId', ''),
                'detail':       addr['PublicIp'],
                'owner':        '—',
                'monthly_cost': 3.60,
            })
            report['total_monthly_waste'] += 3.60

    # Idle load balancers (fewer than 100 requests in 7 days)
    for lb in elbv2.describe_load_balancers()['LoadBalancers']:
        metrics = cw.get_metric_statistics(
            Namespace='AWS/ApplicationELB',
            MetricName='RequestCount',
            Dimensions=[{'Name': 'LoadBalancer',
                         'Value': lb['LoadBalancerArn'].split(':loadbalancer/')[-1]}],
            StartTime=datetime.now() - timedelta(days=7),
            EndTime=datetime.now(),
            Period=604800,
            Statistics=['Sum']
        )['Datapoints']
        total_requests = metrics[0]['Sum'] if metrics else 0

        if total_requests &lt; 100:
            report['items'].append({
                'type':         'Idle Load Balancer',
                'id':           lb['LoadBalancerName'],
                'detail':       f"{int(total_requests)} requests in 7 days",
                'owner':        '—',
                'monthly_cost': 22.0,
            })
            report['total_monthly_waste'] += 22.0

    print(f"\n  Waste Detection Report — {datetime.now().strftime('%Y-%m-%d')}")
    print(f"  Estimated monthly waste: ${report['total_monthly_waste']:.2f}\n")

    for item in sorted(report['items'], key=lambda x: x['monthly_cost'], reverse=True)[:20]:
        print(f"  [{item['type']}]")
        print(f"    ID:     {item['id']}")
        print(f"    Detail: {item['detail']}")
        print(f"    Owner:  {item['owner']}")
        print(f"    Cost:   ${item['monthly_cost']:.2f}/month\n")

    return report


detect_waste()
</code></pre>
<h3 id="heading-13-tagging-strategy-the-foundation-of-all-attribution">1.3 Tagging Strategy — The Foundation of All Attribution</h3>
<p>Every cost attribution model depends on tags. Teams that skip tagging build dashboards that show totals without explanations. The discipline is in making tagging structural rather than procedural: enforced by infrastructure code, not by reminders in Confluence.</p>
<p>Required tag set:</p>
<pre><code class="language-hcl"># terraform/variables.tf
variable "mandatory_tags" {
  description = "Tags applied to every resource in this account"
  type        = map(string)

  validation {
    condition = alltrue([
      contains(keys(var.mandatory_tags), "Environment"),
      contains(keys(var.mandatory_tags), "Team"),
      contains(keys(var.mandatory_tags), "Owner"),
      contains(keys(var.mandatory_tags), "Service"),
    ])
    error_message = "mandatory_tags must include Environment, Team, Owner, and Service."
  }
}

locals {
  common_tags = merge(var.mandatory_tags, {
    ManagedBy    = "terraform"
    LastModified = timestamp()
  })
}

resource "aws_instance" "api_server" {
  ami           = data.aws_ami.amazon_linux_2023.id
  instance_type = "t3.medium"
  tags          = merge(local.common_tags, {Name = "api-server-${var.environment}"})
}
</code></pre>
<p>Find and report untagged resources weekly:</p>
<pre><code class="language-bash">#!/usr/bin/env bash
# find_untagged.sh

echo "Untagged EC2 instances (missing Team tag):"
aws ec2 describe-instances \
  --filters "Name=instance-state-name,Values=running" \
  --query "Reservations[].Instances[?!not_null(Tags[?Key=='Team'].Value|[0])].[InstanceId,InstanceType,LaunchTime]" \
  --output table

echo "Untagged RDS instances:"
aws rds describe-db-instances \
  --query "DBInstances[?!not_null(TagList[?Key=='Team'].Value|[0])].DBInstanceIdentifier" \
  --output table
</code></pre>
<h2 id="heading-part-2-alerting-catch-spikes-before-they-become-invoices">Part 2: Alerting — Catch Spikes Before They Become Invoices</h2>
<p>The typical discovery timeline without proactive alerting: a cost spike happens on the 5th, the monthly invoice arrives on the 20th, someone notices on the 22nd, investigation begins on the 23rd, and two weeks of billed waste can't be recovered. With proactive alerting, discovery happens within hours.</p>
<h3 id="heading-21-the-three-tier-alert-structure">2.1 The Three-Tier Alert Structure</h3>
<p>Alert fatigue is as damaging as no alerting. The three-tier model keeps signal high by routing different severity levels to different channels with different response expectations.</p>
<pre><code class="language-python"># alert_router.py
import boto3
import json
import urllib.request
from enum import Enum

SLACK_INFO_WEBHOOK  = 'https://hooks.slack.com/services/INFO/WEBHOOK'
SLACK_ALERT_WEBHOOK = 'https://hooks.slack.com/services/ALERT/WEBHOOK'
SNS_CRITICAL_TOPIC  = 'arn:aws:sns:us-east-1:YOUR_ACCOUNT:cost-critical'


class AlertTier(Enum):
    INFO     = 1
    WARNING  = 2
    CRITICAL = 3


def route_alert(tier: AlertTier, subject: str, message: str):
    """Send an alert to the appropriate channel for its severity tier."""
    icons   = {AlertTier.INFO: ':information_source:',
               AlertTier.WARNING: ':warning:', AlertTier.CRITICAL: ':rotating_light:'}
    payload = {'text': f"{icons[tier]} *{subject}*\n{message}"}

    if tier == AlertTier.INFO:
        _post_slack(SLACK_INFO_WEBHOOK, payload)
    elif tier == AlertTier.WARNING:
        _post_slack(SLACK_ALERT_WEBHOOK, payload)
        _send_sns(SNS_CRITICAL_TOPIC, subject, f"WARNING: {message}")
    elif tier == AlertTier.CRITICAL:
        _post_slack(SLACK_ALERT_WEBHOOK, payload)
        _send_sns(SNS_CRITICAL_TOPIC, subject, f"CRITICAL: {message}")


def _post_slack(webhook: str, payload: dict):
    req = urllib.request.Request(
        webhook,
        data=json.dumps(payload).encode(),
        headers={'Content-Type': 'application/json'}
    )
    urllib.request.urlopen(req)


def _send_sns(topic_arn: str, subject: str, message: str):
    sns = boto3.client('sns')
    sns.publish(TopicArn=topic_arn, Subject=subject[:100], Message=message)
</code></pre>
<p>The three tiers and what they respond to: Tier 1 INFO goes to a Slack informational channel for daily cost summaries, weekly trend reports, and tag compliance updates. No action required.</p>
<p>Tier 2 WARNING goes to a Slack alert channel plus email for budget above 75% utilisation, 25% week-over-week increases, and expiring Savings Plans. Acknowledge within 24 hours.</p>
<p>Tier 3 CRITICAL goes to PagerDuty plus SMS for budget above 90% utilisation, 100% increase in 24 hours, crypto mining detected, and projected overspend above 120% of plan. Investigate within 1 hour.</p>
<h3 id="heading-22-real-time-budget-monitor">2.2 Real-Time Budget Monitor</h3>
<p>AWS Budgets sends alerts once daily by default. A daily window means a cost spike that begins at 08:00 isn't caught until the next day's alert fires. The Lambda below runs hourly and checks both absolute budget utilisation and hour-over-hour rate of change.</p>
<pre><code class="language-python"># budget_monitor.py
# Lambda triggered by EventBridge every hour

import boto3
from datetime import datetime, timedelta
from alert_router import route_alert, AlertTier

ce      = boto3.client('ce')
budgets = boto3.client('budgets', region_name='us-east-1')
ACCOUNT_ID  = boto3.client('sts').get_caller_identity()['Account']
BUDGET_NAME = 'monthly-infrastructure'


def get_mtd_spend() -&gt; float:
    start = datetime.now().replace(day=1).strftime('%Y-%m-%d')
    end   = datetime.now().strftime('%Y-%m-%d')
    r = ce.get_cost_and_usage(
        TimePeriod={'Start': start, 'End': end},
        Granularity='MONTHLY',
        Metrics=['UnblendedCost']
    )
    return float(r['ResultsByTime'][0]['Total']['UnblendedCost']['Amount'])


def get_budget_limit() -&gt; float:
    r = budgets.describe_budget(AccountId=ACCOUNT_ID, BudgetName=BUDGET_NAME)
    return float(r['Budget']['BudgetLimit']['Amount'])


def get_hourly_costs(hours: int = 4) -&gt; list:
    """Return hourly cost totals for the last N hours."""
    end   = datetime.now()
    start = end - timedelta(hours=hours)
    r = ce.get_cost_and_usage(
        TimePeriod={'Start': start.strftime('%Y-%m-%d'), 'End': end.strftime('%Y-%m-%d')},
        Granularity='HOURLY',
        Metrics=['UnblendedCost']
    )
    return [
        float(period['Total']['UnblendedCost']['Amount'])
        for period in r['ResultsByTime']
    ]


def lambda_handler(event, context):
    mtd_spend    = get_mtd_spend()
    budget_limit = get_budget_limit()
    utilisation  = mtd_spend / budget_limit * 100

    days_elapsed  = datetime.now().day
    projected_eom = (mtd_spend / days_elapsed) * 30
    projected_pct = projected_eom / budget_limit * 100

    if utilisation &gt;= 90:
        route_alert(
            AlertTier.CRITICAL,
            f'Budget at {utilisation:.0f}%',
            f'MTD spend ${mtd_spend:,.2f} is {utilisation:.0f}% of ${budget_limit:,.0f} budget. '
            f'Projected EOM: ${projected_eom:,.2f}.'
        )
    elif utilisation &gt;= 75:
        route_alert(
            AlertTier.WARNING,
            f'Budget at {utilisation:.0f}%',
            f'MTD spend ${mtd_spend:,.2f} is {utilisation:.0f}% of ${budget_limit:,.0f} budget. '
            f'Projected EOM: ${projected_eom:,.2f}.'
        )

    # Check hourly spike
    hourly = get_hourly_costs(hours=4)
    if len(hourly) &gt;= 2:
        last_hour = hourly[-1]
        prev_avg  = sum(hourly[:-1]) / len(hourly[:-1])
        if prev_avg &gt; 0.10 and last_hour &gt; prev_avg * 1.5:
            route_alert(
                AlertTier.WARNING,
                'Hourly cost spike detected',
                f'Last hour: ${last_hour:.2f} vs prior 3-hour avg ${prev_avg:.2f} '
                f'(+{(last_hour/prev_avg - 1)*100:.0f}%)'
            )

    return {
        'mtd_spend':       round(mtd_spend, 2),
        'utilisation_pct': round(utilisation, 1),
        'projected_eom':   round(projected_eom, 2),
    }
</code></pre>
<h2 id="heading-part-3-optimisation-by-service">Part 3: Optimisation by Service</h2>
<h3 id="heading-31-ec2-seven-levers-in-priority-order">3.1 EC2 — Seven Levers in Priority Order</h3>
<p>EC2 is the largest line item in most AWS accounts and the one with the most optimisation options. Work through these levers in order, as each one lowers the baseline that the next lever acts on.</p>
<p>Lever 1: Find truly idle instances (CPU below 1% for 14 days).</p>
<pre><code class="language-python"># ec2_idle_finder.py
import boto3
from datetime import datetime, timedelta

ec2 = boto3.client('ec2')
cw  = boto3.client('cloudwatch')


def find_idle_instances(avg_cpu_threshold: float = 1.0, days: int = 14):
    instances = [
        inst
        for r in ec2.describe_instances(
            Filters=[{'Name': 'instance-state-name', 'Values': ['running']}]
        )['Reservations']
        for inst in r['Instances']
    ]

    idle = []
    for inst in instances:
        iid   = inst['InstanceId']
        stats = cw.get_metric_statistics(
            Namespace='AWS/EC2',
            MetricName='CPUUtilization',
            Dimensions=[{'Name': 'InstanceId', 'Value': iid}],
            StartTime=datetime.utcnow() - timedelta(days=days),
            EndTime=datetime.utcnow(),
            Period=days * 86400,
            Statistics=['Average']
        )['Datapoints']

        avg_cpu = stats[0]['Average'] if stats else 0.0
        if avg_cpu &lt; avg_cpu_threshold:
            tags = {t['Key']: t['Value'] for t in inst.get('Tags', [])}
            idle.append({
                'instance_id':   iid,
                'instance_type': inst['InstanceType'],
                'avg_cpu':       round(avg_cpu, 2),
                'environment':   tags.get('Environment', '—'),
                'owner':         tags.get('Owner', '—'),
            })

    return sorted(idle, key=lambda x: x['avg_cpu'])


for inst in find_idle_instances():
    print(f"  {inst['instance_id']}  {inst['instance_type']}  "
          f"{inst['avg_cpu']}% CPU  env:{inst['environment']}  owner:{inst['owner']}")
</code></pre>
<p>Lever 2: Right-size over-provisioned instances (CPU below 20%, sustained).</p>
<p>Use the same script with <code>avg_cpu_threshold=20.0</code>. These are right-sizing candidates, not shutdown candidates.</p>
<p>Lever 3: Schedule dev and staging shutdowns using EventBridge rules on <code>AutoShutdown=true</code> tagged instances.</p>
<p>Lever 4: Purchase Savings Plans only after completing levers 1–3.</p>
<p>Lever 5: Migrate to Graviton (20% cheaper, same performance for most workloads).</p>
<p>Lever 6: Use Spot for fault-tolerant batch and development workloads.</p>
<p>Lever 7: Migrate containerised workloads to EKS with Karpenter for automatic bin-packing.</p>
<p>Spot savings estimate:</p>
<pre><code class="language-python"># spot_price_analyser.py
import boto3

ec2 = boto3.client('ec2')


def spot_savings_estimate(instance_type: str) -&gt; dict:
    spot_history = ec2.describe_spot_price_history(
        InstanceTypes=[instance_type],
        ProductDescriptions=['Linux/UNIX'],
        MaxResults=1
    )['SpotPriceHistory']
    spot_price = float(spot_history[0]['SpotPrice']) if spot_history else 0

    on_demand_approx = {
        't3.medium': 0.0416, 'm5.large': 0.096,
        'c5.xlarge': 0.17,   'r5.2xlarge': 0.504,
    }
    od_price    = on_demand_approx.get(instance_type, 0)
    savings_pct = ((od_price - spot_price) / od_price * 100) if od_price else 0

    return {
        'instance_type': instance_type,
        'spot_price':    round(spot_price, 4),
        'on_demand':     od_price,
        'savings_pct':   round(savings_pct, 1),
        'monthly_spot':  round(spot_price * 730, 2),
        'monthly_od':    round(od_price * 730, 2),
    }


for itype in ['t3.medium', 'm5.large', 'c5.xlarge']:
    r = spot_savings_estimate(itype)
    print(f"  {r['instance_type']:&lt;15} Spot: ${r['spot_price']}/hr  "
          f"OD: ${r['on_demand']}/hr  Savings: {r['savings_pct']}%")
</code></pre>
<h3 id="heading-32-s3-lifecycle-policies-and-storage-class-selection">3.2 S3 — Lifecycle Policies and Storage Class Selection</h3>
<p>S3 optimisation has two components: moving infrequently accessed data to cheaper storage classes via lifecycle policies, and eliminating waste patterns like incomplete multipart uploads.</p>
<pre><code class="language-python"># s3_lifecycle_applier.py
import boto3

s3 = boto3.client('s3')

LOG_POLICY = {
    'Rules': [{
        'ID': 'standard-tiering',
        'Status': 'Enabled',
        'Filter': {'Prefix': ''},
        'Transitions': [
            {'Days': 30,  'StorageClass': 'STANDARD_IA'},
            {'Days': 90,  'StorageClass': 'GLACIER_IR'},
            {'Days': 365, 'StorageClass': 'DEEP_ARCHIVE'},
        ],
        'Expiration': {'Days': 2555},
        'AbortIncompleteMultipartUpload': {'DaysAfterInitiation': 7},
    }]
}

TEMP_POLICY = {
    'Rules': [{
        'ID': 'temp-data-retention',
        'Status': 'Enabled',
        'Filter': {'Prefix': ''},
        'Expiration': {'Days': 30},
        'AbortIncompleteMultipartUpload': {'DaysAfterInitiation': 1},
    }]
}

for bucket in s3.list_buckets()['Buckets']:
    name = bucket['Name']
    try:
        s3.get_bucket_lifecycle_configuration(Bucket=name)
        print(f"  {name} — policy already exists, skipping")
    except s3.exceptions.ClientError:
        policy = TEMP_POLICY if any(k in name for k in ['temp', 'build', 'cache']) else LOG_POLICY
        s3.put_bucket_lifecycle_configuration(
            Bucket=name, LifecycleConfiguration=policy
        )
        print(f"  {name} — applied {'TEMP' if policy is TEMP_POLICY else 'LOG'} policy")
</code></pre>
<h3 id="heading-33-rds-five-optimisation-levels">3.3 RDS — Five Optimisation Levels</h3>
<table>
<thead>
<tr>
<th>Level</th>
<th>Action</th>
<th>Typical Saving</th>
<th>Risk</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Delete unused read replicas</td>
<td>30–50% of replica cost</td>
<td>Low</td>
</tr>
<tr>
<td>2</td>
<td>Reduce backup retention to compliance minimum</td>
<td>20–30% of storage cost</td>
<td>Low</td>
</tr>
<tr>
<td>3</td>
<td>Right-size instance class (CPU below 20% sustained)</td>
<td>20–40% of compute</td>
<td>Medium</td>
</tr>
<tr>
<td>4</td>
<td>Purchase Reserved Instances for production</td>
<td>30–60% of compute</td>
<td>Low</td>
</tr>
<tr>
<td>5</td>
<td>Migrate variable-load DBs to Aurora Serverless v2</td>
<td>40–70% total</td>
<td>High effort</td>
</tr>
</tbody></table>
<p>Find over-provisioned RDS instances:</p>
<pre><code class="language-python"># rds_rightsizer.py
import boto3
from datetime import datetime, timedelta

rds = boto3.client('rds')
cw  = boto3.client('cloudwatch')


def find_oversized_rds():
    instances  = rds.describe_db_instances()['DBInstances']
    candidates = []

    for inst in instances:
        iid    = inst['DBInstanceIdentifier']
        iclass = inst['DBInstanceClass']

        stats = cw.get_metric_statistics(
            Namespace='AWS/RDS',
            MetricName='CPUUtilization',
            Dimensions=[{'Name': 'DBInstanceIdentifier', 'Value': iid}],
            StartTime=datetime.utcnow() - timedelta(days=14),
            EndTime=datetime.utcnow(),
            Period=1209600,
            Statistics=['Average', 'Maximum']
        )['Datapoints']

        if not stats:
            continue

        avg_cpu = stats[0]['Average']
        max_cpu = stats[0]['Maximum']

        if avg_cpu &lt; 20 and max_cpu &lt; 50:
            candidates.append({
                'id':       iid,
                'class':    iclass,
                'avg_cpu':  round(avg_cpu, 1),
                'max_cpu':  round(max_cpu, 1),
                'engine':   inst['Engine'],
            })

    return candidates


for c in find_oversized_rds():
    print(f"  {c['id']}  {c['class']}  avg:{c['avg_cpu']}%  max:{c['max_cpu']}%  engine:{c['engine']}")
</code></pre>
<h3 id="heading-34-dynamodb-on-demand-vs-provisioned-decision">3.4 DynamoDB — On-Demand vs Provisioned Decision</h3>
<p>On-demand is convenient but can be 3–5× more expensive than provisioned for predictable workloads. Provisioned with auto-scaling covers most cases at significantly lower cost.</p>
<pre><code class="language-python"># dynamodb_mode_advisor.py
import boto3
from datetime import datetime, timedelta

dynamodb = boto3.client('dynamodb')
cw       = boto3.client('cloudwatch')


def analyse_table_billing(table_name: str) -&gt; dict:
    table = dynamodb.describe_table(TableName=table_name)['Table']
    mode  = table.get('BillingModeSummary', {}).get('BillingMode', 'PROVISIONED')

    stats = {}
    for metric in ['ConsumedReadCapacityUnits', 'ConsumedWriteCapacityUnits']:
        data = cw.get_metric_statistics(
            Namespace='AWS/DynamoDB',
            MetricName=metric,
            Dimensions=[{'Name': 'TableName', 'Value': table_name}],
            StartTime=datetime.utcnow() - timedelta(days=30),
            EndTime=datetime.utcnow(),
            Period=86400,
            Statistics=['Average', 'Maximum']
        )['Datapoints']
        if data:
            avg  = sum(d['Average'] for d in data) / len(data)
            peak = max(d['Maximum'] for d in data)
            stats[metric] = {'avg': round(avg, 1), 'peak': round(peak, 1)}

    if mode == 'PAY_PER_REQUEST':
        avg_rcu = stats.get('ConsumedReadCapacityUnits', {}).get('avg', 0)
        avg_wcu = stats.get('ConsumedWriteCapacityUnits', {}).get('avg', 0)

        if avg_rcu &gt; 2000 or avg_wcu &gt; 500:
            return {
                'table':          table_name,
                'current_mode':   'PAY_PER_REQUEST',
                'recommendation': 'Switch to PROVISIONED with auto-scaling',
                'reason': f'Avg {avg_rcu:.0f} RCU/s and {avg_wcu:.0f} WCU/s — predictable pattern',
            }

    return {'table': table_name, 'current_mode': mode, 'recommendation': 'No change needed'}


for table in dynamodb.list_tables()['TableNames']:
    r = analyse_table_billing(table)
    if r['recommendation'] != 'No change needed':
        print(f"  {r['table']}: {r['recommendation']}")
</code></pre>
<h3 id="heading-35-data-transfer-the-three-main-waste-patterns">3.5 Data Transfer — The Three Main Waste Patterns</h3>
<p>Data transfer charges are often the most confusing line item on an AWS bill. Here are the the three main patterns and their fixes:</p>
<p>Cross-AZ traffic (most common, most fixable): services in different AZs incur $0.01/GB in each direction. The fix is to use topology-aware routing on Kubernetes Services or ensure your application tier and database tier use the same AZ placement.</p>
<p>NAT Gateway charges for internal AWS traffic: S3, ECR, DynamoDB, and SQS traffic that routes through NAT Gateway incurs $0.045/GB. The fix: VPC endpoints eliminate this entirely.</p>
<p>Inter-region replication that compliance doesn't require: audit your S3 replication rules quarterly against actual compliance requirements.</p>
<pre><code class="language-bash"># Find S3 buckets with active replication
for bucket in $(aws s3api list-buckets --query 'Buckets[*].Name' --output text); do
    result=$(aws s3api get-bucket-replication --bucket "$bucket" 2&gt;&amp;1)
    if ! echo "$result" | grep -q "ReplicationConfigurationNotFoundError"; then
        echo "  $bucket — replication active, verify compliance requirement"
    fi
done
</code></pre>
<h2 id="heading-part-4-the-30-day-optimisation-sprint">Part 4: The 30-Day Optimisation Sprint</h2>
<p>This sprint produces measurable savings in the first month. It's designed for a single engineer with two to four hours per week of dedicated FinOps time.</p>
<p>Week 1 – Visibility: enable CUR, set up the Athena table, run the three day-one queries, screenshot the results as your baseline, tag 100% of running EC2 and RDS instances, and identify your top three cost drivers with a documented hypothesis for each.</p>
<p>Week 2 – Quick wins: deploy the orphaned resource reporter Lambda, apply S3 lifecycle policies to your three largest buckets, run the idle instance finder and stop anything below 1% average CPU with no owner objection, and add VPC endpoints for S3, ECR, and DynamoDB.</p>
<p>Week 3 – Right-sizing: run the EC2 rightsizing analyser, downsize the three highest-confidence candidates (non-production first), run the RDS rightsizing script, and find read replicas serving minimal traffic and decommission them.</p>
<p>Week 4 – Alerting and automation: deploy the hourly budget monitor Lambda, configure the three-tier alert routing, set up the weekly waste reporter, add the Infracost GitHub Action to your infrastructure repository, and schedule a monthly 30-minute FinOps review meeting.</p>
<p>Expected outcome after 30 days: 15–25% reduction in monthly AWS spend, documented evidence of every change, and a recurring process that prevents the same waste from accumulating again.</p>
<h2 id="heading-best-practices-summary">Best Practices Summary</h2>
<p>✅ <strong>Do:</strong> Set up CUR + Athena before any other monitoring. Cost Explorer is a starting point, while CUR is the source of truth.</p>
<p>✅ <strong>Do:</strong> Enforce tagging in Terraform or CloudFormation. Process-based tagging decays, but infrastructure-enforced tagging is permanent.</p>
<p>✅ <strong>Do:</strong> Run the idle instance finder and waste reporter weekly. Waste accumulates continuously. A weekly report keeps the pile small.</p>
<p>✅ <strong>Do:</strong> Use the three-tier alert model. One alert channel with everything in it creates fatigue and gets muted.</p>
<p>✅ <strong>Do:</strong> Work through the EC2 optimisation levers in order. Right-sizing before Savings Plans prevents locking in waste at a discount.</p>
<p>✅ <strong>Do:</strong> Check DynamoDB billing mode against actual usage patterns quarterly.</p>
<p>❌ <strong>Don't:</strong> Delete untagged resources without investigation. Untagged doesn't mean unused, it means unclaimed.</p>
<p>❌ <strong>Don't:</strong> Apply aggressive S3 lifecycle policies without auditing access patterns first. Glacier retrieval fees can exceed Standard storage costs if data is accessed more frequently than expected.</p>
<p>❌ <strong>Don't:</strong> Run the waste reporter Lambda with auto-deletion enabled on its first deployment. Run in report-only mode for two weeks to validate the output before adding deletion logic.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p><a href="https://docs.aws.amazon.com/cur/latest/userguide/data-dictionary.html"><strong>AWS Cost and Usage Report Data Dictionary</strong></a>: Column reference for all CUR Athena queries in this guide</p>
</li>
<li><p><a href="https://docs.aws.amazon.com/cost-management/latest/APIReference/"><strong>AWS Cost Explorer API Reference</strong></a>: Full reference for the Python boto3 cost queries</p>
</li>
<li><p><a href="https://aws.amazon.com/compute-optimizer/"><strong>AWS Compute Optimizer</strong></a>: ML-powered right-sizing recommendations, useful as a cross-check against the manual analyser scripts</p>
</li>
<li><p><a href="https://aws.amazon.com/dynamodb/pricing/"><strong>Amazon DynamoDB Pricing</strong></a>: The definitive reference for the provisioned vs on-demand cost calculation in Section 3.4</p>
</li>
<li><p><a href="https://aws.amazon.com/solutions/implementations/instance-scheduler-on-aws/"><strong>AWS Instance Scheduler</strong></a>: The official AWS solution for tag-based EC2 and RDS scheduling</p>
</li>
<li><p><a href="https://www.finops.org/framework/"><strong>FinOps Foundation Framework</strong></a>: The practitioner framework that defines the Inform, Optimise, Operate cycle this guide implements</p>
</li>
<li><p><a href="https://github.com/aayostem/platform-toolkit"><strong>Companion Repository</strong></a>: All scripts, Lambda functions, and Terraform modules from this guide</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Convert Prometheus Histograms to OTLP with the OpenTelemetry Collector ]]>
                </title>
                <description>
                    <![CDATA[ Modern applications often expose metrics at a /metrics endpoint using the Prometheus format. Among these metrics, histograms are particularly useful. They show how often values fall into different ran ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-convert-prometheus-histogramsotlp-with-the-opentelemetry-collector/</link>
                <guid isPermaLink="false">6a8c4969642222471a04f943</guid>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Open Source ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloud native ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #prometheus ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops articles ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Purity Udeh ]]>
                </dc:creator>
                <pubDate>Sat, 22 Aug 2026 03:00:00 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/3dccb28f-d024-4c7a-9ac7-353594572842.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Modern applications often expose metrics at a <code>/metrics</code> endpoint using the Prometheus format.</p>
<p>Among these metrics, histograms are particularly useful. They show how often values fall into different ranges, like HTTP request durations, database query times, or queue processing latencies.</p>
<p>Unlike simple averages, histograms show the full picture: you can see how many requests are fast, how many are slow, and where the occasional outliers occur that might be silently degrading the user experience.</p>
<p>In payment systems, for instance, a sudden spike in transactions can expose hidden bottlenecks. Most of the requests might complete quickly, but a small percentage of slow transactions can ripple through the system, impacting retries, failures, and overall throughput. Histograms help identify these issues early by showing how values are distributed and highlighting outliers that averages obscure.</p>
<p>But not all backends understand Prometheus metrics natively. Many modern observability platforms prefer <strong>OTLP (OpenTelemetry Protocol)</strong>. Forwarding Prometheus metrics without converting them can lead to incomplete or misinterpreted data. That’s why we need a pipline to scrape, transform, and export histograms into OTLP so that your observability pipeline remains consistent and actionable.</p>
<p>In this article, we'll use the OpenTelemetry Collector to scrape Prometheus histograms from application <code>/metrics</code> endpoints, map them to the OpenTelemetry Histogram data model, and export them to our observability backend using OTLP. The Collector acts as a bridge that preserves data fidelity while ensuring compatibility with your monitoring platform.</p>
<p>To make this concrete, we'll use a small FastAPI application that simulates payment transactions. It exposes two Prometheus metrics: <code>payment_transaction_duration_seconds</code> (a histogram tracking how long each transaction takes) and <code>payment_transactions_total</code> (a counter of completed transactions). You can follow along with your own instrumented application, as anything exposing Prometheus metrics at <code>/metrics</code> will work the same way. This is the metric we'll follow from the application all the way to the observability backend.</p>
<h2 id="heading-what-well-cover">What We'll Cover:</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-1-how-to-scrape-metrics-with-prometheus-receiver"><strong>1. How to Scrape Metrics with Prometheus Receiver</strong></a></p>
</li>
<li><p><a href="#heading-2-transforming-prometheus-histograms"><strong>2. Transforming Prometheus Histograms</strong></a></p>
</li>
<li><p><a href="#heading-3-exporting-metrics-via-otlp"><strong>3. Exporting Metrics via OTLP</strong></a></p>
</li>
<li><p><a href="#heading-4-putting-the-pipeline-together"><strong>4. Putting the Pipeline Together</strong></a></p>
</li>
<li><p><a href="#heading-5-running-the-opentelemetry-collector"><strong>5. Running the OpenTelemetry Collector</strong></a></p>
<ul>
<li><p><a href="#heading-51-set-up-signoz-cloud"><strong>5.1 Set Up SigNoz Cloud</strong></a></p>
</li>
<li><p><a href="#heading-52-start-the-fastapi-application"><strong>5.2 Start the FastAPI Application</strong></a></p>
</li>
<li><p><a href="#heading-53-start-the-collector"><strong>5.3 Start the Collector</strong></a></p>
</li>
<li><p><a href="#heading-54-generate-test-transactions"><strong>5.4 Generate Test Transactions</strong></a></p>
</li>
<li><p><a href="#heading-55-confirm-backend-receipt"><strong>5.5 Confirm Backend Receipt</strong></a></p>
</li>
<li><p><a href="#heading-56-troubleshooting"><strong>5.6 Troubleshooting</strong></a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusion"><strong>Conclusion</strong></a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you begin, make sure you have:</p>
<ul>
<li><p>Docker installed</p>
</li>
<li><p>An application exposing Prometheus metrics through a <code>/metrics</code> endpoint</p>
</li>
<li><p>An OTLP-compatible observability backend</p>
</li>
<li><p>Basic knowledge of Prometheus metrics</p>
</li>
<li><p>Basic knowledge of YAML</p>
</li>
<li><p>Basic familiarity with Docker and OpenTelemetry</p>
</li>
</ul>
<p>You don't need advanced OpenTelemetry knowledge to follow this tutorial. I'll walk through the Prometheus histogram and show what happens to it as it moves through the OpenTelemetry Collector.</p>
<h2 id="heading-1-how-to-scrape-metrics-with-prometheus-receiver">1. How to Scrape Metrics with Prometheus Receiver</h2>
<p>First, we'll collect metrics from the application. The demo application exposes its Prometheus metrics at the <code>/metrics</code> endpoint, and the Prometheus receiver periodically scrapes this endpoint and ingests the metrics into the observability pipeline. You can also inspect <code>/metrics</code> directly to see the data before the Collector reads it.</p>
<p>Configuration setup example:</p>
<pre><code class="language-yaml">receivers:
  prometheus:
    config:
      scrape_configs:
        - job_name: payment-demo
          scrape_interval: 15s
          static_configs:
            - targets: ["payment-api:8080"]
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/5e891138-0408-41ee-9ae9-a68ac056aa81.png" alt="FastAPI /metrics output showing the payment transaction duration histogram in Prometheus format" style="display: block;" width="2382" height="998" loading="lazy">

<p>What's happening behind the scenes here:</p>
<p><code>scrape_interval</code> controls how often the Collector scrapes the target. Here, we're using 15 seconds; adjust it based on how frequently you need metric updates and the load your application can handle.</p>
<p>In high-throughput systems like payment platforms or real-time processing services, the scrape interval becomes particularly important. Setting it too long may cause you to miss short-lived performance issues or transient errors, while setting it too short risks overwhelming the service with scraping requests or generating excessive network traffic.</p>
<p><code>targets</code> lists the specific endpoints that expose Prometheus metrics. You can add multiple targets when you need to scrape more than one service.</p>
<p>Finally, <code>job_name</code> is an identifier that helps group metrics logically, making them easier to manage and analyze once they reach your backend.</p>
<img src="https://cdn.hashnode.com/res/hashnode/image/upload/v1770317251340/2853e8ee-b3d9-49c4-b931-3194eb2ea7d2.png" alt="Diagram showing an OpenTelemetry Collector discovering metrics targets, assigning targets, querying endpoints, and scraping metrics." style="display: block;" width="1204" height="816" loading="lazy">

<h2 id="heading-2-transforming-prometheus-histograms">2. Transforming Prometheus Histograms</h2>
<p>Once the metrics are scraped, the next step is transformation. Prometheus exposes histograms as multiple time series:</p>
<ul>
<li><p><code>_bucket</code> shows how many requests fall below a certain duration.</p>
</li>
<li><p><code>_sum</code> is the total of all observed durations.</p>
</li>
<li><p><code>_count</code> is the number of observations.</p>
</li>
</ul>
<p>The <code>payment_transaction_duration_seconds</code> histogram records how long each payment transaction takes. Prometheus exposes it as <code>_bucket</code>, <code>_count</code>, and <code>_sum</code> series. When the Prometheus receiver collects these metrics, it converts them into the OpenTelemetry Histogram data model, which can then be exported through OTLP while preserving the information needed to analyze transaction latency and calculate percentiles.</p>
<p>Without histograms, averages obscure latency distributions. If most requests complete in 50ms but 5% take 2+ seconds, the average of 150ms masks that serious performance issue. Histograms capture the complete picture by recording how many observations fall into each latency bucket.</p>
<p>Keeping the distribution allows you to identify changes in latency and investigate issues such as slow database queries, overloaded services, or delays from downstream dependencies.</p>
<h3 id="heading-actual-prometheus-histogram">Actual Prometheus Histogram</h3>
<p>This is how your actual <code>/metrics</code> output would look, for example:</p>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/8d1e4857-7d39-4cb1-99f7-5ce184adb59c.png" alt="Prometheus /metrics output showing the payment transaction duration histogram as _bucket time series with different latency boundaries" style="display: block;" width="1098" height="320" loading="lazy">

<p>And here's the actual OpenTelemetry Histogram representation:</p>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/ef856e3e-99d5-42af-9f39-3eb88d752505.png" alt="OpenTelemetry Collector output showing the payment_transaction_duration_seconds metric as a Histogram with bucket boundaries and counts." style="display: block;" width="1374" height="828" loading="lazy">

<p>The output above shows the same transaction-duration data represented as an OpenTelemetry Histogram. Instead of three separate Prometheus series, the Collector now has one histogram containing the count, sum, explicit bucket boundaries, and bucket counts.</p>
<h2 id="heading-3-exporting-metrics-via-otlp">3. Exporting Metrics via OTLP</h2>
<p>Once the metrics have been scraped and processed, the Collector sends them to your observability backend using OTLP. The OTLP exporter sends the processed metrics, including histograms, counters, and gauges, to the backend.</p>
<pre><code class="language-yaml">exporters:
&nbsp; otlp:
&nbsp; &nbsp; endpoint: "otlp.backend.example.com:4317"
&nbsp; &nbsp; tls:
&nbsp; &nbsp; &nbsp; insecure: false
</code></pre>
<p><code>endpoint</code> specifies the backend address for receiving OTLP metrics. This typically points to a central observability platform that aggregates metrics from multiple services across your infrastructure.</p>
<p><code>tls</code> ensures secure data transmission between the collector and your backend. Set <code>insecure: true</code> only when intentionally connecting to an endpoint that does not use TLS, such as some local development setups</p>
<h2 id="heading-4-putting-the-pipeline-together">4. Putting the Pipeline Together</h2>
<p>We've looked at each part of the pipeline individually. Now let's connect them and follow a metric from the application all the way to the backend.</p>
<pre><code class="language-yaml">service:
  pipelines:
    metrics:
      receivers: [prometheus]
      exporters: [otlp]
</code></pre>
<p>This is the complete path our <code>payment_transaction_duration_seconds</code> metric follows, from the FastAPI application to the observability backend. The diagram above shows this flow. The next section walks through actually running it and connecting to SigNoz</p>
<p><strong>Pipeline flow:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/ba4d6362-0e7a-4194-ba89-6fb3b4687489.png" alt="Pipeline flow architecture" style="display: block;" width="1199" height="1312" loading="lazy">

<p>The above architecture diagram shows a FastAPI application exposing metrics through /metrics, the OpenTelemetry Collector scraping them with the Prometheus receiver, processing the metrics, and exporting them through OTLP to an observability backend.</p>
<h2 id="heading-5-running-the-opentelemetry-collector">5. Running the OpenTelemetry Collector</h2>
<p>We'll run the complete pipeline and verify that the transaction metrics make it from the application to SigNoz. Follow these steps (which I'll walk you through in detail below):</p>
<ol>
<li><p><strong>Set up SigNoz Cloud:</strong> Configure the endpoint and ingestion key that the Collector will use.</p>
</li>
<li><p><strong>Start the FastAPI application:</strong> The application exposes the Prometheus metrics at <code>/metrics</code>.</p>
</li>
<li><p><strong>Start the OpenTelemetry Collector:</strong> The Collector begins scraping the application's <code>/metrics</code> endpoint using the Prometheus receiver.</p>
</li>
<li><p><strong>Generate test transactions:</strong> Send requests to the application to create transaction-duration measurements.</p>
</li>
<li><p><strong>Verify the Collector and backend:</strong> Check the Collector logs to confirm that the pipeline is running, then open SigNoz and verify that the <code>payment_transaction_duration_seconds</code> metric has arrived.</p>
</li>
<li><p>Troubleshooting</p>
</li>
</ol>
<h3 id="heading-51-set-up-signoz-cloud">5.1 Set Up SigNoz Cloud</h3>
<p>SigNoz provides the observability backend that will receive the metrics exported by the OpenTelemetry Collector. For this demo, we'll use SigNoz Cloud as the observability backend, so there's nothing to install locally.</p>
<p>First, sign up for a free account at <a href="http://signoz.io">signoz.io</a>. In the SigNoz Cloud dashboard, go to <strong>Settings → Ingestion Keys</strong>. The page shows your Ingestion URL, Region, and Ingestion Key.</p>
<p>Add these to a <code>.env</code> file in your project directory:</p>
<pre><code class="language-shell">SIGNOZ_INGESTION_KEY=your-real-key
SIGNOZ_OTLP_ENDPOINT=ingest.&lt;your-region&gt;.signoz.cloud:443
</code></pre>
<p>Treat the ingestion key like a password and never commit it or share it publicly.</p>
<p>Reference those variables in your Collector configuration:</p>
<pre><code class="language-shell">exporters:
  otlp:
    endpoint: "${SIGNOZ_OTLP_ENDPOINT}"
    tls:
      insecure: false
    headers:
      signoz-ingestion-key: "${SIGNOZ_INGESTION_KEY}"
</code></pre>
<p>Then add <code>.env</code> to your <code>.gitignore</code> so the key never gets committed:</p>
<pre><code class="language-plaintext">echo ".env" &gt;&gt; .gitignore
</code></pre>
<h3 id="heading-52-start-the-fastapi-application">5.2 Start the FastAPI Application.</h3>
<p>Start the application on port 8080:</p>
<pre><code class="language-shell">uvicorn app.main:app --reload --port 8080
</code></pre>
<p>Verify that the application is running:</p>
<pre><code class="language-shell">curl http://localhost:8080/
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/b27bcf94-e5da-43e6-b51f-f17fe06d90d9.png" alt="FastAPI payment demo running locally at 127.0.0.1:8080, displaying a JSON response confirming that the payment transaction demo is running" style="display: block;" width="1202" height="662" loading="lazy">

<p>Now inspect the Prometheus metrics:</p>
<pre><code class="language-shell">curl -Ls http://localhost:8080/metrics
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/10c4e1d5-d117-419d-8557-f94884f0bad8.png" alt="Browser showing the FastAPI payment demo running successfully at localhost:8080" style="display: block;" width="2312" height="964" loading="lazy">

<p>The <code>/metrics</code> endpoint exposes the application's Prometheus metrics, including the <code>payment_transaction_duration_seconds</code> histogram that the Collector will scrape.</p>
<h3 id="heading-53-start-the-collector">5.3 Start the Collector</h3>
<p>With the application running and the Collector configuration in place, start the Collector:</p>
<pre><code class="language-shell">docker compose up --build
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/3ef283ca-bea7-4fd3-82b5-9684c3b5dea9.png" alt="Docker Compose logs showing the payment API running and the Collector successfully scraping its /metrics endpoint." style="display: block;" width="1630" height="452" loading="lazy">

<p>Check the Collector logs to confirm that the pipeline is running and that metrics are being processed.</p>
<p>This builds the <code>payment-api</code> image, starts both containers on the shared <code>telemetry</code> network, and the Collector immediately begins scraping <code>/metrics</code> from the application using the Prometheus receiver, reading your SigNoz ingestion key and endpoint from <code>.env</code> automatically.</p>
<h3 id="heading-54-generate-test-transactions">5.4 Generate Test Transactions</h3>
<p>Generate transactions with different processing times:</p>
<pre><code class="language-shell">curl -X POST "http://localhost:8080/transactions?delay_ms=50"

curl -X POST "http://localhost:8080/transactions?delay_ms=250"

curl -X POST "http://localhost:8080/transactions?delay_ms=2500"
</code></pre>
<p>Generate additional requests if you want more observations in the histogram.</p>
<p>These requests create transaction-duration observations that are recorded by the <code>payment_transaction_duration_seconds</code> histogram. The Collector picks up the updated metric during its next scrape.</p>
<h3 id="heading-55-confirm-backend-receipt">5.5 Confirm Backend Receipt</h3>
<p>Open SigNoz Cloud and search for:</p>
<pre><code class="language-plaintext">payment_transaction_duration_seconds
</code></pre>
<p>The metric should be represented as a <strong>histogram</strong>, with its bucket distribution <code>_bucket</code>, <code>_sum</code>, and <code>_count</code> available to the backend rather than appearing as unrelated Prometheus series.</p>
<img src="https://cdn.hashnode.com/uploads/covers/611e0999c4783a33f5e25171/e4ea5b60-b7d4-4435-a427-d738e45ad19b.png" alt="SigNoz Metrics Explorer showing payment_transaction_duration_seconds as a histogram with associated bucket, count, and sum data after being exported through the OpenTelemetry Collector." style="display: block;" width="3212" height="1462" loading="lazy">

<h3 id="heading-56-troubleshooting">5.6 Troubleshooting</h3>
<p>If metrics aren't flowing correctly, start by checking the log:</p>
<pre><code class="language-bash">docker logs &lt;collector-container&gt;
</code></pre>
<p>Look for connection errors, authentication failures, failed scrape attempts, or configuration errors.</p>
<p>Also verify that:</p>
<ul>
<li><p>The FastAPI application is running.</p>
</li>
<li><p><code>/metrics</code> is accessible.</p>
</li>
<li><p>The Collector can reach the application.</p>
</li>
<li><p>The SigNoz endpoint and ingestion key are correct.</p>
</li>
<li><p>The <code>.env</code> variables are available to the Collector.</p>
</li>
<li><p>The receiver and exporter names match the pipeline configuration.</p>
</li>
</ul>
<p>Use the <code>--dry-run</code> flag if available to validate before deployment.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Prometheus histograms provide a useful view of transaction latency by showing how observations are distributed across different buckets rather than reducing them to a single average.</p>
<p>In this tutorial, we followed <code>payment_transaction_duration_seconds</code> from a FastAPI application's <code>/metrics</code> endpoint through the OpenTelemetry Collector and into SigNoz.</p>
<p>The Prometheus receiver mapped the <code>_bucket</code>, <code>_count</code>, and <code>_sum</code> series into the OpenTelemetry Histogram data model, preserving the distribution of transaction durations for analysis in the backend.</p>
<p>This allows the same metric to move from a Prometheus-instrumented application into an OTLP-based observability platform without manually reconstructing the histogram.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The Hidden Risks Lurking in Your Cloud Infrastructure ]]>
                </title>
                <description>
                    <![CDATA[ Cloud computing has changed how organisations build, deploy, and scale applications. Businesses can launch new services in minutes, expand into new markets, and reduce the cost of maintaining physical ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-hidden-risks-lurking-in-your-cloud-infrastructure/</link>
                <guid isPermaLink="false">6a872fd36fe3503416ed20d8</guid>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud infrastructure ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Thu, 20 Aug 2026 16:48:19 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/50037557-d27e-4f8c-bbb0-b9bc78582a5b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Cloud computing has changed how organisations build, deploy, and scale applications. Businesses can launch new services in minutes, expand into new markets, and reduce the cost of maintaining physical infrastructure.</p>
<p>While these advantages have fueled rapid cloud adoption, they've also introduced new security challenges that many organisations fail to recognise until it's too late.</p>
<p>Unlike traditional data centers, cloud environments are dynamic. New virtual machines, databases, storage accounts, containers, and serverless functions can appear and disappear throughout the day.</p>
<p>As cloud infrastructure grows, so does the number of potential security gaps. Many of these vulnerabilities remain hidden because they stem from configuration errors rather than software flaws.</p>
<p>This is where cloud security posture management becomes essential. Instead of relying on periodic security reviews, organisations can continuously monitor their cloud environments, identify risks, and remediate issues before they become security incidents.</p>
<p>For each of the five risks below, I'll walk through one concrete example of how the problem actually shows up in a real environment, and the specific steps a team would take to fix it. The examples are deliberately provider-neutral, because the same patterns appear across every major cloud platform under different names.</p>
<p>Understanding these hidden risks is the first step toward building a stronger and more resilient cloud environment.</p>
<h2 id="heading-what-well-cover">What We'll Cover</h2>
<ul>
<li><p><a href="#heading-why-cloud-infrastructure-creates-new-security-challenges">Why Cloud Infrastructure Creates New Security Challenges</a></p>
</li>
<li><p><a href="#heading-hidden-risk-1-cloud-misconfigurations">Hidden Risk #1: Cloud Misconfigurations</a></p>
<ul>
<li><p><a href="#heading-what-this-looks-like-in-practice">What this looks like in practice</a></p>
</li>
<li><p><a href="#heading-how-to-mitigate-it">How to mitigate it</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-hidden-risk-2-excessive-permissions">Hidden Risk #2: Excessive Permissions</a></p>
<ul>
<li><p><a href="#heading-what-this-looks-like-in-practice">What this looks like in practice</a></p>
</li>
<li><p><a href="#heading-how-to-mitigate-it">How to mitigate it</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-hidden-risk-3-forgotten-cloud-resources">Hidden Risk #3: Forgotten Cloud Resources</a></p>
<ul>
<li><p><a href="#heading-what-this-looks-like-in-practice">What this looks like in practice</a></p>
</li>
<li><p><a href="#heading-how-to-mitigate-it">How to mitigate it</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-hidden-risk-4-compliance-drift">Hidden Risk #4: Compliance Drift</a></p>
<ul>
<li><p><a href="#heading-what-this-looks-like-in-practice">What this looks like in practice</a></p>
</li>
<li><p><a href="#heading-how-to-mitigate-it">How to mitigate it</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-hidden-risk-5-limited-visibility-across-multi-cloud-environments">Hidden Risk #5: Limited Visibility Across Multi-Cloud Environments</a></p>
<ul>
<li><p><a href="#heading-what-this-looks-like-in-practice">What this looks like in practice</a></p>
</li>
<li><p><a href="#heading-how-to-mitigate-it">How to mitigate it</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-why-manual-security-reviews-are-no-longer-enough">Why Manual Security Reviews Are No Longer Enough</a></p>
</li>
<li><p><a href="#heading-how-cloud-security-posture-management-strengthens-cloud-security">How Cloud Security Posture Management Strengthens Cloud Security</a></p>
</li>
<li><p><a href="#heading-building-a-stronger-cloud-security-strategy">Building a Stronger Cloud Security Strategy</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-why-cloud-infrastructure-creates-new-security-challenges">Why Cloud Infrastructure Creates New Security Challenges</h2>
<p>Modern cloud environments are far more complex than the on-premises infrastructure many security teams were built to protect. A single organisation may use Amazon Web Services (AWS), Microsoft Azure, and Google Cloud Platform (GCP) simultaneously. Each provider offers hundreds of services, each with its own security settings and access controls.</p>
<p>Development teams also create new cloud resources at an unprecedented pace. <a href="https://aws.amazon.com/what-is/iac/">Infrastructure as Code</a>, automated deployment pipelines, and container orchestration platforms enable provisioning entire environments in minutes. While this improves productivity, it also increases the likelihood of configuration errors.</p>
<p>The shared responsibility model adds another layer of complexity. Cloud providers secure the underlying infrastructure, but customers remain responsible for securing their applications, identities, storage, networks, and data. Many organisations mistakenly assume their cloud provider handles every aspect of security, leaving critical resources exposed.</p>
<h2 id="heading-hidden-risk-1-cloud-misconfigurations">Hidden Risk #1: Cloud Misconfigurations</h2>
<p>Misconfigurations remain one of the leading causes of cloud security incidents. A single incorrect setting can expose sensitive information to the public internet or give attackers access to critical systems.</p>
<p>Examples include publicly accessible storage buckets, databases that allow unrestricted network access, disabled encryption settings, and security groups with overly permissive firewall rules. These mistakes often occur during rapid deployments when security reviews are skipped or delayed.</p>
<p>The challenge is that cloud environments change constantly. Even if an environment is secure today, a new deployment tomorrow may introduce a vulnerability without anyone noticing.</p>
<h3 id="heading-what-this-looks-like-in-practice">What This Looks Like in Practice</h3>
<p>A product team needs to share a set of report images with an external design agency. The quickest path is to create an object storage bucket and set its access policy to allow read access from any identity, rather than setting up temporary signed URLs or a guest account. The share works, the agency delivers, and the ticket is closed.</p>
<p>Six weeks later, the same bucket is reused as the destination for a nightly export job. Nobody rechecks the access policy, because the bucket already exists and "works." The export contains customer names, email addresses, and internal order IDs, and it's now readable by anyone who can guess or enumerate the bucket name. Automated scanners that crawl for open buckets will typically find it within days.</p>
<p>Nothing was hacked here. No credential was stolen and no software was exploited. A setting that was reasonable for one purpose was silently inherited by another.</p>
<h3 id="heading-how-to-mitigate-it">How to Mitigate it</h3>
<ol>
<li><p><strong>Turn on the account-level public access block:</strong> Every major provider offers an org- or account-wide setting that overrides individual resource policies and prevents public exposure of storage. Enable it as the default, and treat any exception as a change that requires approval.</p>
</li>
<li><p><strong>Scan the infrastructure code before it's applied, not after:</strong> Add a policy-as-code check to your deployment pipeline so a template that grants public read on a bucket fails the build. This is where you catch the problem for free, before it exists in the environment.</p>
</li>
<li><p><strong>Replace public access with time-limited access:</strong> For genuine external sharing, use pre-signed URLs with an expiry measured in hours, or a scoped guest identity. This gives the same convenience without leaving a permanent hole.</p>
</li>
<li><p><strong>Alert on the change, not just the state:</strong> Configure your posture management tool to fire when a bucket's access policy changes to allow anonymous principals, and route it to the team that owns the resource. Detecting the drift within minutes is what separates a near miss from a breach notification.</p>
</li>
</ol>
<p>Cloud security posture management continuously scans cloud resources for configuration issues and alerts security teams when risks appear. Instead of waiting for a scheduled audit, organisations gain visibility into problems as they occur.</p>
<h2 id="heading-hidden-risk-2-excessive-permissions">Hidden Risk #2: Excessive Permissions</h2>
<p><a href="https://www.ibm.com/solutions/identity-access-management">Identity and Access Management (IAM)</a> is one of the most important parts of cloud security. Unfortunately, it's also one of the most common sources of risk.</p>
<p>Many organisations grant users, applications, or service accounts more permissions than they actually need. Over time, these excessive privileges accumulate as employees change roles, applications evolve, and temporary permissions become permanent.</p>
<p>If an attacker compromises an account with broad privileges, the damage can spread quickly across the cloud environment. Sensitive data, production systems, and administrative resources may all become accessible.</p>
<h3 id="heading-what-this-looks-like-in-practice">What This Looks Like in Practice</h3>
<p>A team sets up a deployment pipeline. The pipeline needs to create compute instances, update a container registry, and write to a couple of storage locations. Working out the exact permission set takes time, and the first three attempts fail with cryptic access-denied errors. Under deadline pressure, someone attaches the built-in administrator role to the pipeline's service identity "just to unblock it," with the intention of narrowing it later.</p>
<p>Later never comes. The pipeline now holds full administrative rights across the account, and its credentials live in a CI/CD system that dozens of engineers can configure. Anyone who can modify a build file, or who compromises a single developer's account on that CI system, can execute arbitrary commands with administrator privileges like read every database, create new identities, or disable logging.</p>
<p>The same pattern appears with people. An engineer moves from platform to frontend work, keeps the old production access "in case something breaks," and two years later has accumulated permissions spanning four teams they no longer belong to.</p>
<h3 id="heading-how-to-mitigate-it">How to Mitigate it</h3>
<ol>
<li><p><strong>Pull the last-accessed data before you touch anything:</strong> Every major provider records which services an identity has actually used and when. Start by listing every role with administrative privileges and check what it has genuinely called in the past 90 days. In most environments the real usage is a small fraction of what's granted.</p>
</li>
<li><p><strong>Generate a scoped policy from observed activity:</strong> Use the provider's policy-generation tooling (or your posture management platform) to produce a candidate policy based on that access history, then review it manually before applying. Applying the <a href="https://www.freecodecamp.org/news/principle-of-lease-privilege-meaning-cybersecurity/">principle of least privilege</a> is far easier when you start from evidence rather than guesswork.</p>
</li>
<li><p><strong>Roll it out in audit mode first:</strong> Apply the narrowed policy to a non-production copy of the pipeline, or run it alongside the permissive one with denial logging enabled, so you find the missing permissions without breaking deployments.</p>
</li>
<li><p><strong>Set an expiry on elevated access:</strong> Temporary administrative rights should be granted through a just-in-time mechanism that expires automatically, so the "narrow it later" step happens whether or not anyone remembers.</p>
</li>
<li><p><strong>Watch for privilege escalation paths, not just broad roles:</strong> A role that can't do much directly but can create identities, attach policies, or pass a privileged role to a new resource is effectively an administrator. Posture management tools flag these chains, which are almost impossible to spot by reading policies one at a time.</p>
</li>
</ol>
<h2 id="heading-hidden-risk-3-forgotten-cloud-resources">Hidden Risk #3: Forgotten Cloud Resources</h2>
<p>Cloud environments are rarely static. Development teams frequently create temporary virtual machines, test databases, storage accounts, and networking components while building new features. Once projects are completed, these resources are often forgotten rather than removed.</p>
<p>Although these abandoned resources may no longer serve a business purpose, they continue to consume cloud resources and may still contain sensitive information. Some may even remain connected to production networks.</p>
<p>These overlooked assets create unnecessary attack surfaces. Attackers often look for neglected systems because they're less likely to receive security updates or active monitoring.</p>
<h3 id="heading-what-this-looks-like-in-practice">What This Looks Like in Practice</h3>
<p>Ahead of a major release, a team spins up a load-testing environment: a handful of compute instances, a database restored from a production snapshot so the test data is realistic, and a permissive network rule opening administrative access from any address, because the testers are working from home and nobody wants to maintain an IP allowlist for a two-week project.</p>
<p>The release ships. The environment stays. A year on, those instances are running an operating system image that has missed four rounds of patches, the database still contains a year-old copy of real customer records, and the administrative port is still open to the internet. It doesn't appear on any architecture diagram, it isn't in the patching rota, and no alerting is configured against it, because it was never meant to exist for more than a fortnight.</p>
<p>This is one of the most attractive targets in a cloud estate: real data, no monitoring, and a known-vulnerable software version.</p>
<h3 id="heading-how-to-mitigate-it">How to Mitigate it</h3>
<ol>
<li><p><strong>Build an inventory that doesn't depend on memory:</strong> Query every region in every account for running compute, databases, storage, and snapshots, including the regions you believe you don't use, which is where forgotten resources most often hide. Deploying <a href="https://orca.security/resources/blog/open-source-application-security-tools/">application security tools</a> across both active and forgotten resources helps ensure vulnerabilities are caught before they can be exploited.</p>
</li>
<li><p><strong>Sort by last activity, then by exposure:</strong> Rank resources by how recently they saw network traffic or authentication, and cross-reference against public network reachability. A resource that's both idle and internet-facing is the highest-priority item on the list, every time.</p>
</li>
<li><p><strong>Require an owner tag at creation:</strong> Enforce a policy that rejects the creation of any resource without owner and environment tags. Untagged resources become the queue you work through, and the queue stops growing.</p>
</li>
<li><p><strong>Give non-production resources a default lifetime:</strong> Attach an expiry tag to anything created in a test or sandbox account, and run an automated job that stops resources past their date and deletes them after a grace period. Make deletion the default and continuation the thing that requires a decision.</p>
</li>
<li><p><strong>Treat production data in test environments as an incident:</strong> Restoring a production snapshot into a lower environment should trigger an alert. If realistic data is genuinely needed, mask it as part of the restore process.</p>
</li>
</ol>
<p>Continuous asset discovery is another important capability of cloud security posture management. By maintaining an up-to-date inventory of cloud resources, organisations can quickly identify unused assets and remove them before they become security liabilities.</p>
<h2 id="heading-hidden-risk-4-compliance-drift">Hidden Risk #4: Compliance Drift</h2>
<p>Meeting regulatory requirements isn't a one-time project. Standards such as ISO 27001, SOC 2, PCI DSS, HIPAA, and GDPR require organisations to maintain secure configurations over time.</p>
<p>As cloud environments evolve, once-compliant systems can gradually drift away from required security baselines. A disabled encryption setting, an open network port, or missing activity logs may create compliance violations without anyone noticing.</p>
<p>Manual compliance reviews often occur quarterly or annually, leaving long periods during which risks remain undetected.</p>
<h3 id="heading-what-this-looks-like-in-practice">What This Looks Like in Practice</h3>
<p>An organisation passes its SOC 2 audit. Part of the evidence is that API activity logging is enabled across all accounts and all regions, with logs written to a dedicated, immutable archive.</p>
<p>Four months later, an engineer investigating a surprise bill notices that log storage is one of the larger line items. Most of the volume comes from high-frequency read events in a development account. They disable logging in that account to cut costs. It's a sensible-looking decision made with incomplete context, and it's approved in a routine change ticket by someone who doesn't know the account is in the audit scope.</p>
<p>Nothing breaks and no alert fires. The gap surfaces eight months later during audit fieldwork, when the assessor asks for a continuous log record for the period and the organisation can't produce one. There's no way to backfill it. The finding stands, and the same gap means that if an incident had occurred in that account, there would be no record of who did what.</p>
<h3 id="heading-how-to-mitigate-it">How to Mitigate it</h3>
<ol>
<li><p><strong>Map each control to a machine-checkable rule:</strong> "Activity logging is enabled everywhere" becomes a specific check: a multi-region trail exists in every account, log file validation is on, and the destination has retention and object-lock configured. If a control can't be expressed as a check, it can't be monitored continuously.</p>
</li>
<li><p><strong>Make the critical controls structurally hard to disable:</strong> Deploy logging from the organisation level rather than per account, and apply a service control policy that denies the disable and delete actions to everyone except a break-glass identity. This turns a one-click mistake into a deliberate act.</p>
</li>
<li><p><strong>Run the checks continuously and alert on transitions:</strong> The valuable signal isn't the monthly compliance score, it's the moment a control moves from compliant to non-compliant. Route that event to a channel someone actually reads, with the account, the resource, and the identity that made the change.</p>
</li>
<li><p><strong>Add a compliance-scope check to your change process:</strong> Change tickets touching accounts in audit scope should surface that fact automatically, so the approver knows what they're approving.</p>
</li>
<li><p><strong>Keep the evidence as you go:</strong> Store the continuous check results with timestamps. Audit preparation then becomes an export rather than a scramble, and you find gaps months before an assessor does.</p>
</li>
</ol>
<p>Cloud security posture management continuously evaluates cloud environments against security frameworks and compliance standards. This allows organisations to detect violations early, simplify audit preparation, and reduce regulatory risk.</p>
<h2 id="heading-hidden-risk-5-limited-visibility-across-multi-cloud-environments">Hidden Risk #5: Limited Visibility Across Multi-Cloud Environments</h2>
<p>Many organisations now operate workloads across multiple cloud providers. Development teams may choose different platforms based on cost, performance, or specialised services.</p>
<p>While this approach offers flexibility, it also creates fragmented security visibility. Each cloud provider has its own monitoring tools, dashboards, and security controls. Security teams may struggle to understand the overall security posture of the organisation.</p>
<p>Without centralised visibility, important risks can easily go unnoticed. One cloud environment may enforce strong security policies while another contains exposed resources and outdated configurations.</p>
<h3 id="heading-what-this-looks-like-in-practice">What This Looks Like in Practice</h3>
<p>An organisation runs its core platform on one cloud provider, where the security team has spent two years building guardrails: preventative policies, automated scanning, and an on-call rota that responds to findings.</p>
<p>Meanwhile, the data science group adopts a second provider for its managed machine learning tooling. The account is opened on a corporate card, funded through a departmental budget, and never onboarded into the security programme. There's no reason for anyone to notice, no ticket crosses the security team's desk, because from the data team's perspective nothing about their work has changed.</p>
<p>That second environment ends up hosting training datasets derived from production records. Its storage defaults are different from the primary provider's, the equivalent of "block public access" isn't switched on, and the terminology doesn't map cleanly onto the checks the security team already wrote. The organisation's security dashboard shows a healthy posture, and it's accurate for the 70% of the estate it can see.</p>
<p>The failure isn't a weak control. It's that a whole environment is outside the boundary of the thing doing the measuring.</p>
<h3 id="heading-how-to-mitigate-it">How to Mitigate it</h3>
<ol>
<li><p><strong>Start with a billing reconciliation, not a technical scan:</strong> Ask finance for every cloud vendor the organisation pays, then compare that list against the accounts your security tooling monitors. Shadow cloud environments show up in expense reports long before they show up in a scanner.</p>
</li>
<li><p><strong>Write your baseline in terms of outcomes, not provider features:</strong> Define controls as "object storage must not be readable by anonymous principals" rather than naming a specific provider's setting. One statement then maps onto every platform, and the standard doesn't fragment.</p>
</li>
<li><p><strong>Normalise findings into a single queue:</strong> Pull results from each provider into one place with consistent severity scoring, so a critical exposure in the smallest environment ranks above a low-severity finding in the largest. Severity should reflect business impact, not which dashboard raised it.</p>
</li>
<li><p><strong>Make onboarding a condition of account creation:</strong> Require any new cloud account, on any provider, to be enrolled in central monitoring before it can hold production or production-derived data. Pair the requirement with a fast, low-friction path so teams aren't tempted to route around it.</p>
</li>
<li><p><strong>Re-run the discovery on a schedule:</strong> New accounts appear continuously. Repeat the billing reconciliation quarterly, because the environment you don't know about is by definition the one you'll never get an alert for.</p>
</li>
</ol>
<p>Cloud security posture management provides a unified view across multiple cloud providers, allowing organisations to monitor security consistently regardless of where workloads are deployed.</p>
<h2 id="heading-why-manual-security-reviews-are-no-longer-enough">Why Manual Security Reviews Are No Longer Enough</h2>
<p>Traditional security audits were designed for relatively stable infrastructure. Cloud environments operate very differently.</p>
<p>Infrastructure changes can occur hundreds or even thousands of times each day through automated deployment pipelines. New services are launched continuously, configurations are updated frequently, and development teams release software at a rapid pace.</p>
<p>Human reviewers can't keep up with this level of change. By the time a manual assessment is completed, the cloud environment may already look completely different.</p>
<p>Notice what the five examples above have in common. In every case, the environment was compliant at some point in the past, and a single reasonable-looking decision moved it out of compliance without producing an error, an outage, or a complaint. A bucket policy was reused. A role was widened to unblock a deployment. A test environment outlived its project. Logging was disabled to control cost. An account was opened outside the security boundary.</p>
<p>None of these would be caught by a quarterly review, because by the time a reviewer arrives, the change is months old and looks like part of the furniture. Automation has become essential. Continuous monitoring allows organisations to identify new risks immediately instead of discovering them weeks or months later.</p>
<h2 id="heading-how-cloud-security-posture-management-strengthens-cloud-security">How Cloud Security Posture Management Strengthens Cloud Security</h2>
<p>Cloud security posture management provides continuous visibility into cloud environments by automatically evaluating resources against security best practices and organisational policies.</p>
<p>Rather than replacing existing security tools, it complements them by focusing on configuration security, governance, compliance, and risk reduction.</p>
<p>A modern cloud security posture management platform can discover cloud assets, monitor configuration changes, identify excessive permissions, detect publicly exposed resources, validate compliance requirements, and prioritise security findings based on business impact.</p>
<p>Many platforms also support automated remediation. Instead of simply reporting an issue, they can trigger workflows that correct insecure configurations or notify the appropriate teams before vulnerabilities are exploited.</p>
<p>A word of caution on automated remediation: start it in report-only mode. A rule that automatically closes public access is exactly right for a storage bucket holding internal exports and exactly wrong for one serving a public website's static assets. Run new rules in observation mode for a few weeks, review what they would have changed, and only then let them act , beginning with the resource types where a false positive is cheap.</p>
<p>This proactive approach allows organisations to reduce risk without slowing down software development.</p>
<h2 id="heading-building-a-stronger-cloud-security-strategy">Building a Stronger Cloud Security Strategy</h2>
<p>Technology alone can't eliminate cloud security risks. Organisations must also establish strong governance processes and promote security awareness across development, operations, and security teams.</p>
<p>Security should become part of the software development lifecycle rather than an activity performed after deployment. Infrastructure templates should follow secure defaults, access permissions should be reviewed regularly, and cloud resources should be continuously monitored throughout their lifecycle.</p>
<p>If you're starting from scratch, the sequence that tends to work is: inventory first, since you can't secure what you can't see; then public exposure, because that's where the shortest path from mistake to breach runs; then identity, because over-permissioned roles determine how far an attacker gets once inside; and finally compliance mapping, which is far easier once the first three are under control.</p>
<p>Regular security training also helps reduce human error. Developers who understand cloud security best practices are less likely to introduce risky configurations during deployment. It's worth noting that in every example in this article, the person who introduced the risk was doing their job competently under normal constraints. Training works best when it's framed that way, and when the secure path is also the convenient one.</p>
<p>Combining secure development practices with cloud security posture management creates multiple layers of protection that significantly reduce the likelihood of security incidents.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Cloud infrastructure offers tremendous flexibility, but it also introduces risks that are often difficult to detect through traditional security practices. Misconfigured resources, excessive permissions, forgotten assets, compliance drift, and fragmented visibility can quietly weaken an organisation's security posture without triggering immediate alarms.</p>
<p>As cloud environments continue to grow in size and complexity, continuous monitoring has become a necessity rather than a luxury. Cloud security posture management enables organisations to identify vulnerabilities, maintain compliance, and respond to security risks before they lead to costly breaches.</p>
<p>The organisations that succeed in the cloud aren't those that eliminate every possible risk. They're the ones who continuously understand their environment, detect changes as they happen, and act quickly to maintain a secure cloud infrastructure.</p>
<p>In a landscape where new resources are created every day, maintaining visibility into your cloud security posture is one of the most effective ways to stay ahead of evolving threats.</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 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[ The Cloud Pentesting Problem: Why Traditional Security Models Stop Working at Scale ]]>
                </title>
                <description>
                    <![CDATA[ Cloud adoption changed how companies build software. It changed deployment speed, infrastructure management, and the way engineering teams operate. It also changed the security landscape. Applications ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-cloud-pentesting-problem-why-traditional-security-models-stop-working-at-scale/</link>
                <guid isPermaLink="false">6a4548f4ed45771311e74acb</guid>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ penetration testing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ pentesting ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Manish Shivanandhan ]]>
                </dc:creator>
                <pubDate>Wed, 01 Jul 2026 17:05:56 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/388a914f-83be-4b56-ac1c-f66788052097.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Cloud adoption changed how companies build software.</p>
<p>It changed deployment speed, infrastructure management, and the way engineering teams operate. It also changed the security landscape.</p>
<p>Applications that once lived on a few static servers now run across containers, Kubernetes clusters, APIs, serverless functions, and multiple cloud providers.</p>
<p>Many organisations moved from a handful of assets to thousands in only a few years. Yet while infrastructure evolved rapidly, penetration testing models often stayed the same.</p>
<p>The result is a growing mismatch. Traditional pentesting approaches were designed for environments that changed slowly. Cloud environments don't work that way.</p>
<p>Systems spin up and disappear within minutes. New code reaches production many times per day. Infrastructure is increasingly dynamic and distributed.</p>
<p>The problem isn't that traditional pentesting stopped being useful. The problem is that it stopped being enough.</p>
<p>In this article, you'll learn why traditional penetration testing struggles in modern cloud environments, how cloud infrastructure changes the security model, and how organisations are moving toward continuous security validation.</p>
<p>We'll also look at what continuous pentesting means in practice and how automation and human expertise work together.</p>
<p><strong>Prerequisites:</strong> A basic understanding of cloud computing concepts such as virtual machines, containers, APIs, and CI/CD pipelines will help, but no prior penetration testing experience is required.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ul>
<li><p><a href="#heading-traditional-pentesting-was-built-for-stable-environments">Traditional Pentesting Was Built for Stable Environments</a></p>
</li>
<li><p><a href="#heading-infrastructure-growth-creates-an-explosion-of-attack-surface">Infrastructure Growth Creates an Explosion of Attack Surface</a></p>
</li>
<li><p><a href="#heading-multi-cloud-makes-visibility-even-harder">Multi-Cloud Makes Visibility Even Harder</a></p>
</li>
<li><p><a href="#heading-speed-creates-security-gaps">Speed Creates Security Gaps</a></p>
</li>
<li><p><a href="#heading-cloud-infrastructure-is-temporary-by-design">Cloud Infrastructure Is Temporary by Design</a></p>
</li>
<li><p><a href="#heading-security-teams-need-more-than-reports">Security Teams Need More Than Reports</a></p>
</li>
<li><p><a href="#heading-the-shift-toward-continuous-pentesting">The Shift Toward Continuous Pentesting</a></p>
</li>
<li><p><a href="#heading-cloud-changed-the-rules">Cloud Changed the Rules</a></p>
</li>
</ul>
<h2 id="heading-traditional-pentesting-was-built-for-stable-environments"><strong>Traditional Pentesting Was Built for Stable Environments</strong></h2>
<p>For years, pentesting followed a familiar cycle. Companies defined the scope, hired security specialists, conducted an assessment, received a report, addressed the findings, and repeated the process months later.</p>
<p>That process worked well in traditional environments. Infrastructure was relatively static. Applications changed less frequently. Production systems remained predictable enough that a point-in-time assessment could provide value for an extended period.</p>
<p>A financial institution may have deployed major releases every quarter. An enterprise application might only change several times each year. Under those conditions, a pentest represented a useful snapshot of risk.</p>
<p>Cloud environments broke that assumption.</p>
<p>Today, a company running on cloud platforms like <a href="https://azure.microsoft.com">Microsoft Azure</a> or <a href="https://aws.amazon.com">Amazon Web Services</a> can deploy hundreds of changes in a single week. Infrastructure teams use automation tools to create environments instantly. Engineering organisations rely on microservices that continuously evolve.</p>
<p>By the time a pentest report arrives, parts of the environment may already be different.</p>
<p>Security teams are trying to defend a moving target.</p>
<h2 id="heading-infrastructure-growth-creates-an-explosion-of-attack-surface"><strong>Infrastructure Growth Creates an Explosion of Attack Surface</strong></h2>
<p>Cloud systems rarely become simpler as organisations grow. The opposite usually happens.</p>
<p>A small startup may begin with a few virtual machines and a database. A larger organisation eventually accumulates APIs, serverless workloads, container clusters, identity systems, third-party integrations, CI/CD pipelines, and regional deployments.</p>
<p>Every new service introduces new security questions.</p>
<ul>
<li><p>Who has access?</p>
</li>
<li><p>What permissions exist?</p>
</li>
<li><p>Which APIs are exposed externally?</p>
</li>
<li><p>Which workloads communicate internally?</p>
</li>
<li><p>Where are secrets stored?</p>
</li>
<li><p>What changed last week?</p>
</li>
</ul>
<p>Answering those questions manually becomes increasingly difficult.</p>
<p>The challenge isn't simply the number of assets. It's their rate of change.</p>
<p>Traditional pentesting was designed around known systems and a defined scope. Cloud environments continuously generate new scope.</p>
<p>That difference matters.</p>
<p>Security teams may successfully test what exists today while missing what appears tomorrow.</p>
<h2 id="heading-multi-cloud-makes-visibility-even-harder"><strong>Multi-Cloud Makes Visibility Even Harder</strong></h2>
<p>Many organisations no longer operate within a single environment.</p>
<p>Different teams may deploy workloads across cloud platforms for cost, capability, or business reasons. Development teams often make independent technology decisions. Acquisitions introduce entirely new infrastructure stacks.</p>
<p>As a result, environments become fragmented.</p>
<p>An organisation might run applications in AWS, analytics workloads in Azure, and internal systems elsewhere. Each environment introduces different security models, logging systems, identity controls, and operational practices.</p>
<p>Consistency becomes difficult.</p>
<p>Security teams now face a visibility problem as much as a testing problem.</p>
<p>The challenge is no longer just finding vulnerabilities. The challenge is knowing where testing should happen in the first place.</p>
<p>Large enterprises frequently discover forgotten environments, abandoned APIs, unused assets, or infrastructure that security teams never knew existed.</p>
<p>Traditional pentesting assumes complete visibility. But cloud environments often provide the opposite.</p>
<h2 id="heading-speed-creates-security-gaps"><strong>Speed Creates Security Gaps</strong></h2>
<p>Engineering organisations optimise for delivery speed. And that decision makes sense. Faster iteration creates business value.</p>
<p>Modern deployment systems, supported by tools from companies like <a href="https://github.com">GitHub</a> and <a href="https://newrelic.com">New Relic,</a> help teams release features quickly and continuously monitor applications.</p>
<p>But speed creates unintended side effects.</p>
<p>Security processes built around manual reviews often become bottlenecks. When development teams deploy ten times each day, security teams can't manually assess every change.</p>
<p>This creates difficult tradeoffs: either security slows releases or releases move ahead without sufficient validation.</p>
<p>Neither outcome works well.</p>
<p>Organisations often discover a hidden reality: scaling software delivery doesn't automatically scale security operations.</p>
<p>The old process eventually breaks under volume.</p>
<h2 id="heading-cloud-infrastructure-is-temporary-by-design"><strong>Cloud Infrastructure Is Temporary by Design</strong></h2>
<p>Traditional systems generally remained active for long periods.</p>
<p>Cloud infrastructure behaves differently.</p>
<p>Containers may run briefly before replacement. Autoscaling systems create resources during peak traffic and remove them later. Development environments appear and disappear continuously.</p>
<p>Some assets may only exist for hours. Others may live for minutes.</p>
<p>This creates a serious challenge for scheduled assessments. A pentest performed on Monday might never examine infrastructure created on Wednesday.</p>
<p>The concept of testing a fixed environment becomes harder when the environment itself changes constantly.</p>
<p>Security teams increasingly need continuous awareness rather than periodic review.</p>
<p>The question shifts from "Did we test this?" toward "How do we know what changed?"</p>
<p>That's a very different operating model.</p>
<h2 id="heading-security-teams-need-more-than-reports"><strong>Security Teams Need More Than Reports</strong></h2>
<p>Traditional pentesting often ends with a report.</p>
<p>The report identifies findings and severity levels. Engineering teams then review and prioritise remediation work.</p>
<p>This approach creates delays. Findings become disconnected from operational systems. Teams manually transfer issues into workflows. Security and engineering often operate separately.</p>
<p>Modern engineering organisations increasingly expect security to integrate directly into development processes.</p>
<p>Security findings need context, ownership, and prioritisation. And most importantly, they need to fit naturally into how engineering teams already work.</p>
<p>A PDF delivered weeks later doesn't align well with <a href="https://www.ibm.com/think/topics/continuous-deployment">continuous deployment</a> environments.</p>
<p>Security increasingly behaves like an engineering discipline rather than an isolated review function.</p>
<h2 id="heading-the-shift-toward-continuous-pentesting"><strong>The Shift Toward Continuous Pentesting</strong></h2>
<p>Continuous pentesting represents a shift in how organisations approach offensive security. Rather than treating penetration testing as a scheduled activity performed a few times each year, many teams now view security validation as an ongoing process that keeps pace with continuously changing infrastructure.</p>
<p>This approach combines continuous visibility with automation to monitor the current state of cloud environments. Instead of asking whether an assessment happened last quarter, security teams ask whether they have an accurate, real-time understanding of their attack surface.</p>
<p>That means continuously collecting security signals from across the environment. These signals include newly deployed internet-facing services, changes to <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies.html">identity and access management (IAM)</a> permissions, vulnerable container images, exposed secrets, misconfigured storage buckets, infrastructure-as-code changes, dependency vulnerabilities, and unusual authentication or network activity.</p>
<p>By monitoring these signals as infrastructure evolves, teams can detect security issues soon after they appear rather than waiting for the next scheduled assessment.</p>
<p>Many of these checks are automated. Cloud security platforms, vulnerability scanners, infrastructure-as-code analyzers, and CI/CD pipelines continuously discover new assets, scan configurations, identify common vulnerabilities, detect exposed credentials, and monitor changes that could increase an organisation's attack surface.</p>
<p>Instead of producing isolated findings, modern security platforms correlate information from multiple sources to highlight issues that are most likely to represent genuine risk.</p>
<p>This doesn't eliminate the need for human expertise. Experienced security professionals remain essential for validating whether a vulnerability is actually exploitable, understanding business context, chaining together multiple weaknesses into realistic attack paths, prioritising remediation efforts, and performing deep manual assessments that automated tools cannot replicate.</p>
<p>The difference is that repetitive, high-volume work increasingly becomes automated, allowing security teams to spend less time discovering obvious issues and more time investigating the complex risks that require human judgment.</p>
<p>Platforms such as <a href="https://xbow.com/">XBOW</a> reflect this broader shift. As cloud environments become larger and more dynamic, organisations increasingly need systems that continuously validate changing infrastructure and provide ongoing visibility into their security posture rather than relying solely on periodic assessment cycles.</p>
<p>The objective isn't to replace people. It's to enable security professionals to focus their expertise where it delivers the most value while automation handles the scale and speed of modern cloud infrastructure.</p>
<h2 id="heading-cloud-changed-the-rules"><strong>Cloud Changed the Rules</strong></h2>
<p>The central challenge isn't that security teams suddenly became less effective. The environment changed.</p>
<p>Traditional pentesting evolved in a world of stable infrastructure, predictable deployments, and relatively fixed boundaries.</p>
<p>Cloud systems operate differently. Infrastructure changes continuously. Assets appear and disappear rapidly. Development cycles accelerate. Scope expands faster than manual processes can handle.</p>
<p>The security practices that worked well ten years ago are colliding with modern infrastructure realities.</p>
<p>Organisations that recognise this shift early are changing how they think about security operations. They're moving away from isolated assessments and toward continuous visibility.</p>
<p>Because in cloud environments, risk is no longer static. So security can't be static either.</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 Enterprise Teams Manage Infrastructure at Scale with Terraform ]]>
                </title>
                <description>
                    <![CDATA[ Tutorials teach you how to write Terraform, but don't teach you what happens when 60 engineers start writing it together. When you learn Terraform, you work with a single repository, state file, and a ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-enterprise-teams-manage-infrastructure-at-scale-with-terraform/</link>
                <guid isPermaLink="false">6a3aaccb0aca21a37c59db4a</guid>
                
                    <category>
                        <![CDATA[ Terraform ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Infrastructure as code ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Osomudeya Zudonu ]]>
                </dc:creator>
                <pubDate>Tue, 23 Jun 2026 15:56:59 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/fb89a3e9-6826-4fc9-bebb-d16ef6a6d31d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Tutorials teach you how to write Terraform, but don't teach you what happens when 60 engineers start writing it together.</p>
<p>When you learn Terraform, you work with a single repository, state file, and a single environment. You run <code>terraform apply</code> from your laptop, and your infrastructure is provisioned.</p>
<p>That model works fine until the day you join a company and realize engineers rarely apply to production from a laptop.<br>A lot of what you see will not match what you practiced.</p>
<p>This article explains how large engineering teams actually run Terraform, the repositories, workflows, ownership rules, and what goes wrong without them.  </p>
<p>You'll learn how enterprise teams structure repositories and state files, how they store and version reusable modules through GitHub, why infrastructure changes move to production through pipelines, how they catch changes that happen outside of Terraform, and how they recover when things go wrong.</p>
<p>Every practice here exists because a team hit a specific wall and built something to get past it.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You should be comfortable with Terraform before reading this.<br>You should also know how Git pull requests and branch merging work.</p>
<p>This is not a Terraform introduction, it is about what happens after you have learned the basics and start sharing infrastructure with other engineers.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-how-state-corruption-happens">How State Corruption Happens</a></p>
</li>
<li><p><a href="#heading-why-state-file-gets-treated-like-a-production-database">Why State File Gets Treated Like a Production Database</a></p>
</li>
<li><p><a href="#heading-how-enterprise-teams-structure-their-terraform-repositories">How Enterprise Teams Structure Their Terraform Repositories</a></p>
</li>
<li><p><a href="#heading-how-teams-split-state-files-to-protect-each-other">How Teams Split State Files to Protect Each Other</a></p>
</li>
<li><p><a href="#heading-why-some-teams-prefer-directories-over-workspaces-for-production">Why Some Teams Prefer Directories Over Workspaces for Production</a></p>
</li>
<li><p><a href="#heading-how-teams-share-infrastructure-through-modules-on-github">How Teams Share Infrastructure Through Modules on GitHub</a></p>
</li>
<li><p><a href="#heading-how-teams-version-and-release-terraform-modules">How Teams Version and Release Terraform Modules</a></p>
</li>
<li><p><a href="#heading-how-teams-maintain-terraform-modules-at-scale">How Teams Maintain Terraform Modules at Scale</a></p>
</li>
<li><p><a href="#heading-how-teams-share-data-between-state-files">How Teams Share Data Between State Files</a></p>
</li>
<li><p><a href="#heading-how-infrastructure-changes-actually-move-to-production">How Infrastructure Changes Actually Move to Production</a></p>
</li>
<li><p><a href="#heading-how-teams-detect-infrastructure-drift">How Teams Detect Infrastructure Drift</a></p>
</li>
<li><p><a href="#heading-how-teams-recover-when-state-goes-wrong">How Teams Recover When State Goes Wrong</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-how-state-corruption-happens">How State Corruption Happens</h2>
<p>The state file is how Terraform tracks what it has built. It remembers every resource, every ID, and every configuration value. When it gets out of sync with what actually exists in the cloud, that's state corruption.</p>
<p>It gets blamed for a lot of things. But engineers who have dealt with it in production know it usually traces back to one of a handful of situations, each with a different cause and a different fix.</p>
<h3 id="heading-two-engineers-run-terraform-apply-at-the-same-time">Two Engineers Run <code>terraform apply</code> at the Same Time</h3>
<p>Before understanding this one, you need to understand something about how Terraform works.</p>
<p>When you run <code>terraform apply</code>, two things happen separately:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/1fdf9458-9c60-4b65-8bd7-2126b8d47065.png" alt="When you run terraform apply, two things happen separately. Step 1: Terraform tells AWS to create the subnet and AWS creates it in the cloud. Step 2: Terraform updates the state file to record that the subnet now exists. AWS holds the real infrastructure. The state file is Terraform's notebook about it. They are separate and can get out of sync." style="display: block;" width="1536" height="1024" loading="lazy">

<p>First, Terraform talks to AWS, and the resource gets created in the cloud. Second, Terraform updates the state file to record what was just built.</p>
<p>These are two different systems. AWS holds the real infrastructure, and the state file is Terraform's notebook about it. If anything interrupts the process between step one and step two, they fall out of sync.</p>
<p>Now here's what happens when two engineers apply at the same time without locking:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/724c562a-ef35-42e5-a9f2-4683f8acef31.png" alt="Diagram showing Sarah and Marcus both open the same Terraform state file at the same time. Sarah reads the state, adds a subnet, and saves. Marcus reads the same original state, updates the NAT gateway, and saves last. His save overwrites Sarah's. The final state file contains the NAT gateway update but the subnet record is gone, even though the subnet still exists in AWS. Caption: Two people. Same state file. Different changes. Last write wins. Terraform state file simultaneously, causing one engineer's changes to overwrite the other's." style="display: block;" width="1536" height="1024" loading="lazy">

<p>Sarah opens the state file and starts adding a subnet. Marcus opens the same state file at the same moment and starts updating a NAT gateway. Both are working from the same starting copy.</p>
<p>Sarah finishes first. Her apply creates the subnet in AWS and updates the state file to record it.</p>
<p>Marcus finishes second. His apply updates the NAT gateway in AWS. Terraform then updates the state file using the version of state Marcus read when his apply started.</p>
<p>That version didn't include Sarah's subnet, so the updated state no longer contains a record of it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/07d237af-96dd-450c-8284-7b2be89b2a41.png" alt="comparison showing AWS contains both the subnet and NAT gateway update, while Terraform's state file is missing the subnet record" style="display: block;" width="1536" height="1024" loading="lazy">

<p>The subnet exists in AWS. But Terraform's notebook no longer has a record of it. The next <code>terraform plan</code> thinks the subnet was never created and proposes building it again.</p>
<p>State locking prevents this. Sarah's apply acquires a lock before it starts. When Marcus tries to apply, Terraform makes him wait.</p>
<p>After Sarah finishes, Terraform updates the state file and releases the lock. Marcus then runs against the updated state, so both the subnet and NAT gateway changes are recorded correctly.</p>
<h3 id="heading-an-apply-gets-interrupted">An Apply Gets Interrupted</h3>
<p>A GitHub Actions pipeline is applying changes to the payments infrastructure, adding three new security group rules and a database parameter group. Halfway through, the pipeline runner hits its 60-minute timeout limit, and the job gets killed.</p>
<p>Here's what the apply actually managed to do before dying:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/40cba2d8-56c4-4b03-bd46-0c33a7b1b7af.png" alt="A terminal showing terraform apply running. Three security group rules are created successfully at 12:00. At 12:00:07, the database parameter group starts creating. At 12:01:30, two errors appear in red: Job exceeded maximum runtime 60m and Runner terminated. A pipeline summary below shows security group rules 1, 2, and 3 as created with green checkmarks, database parameter as not created with a red X, and state file update as never wrote because the job died first, also with a red X." style="display: block;" width="1536" height="1024" loading="lazy">

<p>The terminal image above shows three security group rules completing successfully before the pipeline hits its 60-minute runtime limit. The runner is then terminated. The database parameter group never finishes creating, and the state file update never runs because the job died first.</p>
<pre><code class="language-plaintext">Security group rule 1  → created ✓
Security group rule 2  → created ✓
Security group rule 3  → created ✓
Database parameter     → not created ✗
State file update      → never wrote (job died first)
</code></pre>
<p>The three security group rules now exist in AWS. The problem is that the pipeline died before Terraform could finish updating the state file. AWS knows the rules exist. Terraform's state file does not.</p>
<p>At this point, reality and the state file no longer match.</p>
<p>Fortunately, this is usually easy to recover from. When the pipeline runs again, Terraform checks what already exists in AWS. It sees the three security group rules and doesn't try to create them again. It then creates the database parameter group that never got built.</p>
<p>The second run completes successfully and the state file catches up.</p>
<p>This works because Terraform is idempotent, running the same configuration again moves infrastructure toward the desired state rather than blindly creating everything from scratch.</p>
<p>One small complication remains: the state lock.</p>
<p>If the pipeline was interrupted while holding a lock, Terraform may still think another apply is running. The next pipeline run fails immediately with an error like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/cbb56e69-7c40-4e61-8966-4cedbdaf2649.png" alt="terminal image showing terraform apply failing because the previous job left a state lock behind. The error includes the lock ID, the path to the state file, and the name of the process that acquired it. Terraform refuses to proceed until the lock is released or manually cleared." style="display: block;" width="1536" height="1024" loading="lazy">

<p>The terminal above shows terraform apply failing because the previous job left a state lock behind. The error includes the lock ID, the path to the state file, and the name of the process that acquired it. Terraform refuses to proceed until the lock is released or manually cleared.</p>
<p>Before clearing the lock, make sure no Terraform apply is still running.</p>
<p>Open your CI/CD system. GitHub Actions, GitLab CI, Jenkins, or whatever your team uses and check the pipeline history for that environment:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/716f6d0c-62d7-4bea-b7f4-ce6cfdda1188.png" alt="The GitHub Actions pipeline history shows four recent runs. terraform-plan completed successfully. Two terraform-apply jobs show as cancelled and timed out, both flagged as lock may be stale. A fourth terraform-apply job is currently in progress, this one should not be unlocked until it finishes." style="display: block;" width="1171" height="1343" loading="lazy">

<p>The GitHub Actions pipeline history image above shows four recent runs. terraform-plan completed successfully. Two terraform-apply jobs show as cancelled and timed out, both flagged as lock may be stale. A fourth terraform-apply job is currently in progress, and this one shouldn't be unlocked until it finishes.</p>
<p>If the previous apply was cancelled or timed out, the lock is stale. Clear it with <code>terraform force-unlock</code> plus the lock ID from the error. The pipeline then runs normally.</p>
<p>Only force-unlock when you're certain nothing is actively running. Clearing a live lock lets two applies write to the same state at the same time, which is exactly the problem locking was built to prevent.</p>
<h3 id="heading-someone-runs-a-terraform-state-command-in-the-wrong-environment">Someone Runs a Terraform State Command in the Wrong Environment</h3>
<p>A database engineer is cleaning up an old test database in the staging environment.</p>
<p>The database still exists in AWS, but Terraform should stop managing it. To do that, the engineer uses <code>terraform state rm</code>.</p>
<p>This command doesn't delete anything in AWS. It only removes Terraform's record of the resource from the state file. Think of it as telling Terraform: <em>"forget this resource exists, but leave it running."</em></p>
<p>The engineer intends to run it against staging:</p>
<pre><code class="language-plaintext">Intended:  staging state       → forget the old test database
</code></pre>
<p>But they're working in the wrong directory. They run it against production instead.</p>
<pre><code class="language-plaintext">Actual:    production state    → forget the live payments database
</code></pre>
<p>Nothing gets deleted. The production database is still running in AWS. But Terraform has now forgotten it exists.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/5a1dd566-4f38-4ceb-8b41-70bf6ebc69c3.png" alt="Image showing database exists in AWS but is missing from Terraform state." style="display: block;" width="1774" height="887" loading="lazy">

<p>Now Terraform and reality disagree. The next <code>terraform plan</code> sees a database defined in the code but missing from the state file, so it assumes the database doesn't exist and proposes creating a new one.</p>
<p>If nobody catches it in the plan output, Terraform creates a second production database alongside the original: two databases running in production, neither fully managed, and a very expensive mess to untangle.</p>
<p><code>terraform state rm</code>, <code>terraform import</code>, and <code>terraform state mv</code> make immediate changes to the state file with no confirmation prompt. Run them from the wrong directory, the wrong workspace, or with the wrong resource address and you change the wrong state in seconds.</p>
<h3 id="heading-two-teams-manage-the-same-resource">Two Teams Manage the Same Resource</h3>
<p>The networking team owns a security group that controls access to the payments database. When a new microservice needs database access, a payments engineer has two options: ask the networking team to add a new rule, or manage the security group themselves.</p>
<p>They choose the second option. The engineer imports the existing security group into the payments state file and adds a rule for Microservice C.<br>From that moment, both teams think they own the same security group.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/a7780c50-c6c0-49f5-b161-7b4884bc0394.png" alt="Two Terraform state files managing the same security group with different access rules" style="display: block;" width="1672" height="941" loading="lazy">

<p>The problem is that Terraform does exactly what each state file tells it to do. The networking state says the security group should allow A and B. The payments state says it should allow A, B, and Microservice C. Both can't be true at the same time.</p>
<p>When the payments team applies their state, Microservice C gets access. But later that night, the networking pipeline runs. Terraform reads the networking state, sees only A and B, and updates the security group to match. Microservice C's rule disappears silently.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/df2c9e46-2d6a-49dc-9f5a-f23a06575452.png" alt="image showing the flow of When the payments team applies their state, Microservice C gets access. But later that night, the networking pipeline runs. Terraform reads the networking state, sees only A and B, and updates the security group to match. Microservice C's rule disappears silently." style="display: block;" width="1536" height="1024" loading="lazy">

<p>No errors are seen and both pipelines pass, which is exactly what makes this so hard to debug. Terraform isn't broken, it's receiving conflicting instructions from two different state files and doing exactly what each one says.</p>
<p>This isn't something to be fixed with Terraform commands. It's an ownership decision that should have been made before anyone ran an import. If the payments team had submitted a pull request to the networking repository asking them to add the rule, one team would own the security group, one state file would manage it, and the conflict could never have happened.</p>
<h2 id="heading-why-state-file-gets-treated-like-a-production-database">Why State File Gets Treated Like a Production Database</h2>
<p>The state file looks like bookkeeping: a record of what Terraform created. The reason teams treat it differently is that it often contains secrets.</p>
<p>The state file stores sensitive values in plaintext. Database passwords, API keys, connection strings&nbsp;– if those values were passed to a Terraform resource during an apply, they're now sitting in the state file. Even if you marked the variable as <code>sensitive</code> in your Terraform code, the value still lands in the state file. Terraform needs it there to compute diffs on future plans.</p>
<p>That means: <strong>whoever can read the state file can read your database password.</strong></p>
<p>In large organizations, engineers typically don't have direct access to the production state bucket. Instead, Terraform runs through a CI/CD pipeline that assumes a dedicated IAM role with permission to read and write the state bucket and perform applies. Engineers interact with infrastructure through pull requests and plan output, not by touching the state bucket directly.</p>
<p>This separation reduces risk and creates an audit trail. Every state change is performed by the pipeline and logged, making it straightforward to trace what changed and when.</p>
<h2 id="heading-how-enterprise-teams-structure-their-terraform-repositories">How Enterprise Teams Structure Their Terraform Repositories</h2>
<p>When you join a large engineering organization, the first thing you notice is the number of repositories. You might expect one repository for all infrastructure, but what you find is dozens.</p>
<p>The structure maps directly to ownership. Each repository belongs to one team, and that team is responsible for everything in it. A typical layout looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ca81c9b1-b310-4321-8001-f59ab258c652.png" alt="diagram showing how platform, security, and product teams organize Terraform repositories and ownership" style="display: block;" width="1536" height="1024" loading="lazy">

<p>The diagram shows two types of repositories. The first type belongs to the platform team and contains reusable modules: things like VPC configurations, database templates, and security group patterns. These repositories don't create production resources directly.</p>
<p>The second type belongs to individual product teams, such as the payments team or the auth team. These repositories call the platform modules and use them to build their actual infrastructure. A mistake in a product team repository affects only that team. A mistake in a shared platform module can affect every team that depends on it.</p>
<p>The key thing to understand here is that the platform team repositories don't create production resources. They create reusable modules that the product teams call when building their actual infrastructure.</p>
<p>That distinction matters because some repositories are used by one team, while others are shared by everyone.</p>
<p>A mistake in a product team's repository usually affects only that team. A mistake in a shared module can affect every team that depends on it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/7fbefdda-c8bb-4e66-a8bc-adc19ae931e7.png" alt="diagram showing how bugs in shared Terraform modules affect more teams than bugs in product-specific repositories." style="display: block;" width="1536" height="1024" loading="lazy">

<p>The diagram illustrates why shared repositories carry more risk than product-specific ones. A bug in the <code>payments-infra</code> repository affects only the payments team. A bug in the <code>terraform-aws-postgres</code> module affects every team that uses it to provision databases. A bug in the <code>terraform-policies</code> repository affects every pipeline in the company. The wider the module is shared, the larger the blast radius when something goes wrong.</p>
<p>This is why experienced engineers pay close attention to shared modules and policy repositories.</p>
<p>If the payments team's infrastructure breaks, the problem is probably in the payments repository.</p>
<p>If five different teams start seeing the same issue at the same time, the shared modules and policy repositories become the first place to investigate.</p>
<h2 id="heading-how-teams-split-state-files-to-protect-each-other">How Teams Split State Files to Protect Each Other</h2>
<p>A single state file managing everything, VPC, Kubernetes cluster, databases, monitoring, is fine when one person is running things, but quickly becomes a problem when multiple teams share it.</p>
<p>Three specific problems emerge.</p>
<ol>
<li><p><strong>Blast radius:</strong> If the networking configuration and the database configuration live in the same state file, a bad networking apply can accidentally affect database resources that had nothing to do with the change. Separate state files keep failures contained.</p>
</li>
<li><p><strong>Deployment speed:</strong> Networking infrastructure might change a few times a year. Applications might deploy dozens of times a day. If they share a state file, teams end up waiting on each other's locks.</p>
</li>
<li><p><strong>Ownership conflicts:</strong> When multiple teams share a state file, one team can change something the other team depends on without realizing it.</p>
</li>
</ol>
<p>The solution is to split state along ownership boundaries. A structure that addresses all three problems looks like this:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/5abbcdbd-af2b-42b7-8dce-00389dbb91eb.png" alt="5abbcdbd-af2b-42b7-8dce-00389dbb91eb" style="display: block;" width="1536" height="1024" loading="lazy">

<p>The structure image above shows one state file per domain under a production folder.</p>
<ul>
<li><p>networking handles VPC, subnets, routing, and NAT gateways.</p>
</li>
<li><p>identity handles IAM roles, policies, and service accounts.</p>
</li>
<li><p>platform handles the Kubernetes cluster, node pools, and add-ons.</p>
</li>
<li><p>database handles RDS instances, Redis clusters, and backups.</p>
</li>
<li><p>security handles security groups, WAF rules, and certificates.</p>
</li>
<li><p>monitoring handles Prometheus, Grafana, and alerting pipelines.</p>
</li>
<li><p>payments handles payment service infrastructure.</p>
</li>
</ul>
<pre><code class="language-plaintext">production/
  networking/terraform.tfstate   → VPC, subnets, routing, NAT gateways
  identity/terraform.tfstate     → IAM roles, policies, service accounts
  platform/terraform.tfstate     → Kubernetes cluster, node pools, add-ons
  database/terraform.tfstate     → RDS instances, Redis clusters, backups
  security/terraform.tfstate     → Security groups, WAF rules, certificates
  monitoring/terraform.tfstate   → Prometheus, Grafana, alerting pipelines
  payments/terraform.tfstate     → Payment service infrastructure
</code></pre>
<p>This is one example, not a universal standard. Larger organizations often split further. The principle is the same: one owning team per state file, one pipeline, one blast radius.</p>
<p>The rule is simple: every resource belongs to one state file. If the networking team owns a security group, it stays in the networking state. Other teams can reference it as a data source, but they don't import it into their own state.<br>That is what prevents the ownership collision described in the first section.</p>
<h2 id="heading-why-some-teams-prefer-directories-over-workspaces-for-production">Why Some Teams Prefer Directories Over Workspaces for Production</h2>
<p>Terraform CLI workspaces let you manage multiple environments like dev, staging, and production from a single directory. Each workspace gets its own state file, but they all share the same <code>.tf</code> configuration files.</p>
<pre><code class="language-plaintext">infra/
  main.tf          ← same code runs for ALL environments
  variables.tf

  terraform.tfstate.d/
    dev/
    staging/
    production/    ← separate state, same code
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/4461ccbb-3d64-45d2-af7b-143a778b5649.png" alt="The workspace approach keeps all environments in one directory called infra. It contains a single main.tf file that runs for all environments. State is stored separately under terraform.tfstate.d with folders for dev, staging, and production, but all three share the same code." style="display: block;" width="1536" height="1024" loading="lazy">

<p>The workspace approach keeps all environments in one directory called infra. It contains a single main.tf file that runs for all environments. State is stored separately under terraform.tfstate.d with folders for dev, staging, and production, but all three share the same code.</p>
<p>You switch environments with <code>terraform workspace select production</code>, then apply.</p>
<p>The risk is that switching workspaces is a manual step. If the wrong workspace is active, changes meant for staging can end up in production.</p>
<p>Many teams prefer separate directories for long-lived environments:</p>
<pre><code class="language-plaintext">environments/
  dev/
    main.tf      ← its own code path
    backend.tf   ← points to the dev state bucket
  staging/
    main.tf      ← its own code path
    backend.tf   ← points to the staging state bucket
  production/
    main.tf      ← its own code path
    backend.tf   ← points to the production state bucket
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/83604abf-302c-400e-a322-f53e7d0b7d56.png" alt="project structure showing separate Terraform directories for dev, staging, and production environments." style="display: block;" width="1536" height="1024" loading="lazy">

<p>The directory approach gives each environment its own folder under environments. Dev, staging, and production each have their own main.tf with a separate code path, and their own backend.tf pointing to a different state bucket. The environments are completely separate from each other.</p>
<p>To apply against production, you have to be in the production directory. Each environment has its own state, backend, and execution path.</p>
<p>The tradeoff is duplication. Teams usually solve that with shared modules, so each environment directory contains only environment-specific configuration.</p>
<p>Workspaces are still useful for short-lived environments such as feature branches, preview deployments, and temporary test infrastructure.</p>
<h2 id="heading-how-teams-share-infrastructure-through-modules-on-github">How Teams Share Infrastructure Through Modules on GitHub</h2>
<p>When 30 teams each need a PostgreSQL database, two things happen.</p>
<p><strong>Without a shared standard</strong>, every team writes their own database configuration. Six months later, a security audit runs across all environments and finds that:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/392e5cee-408e-49d5-9cc5-5a53f3537562.png" alt="Diagram showing four teams and their database misconfigurations: Team A with no backups, Team B with unencrypted storage, Team C with no tags, Team D with deletion protection disabled." style="display: block;" width="1536" height="1024" loading="lazy">

<p>The diagram shows what a security audit found when four teams each wrote their own database configuration independently.</p>
<p>Team A set <code>backup_retention_period = 0</code>, meaning their database was never backed up. Team B set <code>storage_encrypted = false</code>, leaving data in plaintext. Team C passed an empty <code>tags = {}</code>, so there was no cost tracking. Team D set <code>deletion_protection = false</code>, leaving the database one accident away from permanent data loss.</p>
<p>Nobody skipped those things on purpose, there was just no shared standard.</p>
<p><strong>With a shared module</strong>, the platform team writes a <code>postgres</code> module once. They encode every organizational requirement into it: encryption on, 7-day backups, monitoring alarms, required tags, deletion protection enabled. They publish it to a GitHub repository called <code>terraform-aws-postgres</code>.</p>
<p>Every team that needs a database now writes this:</p>
<pre><code class="language-hcl">module "payments_db" {
  source         = "git::ssh://github.company.com/platform/terraform-aws-postgres.git?ref=v2.1.0"
  name           = "payments"
  environment    = "production"
  instance_class = "db.m5.large"
}
</code></pre>
<p>Four inputs. Everything else is handled by the module.</p>
<p>Large organizations usually expose approved modules through an internal registry so engineers can discover and version them without browsing GitHub repositories. Instead of the full Git URL, the reference becomes:</p>
<pre><code class="language-csharp">module "payments_db" {
  source  = "app.terraform.io/mycompany/postgres/aws"
  version = "~&gt; 2.1"
}
</code></pre>
<p>HCP Terraform and Terraform Enterprise both include a private registry that connects to GitHub, watches for version tags on module repositories, and publishes new versions automatically.</p>
<h2 id="heading-how-teams-version-and-release-terraform-modules">How Teams Version and Release Terraform Modules</h2>
<p>The <code>?ref=v2.1.0</code> in a module source URL isn't decoration. At the scale of 40 teams sharing one module, it's the thing that prevents a well-intentioned change from becoming a company-wide incident.</p>
<p>Without version pinning, the payments team references the Postgres module from <code>main</code> meaning whatever the latest code is at any given moment. The module owners rename an output variable from <code>db_endpoint</code> to <code>database_endpoint</code> to match a new naming convention. The next time any team runs <code>terraform init</code>, they pull that change. Their configuration still references <code>db_endpoint</code>.</p>
<p>Plans break:</p>
<pre><code class="language-plaintext">payments-infra                        → plan fails
analytics-infra                       → plan fails
auth-infra                            → plan fails
reporting-infra                       → plan fails
</code></pre>
<p>Version pinning prevents this. The payments team stays on <code>v2.1.0</code>. The module owners release <code>v2.2.0</code> with the renamed output and write a changelog. Teams upgrade when they're ready, after testing in staging. Nobody's pipeline breaks without warning.</p>
<p>The versioning convention is called semantic versioning:</p>
<pre><code class="language-plaintext">v2.1.1  → patch:  bug fix. Safe to upgrade. Nothing to change in your code.
v2.2.0  → minor:  new optional feature. Safe to upgrade. Nothing to change.
v3.0.0  → major:  breaking change. Read the changelog. Update your code first.
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/e23a58d7-0f3b-4f12-921e-1128f33d6c40.png" alt="image of module semantic versioning" style="display: block;" width="1774" height="887" loading="lazy">

<p>The table shows three version types. A patch version like v2.1.1 means a bug fix, safe to upgrade with nothing to change in your code. A minor version like v2.2.0 means a new optional feature, also safe to upgrade with nothing to change. A major version like v3.0.0 means a breaking change, so you need to read the changelog and update your code before upgrading.</p>
<h2 id="heading-how-teams-maintain-terraform-modules-at-scale">How Teams Maintain Terraform Modules at Scale</h2>
<p>Building a Terraform module takes an afternoon, bit maintaining it for two years is a different job entirely.</p>
<p>A networking engineer needs a VPC module. The platform team has one, but their backlog is full. So the engineer creates a slightly different version. Three months later, another team does the same. Then another. Now this exists:</p>
<pre><code class="language-plaintext">terraform-aws-vpc           ← original, maintained by platform team
terraform-aws-vpc-v2        ← created by the app team, author unknown
terraform-aws-vpc-shared    ← no idea which environments use this
terraform-aws-vpc-prod      ← unclear if this was ever different from the original
</code></pre>
<p>No one created a module graveyard on purpose. It grew one <em>"I'll just make a quick variation"</em> at a time. Each variant has slightly different security settings, different tagging, different defaults. When a compliance audit requires all VPCs to enable flow logging, the team has to investigate four different modules to figure out which environments are compliant.</p>
<p>Teams that avoid this treat their modules like shared services: named owner, contributions through pull requests, breaking changes in major versions with a migration guide, and deprecated modules with a retirement date. A <code>CODEOWNERS</code> file routes every pull request to the right reviewer automatically.</p>
<p>Organizations that skip this end up with modules that nobody owns, nobody wants to touch, and nobody is sure can be safely removed.</p>
<h2 id="heading-how-teams-share-data-between-state-files">How Teams Share Data Between State Files</h2>
<p>Once infrastructure is split into separate state files, a practical problem surfaces: teams need information from each other's infrastructure. The platform team's Kubernetes state needs the VPC ID from the networking team's state. The database state needs subnet IDs. The payments state needs the database endpoint.</p>
<p>Two patterns exist for solving this.</p>
<h3 id="heading-reading-another-teams-state-outputs">Reading Another Team's State Outputs</h3>
<p>The <code>terraform_remote_state</code> data source lets one state read the outputs of another. The networking team marks their VPC ID and subnet IDs as outputs. The database team reads those outputs and uses them to place databases in the right subnets.</p>
<pre><code class="language-plaintext">Networking state
  └── outputs: vpc_id, private_subnet_ids
                          ↓
               Database state reads them
               └── places RDS in the right subnets
</code></pre>
<p>This works, but there's a limitation. Reading another team's state requires full read access to their entire state file, not just the outputs you want. State files contain database passwords and API keys in plaintext. More dependencies means more teams reading each other's secrets.</p>
<h3 id="heading-looking-up-resources-directly-from-the-cloud">Looking Up Resources Directly From the Cloud</h3>
<p>The alternative, and the one HashiCorp now recommends, is to look up resources through the cloud provider's API instead of reading another team's state:</p>
<pre><code class="language-hcl">data "aws_vpc" "main" {
  tags = {
    Name        = "production-vpc"
    Environment = "production"
  }
}
</code></pre>
<p>No cross-team state access needed, and each team's state stays isolated. The tradeoff is consistent tagging: the networking team has to tag their VPC in a way the database team can reliably search for, which forces teams to agree on naming conventions early.</p>
<p>Many teams use both. Remote state for a small number of trusted, tightly coupled dependencies. Cloud data sources for everything broader.</p>
<h2 id="heading-how-infrastructure-changes-actually-move-to-production">How Infrastructure Changes Actually Move to Production</h2>
<p>In large organizations managing production Terraform at scale, changes don't come from someone's laptop. Applying directly from a local machine requires production cloud credentials sitting on that machine, a security risk and leaves no audit trail if something breaks.</p>
<p>Instead, production changes move through a pipeline. Every change goes through a pull request in GitHub, and the pipeline does the work:</p>
<pre><code class="language-plaintext">Engineer opens a pull request
        ↓
Pipeline: terraform validate + fmt check
        ↓
Pipeline: security scan (Checkov, tfsec, or similar)
        ↓
Pipeline: terraform plan → posts the full output as a comment on the PR
        ↓
Reviewer reads the plan output (not just the code)
        ↓
Required reviewers approve (enforced by CODEOWNERS + branch protection)
        ↓
Merge triggers the apply pipeline
        ↓
Pipeline: acquires state lock → applies → releases lock → logs result
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/d154589a-67b6-41e0-bda1-d7243521878f.png" alt="CI pipeline flowchart with Terraform" style="display: block;" width="1024" height="1536" loading="lazy">

<p>The diagram above shows eight steps in order. An engineer opens a pull request. The pipeline runs terraform validate and a format check. A security scan runs using Checkov, tfsec, or similar. The pipeline runs terraform plan and posts the output as a comment on the pull request. A reviewer reads the full plan output. Required reviewers approve, enforced by CODEOWNERS and branch protection rules. Merging triggers the apply pipeline. The pipeline acquires the state lock, applies the changes, releases the lock, and logs the result.</p>
<p>The part that surprises engineers when they first encounter this is that the reviewer isn't approving the code. They're approving the <strong>plan output</strong> and the list of exactly what will be created, changed, or destroyed in the cloud.</p>
<p>A code change can look completely harmless and produce a destructive plan. Changing one database parameter might force a resource replacement, meaning Terraform destroys the current database and creates a new one. Seeing this in the plan output before the PR merges:</p>
<pre><code class="language-plaintext"># aws_db_instance.payments must be replaced
-/+ resource "aws_db_instance" "payments" {
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/4b39ca51-ab6d-4187-b7b7-a68b35d13959.png" alt="Terraform plan output in terminal - aws_db_instance.payments" style="display: block;" width="1567" height="1004" loading="lazy">

<p>The image above shows a plan output that aws_db_instance.payments must be replaced, meaning Terraform will destroy the existing database and create a new one, not update it in place.</p>
<p>Catching that before merge is the entire point of reviewing the plan. Not the code.</p>
<h3 id="heading-how-codeowners-enforces-who-reviews-what">How CODEOWNERS Enforces Who Reviews What</h3>
<p>Earlier, we talked about module ownership. A VPC module might belong to the platform team, while database infrastructure belongs to the database team.</p>
<p>The challenge is making sure changes are actually reviewed by the people who own them.</p>
<p>GitHub solves this with a feature called <strong>CODEOWNERS</strong>. It lets a repository define which team is responsible for which directories. When someone opens a pull request that touches those files, GitHub automatically requests reviews from the correct team.</p>
<p>For example, if an engineer modifies the PostgreSQL module, GitHub can automatically require approval from the platform team before the change can be merged.</p>
<p>Without CODEOWNERS, engineers have to remember who owns which parts of the infrastructure.</p>
<p>CODEOWNERS makes ownership explicit and automatically requests reviews from the right team.</p>
<h2 id="heading-how-teams-detect-infrastructure-drift">How Teams Detect Infrastructure Drift</h2>
<p>Drift is the diff between what Terraform says should exist and what actually exists in the cloud.</p>
<p>Here's the scenario that produces drift more reliably than anything else:</p>
<pre><code class="language-plaintext">Monday 3:00 AM  Production database CPU spikes. Outage.
Monday 3:15 AM  Engineer resizes database in AWS console: db.m5.large → db.m5.4xlarge
Monday 3:20 AM  Incident resolved. Engineer goes to sleep.
Monday 3:21 AM  Terraform state file: still says db.m5.large
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/a1d72ad3-42f2-45d1-8d5a-3fdc8a4cbb99.png" alt="Four panels showing how drift happens: the database CPU spikes at 3:00 AM, an engineer resizes it manually in the AWS console at 3:15 AM, the incident resolves at 3:20 AM, and by 3:21 AM the Terraform state file still says db.m5.large, unaware of the change." style="display: block;" width="1536" height="1024" loading="lazy">

<p>The incident is forgotten, the ticket is closed, and life moves on.</p>
<p>Three months later, a routine Terraform apply runs. Terraform sees <code>db.m5.large</code> in the configuration but finds <code>db.m5.4xlarge</code> running in AWS. From Terraform's perspective, the database is larger than it should be, so the plan proposes changing it back.</p>
<p>Nobody notices the change in the plan output. The apply goes through, the database is downsized, and users begin reporting slow queries. The team spends hours investigating before eventually tracing the issue back to a Terraform change that reverted the emergency fix from months earlier.</p>
<p>Teams that handle this well run scheduled <code>terraform plan</code> jobs against every production state. If <code>terraform plan</code> exits with code <code>2</code>, differences were found and an alert fires. The team then decides whether to apply to restore declared state or update the configuration to match reality. Either way, the change is visible and deliberate. Invisible drift always gets worse.</p>
<h2 id="heading-how-teams-recover-when-state-goes-wrong">How Teams Recover When State Goes Wrong</h2>
<p>State is recoverable in almost every situation, as long as the team set things up correctly before the incident happened.</p>
<p>The teams that recover in twenty minutes instead of three days aren't the ones with the deepest Terraform expertise. They're the ones who prepared.</p>
<h3 id="heading-step-1-pull-a-backup-before-touching-anything">Step 1: Pull a Backup Before Touching Anything.</h3>
<pre><code class="language-bash">terraform state pull &gt; backup-$(date +%Y%m%d-%H%M%S).json
</code></pre>
<p>This saves the current state to a local file. Whatever you try next, you have a starting point to return to.</p>
<h3 id="heading-step-2-run-terraform-plan-and-look-at-what-it-proposes">Step 2: Run <code>terraform plan</code> and Look at What it Proposes.</h3>
<p>If Terraform proposes destroying resources that still exist in the cloud, the state is behind reality. If it proposes creating resources that already exist, reality is ahead of the state. Either way, the plan output tells you which direction the mismatch runs.</p>
<h3 id="heading-step-3-restore-from-s3-versioning-if-the-state-is-corrupted">Step 3: Restore from S3 Versioning if the State is Corrupted.</h3>
<p>Every write to a versioned S3 bucket saves a new version automatically. If the state file is corrupted or wrong, list the previous versions, download the last known good one, and push it back:</p>
<pre><code class="language-bash"># List previous versions
aws s3api list-object-versions \
  --bucket mycompany-terraform-state \
  --prefix production/database/terraform.tfstate

# Download a specific version
aws s3api get-object \
  --bucket mycompany-terraform-state \
  --key production/database/terraform.tfstate \
  --version-id "the-version-id-here" \
  recovered-state.json

# Push it back
terraform state push recovered-state.json
</code></pre>
<p>Run <code>terraform plan</code> after restoring to confirm it looks correct before running any apply.</p>
<h3 id="heading-step-4-clear-a-stale-lock-if-the-pipeline-is-blocked">Step 4: Clear a Stale Lock if the Pipeline is Blocked.</h3>
<p>If a lock was never released after a failed apply, clear it:</p>
<pre><code class="language-bash">terraform force-unlock LOCK_ID
</code></pre>
<p>Only do this after confirming no apply is actively running. Clearing a live lock corrupts the state.</p>
<h3 id="heading-step-5-re-import-resources-that-fell-out-of-state">Step 5: Re-import Resources That Fell Out of State.</h3>
<p>If a resource exists in the cloud but Terraform no longer knows about it — because of an accidental <code>terraform state rm</code> — bring it back without recreating it:</p>
<pre><code class="language-bash">terraform import aws_db_instance.payments db-ABCD1234EFGH5678
</code></pre>
<p>Run <code>terraform plan</code> after importing to confirm no unexpected changes are proposed.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Every practice in this article traces back to a specific problem teams ran into as Terraform usage grew.</p>
<p>State locking prevents engineers from overwriting each other's changes.<br>State splitting reduces blast radius. Module versioning prevents shared infrastructure from breaking unexpectedly. Drift detection catches changes made outside Terraform. CODEOWNERS ensures the right people review the right changes.</p>
<p>Different problems with different solutions. But they all point to the same underlying theme which is ownership.</p>
<p>As teams grow, many Terraform problems have less to do with infrastructure and more to do with ownership.</p>
<p>State collisions happen when multiple people can modify the same state.<br>Module sprawl happens when nobody is responsible for maintaining a shared standard.</p>
<p>Drift becomes dangerous when changes are made without anyone taking ownership of bringing Terraform and reality back into alignment. Even review bottlenecks often trace back to uncertainty about who should approve what.</p>
<p>Understanding this changes how you read an unfamiliar Terraform repository.</p>
<p>Dozens of small state files aren't necessarily over-engineering. They're often ownership boundaries. A CODEOWNERS file is not bureaucracy. It's an ownership map. A pipeline that posts plan output on a pull request isn't just automation, it's a review process built around infrastructure consequences rather than code.</p>
<p>The infrastructure matters. But as teams grow, ownership is what keeps the system understandable.</p>
<p><em>I write about DevOps engineering, production systems, and the things tutorials do not cover weekly. If this was useful,</em> <a href="https://osomudeya.kit.com/23db7ca59f"><em>please join the newsletter.</em></a><br><em>If you enjoyed reading this, we can also connect on</em> <a href="https://www.linkedin.com/in/osomudeya-zudonu-17290b124">Linkedin</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The EKS Cost Optimization Handbook: Reduce Your AWS Bill by 60% Using Karpenter and Rightsizing ]]>
                </title>
                <description>
                    <![CDATA[ This handbook is a complete guide to the 7-step playbook that took one EKS bill from $85,000/month to $34,000/month — without touching a single line of product code. I've audited EKS clusters at more  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/eks-cost-optimization-reduce-your-aws-bill-using-karpenter-and-rightsizing/</link>
                <guid isPermaLink="false">6a396515fa8e37864960ddb6</guid>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ optimization ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ayobami Adejumo ]]>
                </dc:creator>
                <pubDate>Mon, 22 Jun 2026 16:38:45 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/cd12d552-bcf2-466a-a98e-7674c436afaa.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>This handbook is a complete guide to the 7-step playbook that took one EKS bill from <code>$85,000</code>/month to <code>$34,000</code>/month — without touching a single line of product code.</p>
<p>I've audited EKS clusters at more than 10 companies. The same waste patterns appear every time: over-provisioned nodes, cross-AZ data transfer, idle EBS volumes, and so on. And the most expensive mistake of all: buying compute commitments before rightsizing.</p>
<p>This handbook is the fix. I've used this 7-step playbook to reduce EKS costs by 50–60% at every company where I've implemented it. There are no product code changes, and no downtime. Just infrastructure optimization executed in the right order.</p>
<p>By the end of this guide, you'll know how to right-size pod resource requests, implement Karpenter for intelligent bin-packing and Spot diversification, migrate compatible workloads to Graviton for 20% cheaper compute, and eliminate NAT Gateway charges entirely with VPC endpoints.</p>
<p>All Terraform modules, NodePool templates, and automation scripts referenced in this guide are available in the companion repository at <a href="https://github.com/aayostem/eks-cost-optimization">github.com/aayostem/eks-cost-optimization</a>. The repo includes ready-to-deploy configurations for every step so you can move from reading to implementing in the same afternoon.</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-the-baseline-where-your-eks-money-is-going">Part 1: The Baseline — Where Your EKS Money Is Going</a></p>
</li>
<li><p><a href="#heading-part-2-right-sizing-pod-resource-requests">Part 2: Right-Sizing Pod Resource Requests</a></p>
</li>
<li><p><a href="#heading-part-3-karpenter-for-bin-packing-and-spot-diversification">Part 3: Karpenter for Bin-Packing and Spot Diversification</a></p>
</li>
<li><p><a href="#heading-part-4-graviton-migration">Part 4: Graviton Migration</a></p>
</li>
<li><p><a href="#heading-part-5-vpc-endpoints-for-data-transfer">Part 5: VPC Endpoints for Data Transfer</a></p>
</li>
<li><p><a href="#heading-part-6-ebs-volume-optimisation">Part 6: EBS Volume Optimisation</a></p>
</li>
<li><p><a href="#heading-part-7-load-balancer-consolidation">Part 7: Load Balancer Consolidation</a></p>
</li>
<li><p><a href="#heading-the-complete-7-step-sequence">The Complete 7-Step Sequence</a></p>
</li>
<li><p><a href="#heading-best-practices-for-eks-cost-optimisation">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>How to right-size pod resource requests using VPA recommendations</p>
</li>
<li><p>The complete Karpenter setup with Spot diversification and automatic consolidation</p>
</li>
<li><p>Graviton3 migration for all non-GPU workloads</p>
</li>
<li><p>VPC endpoints to eliminate NAT Gateway data transfer charges</p>
</li>
<li><p>EBS gp2 to gp3 migration — 20% cheaper with zero performance loss</p>
</li>
<li><p>Load balancer consolidation with shared Ingress</p>
</li>
<li><p>The 7-step sequence that maximises ROI — and why the order isn't optional</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>Working familiarity with Kubernetes — you can deploy an application and inspect pods</p>
</li>
<li><p>Basic AWS knowledge — you understand EC2 instance types, VPCs, and EBS volumes</p>
</li>
<li><p>Comfort reading Terraform HCL and Kubernetes YAML</p>
</li>
</ul>
<p><strong>Tools and access:</strong></p>
<ul>
<li><p>An existing EKS cluster running Kubernetes 1.27 or later</p>
</li>
<li><p><code>kubectl</code> configured and pointing at your cluster</p>
</li>
<li><p>AWS CLI v2 installed and authenticated with appropriate permissions</p>
</li>
<li><p>Helm 3 installed (for Karpenter and Kubecost)</p>
</li>
<li><p><a href="https://github.com/kubernetes-sigs/metrics-server">Metrics Server</a> installed in your cluster</p>
</li>
</ul>
<p><strong>Companion repository:</strong> Clone the repo before starting. It contains all YAML, Terraform, and shell scripts referenced in this guide:</p>
<pre><code class="language-bash">git clone https://github.com/aayostem/eks-cost-optimization
cd eks-cost-optimization
</code></pre>
<p><strong>Estimated savings:</strong> For a cluster running at <code>$85,000</code>/month with typical over-provisioning, expect <code>$40,000</code> to <code>$55,000</code>/month in savings after completing all 7 steps. Smaller clusters under <code>$10,000</code>/month typically see 40–50% reduction.</p>
<h2 id="heading-part-1-the-baseline-where-your-eks-money-is-going">Part 1: The Baseline — Where Your EKS Money Is Going</h2>
<h3 id="heading-11-the-typical-eks-cost-breakdown">1.1 The Typical EKS Cost Breakdown</h3>
<p>Before touching anything, you need to know exactly where the money is going. Optimising the wrong category first is how teams waste weeks of engineering time and see no meaningful reduction.</p>
<p>Here's what a typical <code>$85,000</code>/month EKS cluster looks like when you break it down:</p>
<table>
<thead>
<tr>
<th>Category</th>
<th>Monthly Cost</th>
<th>Percentage</th>
<th>Waste Potential</th>
</tr>
</thead>
<tbody><tr>
<td>Compute (EC2 nodes)</td>
<td>$52,000</td>
<td>61%</td>
<td>High — over-provisioning, wrong instance types</td>
</tr>
<tr>
<td>Data Transfer</td>
<td>$15,300</td>
<td>18%</td>
<td>Very High — cross-AZ and NAT Gateway charges</td>
</tr>
<tr>
<td>Storage (EBS volumes)</td>
<td>$10,200</td>
<td>12%</td>
<td>Medium — unattached volumes and gp2 vs gp3</td>
</tr>
<tr>
<td>Load Balancers</td>
<td>$4,250</td>
<td>5%</td>
<td>Low to Medium — single-service ALBs</td>
</tr>
<tr>
<td>EKS Control Plane</td>
<td>$72</td>
<td>&lt;1%</td>
<td>None — this is a fixed cost</td>
</tr>
<tr>
<td>Other</td>
<td>$3,178</td>
<td>4%</td>
<td>Low</td>
</tr>
</tbody></table>
<p>Compute and Data Transfer together represent 79% of the bill and account for 90% of the correctable waste. Those are the targets.</p>
<p>Run this command to see your own breakdown before starting anything:</p>
<pre><code class="language-bash"># Pull last month's cost breakdown by service
# Save this output — it becomes your before number
aws ce get-cost-and-usage \
  --time-period Start=$(date -d 'last month' +%Y-%m-01),End=$(date +%Y-%m-01) \
  --granularity MONTHLY \
  --group-by Type=DIMENSION,Key=SERVICE \
  --metrics UnblendedCost \
  --query 'ResultsByTime[0].Groups[*].{Service:Keys[0],Cost:Metrics.UnblendedCost.Amount}' \
  --output table | sort -k3 -rn
</code></pre>
<p>Screenshot the output and save it. You'll compare against it after each step to verify actual savings before moving to the next one.</p>
<h3 id="heading-12-the-most-expensive-mistake-wrong-optimisation-order">1.2 The Most Expensive Mistake: Wrong Optimisation Order</h3>
<p>Here's what most teams do when they get a large AWS bill:</p>
<ol>
<li><p>Buy Savings Plans immediately, locking in waste at a 30% discount</p>
</li>
<li><p>Then implement Karpenter, discovering they've over-committed the wrong instance family</p>
</li>
<li><p>Then migrate to Graviton, discovering their Savings Plan doesn't cover ARM instances</p>
</li>
</ol>
<p>The result: a 12–36 month commitment paying for waste they could have eliminated in three weeks.</p>
<p>The correct sequence is:</p>
<pre><code class="language-plaintext">Step 1: Right-size pod requests        ← Always first
Step 2: Implement Karpenter            ← Dynamic provisioning on rightsized requests
Step 3: Enable Spot for non-prod       ← Karpenter handles fallback automatically
Step 4: Migrate to Graviton            ← Karpenter makes this seamless
Step 5: Add VPC endpoints              ← Eliminate data transfer charges
Step 6: Optimise EBS volumes           ← Quick win, run alongside other steps
Step 7: Consolidate load balancers     ← Final structural cleanup
</code></pre>
<p>Then, and only then, buy Savings Plans — against the optimised baseline you've just established.</p>
<p>The one rule: optimise first, then commit. Every step before the Savings Plan purchase reduces what you're locking in for 1–3 years.</p>
<h2 id="heading-part-2-right-sizing-pod-resource-requests">Part 2: Right-Sizing Pod Resource Requests</h2>
<h3 id="heading-21-why-over-provisioned-requests-are-so-expensive">2.1 Why Over-Provisioned Requests Are So Expensive</h3>
<p>Kubernetes schedules pods based on resource <em>requests</em> — not actual usage. A pod that requests 2 vCPUs and 4GB of memory requires a node with that capacity available, regardless of whether the pod is actually using it.</p>
<p>Here's the incorrect approach with the requests set to worst-case peak estimates:</p>
<pre><code class="language-yaml"># Bad: Resource requests set during initial deployment, never revisited
# This pod actually uses 250m CPU and 512Mi memory on average
resources:
  requests:
    cpu: "2"        # 8x more than actual usage
    memory: "4Gi"   # 8x more than actual usage
  limits:
    cpu: "4"
    memory: "8Gi"
</code></pre>
<p>When every pod is over-requested by 8x, your cluster needs 8x more nodes than your workloads actually require. That's where the 61% compute line in your bill comes from.</p>
<p>First, verify actual usage before changing anything:</p>
<pre><code class="language-bash"># Install Metrics Server if not already running
kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml

# Check actual CPU and memory usage per pod
# Compare these numbers against your current resource requests
kubectl top pods --all-namespaces --sort-by=cpu
</code></pre>
<p>Expected output showing the typical gap:</p>
<pre><code class="language-plaintext">NAMESPACE     NAME                    CPU(cores)   MEMORY(bytes)
production    payment-api-xxx         25m          128Mi
production    user-api-xxx            15m          96Mi
production    notification-svc-xxx    5m           64Mi
staging       worker-xxx              10m          256Mi
</code></pre>
<p>If your pods are requesting 2 CPU cores each but using 25m–15m cores in practice, you have a 50–80x over-request ratio. Every node in your cluster is mostly empty space you're paying for.</p>
<h3 id="heading-22-using-the-vertical-pod-autoscaler-for-recommendations">2.2 Using the Vertical Pod Autoscaler for Recommendations</h3>
<p>The Vertical Pod Autoscaler (VPA) is a Kubernetes component that analyses historical CPU and memory usage for each deployment and recommends optimal resource requests. You use it in recommendation-only mode first — it tells you what to set without changing anything automatically, so you can review and apply the changes yourself with full control.</p>
<p>Here's the correct implementation:</p>
<pre><code class="language-yaml"># Good: VPA in recommendation-only mode
# Watches your pod's actual usage for 24+ hours, then recommends right-sized requests
# updateMode: "Off" means it only recommends — it never restarts your pods
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: payment-api-vpa
  namespace: production
spec:
  targetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: payment-api
  updatePolicy:
    updateMode: "Off"   # Recommendation only — you apply manually after review
  resourcePolicy:
    containerPolicies:
    - containerName: "*"
      minAllowed:
        cpu: "100m"     # VPA will never recommend below this floor
        memory: "256Mi"
      maxAllowed:
        cpu: "2"        # VPA will never recommend above this ceiling
        memory: "4Gi"
</code></pre>
<p>Install VPA and retrieve recommendations:</p>
<pre><code class="language-bash"># Install VPA components
kubectl apply -f https://github.com/kubernetes/autoscaler/releases/download/vertical-pod-autoscaler-1.0.0/vpa-v1.0.0.yaml

# Apply the VPA manifest for each deployment you want to right-size
kubectl apply -f vpa/payment-api-vpa.yaml

# Wait 24 hours for VPA to collect usage data, then check recommendations
kubectl describe vpa payment-api-vpa -n production
</code></pre>
<p>What a VPA recommendation looks like:</p>
<pre><code class="language-plaintext">Recommendation:
  Container Recommendations:
    Container Name: payment-api
    Lower Bound:
      cpu:     50m
      memory:  128Mi
    Target:
      cpu:     250m      ← Set your requests to this value
      memory:  512Mi     ← Set your requests to this value
    Upper Bound:
      cpu:     500m
      memory:  1Gi
</code></pre>
<p>Apply the recommendation to your deployment:</p>
<pre><code class="language-yaml"># Good: Right-sized requests based on VPA Target recommendation
resources:
  requests:
    cpu: "250m"     # Down from 2000m — an 8x reduction
    memory: "512Mi" # Down from 4096Mi — an 8x reduction
  limits:
    cpu: "500m"     # 2x the request — headroom for genuine spikes
    memory: "1Gi"   # 2x the request
</code></pre>
<p>All VPA manifests for common deployment types are in <code>vpa/</code> in the <a href="https://github.com/aayostem/eks-cost-optimization/tree/main/vpa">companion repo</a>.</p>
<h3 id="heading-23-the-roi-of-right-sizing">2.3 The ROI of Right-Sizing</h3>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Before</th>
<th>After</th>
<th>Improvement</th>
</tr>
</thead>
<tbody><tr>
<td>Average CPU utilisation</td>
<td>18%</td>
<td>65%</td>
<td>+47 percentage points</td>
</tr>
<tr>
<td>Node count required</td>
<td>42</td>
<td>28</td>
<td>-33%</td>
</tr>
<tr>
<td>Monthly compute cost</td>
<td>$52,000</td>
<td>$36,400</td>
<td>-$15,600/month</td>
</tr>
</tbody></table>
<p>Verify the improvement after applying recommendations:</p>
<pre><code class="language-bash"># Check cluster-level utilisation after right-sizing
# Target: 60–75% CPU and memory utilisation across nodes
kubectl top nodes
</code></pre>
<h2 id="heading-part-3-karpenter-for-bin-packing-and-spot-diversification">Part 3: Karpenter for Bin-Packing and Spot Diversification</h2>
<p>Karpenter is an open-source Kubernetes node provisioner built by AWS and donated to the CNCF.</p>
<p>Where the default Kubernetes Cluster Autoscaler scales pre-configured node groups up and down, Karpenter watches the actual resource requests of pending pods and provisions exactly the right EC2 instance type to satisfy them — selecting dynamically from thousands of available instance families rather than the two or three you pre-configured. It also continuously monitors running nodes for underutilisation and consolidates workloads onto fewer nodes, terminating the empty ones automatically.</p>
<p>The result is a cluster that is always sized to what your workloads actually need right now, not what you anticipated at setup time.</p>
<h3 id="heading-31-the-ceiling-with-cluster-autoscaler">3.1 The Ceiling with Cluster Autoscaler</h3>
<p>Cluster Autoscaler works with pre-defined node groups. You configure which instance types are available and it scales those groups up and down.</p>
<p>The limitation is that it can only provision instances from the types you pre-configured. It can't dynamically select the right instance type based on what the workload actually needs right now.</p>
<p>Here's the incorrect approach using static node groups:</p>
<pre><code class="language-bash"># Bad: Two static node groups, each over-provisioning against worst-case scenarios
# CPU-optimised group runs even when workloads are memory-bound
# Memory-optimised group runs even when workloads are CPU-bound
eksctl create nodegroup \
  --cluster my-cluster \
  --name cpu-optimized \
  --instance-types c5.2xlarge \
  --nodes-min 5 --nodes-max 20

eksctl create nodegroup \
  --cluster my-cluster \
  --name memory-optimized \
  --instance-types r5.2xlarge \
  --nodes-min 3 --nodes-max 10
</code></pre>
<p>You're provisioning for the worst case in each family simultaneously. At any given moment, one group is underutilised while the other is scaling. Neither is right.</p>
<h3 id="heading-32-how-karpenter-solves-this">3.2 How Karpenter Solves This</h3>
<p>Karpenter watches the actual resource requests of pending pods and provisions exactly the right instance type to fit them. It selects from thousands of available instance types, not just the two you pre-configured. It also consolidates running workloads onto fewer nodes when utilisation drops, automatically terminating underutilised nodes.</p>
<p>Here's the correct implementation:</p>
<pre><code class="language-yaml"># Good: Karpenter NodePool
# Karpenter selects the optimal instance type based on pending pod requirements
# Tries Spot first, falls back to On-Demand automatically when Spot isn't available
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
  name: default
spec:
  template:
    spec:
      requirements:
        # Allow both x86 and ARM (Graviton) — Karpenter picks the cheaper option
        - key: kubernetes.io/arch
          operator: In
          values: ["amd64", "arm64"]
        # Try Spot first, fall back to On-Demand if unavailable
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot", "on-demand"]
        # Exclude families with poor price-to-performance ratio
        - key: karpenter.k8s.aws/instance-family
          operator: NotIn
          values: ["t2", "t3a"]
  limits:
    cpu: "1000"
    memory: "4000Gi"
  disruption:
    # Remove underutilised nodes and reschedule their pods automatically
    consolidationPolicy: WhenUnderutilized
    # Recycle nodes after 30 days to ensure fresh, patched AMIs
    expireAfter: 720h
</code></pre>
<p>What each setting does:</p>
<ul>
<li><p><code>consolidationPolicy: WhenUnderutilized</code>: Karpenter continuously monitors node utilisation and removes underused nodes, moving their pods elsewhere. Your node count decreases automatically as load drops without any manual intervention.</p>
</li>
<li><p><code>expireAfter: 720h</code>: Nodes older than 30 days are gracefully replaced, ensuring your infrastructure always runs the latest EKS-optimised AMI with current security patches.</p>
</li>
<li><p><code>values: ["spot", "on-demand"]</code>: Karpenter attempts Spot capacity first. If Spot is unavailable for the requested instance type, it falls back to On-Demand with no alerts and no manual action required.</p>
</li>
</ul>
<p>Migrating from Cluster Autoscaler safely:</p>
<pre><code class="language-bash"># Step 1: Install Karpenter alongside Cluster Autoscaler — do not remove CAS yet
helm repo add karpenter https://charts.karpenter.sh
helm install karpenter karpenter/karpenter \
  --namespace karpenter \
  --create-namespace \
  --set settings.clusterName=your-cluster-name

# Step 2: Apply NodePool and NodeClass configuration
kubectl apply -f karpenter/nodepool.yaml
kubectl apply -f karpenter/nodeclass.yaml

# Step 3: Taint existing legacy nodes so new pods schedule on Karpenter nodes
# This migrates workloads gradually — zero downtime
kubectl taint nodes -l eks.amazonaws.com/nodegroup=cpu-optimized \
  group=legacy:NoSchedule

# Step 4: Watch pods reschedule to Karpenter-managed nodes over the next hour
kubectl get pods -o wide --all-namespaces | grep -v legacy

# Step 5: After 30 days of stable operation, remove the old node groups
eksctl delete nodegroup --cluster my-cluster --name cpu-optimized
eksctl delete nodegroup --cluster my-cluster --name memory-optimized
</code></pre>
<p>Ready-to-deploy NodePool and NodeClass templates are in <code>karpenter/</code> in the <a href="https://github.com/aayostem/eks-cost-optimization/tree/main/karpenter">companion repo</a>.</p>
<h3 id="heading-33-spot-instances-for-non-production-workloads">3.3 Spot Instances for Non-Production Workloads</h3>
<p>Staging and development workloads don't need the reliability guarantees of On-Demand instances. Moving them to Spot saves 60–90% on those node costs. Karpenter handles Spot interruptions by rescheduling pods automatically. For stateless workloads, interruptions are invisible to users.</p>
<pre><code class="language-yaml"># Good: Spot-only NodePool for staging environments
apiVersion: karpenter.sh/v1beta1
kind: NodePool
metadata:
  name: staging-spot
spec:
  template:
    metadata:
      labels:
        billing/environment: staging
    spec:
      taints:
        - key: environment
          value: staging
          effect: NoSchedule  # Only pods that tolerate this taint schedule here
      requirements:
        - key: karpenter.sh/capacity-type
          operator: In
          values: ["spot"]   # Spot only for non-production
  disruption:
    consolidationPolicy: WhenUnderutilized
</code></pre>
<h3 id="heading-34-the-roi-of-karpenter-and-spot">3.4 The ROI of Karpenter and Spot</h3>
<table>
<thead>
<tr>
<th>Metric</th>
<th>Before (Cluster Autoscaler)</th>
<th>After (Karpenter + Spot)</th>
<th>Improvement</th>
</tr>
</thead>
<tbody><tr>
<td>Average node count</td>
<td>28</td>
<td>18</td>
<td>-36%</td>
</tr>
<tr>
<td>Average CPU utilisation</td>
<td>65%</td>
<td>82%</td>
<td>+17 percentage points</td>
</tr>
<tr>
<td>Staging environment cost</td>
<td>$8,000/month</td>
<td>$2,400/month</td>
<td>-70%</td>
</tr>
<tr>
<td>Scale-up time for new pods</td>
<td>3–5 minutes</td>
<td>30–60 seconds</td>
<td>-80%</td>
</tr>
</tbody></table>
<h2 id="heading-part-4-graviton-migration">Part 4: Graviton Migration</h2>
<p>AWS Graviton is Amazon's own ARM-based processor family, available across EC2 instance types with names ending in <code>g</code> — <code>m7g</code>, <code>c7g</code>, <code>r7g</code>, and so on.</p>
<p>Graviton instances are priced approximately 20% lower than equivalent Intel or AMD x86 instances. For most server-side workloads — Node.js, Python, Go, Java — they also deliver 20–40% better performance per dollar because the processor architecture is optimised specifically for these workload types.</p>
<p>You don't change your application code to use Graviton. You change the architecture flag in your container image build and the node selector in your Kubernetes deployment.</p>
<h3 id="heading-41-why-graviton-reduces-cost-without-reducing-performance">4.1 Why Graviton Reduces Cost Without Reducing Performance</h3>
<p>The first question to answer before migrating is whether your container images support ARM64. Most official images from Docker Hub ship as multi-architecture images. Your own application images need to be built for both architectures explicitly.</p>
<p>Check whether your images support ARM64:</p>
<pre><code class="language-bash"># Check if an image has an ARM64 manifest
docker manifest inspect your-registry/your-app:latest | jq '.manifests[].platform'
</code></pre>
<p>Expected output for a multi-arch image:</p>
<pre><code class="language-json">{"architecture": "amd64", "os": "linux"},
{"architecture": "arm64", "os": "linux", "variant": "v8"}
</code></pre>
<p>If <code>arm64</code> appears, the image is ready. If not, you need to build and push a multi-arch image first.</p>
<p>Build and push a multi-architecture image:</p>
<pre><code class="language-bash"># Build for both x86 and ARM in a single command using Docker Buildx
docker buildx create --use --name multi-arch-builder

docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag your-registry/your-app:latest \
  --push \
  .
</code></pre>
<h3 id="heading-42-migrating-workloads-to-graviton">4.2 Migrating Workloads to Graviton</h3>
<p>With Karpenter already installed, Graviton migration is a single label change on your deployment. Karpenter provisions the appropriate ARM64 node automatically.</p>
<p>Here's the correct implementation:</p>
<pre><code class="language-yaml"># Good: nodeSelector directs the pod to Graviton nodes
# Karpenter provisions an arm64 node if one isn't already available
apiVersion: apps/v1
kind: Deployment
metadata:
  name: payment-api
spec:
  template:
    spec:
      nodeSelector:
        kubernetes.io/arch: arm64   # Schedule exclusively on Graviton nodes
      containers:
        - name: api
          image: your-registry/payment-api:latest  # Must be multi-arch
</code></pre>
<p>Migrate gradually, starting with stateless services:</p>
<pre><code class="language-bash"># Step 1: Migrate one stateless service and monitor for 48 hours
kubectl patch deployment payment-api \
  -p '{"spec":{"template":{"spec":{"nodeSelector":{"kubernetes.io/arch":"arm64"}}}}}'

# Step 2: Watch for errors in the first 30 minutes
kubectl logs -l app=payment-api --tail=100 -f

# Step 3: Verify the pod is running on a Graviton node
# The NODE column should show a Graviton instance type (m7g, c7g, r7g)
kubectl get pods -l app=payment-api -o wide

# Step 4: After 48 hours of stable operation, migrate the next service
</code></pre>
<p>There are some situations where you shouldn't migrate to Graviton: GPU workloads, applications with native x86 binary dependencies, or any workload where you haven't yet built multi-arch images.</p>
<h3 id="heading-43-the-roi-of-graviton">4.3 The ROI of Graviton</h3>
<table>
<thead>
<tr>
<th>Workload Type</th>
<th>x86 Monthly Cost</th>
<th>Graviton Monthly Cost</th>
<th>Saving</th>
</tr>
</thead>
<tbody><tr>
<td>Web services (Node.js, Python)</td>
<td>$18,000</td>
<td>$14,400</td>
<td>$3,600/month</td>
</tr>
<tr>
<td>Data processing</td>
<td>$12,000</td>
<td>$9,600</td>
<td>$2,400/month</td>
</tr>
<tr>
<td>API services (Go, Java)</td>
<td>$8,000</td>
<td>$6,400</td>
<td>$1,600/month</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>$38,000</strong></td>
<td><strong>$30,400</strong></td>
<td><strong>$7,600/month</strong></td>
</tr>
</tbody></table>
<h2 id="heading-part-5-vpc-endpoints-for-data-transfer">Part 5: VPC Endpoints for Data Transfer</h2>
<h3 id="heading-51-the-nat-gateway-tax">5.1 The NAT Gateway Tax</h3>
<p>Every byte that travels from your EKS pods to an AWS service — S3, DynamoDB, ECR, SQS — goes through a NAT Gateway if you haven't configured VPC endpoints. NAT Gateway charges <code>$0.045</code> per GB of data processed.</p>
<p>A busy EKS cluster pulling container images from ECR, writing to S3, and polling SQS queues can process hundreds of terabytes per month through NAT Gateway — generating thousands of dollars in charges for traffic that never actually left the AWS network.</p>
<p>Measure your current NAT Gateway cost before adding endpoints:</p>
<pre><code class="language-bash"># Get last month's NAT Gateway data processing charges
aws ce get-cost-and-usage \
  --time-period Start=$(date -d 'last month' +%Y-%m-01),End=$(date +%Y-%m-01) \
  --granularity DAILY \
  --filter '{
    "Dimensions": {
      "Key": "USAGE_TYPE",
      "Values": ["NATGateway-Bytes"]
    }
  }' \
  --metrics UnblendedCost \
  --query 'ResultsByTime[*].{Date:TimePeriod.Start,Cost:Total.UnblendedCost.Amount}' \
  --output table
</code></pre>
<h3 id="heading-52-vpc-endpoints-the-fix-that-takes-30-minutes">5.2 VPC Endpoints — The Fix That Takes 30 Minutes</h3>
<p>A VPC endpoint creates a private connection between your VPC and an AWS service, routing traffic through the AWS backbone without touching the NAT Gateway. The data transfer becomes free. Each endpoint costs approximately <code>$0.01</code>/hour — roughly <code>$7.20</code>/month — far less than the NAT Gateway processing charges it replaces.</p>
<p>Here's the complete implementation for the four most common EKS traffic destinations:</p>
<pre><code class="language-bash"># Get your VPC ID and primary route table ID first
VPC_ID=$(aws eks describe-cluster --name your-cluster \
  --query 'cluster.resourcesVpcConfig.vpcId' --output text)

ROUTE_TABLE_ID=$(aws ec2 describe-route-tables \
  --filters Name=vpc-id,Values=$VPC_ID Name=association.main,Values=true \
  --query 'RouteTables[0].RouteTableId' --output text)

echo "VPC: $VPC_ID | Route Table: $ROUTE_TABLE_ID"

# S3 gateway endpoint — free to create, eliminates all S3 traffic through NAT
aws ec2 create-vpc-endpoint \
  --vpc-id $VPC_ID \
  --service-name com.amazonaws.us-east-1.s3 \
  --route-table-ids $ROUTE_TABLE_ID

# DynamoDB gateway endpoint — also free, same mechanism as S3
aws ec2 create-vpc-endpoint \
  --vpc-id $VPC_ID \
  --service-name com.amazonaws.us-east-1.dynamodb \
  --route-table-ids $ROUTE_TABLE_ID

# ECR API interface endpoint — eliminates NAT charges on image pulls
aws ec2 create-vpc-endpoint \
  --vpc-id $VPC_ID \
  --vpc-endpoint-type Interface \
  --service-name com.amazonaws.us-east-1.ecr.api \
  --subnet-ids $(aws ec2 describe-subnets \
    --filters Name=vpc-id,Values=$VPC_ID Name=tag:Tier,Values=private \
    --query 'Subnets[*].SubnetId' --output text)

# ECR Docker endpoint — required alongside ECR API for complete image pull coverage
aws ec2 create-vpc-endpoint \
  --vpc-id $VPC_ID \
  --vpc-endpoint-type Interface \
  --service-name com.amazonaws.us-east-1.ecr.dkr \
  --subnet-ids $(aws ec2 describe-subnets \
    --filters Name=vpc-id,Values=$VPC_ID Name=tag:Tier,Values=private \
    --query 'Subnets[*].SubnetId' --output text)
</code></pre>
<p>The Terraform module that creates all four endpoints in a single <code>apply</code> is in <code>terraform/vpc-endpoints/</code> in the <a href="https://github.com/aayostem/eks-cost-optimization/tree/main/terraform/vpc-endpoints">companion repo</a>.</p>
<p>Verify that the endpoints are routing traffic correctly:</p>
<pre><code class="language-bash">aws ec2 describe-vpc-endpoints \
  --filters Name=vpc-id,Values=$VPC_ID \
  --query 'VpcEndpoints[*].{Service:ServiceName,State:State,Type:VpcEndpointType}' \
  --output table
# Expected: all endpoints showing State=available
</code></pre>
<h3 id="heading-53-the-roi-of-vpc-endpoints">5.3 The ROI of VPC Endpoints</h3>
<table>
<thead>
<tr>
<th>Service</th>
<th>Before (Through NAT)</th>
<th>After (VPC Endpoint)</th>
<th>Monthly Saving</th>
</tr>
</thead>
<tbody><tr>
<td>S3 data transfer</td>
<td>$4,500</td>
<td>$0</td>
<td>$4,500</td>
</tr>
<tr>
<td>ECR image pulls</td>
<td>$800</td>
<td>$0</td>
<td>$800</td>
</tr>
<tr>
<td>DynamoDB queries</td>
<td>$1,200</td>
<td>$0</td>
<td>$1,200</td>
</tr>
<tr>
<td>Endpoint cost</td>
<td>—</td>
<td>$29 (4 endpoints)</td>
<td>-$29</td>
</tr>
<tr>
<td><strong>Net saving</strong></td>
<td></td>
<td></td>
<td><strong>$6,471/month</strong></td>
</tr>
</tbody></table>
<h2 id="heading-part-6-ebs-volume-optimisation">Part 6: EBS Volume Optimisation</h2>
<h3 id="heading-61-the-gp2-to-gp3-migration">6.1 The gp2 to gp3 Migration</h3>
<p>EBS gp2 volumes price their IOPS based on storage size — 3 IOPS per GB, with a 100 IOPS minimum. EBS gp3 volumes provide 3,000 IOPS baseline regardless of size, and cost 20% less per GB. The migration runs online with no downtime.</p>
<p>Find and migrate all gp2 volumes:</p>
<pre><code class="language-bash"># Step 1: List all gp2 volumes and their sizes
aws ec2 describe-volumes \
  --filters Name=volume-type,Values=gp2 \
  --query 'Volumes[*].{ID:VolumeId,Size:Size,State:State}' \
  --output table

# Step 2: Migrate each gp2 volume to gp3 — no instance stop required
# The modify operation runs online while the volume stays attached and in use
aws ec2 describe-volumes \
  --filters Name=volume-type,Values=gp2 \
  --query 'Volumes[*].VolumeId' \
  --output text | tr '\t' '\n' | while read vol; do
    echo "Migrating $vol from gp2 to gp3..."
    aws ec2 modify-volume \
      --volume-id $vol \
      --volume-type gp3
done

# Step 3: Verify all volumes are now gp3
aws ec2 describe-volumes \
  --filters Name=volume-type,Values=gp2 \
  --query 'Volumes[*].VolumeId' \
  --output text
# Expected: empty output — zero gp2 volumes remaining
</code></pre>
<h3 id="heading-62-finding-and-removing-orphaned-volumes-and-snapshots">6.2 Finding and Removing Orphaned Volumes and Snapshots</h3>
<p>When Kubernetes PersistentVolumeClaims are deleted, the underlying EBS volumes sometimes aren't cleaned up. They keep running — and billing — indefinitely.</p>
<pre><code class="language-bash"># Find unattached EBS volumes — status=available means not attached to any instance
aws ec2 describe-volumes \
  --filters Name=status,Values=available \
  --query 'Volumes[*].{ID:VolumeId,Size:Size,Created:CreateTime}' \
  --output table

# Find EBS snapshots older than 90 days
aws ec2 describe-snapshots \
  --owner-ids self \
  --query "Snapshots[?StartTime&lt;='$(date -d '90 days ago' --iso-8601=seconds)'].[SnapshotId,StartTime,VolumeSize]" \
  --output table
</code></pre>
<p>Before deleting any snapshot, cross-reference with your RDS automated backup schedule to confirm it's not the only backup for a production database.</p>
<h3 id="heading-63-the-roi-of-ebs-optimisation">6.3 The ROI of EBS Optimisation</h3>
<table>
<thead>
<tr>
<th>Resource</th>
<th>Before</th>
<th>After</th>
<th>Monthly Saving</th>
</tr>
</thead>
<tbody><tr>
<td>gp2 → gp3 migration (1TB total)</td>
<td>$102</td>
<td>$72</td>
<td>$30</td>
</tr>
<tr>
<td>Unattached volumes removed (50 × 100GB)</td>
<td>$500</td>
<td>$0</td>
<td>$500</td>
</tr>
<tr>
<td>Old snapshots cleaned (500GB)</td>
<td>$25</td>
<td>$0</td>
<td>$25</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td><strong>$627</strong></td>
<td><strong>$72</strong></td>
<td><strong>$555/month</strong></td>
</tr>
</tbody></table>
<h2 id="heading-part-7-load-balancer-consolidation">Part 7: Load Balancer Consolidation</h2>
<h3 id="heading-71-the-problem-one-load-balancer-per-service">7.1 The Problem — One Load Balancer Per Service</h3>
<p>Many teams create a separate <code>LoadBalancer</code> Service for every microservice. On AWS, each Application Load Balancer costs approximately <code>$16.20</code>/month base charge plus <code>$0.008</code>/LCU-hour for traffic processed. At 20 microservices, that's <code>$324</code>/month before a single request is processed.</p>
<p>Here's the incorrect approach:</p>
<pre><code class="language-yaml"># Bad: This creates a dedicated AWS ALB every time it's applied
# 20 microservices = 20 ALBs = $324+/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-72-the-fix-shared-ingress-controller">7.2 The Fix — Shared Ingress Controller</h3>
<p>An Ingress controller is a Kubernetes component that runs as a pod inside your cluster and programs a single external load balancer to route traffic to multiple services based on hostname and URL path. Instead of one AWS Application Load Balancer per microservice, you get one ALB total — with path-based routing directing each request to the right backend service. The result is the same routing behaviour at a fraction of the cost.</p>
<p>Here's the correct implementation:</p>
<pre><code class="language-yaml"># Good: One Ingress resource routes all external traffic
# The AWS Load Balancer Controller creates one ALB for all services listed here
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>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>
<tr>
<td><strong>Monthly saving</strong></td>
<td></td>
<td><strong>~$373/month</strong></td>
</tr>
</tbody></table>
<p>The shared Ingress manifest is in <code>k8s/ingress/</code> in the <a href="https://github.com/aayostem/eks-cost-optimization/tree/main/k8s/ingress">companion repo</a>.</p>
<h2 id="heading-the-complete-7-step-sequence">The Complete 7-Step Sequence</h2>
<table>
<thead>
<tr>
<th>Step</th>
<th>Action</th>
<th>Time to Implement</th>
<th>Expected Monthly Saving</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Right-size pod resource requests (VPA)</td>
<td>1 week</td>
<td>$15,600</td>
</tr>
<tr>
<td>2</td>
<td>Install Karpenter with consolidation</td>
<td>1 week</td>
<td>$8,400</td>
</tr>
<tr>
<td>3</td>
<td>Move staging and dev to Spot</td>
<td>1 week</td>
<td>$11,200</td>
</tr>
<tr>
<td>4</td>
<td>Migrate compatible workloads to Graviton</td>
<td>2 weeks</td>
<td>$7,600</td>
</tr>
<tr>
<td>5</td>
<td>Add VPC endpoints for S3, ECR, DynamoDB</td>
<td>1 day</td>
<td>$6,471</td>
</tr>
<tr>
<td>6</td>
<td>Migrate gp2 to gp3 and delete orphaned volumes</td>
<td>1 day</td>
<td>$555</td>
</tr>
<tr>
<td>7</td>
<td>Consolidate load balancers with shared Ingress</td>
<td>1 day</td>
<td>$373</td>
</tr>
<tr>
<td><strong>Total</strong></td>
<td></td>
<td><strong>3–4 weeks</strong></td>
<td><strong>$49,799/month</strong></td>
</tr>
</tbody></table>
<p>Annual saving at this rate: <code>$597,588</code>. Engineering time required: one engineer, one sprint per step.</p>
<h2 id="heading-best-practices-for-eks-cost-optimisation">Best Practices for EKS Cost Optimisation</h2>
<p>✅ <strong>Do:</strong> Right-size pod resource requests before any other optimisation. Every subsequent step depends on accurate requests.</p>
<p>✅ <strong>Do:</strong> Implement Karpenter with <code>consolidationPolicy: WhenUnderutilized</code>. Let it continuously optimise your node count automatically.</p>
<p>✅ <strong>Do:</strong> Move staging and development workloads to Spot. 60–90% savings for workloads that tolerate interruption.</p>
<p>✅ <strong>Do:</strong> Migrate compatible workloads to Graviton. Most web services and APIs run without code changes.</p>
<p>✅ <strong>Do:</strong> Add VPC endpoints for S3, DynamoDB, and ECR before reviewing data transfer costs.</p>
<p>✅ <strong>Do:</strong> Migrate gp2 volumes to gp3. It's online, zero downtime, and immediately 20% cheaper.</p>
<p>✅ <strong>Do:</strong> Use a single shared Ingress controller for all external traffic instead of per-service load balancers.</p>
<p>❌ <strong>Don't:</strong> Buy Savings Plans before completing steps 1–6. You'll lock in waste for 1–3 years.</p>
<p>❌ <strong>Don't:</strong> Use static node groups with Cluster Autoscaler when your workload mix changes. Karpenter handles this dynamically.</p>
<p>❌ <strong>Don't:</strong> Run staging and development environments on On-Demand instances. Spot interruptions are manageable, but the cost difference is not.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p><a href="https://karpenter.sh/docs/"><strong>Karpenter Documentation</strong></a> — Official NodePool configuration reference and installation guide</p>
</li>
<li><p><a href="https://github.com/aws/aws-graviton-getting-started"><strong>AWS Graviton Getting Started Guide</strong></a> — Language-specific compatibility notes and migration guidance from AWS</p>
</li>
<li><p><a href="https://github.com/kubernetes/autoscaler/tree/master/vertical-pod-autoscaler"><strong>Vertical Pod Autoscaler GitHub</strong></a> — VPA installation and configuration documentation</p>
</li>
<li><p><a href="https://docs.aws.amazon.com/vpc/latest/privatelink/vpc-endpoints.html"><strong>AWS VPC Endpoints Documentation</strong></a> — Complete list of available VPC endpoints and configuration options</p>
</li>
<li><p><a href="https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/requesting-ebs-volume-modifications.html"><strong>EBS Volume Modification Documentation</strong></a> — AWS guide for online volume type migration with zero downtime</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</p>
</li>
<li><p><a href="https://docs.aws.amazon.com/cost-management/latest/APIReference/API_GetCostAndUsage.html"><strong>AWS Cost Explorer API Reference</strong></a> — Full reference for the cost breakdown commands used throughout this guide</p>
</li>
<li><p><a href="https://aws.github.io/aws-eks-best-practices/cost_optimization/cfm_framework/"><strong>EKS Best Practices Guide — Cost Optimisation</strong></a> — AWS's official EKS cost optimisation framework</p>
</li>
<li><p><a href="https://github.com/aayostem/eks-cost-optimization"><strong>Companion Repository</strong></a> — All Terraform modules, NodePool templates, VPA manifests, and automation scripts from this guide</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The 2026 FinOps Roadmap: From Cost-Blind Engineer to Cloud Financial Manager ]]>
                </title>
                <description>
                    <![CDATA[ My first AWS bill was $23,000. I had been working at the company for three weeks. Nobody told me. The bill just grew quietly in the background while I was proud of the feature I shipped. A Lambda func ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-2026-finops-roadmap-from-cost-blind-engineer-to-cloud-financial-manager/</link>
                <guid isPermaLink="false">6a30894af07f26c8d93079b8</guid>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ finops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Roadmap ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ayobami Adejumo ]]>
                </dc:creator>
                <pubDate>Mon, 15 Jun 2026 23:22:50 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/365d29dc-738d-4c21-a9a5-8f818c36cc95.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>My first AWS bill was $23,000. I had been working at the company for three weeks.</p>
<p>Nobody told me. The bill just grew quietly in the background while I was proud of the feature I shipped. A Lambda function that called an external enrichment API on every user event. Clean code. Solid tests. Thirty-two million events that month. At $0.0007 per API call.</p>
<p>My engineering manager forwarded the invoice with two words: "Please explain."</p>
<p>That was the moment I discovered FinOps — not from a conference talk or a certification course, but from the specific shame of having written expensive code and not knowing it until the damage was done.</p>
<p>This roadmap is what I needed that day. A complete, honest guide to transforming from an engineer who builds things that work into an engineer who builds things that work <em>and</em> cost what they should. By the end of this guide, you'll have the skills, the scripts, and the vocabulary to talk about cloud spend the way a CFO and a CTO both want to hear.</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-the-four-stages-overview">The Four Stages Overview</a></p>
</li>
<li><p><a href="#heading-stage-1-the-cost-aware-engineer-months-1-to-3">Stage 1: The Cost-Aware Engineer — Months 1 to 3</a></p>
</li>
<li><p><a href="#heading-stage-2-the-optimisation-specialist-months-4-to-8">Stage 2: The Optimisation Specialist — Months 4 to 8</a></p>
</li>
<li><p><a href="#heading-stage-3-the-automation-architect-months-9-to-15">Stage 3: The Automation Architect — Months 9 to 15</a></p>
</li>
<li><p><a href="#heading-stage-4-the-cloud-financial-manager-months-16-to-24">Stage 4: The Cloud Financial Manager — Months 16 to 24</a></p>
</li>
<li><p><a href="#heading-essential-tools-and-certifications">Essential Tools and Certifications</a></p>
</li>
<li><p><a href="#heading-your-90-day-action-plan">Your 90-Day Action Plan</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>How to read your AWS bill as an engineer, not as a passive observer</p>
</li>
<li><p>The exact tagging strategy that makes cost attribution possible</p>
</li>
<li><p>How to right-size EC2 and RDS instances using CloudWatch data you already have</p>
</li>
<li><p>The correct sequence for purchasing Savings Plans — and why sequence matters more than the discount percentage</p>
</li>
<li><p>How to build automated cleanup systems for orphaned resources</p>
</li>
<li><p>How to present cloud cost findings to engineering leadership with data that drives decisions</p>
</li>
<li><p>The chargeback and showback models that make cost accountability stick</p>
</li>
</ul>
<p>Let's begin.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before following this roadmap, you should have some skills and tools ready to go.</p>
<p><strong>Knowledge:</strong></p>
<ul>
<li><p>You can deploy an application to AWS (EC2, Lambda, or containers)</p>
</li>
<li><p>You understand basic AWS services: S3, RDS, EC2, VPC, IAM</p>
</li>
<li><p>You're comfortable reading Python and writing simple bash scripts</p>
</li>
<li><p>You know what a pull request is and have gone through at least one code review</p>
</li>
</ul>
<p><strong>Access:</strong></p>
<ul>
<li><p>Read-only access to your AWS billing console and Cost Explorer</p>
</li>
<li><p>AWS CLI v2 configured with at least <code>ReadOnlyAccess</code> policy attached</p>
</li>
<li><p>Python 3.9 or later for running the audit scripts in this guide</p>
</li>
</ul>
<p><strong>Mindset:</strong> You don't need to be a finance expert. But you do need to be willing to look at numbers that might be uncomfortable. Every engineer I've worked with who became excellent at FinOps had one thing in common: they were willing to be the person who asked "but what does this cost?" in a room where nobody else wanted to.</p>
<p><strong>Estimated time:</strong> This roadmap covers 24 months of deliberate skill-building. You can absorb the reading in a few evenings. The practice is the 24 months.</p>
<h2 id="heading-the-four-stages-overview">The Four Stages Overview</h2>
<p>Before going deep, here's the complete picture of where you're going:</p>
<pre><code class="language-plaintext">Stage 1 — Cost-Aware Engineer (Months 1–3)
├── Read your cloud bill and understand it
├── Tag every resource with meaningful metadata
├── Identify your top 5 cost drivers
└── Block your first expensive PR with cost justification

Stage 2 — Optimisation Specialist (Months 4–8)
├── Right-size every over-provisioned resource
├── Implement storage lifecycle policies
├── Move non-production to Spot instances
└── Purchase your first Savings Plan in the right order

Stage 3 — Automation Architect (Months 9–15)
├── Build automated cleanup for orphaned resources
├── Add cost estimation to your CI/CD pipeline
├── Create cost-aware auto-scaling triggers
└── Deploy a self-service FinOps dashboard

Stage 4 — Cloud Financial Manager (Months 16–24)
├── Lead monthly FinOps reviews with engineering leadership
├── Build chargeback models for departments
├── Negotiate enterprise agreements with AWS
└── Forecast cloud spend within 5% variance
</code></pre>
<p>The reason this is a 24-month journey and not a weekend project: each stage builds on the previous one. Engineers who jump straight to Savings Plans without rightsizing first end up paying discounted prices for waste. Engineers who build dashboards before tagging get beautiful charts with no actionable data. The sequence isn't arbitrary.</p>
<h2 id="heading-stage-1-the-cost-aware-engineer-months-1-to-3">Stage 1: The Cost-Aware Engineer — Months 1 to 3</h2>
<h3 id="heading-11-reading-the-bill-like-an-engineer-not-an-accountant">1.1 Reading the Bill Like an Engineer, Not an Accountant</h3>
<p>The default AWS Cost Explorer view shows you service-level totals. That's accounting. What you need is engineering-level decomposition: which specific resources cost money, what business function they serve, and whether each dollar is justified.</p>
<p>Start by pulling a proper breakdown:</p>
<pre><code class="language-bash"># Pull last month's cost breakdown grouped by service
# Run this before touching any optimisation — this is your baseline
aws ce get-cost-and-usage \
  --time-period Start=\((date -d 'last month' +%Y-%m-01),End=\)(date +%Y-%m-01) \
  --granularity MONTHLY \
  --group-by Type=DIMENSION,Key=SERVICE \
  --metrics UnblendedCost \
  --query 'ResultsByTime[0].Groups[*].{Service:Keys[0],Cost:Metrics.UnblendedCost.Amount}' \
  --output table | sort -k3 -rn
</code></pre>
<p>Save the output. Name the file <code>aws-baseline-YYYY-MM.txt</code>. You'll compare every future month against this number. Without a baseline, you can't measure progress — and without measurable progress, you can't make the case to leadership that the work is worth engineering time.</p>
<h4 id="heading-three-questions-for-every-service-in-your-top-5">Three questions for every service in your top 5:</h4>
<p>Most engineers stop at "what is this service?" and never reach the useful question. Here's the framework I use when I first audit an account:</p>
<p>The first question is whether you know what specific business function this service is performing. Not the product name, the function. "S3" isn't an answer. "Storing unprocessed video uploads that sit for 90 days before anyone watches them" is an answer.</p>
<p>The second question is whether the cost is growing, stable, or shrinking when you look at the past three months. A stable \(12,000/month is a different problem from a \)12,000/month line that was $4,000 six months ago.</p>
<p>The third question is what percentage of your total bill this service represents. Optimising a 1% line item while a 40% line item runs unchecked is a common time-wasting trap.</p>
<h3 id="heading-12-the-tagging-strategy-that-actually-survives">1.2 The Tagging Strategy That Actually Survives</h3>
<p>Here's the honest truth about tagging: most tagging strategies die within six months because they're designed for reporting rather than for engineers. Engineers don't tag things well when they're moving fast. The solution isn't to demand more discipline. Instead, it's to make tagging enforced at the infrastructure layer.</p>
<p>Here's the minimal viable tag set (the six tags that cover 90% of attribution needs):</p>
<pre><code class="language-yaml"># These six tags enable cost attribution, accountability, and automated remediation
# Add these to every resource in your AWS account — EC2, RDS, S3, Lambda, everything

Environment: "production" | "staging" | "dev"
Team: "platform" | "backend" | "data" | "ml"
Service: "payment-api" | "fraud-detection" | "user-service"
Owner: "ayo@cloudfrugal.com"     # Person responsible for this resource
CostCenter: "engineering"         # For chargeback reporting
AutoShutdown: "true" | "false"    # Enables automated remediation
</code></pre>
<p>Enforce tags at the Terraform level so they can't be skipped:</p>
<pre><code class="language-hcl"># variables.tf
# Add this to your Terraform root module
# Any plan that creates a resource without these tags will fail validation

variable "required_tags" {
  description = "Tags required on every resource in this account"
  type = map(string)
  
  validation {
    condition = contains(keys(var.required_tags), "Environment") &amp;&amp;
                contains(keys(var.required_tags), "Team") &amp;&amp;
                contains(keys(var.required_tags), "Owner")
    error_message = "required_tags must include Environment, Team, and Owner."
  }
}

# Apply in every resource
resource "aws_instance" "app_server" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t3.medium"

  tags = merge(var.required_tags, {
    Name    = "app-server-${var.environment}"
    Service = "payment-api"
  })
}
</code></pre>
<p>Find everything that's currently untagged:</p>
<pre><code class="language-bash"># List EC2 instances missing the Team tag
# Run this weekly until you hit zero results
aws ec2 describe-instances \
  --query "Reservations[].Instances[?!not_null(Tags[?Key=='Team'].Value | [0])].[InstanceId, InstanceType, State.Name]" \
  --output table
</code></pre>
<p>Once you start finding untagged resources, you'll discover a pattern: the oldest resources in the account are the least tagged, and they're often the most expensive. An EC2 instance from 2021 that predates your tagging policy is exactly the kind of thing that generates a $3,000/month line item nobody can explain.</p>
<h3 id="heading-13-the-cost-aware-code-review">1.3 The Cost-Aware Code Review</h3>
<p>The most underused FinOps practice in engineering teams is reviewing code changes for cost implications before they merge. It takes thirty seconds per PR once you build the habit, and it prevents the kind of problem that opened this guide: the expensive feature that nobody priced before shipping.</p>
<p>Add this section to your PR template:</p>
<pre><code class="language-markdown">## Cost Impact (required for infrastructure and data changes)

- [ ] This change does not affect cloud resource usage
- [ ] New API calls introduced: estimated cost per call $______, calls/month ______
- [ ] New data storage: estimated monthly delta $______
- [ ] Cross-region data transfer introduced: yes / no
- [ ] New external service dependency with per-call pricing: yes / no

If any box other than the first is checked, add a cost estimate before requesting review.
</code></pre>
<p>The discipline is in making cost estimation a first-class review concern, not an afterthought that gets caught by the finance team on the 15th of the month.</p>
<h3 id="heading-stage-1-outcomes">Stage 1 Outcomes</h3>
<p>By the end of month 3, you should have a baseline cost breakdown on file, 100% tag coverage on active resources, identified your top 5 cost drivers with specific reduction targets, and blocked at least one expensive PR with a cost justification that held up in review.</p>
<h2 id="heading-stage-2-the-optimisation-specialist-months-4-to-8">Stage 2: The Optimisation Specialist — Months 4 to 8</h2>
<h3 id="heading-21-right-sizing-the-8020-of-cloud-savings">2.1 Right-Sizing: The 80/20 of Cloud Savings</h3>
<p>The single most reliable source of cloud waste I find in every account I audit is over-provisioned compute.</p>
<p>The pattern is consistent: an engineer provisions an instance at a size that handles their anticipated peak load, the peak never quite materialises at the expected scale, and nobody revisits the instance size because there's no automatic signal that says "this machine is 75% empty."</p>
<p>Make sure you verify actual utilisation before changing anything:</p>
<pre><code class="language-python"># rightsize_analyzer.py
# Finds EC2 instances running below 20% average CPU for 14 days
# These are right-sizing candidates — not automatic deletions

import boto3
from datetime import datetime, timedelta

def find_oversized_instances(region='us-east-1'):
    """
    Returns instances with average CPU below 20% for the last 14 days.
    Low CPU alone doesn't mean right-size — check memory too if CW agent installed.
    """
    ec2 = boto3.client('ec2', region_name=region)
    cw  = boto3.client('cloudwatch', region_name=region)

    reservations = ec2.describe_instances(
        Filters=[{'Name': 'instance-state-name', 'Values': ['running']}]
    )['Reservations']

    candidates = []

    for r in reservations:
        for inst in r['Instances']:
            iid  = inst['InstanceId']
            itype = inst['InstanceType']
            tags = {t['Key']: t['Value'] for t in inst.get('Tags', [])}

            # Pull 14-day average CPU from CloudWatch
            stats = cw.get_metric_statistics(
                Namespace='AWS/EC2',
                MetricName='CPUUtilization',
                Dimensions=[{'Name': 'InstanceId', 'Value': iid}],
                StartTime=datetime.utcnow() - timedelta(days=14),
                EndTime=datetime.utcnow(),
                Period=1209600,   # One 14-day period
                Statistics=['Average']
            )['Datapoints']

            avg_cpu = stats[0]['Average'] if stats else 0.0

            if avg_cpu &lt; 20.0:
                candidates.append({
                    'instance_id':  iid,
                    'instance_type': itype,
                    'avg_cpu_pct':  round(avg_cpu, 1),
                    'environment':  tags.get('Environment', 'unknown'),
                    'owner':        tags.get('Owner', 'unknown'),
                    'team':         tags.get('Team', 'unknown'),
                })

    return sorted(candidates, key=lambda x: x['avg_cpu_pct'])

if __name__ == '__main__':
    results = find_oversized_instances()
    print(f"\nFound {len(results)} right-sizing candidates:\n")
    for r in results:
        print(f"  {r['instance_id']} ({r['instance_type']}) — "
              f"{r['avg_cpu_pct']}% avg CPU — "
              f"owner: {r['owner']}")
</code></pre>
<p>A word of caution: CPU utilisation below 20% is a signal, not a verdict. Some workloads are memory-intensive or I/O-bound and will show low CPU while being correctly sized. Before acting on any right-sizing recommendation, check memory utilisation (requires the CloudWatch agent) and network I/O patterns alongside CPU.</p>
<h3 id="heading-22-storage-tiering-stop-paying-retail-for-cold-data">2.2 Storage Tiering: Stop Paying Retail for Cold Data</h3>
<p>S3 Standard costs \(0.023 per GB per month. S3 Glacier Deep Archive costs \)0.00099 per GB per month. The difference is a factor of 23. If you have data that you last accessed six months ago and you're keeping it in S3 Standard because nobody set up lifecycle policies, you're paying 23x more than necessary.</p>
<p><strong>The complete S3 lifecycle policy for engineering teams:</strong></p>
<pre><code class="language-json">{
  "Rules": [
    {
      "ID": "application-logs-lifecycle",
      "Status": "Enabled",
      "Filter": {"Prefix": "logs/"},
      "Transitions": [
        {"Days": 30,  "StorageClass": "STANDARD_IA"},
        {"Days": 90,  "StorageClass": "GLACIER_IR"},
        {"Days": 365, "StorageClass": "DEEP_ARCHIVE"}
      ],
      "Expiration": {"Days": 2555},
      "AbortIncompleteMultipartUpload": {"DaysAfterInitiation": 7}
    },
    {
      "ID": "training-checkpoints-lifecycle",
      "Status": "Enabled",
      "Filter": {"Prefix": "ml-checkpoints/"},
      "Transitions": [
        {"Days": 7,  "StorageClass": "STANDARD_IA"},
        {"Days": 30, "StorageClass": "GLACIER_IR"}
      ],
      "Expiration": {"Days": 90}
    }
  ]
}
</code></pre>
<pre><code class="language-bash"># Apply the lifecycle policy to a bucket
aws s3api put-bucket-lifecycle-configuration \
  --bucket your-logs-bucket \
  --lifecycle-configuration file://lifecycle.json

# Verify it applied correctly
aws s3api get-bucket-lifecycle-configuration \
  --bucket your-logs-bucket
</code></pre>
<h3 id="heading-23-savings-plans-the-sequence-is-everything">2.3 Savings Plans: The Sequence Is Everything</h3>
<p>A Savings Plan is a commitment to spend a minimum dollar amount per hour on AWS compute for one or three years, in exchange for discounts of 30–70% off On-Demand rates. The discount is real. The trap is buying before optimising.</p>
<p><strong>The wrong order:</strong> You have a \(50,000/month EC2 bill. You buy a Savings Plan covering \)35,000/hour. Then you implement right-sizing and Spot instances — and your actual spend drops to \(22,000/month. You've committed to paying \)35,000/month for 12 months against a need of \(22,000. You're paying \)13,000/month for compute you don't use, at a 30% discount. Congratulations on your discounted waste.</p>
<p><strong>The right order:</strong></p>
<pre><code class="language-plaintext">Month 1-2: Right-size all instances using VPA and CloudWatch data
Month 3:   Move staging and development to Spot instances
Month 4:   Migrate compatible workloads to Graviton (20% cheaper)
Month 5:   Add VPC endpoints to eliminate NAT Gateway charges
Month 6:   THEN look at your steady-state On-Demand spend
Month 6+:  Purchase Savings Plans covering 70% of that optimised baseline
</code></pre>
<p><strong>Calculate what to commit to:</strong></p>
<pre><code class="language-bash"># Get your On-Demand EC2 spend for the last 30 days
# This is your rightsized baseline — the number to commit against
aws ce get-cost-and-usage \
  --time-period Start=\((date -d '30 days ago' +%Y-%m-%d),End=\)(date +%Y-%m-%d) \
  --granularity DAILY \
  --filter '{
    "And": [
      {"Dimensions": {"Key": "SERVICE",       "Values": ["Amazon Elastic Compute Cloud - Compute"]}},
      {"Dimensions": {"Key": "PURCHASE_TYPE", "Values": ["On-Demand"]}}
    ]
  }' \
  --metrics UnblendedCost \
  --query 'ResultsByTime[*].{Date:TimePeriod.Start,Cost:Total.UnblendedCost.Amount}' \
  --output table

# Get AWS's own recommendation for what to commit
aws savingsplans get-savings-plans-purchase-recommendation \
  --savings-plans-type COMPUTE_SP \
  --term-in-years ONE_YEAR \
  --payment-option NO_UPFRONT \
  --lookback-period-in-days THIRTY_DAYS
</code></pre>
<h2 id="heading-stage-3-the-automation-architect-months-9-to-15">Stage 3: The Automation Architect — Months 9 to 15</h2>
<h3 id="heading-31-the-orphaned-resource-problem-and-why-it-never-fixes-itself">3.1 The Orphaned Resource Problem — And Why It Never Fixes Itself</h3>
<p>Orphaned resources are the cloud equivalent of a gym membership you forgot to cancel. They exist, they charge you, but nobody notices until the annual audit.</p>
<p>The root cause isn't laziness. It's the absence of lifecycle management at the infrastructure layer. When an engineer spins up an EC2 instance for a one-week experiment and then leaves the company, there's no automatic signal that the instance is now orphaned. It sits there, billing $140/month, until someone hunts it down.</p>
<p>The fix is a weekly automated audit that surfaces candidates for deletion and notifies the registered owner, not a process change that depends on engineers remembering to clean up.</p>
<pre><code class="language-python"># orphan_reporter.py
# Runs every Sunday via EventBridge → Lambda
# Posts a Slack report of orphaned resources for human review
# DOES NOT auto-delete — deletion requires a human decision

import boto3
import json
import urllib.request
from datetime import datetime, timedelta, timezone

SLACK_WEBHOOK = 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL'
UNATTACHED_VOLUME_AGE_DAYS = 14
SNAPSHOT_AGE_DAYS = 90


def find_orphaned_resources():
    ec2 = boto3.client('ec2')
    report = {'monthly_waste_usd': 0, 'items': []}

    # Unattached EBS volumes
    for vol in ec2.describe_volumes(
        Filters=[{'Name': 'status', 'Values': ['available']}]
    )['Volumes']:
        age = (datetime.now(timezone.utc) - vol['CreateTime']).days
        if age &gt;= UNATTACHED_VOLUME_AGE_DAYS:
            cost = round(vol['Size'] * 0.08, 2)  # gp3 rate
            tags = {t['Key']: t['Value'] for t in vol.get('Tags', [])}
            report['items'].append({
                'type':  'Unattached EBS Volume',
                'id':    vol['VolumeId'],
                'detail': f"{vol['Size']}GB {vol['VolumeType']} — {age} days old",
                'owner': tags.get('Owner', 'unknown'),
                'monthly_cost_usd': cost,
            })
            report['monthly_waste_usd'] += cost

    # Unassociated Elastic IPs
    for addr in ec2.describe_addresses()['Addresses']:
        if 'AssociationId' not in addr:
            report['items'].append({
                'type':  'Unassociated Elastic IP',
                'id':    addr.get('AllocationId', addr['PublicIp']),
                'detail': addr['PublicIp'],
                'owner': 'unknown',
                'monthly_cost_usd': 3.60,
            })
            report['monthly_waste_usd'] += 3.60

    # Old snapshots
    cutoff = (datetime.now(timezone.utc) - timedelta(days=SNAPSHOT_AGE_DAYS)).isoformat()
    for snap in ec2.describe_snapshots(OwnerIds=['self'])['Snapshots']:
        if snap['StartTime'].isoformat() &lt; cutoff:
            cost = round(snap.get('VolumeSize', 0) * 0.05, 2)
            report['items'].append({
                'type':  f'Snapshot ({SNAPSHOT_AGE_DAYS}+ days old)',
                'id':    snap['SnapshotId'],
                'detail': f"Created {snap['StartTime'].strftime('%Y-%m-%d')}",
                'owner': 'unknown',
                'monthly_cost_usd': cost,
            })
            report['monthly_waste_usd'] += cost

    return report


def post_to_slack(report):
    lines = [
        f":money_with_wings: *Weekly Orphaned Resource Report*",
        f"Found *{len(report['items'])} orphaned resources* "
        f"costing *${report['monthly_waste_usd']:.2f}/month*\n",
    ]
    for item in report['items'][:20]:  # Cap at 20 lines to stay readable
        lines.append(
            f"• `{item['type']}` {item['id']} — {item['detail']} "
            f"— *${item['monthly_cost_usd']:.2f}/mo* — owner: {item['owner']}"
        )
    lines.append("\nReview and delete anything no longer needed.")

    req = urllib.request.Request(
        SLACK_WEBHOOK,
        data=json.dumps({'text': '\n'.join(lines)}).encode(),
        headers={'Content-Type': 'application/json'}
    )
    urllib.request.urlopen(req)


def lambda_handler(event, context):
    report = find_orphaned_resources()
    post_to_slack(report)
    return {
        'items_found': len(report['items']),
        'monthly_waste': report['monthly_waste_usd'],
    }
</code></pre>
<h3 id="heading-32-cost-estimation-in-your-cicd-pipeline">3.2 Cost Estimation in Your CI/CD Pipeline</h3>
<p>The goal is to catch expensive infrastructure changes at the PR stage — before they deploy and before they generate a billing surprise.</p>
<pre><code class="language-yaml"># .github/workflows/cost-check.yml
# Runs on any PR that touches infrastructure files
# Uses Infracost to estimate the monthly cost delta

name: Infrastructure Cost Check

on:
  pull_request:
    paths:
      - 'terraform/**'
      - 'infrastructure/**'
      - '*.tf'

jobs:
  cost-estimate:
    name: Estimate monthly cost change
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4

      - name: Setup Infracost
        uses: infracost/actions/setup@v3
        with:
          api-key: ${{ secrets.INFRACOST_API_KEY }}

      - name: Generate cost estimate
        run: |
          infracost breakdown \
            --path terraform/ \
            --format json \
            --out-file /tmp/infracost.json

      - name: Post cost diff to PR
        uses: infracost/actions/comment@v3
        with:
          path: /tmp/infracost.json
          behavior: update

      - name: Block if monthly increase exceeds threshold
        run: |
          MONTHLY_DELTA=$(cat /tmp/infracost.json | \
            jq '.projects[0].diff.totalMonthlyCost' | tr -d '"')

          echo "Estimated monthly cost change: \$$MONTHLY_DELTA"

          # Fail the PR if this change adds more than $500/month
          python3 -c "
          import sys
          delta = float('$MONTHLY_DELTA')
          if delta &gt; 500:
              print(f'PR blocked: estimated +\\({delta:.2f}/month exceeds \\)500 threshold')
              sys.exit(1)
          else:
              print(f'Cost check passed: estimated +\${delta:.2f}/month')
          "
</code></pre>
<h2 id="heading-stage-4-the-cloud-financial-manager-months-16-to-24">Stage 4: The Cloud Financial Manager — Months 16 to 24</h2>
<h3 id="heading-41-leading-finops-reviews-with-executives">4.1 Leading FinOps Reviews with Executives</h3>
<p>By month 16, you have the data. What changes at Stage 4 is the audience. You're no longer presenting to engineers who understand instance types and NAT Gateway pricing. You're presenting to a CTO who wants to know if the infrastructure investment is proportional to the business value it produces, and a CFO who wants to know when the line will stop going up.</p>
<p>The vocabulary shift is simple but important. You stop saying "we right-sized our EC2 instances" and start saying "we reduced our infrastructure unit cost by 28% while maintaining the same request throughput." You stop saying "we eliminated NAT Gateway charges" and start saying "we closed a $6,400/month gap between what we were paying and what was necessary."</p>
<p>The metric that anchors every executive FinOps conversation is cost per business unit. Not total bill (cost per API call, cost per user, cost per transaction, cost per model inference). That ratio tells the story of whether your infrastructure efficiency is improving as the business scales.</p>
<pre><code class="language-python"># unit_economics.py
# Calculate cost per transaction — the metric that matters to leadership

import boto3
from datetime import datetime, timedelta

def calculate_cost_per_transaction(service_name, transaction_count, days_back=30):
    """
    Returns cost per transaction for a given service over the last N days.
    transaction_count: total transactions for the same period (from your metrics)
    """
    ce = boto3.client('ce')

    response = ce.get_cost_and_usage(
        TimePeriod={
            'Start': (datetime.now() - timedelta(days=days_back)).strftime('%Y-%m-%d'),
            'End':   datetime.now().strftime('%Y-%m-%d'),
        },
        Granularity='MONTHLY',
        Metrics=['UnblendedCost'],
        Filter={
            'Tags': {
                'Key':    'Service',
                'Values': [service_name]
            }
        }
    )

    total_cost = sum(
        float(period['Total']['UnblendedCost']['Amount'])
        for period in response['ResultsByTime']
    )

    cost_per_txn = total_cost / transaction_count if transaction_count &gt; 0 else 0

    return {
        'service':           service_name,
        'period_days':       days_back,
        'total_cost_usd':    round(total_cost, 2),
        'transactions':      transaction_count,
        'cost_per_txn_usd':  round(cost_per_txn, 6),
    }


# Example: payment service processed 4.2M transactions this month
result = calculate_cost_per_transaction('payment-api', 4_200_000)
print(f"Cost per transaction: ${result['cost_per_txn_usd']:.6f}")
print(f"Total infrastructure cost: ${result['total_cost_usd']:,.2f}")
</code></pre>
<h3 id="heading-42-the-chargeback-and-showback-models">4.2 The Chargeback and Showback Models</h3>
<p>Chargeback means actually billing departments for their cloud usage. Showback means showing departments their usage costs without the internal billing transfer. Both create the same outcome: engineers start caring about what they consume because someone they work with is paying attention to it.</p>
<pre><code class="language-python"># showback_report.py
# Generates monthly cost-by-team report for distribution to engineering leads

import boto3
from datetime import datetime

def generate_team_showback():
    ce = boto3.client('ce')

    response = ce.get_cost_and_usage(
        TimePeriod={
            'Start': datetime.now().replace(day=1).strftime('%Y-%m-%d'),
            'End':   datetime.now().strftime('%Y-%m-%d'),
        },
        Granularity='MONTHLY',
        Metrics=['UnblendedCost'],
        GroupBy=[
            {'Type': 'TAG',       'Key': 'Team'},
            {'Type': 'DIMENSION', 'Key': 'SERVICE'},
        ]
    )

    by_team = {}
    for group in response['ResultsByTime'][0].get('Groups', []):
        team    = group['Keys'][0].replace('Team$', '') or 'untagged'
        service = group['Keys'][1]
        cost    = float(group['Metrics']['UnblendedCost']['Amount'])

        if team not in by_team:
            by_team[team] = {'total': 0, 'services': {}}
        by_team[team]['total'] += cost
        by_team[team]['services'][service] = round(cost, 2)

    # Print sorted by total cost descending
    print(f"\n{'='*52}")
    print(f"  Month-to-Date Cloud Spend by Team")
    print(f"  Generated: {datetime.now().strftime('%Y-%m-%d')}")
    print(f"{'='*52}\n")

    for team, data in sorted(by_team.items(), key=lambda x: x[1]['total'], reverse=True):
        print(f"  {team:&lt;20} ${data['total']:&gt;10,.2f}/month")
        top_services = sorted(data['services'].items(), key=lambda x: x[1], reverse=True)[:3]
        for svc, cost in top_services:
            print(f"    └─ {svc:&lt;30} ${cost:&gt;8,.2f}")
    print()

generate_team_showback()
</code></pre>
<h2 id="heading-essential-tools-and-certifications">Essential Tools and Certifications</h2>
<p>The tools that matter at each stage of this roadmap:</p>
<table>
<thead>
<tr>
<th>Stage</th>
<th>Tool</th>
<th>Why It Matters</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>AWS Cost Explorer</td>
<td>Free, built-in, the starting point for all cost analysis</td>
</tr>
<tr>
<td>1</td>
<td>AWS CLI <code>ce</code> commands</td>
<td>Scriptable cost queries — dashboards can't be automated</td>
</tr>
<tr>
<td>2</td>
<td>AWS Compute Optimizer</td>
<td>ML-powered rightsizing recommendations for EC2 and RDS</td>
</tr>
<tr>
<td>2</td>
<td>VPA (Kubernetes)</td>
<td>Pod-level rightsizing recommendations using actual usage</td>
</tr>
<tr>
<td>3</td>
<td>Infracost</td>
<td>PR-level cost estimation for Terraform changes</td>
</tr>
<tr>
<td>3</td>
<td>AWS Budgets</td>
<td>Proactive alerts — catches problems before the monthly invoice</td>
</tr>
<tr>
<td>4</td>
<td>AWS Cost and Usage Report + Athena</td>
<td>SQL-level billing analysis at any granularity</td>
</tr>
<tr>
<td>4</td>
<td>CloudHealth or Vantage</td>
<td>Multi-account, multi-cloud cost management</td>
</tr>
</tbody></table>
<p><strong>The one certification worth your time:</strong> FinOps Certified Practitioner from the FinOps Foundation. It takes 20 hours to prepare and $300 to sit. It signals to hiring managers and clients that you understand the discipline formally — which matters when you're the person leading FinOps conversations at the executive level.</p>
<h2 id="heading-your-90-day-action-plan">Your 90-Day Action Plan</h2>
<h3 id="heading-month-1-foundation">Month 1 — Foundation:</h3>
<p>Enable Cost Explorer if it isn't already on. Pull the baseline command from Section 1.1 and save the output. Run the untagged resource query from Section 1.2 and document how many resources are missing tags. Find your top three cost drivers. Present the findings to your engineering manager — not as a problem, but as an opportunity with a dollar figure attached.</p>
<h3 id="heading-month-2-quick-wins">Month 2 — Quick Wins:</h3>
<p>Run the rightsizing analyser from Section 2.1 on your EC2 fleet. Downsize the three highest-confidence candidates. Apply S3 lifecycle policies to your two largest buckets. Create VPC endpoints for S3, ECR, and DynamoDB. Estimate the savings from each action and document them against your baseline.</p>
<h3 id="heading-month-3-automation-and-habits">Month 3 — Automation and Habits:</h3>
<p>Deploy the orphan reporter Lambda on a Sunday schedule. Add the cost check GitHub Action to your infrastructure repository. Start a monthly FinOps review meeting — even if it's just you and one other engineer. Build the habit before you need the audience.</p>
<h2 id="heading-best-practices-summary">Best Practices Summary</h2>
<p>✅ <strong>Do:</strong> Establish a cost baseline before any optimisation. The number is meaningless without a comparison point.</p>
<p>✅ <strong>Do:</strong> Right-size before buying Savings Plans. Always. The sequence changes the outcome.</p>
<p>✅ <strong>Do:</strong> Enforce tagging at the infrastructure layer — Terraform or CloudFormation — not as a process reminder.</p>
<p>✅ <strong>Do:</strong> Move staging and development to Spot instances. The interruption rate is manageable, while the 70% cost difference is not.</p>
<p>✅ <strong>Do:</strong> Add VPC endpoints for S3, ECR, and DynamoDB before reviewing data transfer costs. It's a 30-minute fix for a multi-thousand-dollar line item.</p>
<p>✅ <strong>Do:</strong> Present cost findings as cost-per-business-metric, not as total bill. "We reduced cost per transaction from \(0.0021 to \)0.0013" is a business result. "$38,000/month reduction" is an accounting result.</p>
<p>❌ <strong>Don't:</strong> Buy Savings Plans on an unoptimised baseline. You'll lock in discounted waste.</p>
<p>❌ <strong>Don't:</strong> Build FinOps dashboards before tagging is complete. Beautiful charts with no attribution data answer no questions.</p>
<p>❌ <strong>Don't:</strong> Run orphaned resource cleanup without human review first. Run in report-only mode for two weeks, verify the candidates are genuinely orphaned, then add deletion logic.</p>
<h2 id="heading-resources">Resources</h2>
<ul>
<li><p><a href="https://www.finops.org/framework/"><strong>FinOps Foundation Framework</strong></a> — The practitioner framework that defines the Inform, Optimise, and Operate cycle this roadmap is built on</p>
</li>
<li><p><a href="https://docs.aws.amazon.com/cost-management/latest/APIReference/API_GetCostAndUsage.html"><strong>AWS Cost Explorer API Reference</strong></a> — Full reference for the cost query commands used throughout this guide</p>
</li>
<li><p><a href="https://aws.amazon.com/compute-optimizer/"><strong>AWS Compute Optimizer</strong></a> — AWS's own rightsizing recommendation service; complements the manual analysis in Stage 2</p>
</li>
<li><p><a href="https://www.infracost.io/docs/"><strong>Infracost Documentation</strong></a> — Setup guide for the PR-level cost estimation tool in Stage 3</p>
</li>
<li><p><a href="https://learn.finops.org/path/finops-certified-practitioner"><strong>FinOps Certified Practitioner Exam</strong></a> — The certification referenced in the tools section</p>
</li>
<li><p><a href="https://docs.aws.amazon.com/savingsplans/latest/userguide/what-is-savings-plans.html"><strong>AWS Savings Plans Documentation</strong></a> — The authoritative reference on commitment types, coverage rules, and purchase strategy</p>
</li>
<li><p><a href="https://github.com/aayostem"><strong>Companion Repository</strong></a> — All scripts from this guide, including the rightsizing analyser, orphan reporter, and showback report generator</p>
</li>
</ul>
<p><a href="https://github.com/aayostem"><em>Ayobami Adejumo</em></a> <em>is a senior platform engineer and FinOps consultant. He has audited AWS infrastructure for 20+ Series A and Series B companies. He is an active FinOps Foundation Supporter</em></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[ Common DevOps Mistakes and How to Avoid Them — Tips for Startups ]]>
                </title>
                <description>
                    <![CDATA[ Most DevOps engineers don't fail because they lack knowledge about tools. They fail because nobody told them what not to do before they got into production. Startup environments make this worse. The p ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-avoid-devops-mistakes/</link>
                <guid isPermaLink="false">6a060c22baf09db7a6253878</guid>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ startup ]]>
                    </category>
                
                    <category>
                        <![CDATA[ tips ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tolani Akintayo ]]>
                </dc:creator>
                <pubDate>Thu, 14 May 2026 17:53:38 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/6fcabd5e-272f-4f1d-b035-8241896e8296.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most DevOps engineers don't fail because they lack knowledge about tools. They fail because nobody told them what <em>not</em> to do before they got into production.</p>
<p>Startup environments make this worse. The pressure to ship fast, the small team sizes, and the absence of senior engineers to review your decisions means mistakes happen quietly until they become outages, data loss events, or security incidents that cost the company thousands of dollars and weeks of recovery time.</p>
<p>This article is a direct breakdown of the ten most costly DevOps mistakes engineers make early in their careers at startups. For each mistake, you will get the real-world scenario, the business impact, and the concrete fix you can apply immediately.</p>
<p>Whether you are setting up your first production environment or auditing an existing one, this guide will help you build systems that are reliable, secure, and aligned with what the business actually needs.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-who-this-article-is-for">Who This Article Is For</a></p>
</li>
<li><p><a href="#heading-why-startups-are-a-different-environment">Why Startups Are a Different Environment</a></p>
</li>
<li><p><a href="#heading-mistake-1-deploying-without-understanding-what-youre-deploying">Mistake 1: Deploying Without Understanding What You're Deploying</a></p>
</li>
<li><p><a href="#heading-mistake-2-using-production-as-a-development-environment">Mistake 2: Using Production as a Development Environment</a></p>
</li>
<li><p><a href="#heading-mistake-3-hardcoding-secrets-and-credentials">Mistake 3: Hardcoding Secrets and Credentials</a></p>
</li>
<li><p><a href="#heading-mistake-4-overengineering-for-problems-you-dont-have-yet">Mistake 4: Overengineering for Problems You Don't Have Yet</a></p>
</li>
<li><p><a href="#heading-mistake-5-no-observability-before-launch">Mistake 5: No Observability Before Launch</a></p>
</li>
<li><p><a href="#heading-mistake-6-treating-security-as-a-final-step">Mistake 6: Treating Security as a Final Step</a></p>
</li>
<li><p><a href="#heading-mistake-7-manual-deployments-in-production">Mistake 7: Manual Deployments in Production</a></p>
</li>
<li><p><a href="#heading-mistake-8-no-disaster-recovery-plan">Mistake 8: No Disaster Recovery Plan</a></p>
</li>
<li><p><a href="#heading-mistake-9-no-documentation-or-runbooks">Mistake 9: No Documentation or Runbooks</a></p>
</li>
<li><p><a href="#heading-mistake-10-solving-technical-problems-without-understanding-the-business">Mistake 10: Solving Technical Problems Without Understanding the Business</a></p>
</li>
<li><p><a href="#heading-the-system-thinking-framework-every-devops-engineer-needs">The System Thinking Framework Every DevOps Engineer Needs</a></p>
</li>
<li><p><a href="#heading-your-production-readiness-checklist">Your Production Readiness Checklist</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-who-this-article-is-for">Who This Article Is For</h2>
<ul>
<li><p><strong>Early-career DevOps and cloud engineers</strong> who are building or maintaining production infrastructure at a startup.</p>
</li>
<li><p><strong>Backend developers</strong> who have recently taken on DevOps responsibilities.</p>
</li>
<li><p><strong>Engineers joining a startup</strong> who want to understand what operational discipline actually looks like in a fast-moving environment.</p>
</li>
</ul>
<p>You do not need to be an expert in any specific tool to follow this article. The focus is on decision-making patterns and operational discipline, not tool configuration.</p>
<h2 id="heading-why-startups-are-a-different-environment">Why Startups Are a Different Environment</h2>
<p>Before getting into the mistakes, you have to understand why startups produce them in the first place.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/f9bec1fa-8938-4144-b934-9e5af4edf4ad.svg" alt="diagram showing the startup DevOps reality, a single engineer handling infra, CI/CD, security, monitoring, and deployment pipelines simultaneously" style="display: block;" width="680" height="506" loading="lazy">

<p>In a large company, you typically have dedicated security engineers, an SRE team, a platform team, and multiple reviewers for every infrastructure change. In a startup, you mostly likely have one engineer responsible for all of that simultaneously.</p>
<p>This creates four specific pressure points:</p>
<ol>
<li><p><strong>Speed pressure.</strong> The business needs features shipped now. Operational discipline gets treated as optional because nobody is watching closely yet.</p>
</li>
<li><p><strong>Budget constraints.</strong> Every infrastructure decision has a direct impact on company runway. Engineers optimize for the cheapest option rather than the most reliable one.</p>
</li>
<li><p><strong>Absent guardrails.</strong> There is no senior engineer reviewing your Terraform plans. There is no security audit before launch. The absence of immediate consequences can make bad decisions feel like good ones.</p>
</li>
<li><p><strong>Constantly changing requirements.</strong> The architecture you design today may need to support a completely different product in six months. None of these pressures are excuses for poor decisions. But understanding them helps you see why the following mistakes happen so consistently.</p>
</li>
</ol>
<h2 id="heading-mistake-1-deploying-without-understanding-what-youre-deploying">Mistake 1: Deploying Without Understanding What You're Deploying</h2>
<h3 id="heading-the-scenario">The Scenario</h3>
<p>A junior engineer is asked to deploy the company's Node.js API to AWS. They find a tutorial for Elastic Beanstalk, follow it, and it works. Two weeks later, traffic increases. They try to scale "the same way as in the tutorial." The application goes down. They cannot debug it because they never understood what the deployment was actually doing.</p>
<h3 id="heading-the-business-impact">The Business Impact</h3>
<p>When production breaks and the person who deployed the system cannot explain how it works, diagnosis takes hours instead of minutes. The longer the incident runs, the higher the cost in customer trust, team morale, and potentially direct revenue loss.</p>
<h3 id="heading-the-fix">The Fix</h3>
<p>Before you deploy anything to production, you should be able to answer these five questions in writing:</p>
<ol>
<li><p><strong>What compute type is running my code?</strong> (EC2, Lambda, Fargate, container?)</p>
</li>
<li><p><strong>How does a new version replace the old one?</strong> (Rolling? Blue/green? All-at-once?)</p>
</li>
<li><p><strong>Where does configuration and secrets come from?</strong> (SSM? Secrets Manager? Environment file?)</p>
</li>
<li><p><strong>What downstream services depend on this?</strong> (Database connections? Other APIs? Cache?)</p>
</li>
<li><p><strong>How do I roll back in under five minutes if this breaks?</strong></p>
</li>
</ol>
<p>If you cannot answer all five, do not deploy until you can. The tutorial that got it running is not the documentation for how it operates.</p>
<blockquote>
<p>"It is better to spend two hours understanding a system before deploying it than two days debugging it after something breaks."</p>
</blockquote>
<p>Personally, when learning a new technology, tool, or implementing something I have not worked with before, I usually focus on three core questions: What, Why, and How.</p>
<ul>
<li><p><strong>The first question is: What is this technology or concept about?</strong><br>This helps me build a solid foundation by doing deep research, studying the official documentation, understanding the core principles, and sometimes even learning the history behind the tool or technology. I believe having a well-grounded understanding before implementation is very important.</p>
</li>
<li><p><strong>The second question is: Why do we need it?</strong><br>I try to understand the value the technology brings, why it should be implemented, what problem it solves, and how it benefits the team or organization. This helps me make informed technical decisions instead of just implementing tools without understanding their purpose.</p>
</li>
<li><p><strong>The third question is: How should it be implemented?</strong><br>There are usually multiple approaches to solving a problem or implementing a technology, so I focus on understanding the best and most practical approach based on the use case and expected outcome.</p>
</li>
</ul>
<p>This structured approach has helped me learn new technologies quickly, adapt fast, and implement solutions effectively in real-world environments.</p>
<h2 id="heading-mistake-2-using-production-as-a-development-environment">Mistake 2: Using Production as a Development Environment</h2>
<h3 id="heading-the-scenario">The Scenario</h3>
<p>To save time, an engineer tests a new deployment script directly in the production AWS account. They accidentally run a command that terminates the production database instance. Automated backups exist but were misconfigured. Six hours of customer data is unrecoverable.</p>
<p>This scenario happens more often than you would expect. The reasoning is always the same: "It will only take a minute."</p>
<h3 id="heading-the-business-impact">The Business Impact</h3>
<p>A single test-in-production incident can result in data loss, hours of downtime, and a customer communication crisis. In a startup, that can permanently damage the company's reputation before it has had the chance to build one.</p>
<h3 id="heading-the-fix">The Fix</h3>
<p>You need at minimum three separate environments and ideally three separate AWS accounts:</p>
<table>
<thead>
<tr>
<th>Environment</th>
<th>Purpose</th>
<th>Access Level</th>
</tr>
</thead>
<tbody><tr>
<td><strong>dev</strong></td>
<td>Break things freely. No real data.</td>
<td>Engineers have broad access</td>
</tr>
<tr>
<td><strong>staging</strong></td>
<td>Mirror of production. Final verification.</td>
<td>Controlled access</td>
</tr>
<tr>
<td><strong>production</strong></td>
<td>Real customers. Real data.</td>
<td>MFA required. No manual deployments.</td>
</tr>
</tbody></table>
<p>Using separate AWS accounts (not just separate VPCs) gives you account-level isolation. A permission error in the dev account cannot accidentally touch production infrastructure at the API level.</p>
<p>Infrastructure as Code (Terraform or CloudFormation) makes this affordable, you write the configuration once and apply it three times with different variable files.</p>
<pre><code class="language-hcl"># terraform/environments/prod/main.tf
module "app" {
  source      = "../../modules/app"
  environment = "production"
  instance_type = "t3.medium"
  db_instance_class = "db.t3.medium"
  multi_az          = true
}
</code></pre>
<pre><code class="language-hcl"># terraform/environments/staging/main.tf
module "app" {
  source      = "../../modules/app"
  environment = "staging"
  instance_type = "t3.small"
  db_instance_class = "db.t3.small"
  multi_az          = false
}
</code></pre>
<p>The module is the same. The environment-specific variables are different. Separate environments are not a luxury, they are the minimum operating standard for any team running real software.</p>
<h2 id="heading-mistake-3-hardcoding-secrets-and-credentials">Mistake 3: Hardcoding Secrets and Credentials</h2>
<h3 id="heading-the-scenario">The Scenario</h3>
<p>A new engineer joins a startup and clones the repository. Inside they find a <code>.env</code> file committed to Git containing the production database password, the Stripe secret key, and an AWS access key with admin permissions. The repository has been public for six months.</p>
<p>GitHub's automated secret scanning never triggered because the secrets were inside a <code>.env</code> file rather than raw in the code. The credentials had been valid and actively used for over six months.</p>
<h3 id="heading-the-business-impact">The Business Impact</h3>
<p>Automated scanners run by attackers find exposed credentials within minutes of them being pushed to a public repository. A single exposed AWS access key with admin permissions can result in:</p>
<ul>
<li><p>Crypto-mining workloads generating thousands of dollars in cloud bills overnight</p>
</li>
<li><p>Complete exfiltration of customer data from every S3 bucket</p>
</li>
<li><p>Privilege escalation: the attacker creates new admin users and locks you out of your own account</p>
</li>
<li><p>AWS account suspension while the investigation runs</p>
</li>
</ul>
<p>According to <a href="https://github.blog/security/vulnerability-research/securing-millions-of-developers-together/">GitHub's annual security report</a>, millions of secrets are exposed in public repositories every year. The average time to detect a compromised cloud credential is 197 days.</p>
<h2 id="heading-the-fix">The Fix</h2>
<p><strong>Step 1: Never commit secrets to Git.</strong> Not temporarily. Not in a branch. Not in a private repository.</p>
<p><strong>Step 2: Add</strong> <code>.gitignore</code> <strong>before you create the first file.</strong> Check in the <code>.gitignore</code> with the first line of code before any <code>.env</code> files exist.</p>
<pre><code class="language-gitignore"># .gitignore
.env
.env.*
*.pem
*.key
secrets/
</code></pre>
<p><strong>Step 3: Use AWS Secrets Manager or SSM Parameter Store for all production secrets.</strong> Your application reads secrets at runtime:</p>
<pre><code class="language-python"># Python example — fetch secret at runtime, never at build time
import boto3
import json
 
def get_secret(secret_name: str, region: str = "us-east-1") -&gt; dict:
    client = boto3.client("secretsmanager", region_name=region)
    response = client.get_secret_value(SecretId=secret_name)
    return json.loads(response["SecretString"])
 
# Usage
db_config = get_secret("prod/myapp/database")
DATABASE_URL = db_config["connection_string"]
</code></pre>
<p><strong>Step 4: Scan your existing repositories immediately.</strong> You may already have a problem:</p>
<pre><code class="language-bash"># Install trufflehog to scan for exposed secrets in your repo history
pip install trufflehog
 
# Scan the entire commit history of your repository
trufflehog git file://.
 
# Or scan a remote GitHub repo
trufflehog github --repo https://github.com/your-org/your-repo
</code></pre>
<p><strong>Step 5: Add a pre-commit hook to prevent future accidents:</strong></p>
<pre><code class="language-bash">pip install pre-commit
</code></pre>
<pre><code class="language-yaml"># .pre-commit-config.yaml
repos:
  - repo: https://github.com/awslabs/git-secrets
    rev: master
    hooks:
      - id: git-secrets
  - repo: https://github.com/Yelp/detect-secrets
    rev: v1.4.0
    hooks:
      - id: detect-secrets
</code></pre>
<pre><code class="language-bash">pre-commit install
# Now the hook runs before every commit and blocks detected secrets
</code></pre>
<p>There is no recovery from a publicly exposed database password. The fix takes ten minutes upfront. The incident takes weeks.</p>
<h2 id="heading-mistake-4-overengineering-for-problems-you-dont-have-yet">Mistake 4: Overengineering for Problems You Don't Have Yet</h2>
<h3 id="heading-the-scenario">The Scenario</h3>
<p>A five-person startup with 200 users decides to build a microservices architecture on Kubernetes because "Netflix uses it." They spend three months setting up Kubernetes, Istio service mesh, ArgoCD, Vault, Prometheus, and Grafana. Their product has not shipped a new feature in three months. A competitor with a monolith on a single EC2 instance shipped twelve new features in the same period.</p>
<h3 id="heading-the-business-impact">The Business Impact</h3>
<p>Every layer of infrastructure you add is a layer that can break, a layer that requires expertise to operate, and a layer that slows down every future change. Kubernetes is the right answer for organizations with the scale and team size to operate it. For a five-person startup, it is an expensive distraction.</p>
<p>Premature complexity does not just cost engineering time. It costs the competitive advantage that speed provides in the early stage.</p>
<h3 id="heading-the-fix">The Fix</h3>
<p>Match your infrastructure to your actual stage:</p>
<table>
<thead>
<tr>
<th>Scale</th>
<th>Right Infrastructure</th>
<th>Cost Range</th>
</tr>
</thead>
<tbody><tr>
<td><strong>1–1,000 users</strong></td>
<td>Single EC2 + RDS + Nginx reverse proxy</td>
<td>$20–50/month</td>
</tr>
<tr>
<td><strong>1K–50K users</strong></td>
<td>Auto-scaling group, RDS Multi-AZ, ALB, basic CI/CD</td>
<td>$200-500/month</td>
</tr>
<tr>
<td><strong>50K–500K users</strong></td>
<td>ECS Fargate, RDS read replicas, ElastiCache, full observability</td>
<td>$1K-5K/month</td>
</tr>
<tr>
<td><strong>500K+ users</strong></td>
<td>Multi-region, managed Kubernetes, dedicated SRE</td>
<td>$10K+/month</td>
</tr>
</tbody></table>
<p>The question to ask before every infrastructure decision is: <strong>"What specific, measurable problem does this solve today that my current setup cannot solve?"</strong></p>
<p>Amazon, Netflix, and Uber did not start with microservices. They started with monoliths and extracted services only when the monolith became the actual bottleneck. You are not Netflix. You are solving the problems in front of you today.</p>
<p>Use managed services wherever possible, RDS instead of self-hosted Postgres, Fargate instead of self-managed Kubernetes, ElastiCache instead of self-hosted Redis. Managed services let your team focus on the product instead of the infrastructure.</p>
<h2 id="heading-mistake-5-no-observability-before-launch">Mistake 5: No Observability Before Launch</h2>
<h3 id="heading-the-scenario">The Scenario</h3>
<p>A startup's checkout flow breaks on a Friday evening. Users are abandoning their carts and the company is losing revenue. The DevOps engineer finds out 45 minutes later because a customer sent a direct message to the CEO on Twitter.</p>
<p>The engineer has no dashboards, no log aggregation, and no alerting. They SSH into the production server and scroll through raw log files. Two hours later, they find the issue: a database connection pool was exhausted by a memory leak introduced in that morning's deployment.</p>
<h3 id="heading-business-impact">Business Impact</h3>
<p>Without observability:</p>
<ul>
<li><p>You find out about production problems from users, not from your systems</p>
</li>
<li><p>Incidents take 10x longer to resolve because diagnosis is guesswork</p>
</li>
<li><p>You cannot tell whether a deployment improved or degraded performance</p>
</li>
<li><p>You have no data for making better architecture decisions</p>
</li>
</ul>
<h3 id="heading-the-fix">The Fix</h3>
<p>Implement the four golden signals before any service goes to production. These come from <a href="https://sre.google/sre-book/monitoring-distributed-systems/">Google's Site Reliability Engineering book</a>:</p>
<ol>
<li><p><strong>Latency</strong>: How long requests take to complete (p50, p95, p99)</p>
</li>
<li><p><strong>Traffic</strong>: How many requests per second the system is handling</p>
</li>
<li><p><strong>Errors</strong>: The rate of failed requests (5xx responses per minute)</p>
</li>
<li><p><strong>Saturation</strong>: How close the system is to its limits (CPU, memory, connection pool)</p>
</li>
</ol>
<p>Here is a minimal CloudWatch alarm setup using the AWS CLI:</p>
<pre><code class="language-shell"># Alert when error rate exceeds 1% for 5 consecutive minutes

aws cloudwatch put-metric-alarm \
  --alarm-name "high-error-rate-production" \
  --alarm-description "Error rate exceeded 1% for 5 minutes" \
  --metric-name "5XXError" \
  --namespace "AWS/ApplicationELB" \
  --statistic "Average" \
  --period 60 \
  --evaluation-periods 5 \
  --threshold 0.01 \
  --comparison-operator "GreaterThanOrEqualToThreshold" \
  --alarm-actions "arn:aws:sns:us-east-1:123456789:pagerduty-production" \
  --dimensions Name=LoadBalancer,Value=app/my-alb/1234567890abcdef
</code></pre>
<p>Every application should also expose a <code>/health</code> endpoint that returns <code>200 OK</code> when healthy:</p>
<pre><code class="language-python"># FastAPI example

from fastapi import FastAPI
from sqlalchemy import text
 
app = FastAPI()
 
@app.get("/health")
async def health_check():
    # Check database connectivity
    try:
        db.execute(text("SELECT 1"))
        db_status = "healthy"
    except Exception:
        db_status = "unhealthy"
 
    return {
        "status": "healthy" if db_status == "healthy" else "degraded",
        "database": db_status,
        "version": os.getenv("APP_VERSION", "unknown")
    }
</code></pre>
<p>Your load balancer checks this endpoint. Your uptime monitor checks it. You check it after every deployment.</p>
<blockquote>
<p>You do not get to say a system is working unless you have data to prove it. "Nobody complained" is not the same as "nothing is broken."</p>
</blockquote>
<h2 id="heading-mistake-6-treating-security-as-a-final-step">Mistake 6: Treating Security as a Final Step</h2>
<h3 id="heading-the-scenario">The Scenario</h3>
<p>A startup rushes to launch their MVP. Security reviews are "planned for after launch." Six months later, a potential enterprise customer requires a security audit before signing a contract. The audit reveals:</p>
<ul>
<li><p>S3 buckets publicly accessible by default</p>
</li>
<li><p>EC2 instances with port 22 open to <code>0.0.0.0/0</code></p>
</li>
<li><p>IAM users with <code>AdministratorAccess</code> for the entire team</p>
</li>
<li><p>No encryption on the database at rest</p>
</li>
<li><p>JWT secrets hardcoded in environment variables The audit fails. The enterprise deal worth $120,000 annually is lost. Remediation takes four weeks of engineering time.</p>
</li>
</ul>
<h3 id="heading-the-business-impact">The Business Impact</h3>
<p>Security debt is the most expensive technical debt you can accumulate. Unlike performance debt that degrades gradually, security vulnerabilities cause sudden, catastrophic events: data breaches, ransomware, account takeovers, and regulatory fines. At a startup, any one of these can end the company.</p>
<h3 id="heading-the-fix">The Fix</h3>
<p>Apply these six security controls before the first line of production code ships:</p>
<p><strong>1. Principle of Least Privilege every IAM role gets only what it needs:</strong></p>
<p>One of the most common security mistakes in AWS is granting roles more permissions than they need either out of convenience (<code>s3:*</code>) or uncertainty about what the service actually requires. This creates unnecessary risk: if a role is compromised, the attacker inherits every permission you granted.</p>
<p>The fix is simple: look at what your service actually does, then write a policy that allows exactly that.</p>
<p>If your app uploads and reads files from a specific S3 bucket, the policy should say exactly that:</p>
<pre><code class="language-json">{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:PutObject"
      ],
      "Resource": "arn:aws:s3:::my-app-uploads/*"
    }
  ]
}
</code></pre>
<p>Notice the <code>Resource</code> is scoped to <code>my-app-uploads/*</code> not all S3 buckets. And the <code>Action</code> list covers only <code>GetObject</code> and <code>PutObject</code> not <code>DeleteObject</code>, not <code>s3:*</code>. If the service gets compromised, the attacker can read and write to that one bucket. That is it. The rest of your account is untouched.</p>
<p><strong>2. Block all S3 public access by default:</strong></p>
<p>AWS S3 buckets are private by default when created but that can be overridden at the bucket level, the object level, or through a bucket policy. Misconfigured S3 buckets are one of the most common causes of data breaches, and they are almost always accidental.</p>
<p>The safest approach is to enable the "Block Public Access" setting at the account level, which overrides all other settings and prevents any bucket from being made public even if someone tries:</p>
<pre><code class="language-bash">aws s3api put-public-access-block \
  --bucket my-app-bucket \
  --public-access-block-configuration \
    "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
</code></pre>
<p>Run this for every bucket you create. Better yet, enable it at the AWS account level so it applies automatically to all future buckets by default.</p>
<p><strong>3. Never open SSH to the internet, use AWS Systems Manager Session Manager instead:</strong></p>
<p>Port 22 open to <code>0.0.0.0/0</code> is an attack surface that exists on thousands of AWS instances right now. Brute-force bots scan the internet continuously looking for open SSH ports. Even with a strong key, the exposure is unnecessary because AWS provides a better alternative.</p>
<p>AWS Systems Manager Session Manager gives you full shell access to any EC2 instance without opening a single inbound port on the security group. There is no port to scan, no port to attack, and every session is logged automatically to CloudTrail:</p>
<pre><code class="language-bash"># Start a session on an EC2 instance without port 22 open
aws ssm start-session --target i-0123456789abcdef0
</code></pre>
<p>To use Session Manager, the EC2 instance needs the SSM Agent installed (included by default on Amazon Linux 2 and Ubuntu 20.04+) and an IAM instance profile with the <code>AmazonSSMManagedInstanceCore</code> policy attached. Once that is set up, you can close port 22 on the security group entirely.</p>
<p><strong>4. Enable MFA for all IAM users and enforce it via policy:</strong></p>
<p>A leaked IAM username and password with no MFA is a fully compromised account. Multi-factor authentication is the single most effective control against credential theft, and it costs nothing to enable.</p>
<p>Enforce it through an IAM policy that denies all actions when MFA is not present, except the actions needed to set up MFA in the first place. This means even if a set of credentials is stolen, the attacker cannot do anything without the second factor.</p>
<p>The AWS documentation provides the <a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/tutorial_users-self-manage-mfa-and-creds.html">Complete Deny Without MFA Policy</a>, attach it to every IAM user or group in your account. This is a one-time setup that permanently raises your account's security baseline.</p>
<p><strong>5. Enable CloudTrail in all regions:</strong></p>
<p>Without CloudTrail, you have no record of who did what in your AWS account. If a credential is compromised, you cannot investigate what the attacker accessed. If an engineer accidentally deletes a resource, you cannot trace it. You are operating blind.</p>
<p>CloudTrail logs every AWS API call who made it, from which IP, at what time, and what the response was. Enable it across all regions so activity in regions you do not actively use is also captured:</p>
<pre><code class="language-bash">aws cloudtrail create-trail \
  --name production-audit-trail \
  --s3-bucket-name my-cloudtrail-logs \
  --is-multi-region-trail \
  --enable-log-file-validation
</code></pre>
<p>The <code>--enable-log-file-validation</code> flag generates a digest file for each log that lets you verify the log has not been tampered with, this is important if you ever need to use these logs in a security investigation or compliance audit. Once this is running, every <code>AssumeRole</code>, every <code>DeleteBucket</code>, and every <code>RunInstances</code> call in your account is permanently recorded.</p>
<p><strong>6. Run AWS Security Hub from day one:</strong></p>
<p>Most teams only discover security misconfigurations after a breach or a compliance audit. Security Hub inverts this, it continuously scans your AWS environment against industry-standard frameworks (CIS AWS Foundations Benchmark, AWS Foundational Security Best Practices) and surfaces findings before they become incidents.</p>
<p>Enabling it takes a single command:</p>
<pre><code class="language-bash">aws securityhub enable-security-hub
</code></pre>
<p>Within minutes, Security Hub gives your account a compliance score and a prioritized list of findings. A finding might tell you that a security group has port 22 open to the world, that an S3 bucket has logging disabled, or that root account credentials were recently used. Each finding includes the affected resource and a remediation guide.</p>
<p>Treat every Security Hub finding the same way you treat a production bug: assign it a priority, assign an owner, and close it. A finding sitting unaddressed for 30 days is a known vulnerability you chose to leave open.</p>
<h2 id="heading-mistake-7-manual-deployments-in-production">Mistake 7: Manual Deployments in Production</h2>
<h3 id="heading-the-scenario">The Scenario</h3>
<p>A startup's deployment process is documented in a Notion page that is four months out of date. It involves SSH-ing into the server, running <code>git pull</code>, running <code>npm install</code>, and restarting the PM2 process. Different engineers do it slightly differently. One engineer, rushing a late-night release, skips <code>npm install</code>. The application starts crashing because a new dependency is missing.</p>
<h3 id="heading-the-business-impact">The Business Impact</h3>
<p>Manual deployment processes are inherently unreliable. Humans under pressure skip steps, perform steps in the wrong order, and remember procedures differently. Every manual step in a production deployment process is a scheduled incident waiting for the right moment of stress.</p>
<h3 id="heading-the-fix">The Fix</h3>
<p>If a deployment step is performed manually more than twice, it needs to be automated. Here is a minimal but complete GitHub Actions deployment workflow for an ECS Fargate service:</p>
<pre><code class="language-yaml"># .github/workflows/deploy.yml
name: Deploy to Production
 
on:
  push:
    branches:
      - main
 
permissions:
  id-token: write   # Required for OIDC authentication with AWS
  contents: read
 
jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
 
    steps:
      - name: Checkout code
        uses: actions/checkout@v4
 
      - name: Configure AWS credentials via OIDC
        uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
          aws-region: us-east-1
 
      - name: Login to Amazon ECR
        id: login-ecr
        uses: aws-actions/amazon-ecr-login@v2
 
      - name: Build and push Docker image
        id: build
        env:
          ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
          IMAGE_TAG: ${{ github.sha }}
        run: |
          docker build -t \(ECR_REGISTRY/my-app:\)IMAGE_TAG .
          docker push \(ECR_REGISTRY/my-app:\)IMAGE_TAG
          echo "image=\(ECR_REGISTRY/my-app:\)IMAGE_TAG" &gt;&gt; $GITHUB_OUTPUT
 
      - name: Deploy to Amazon ECS
        uses: aws-actions/amazon-ecs-deploy-task-definition@v1
        with:
          task-definition: task-definition.json
          service: my-app-service
          cluster: production
          wait-for-service-stability: true
</code></pre>
<p>Notice <code>wait-for-service-stability: true</code>. Without this, the workflow reports success the moment ECS accepts the new task definition before the containers are actually healthy. With it, the workflow fails if the new containers crash. You want to know immediately, not discover it from user reports thirty minutes later.</p>
<h2 id="heading-mistake-8-no-disaster-recovery-plan">Mistake 8: No Disaster Recovery Plan</h2>
<h3 id="heading-the-scenario">The Scenario</h3>
<p>A startup's production database runs on a single RDS instance with no Multi-AZ configuration. Automated backups are enabled but have never been tested. The EBS volume backing the instance fails. AWS provisions a new instance from the last snapshot, which is 18 hours old. 18 hours of customer data is permanently lost.</p>
<p>The startup had no disaster recovery plan, no tested recovery procedure, and no communication template ready for customers.</p>
<h3 id="heading-the-business-impact">The Business Impact</h3>
<p>The question is not whether your infrastructure will fail. It will fail. Every database, every server, every availability zone experiences failures. The question is whether you have a tested plan for when it does.</p>
<p>Data loss of any magnitude is serious. For startups that handle financial data, healthcare data, or anything under GDPR, even partial data loss can trigger regulatory consequences.</p>
<h3 id="heading-the-fix">The Fix</h3>
<p><strong>Define your RTO and RPO before you design anything:</strong></p>
<ul>
<li><p><strong>RTO (Recovery Time Objective):</strong> How long can the business survive without this system? A payment API might have an RTO of 15 minutes. An internal analytics dashboard might have an RTO of 4 hours.</p>
</li>
<li><p><strong>RPO (Recovery Point Objective):</strong> How much data loss is acceptable? Zero means real-time replication. One hour means hourly snapshots are sufficient. This directly determines your backup frequency and architecture.</p>
</li>
</ul>
<p><strong>Enable RDS Multi-AZ for all production databases:</strong></p>
<pre><code class="language-hcl"># Terraform
resource "aws_db_instance" "production" {
  identifier        = "prod-postgres"
  engine            = "postgres"
  engine_version    = "15.4"
  instance_class    = "db.t3.medium"
  allocated_storage = 100
 
  # Multi-AZ: automatic failover to standby in a different AZ
  # No data loss. Automatic failover in ~60-120 seconds.
  multi_az = true
 
  # Encryption at rest — non-negotiable
  storage_encrypted = true
 
  # Automated backups with 7-day retention
  backup_retention_period = 7
  backup_window           = "03:00-04:00"
 
  # Enable deletion protection in production
  deletion_protection = true
 
  tags = {
    Environment = "production"
  }
}
</code></pre>
<p><strong>Test your backups on a schedule.</strong> Create a monthly calendar event: "Restore production backup to staging and verify data integrity." An untested backup is not a backup, it is a hope.</p>
<pre><code class="language-bash"># Restore a snapshot to a test instance and verify
aws rds restore-db-instance-from-db-snapshot \
  --db-instance-identifier recovery-test \
  --db-snapshot-identifier rds:prod-postgres-2025-01-15 \
  --db-instance-class db.t3.medium \
  --no-multi-az
 
# Connect and verify row counts
psql -h recovery-test.xxxx.rds.amazonaws.com -U admin -d mydb \
  -c "SELECT COUNT(*) FROM users; SELECT COUNT(*) FROM orders;"
</code></pre>
<p>For official guidance on RDS backup and restore, refer to the <a href="https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_WorkingWithAutomatedBackups.html">AWS RDS Backup and Restore documentation</a>.</p>
<h2 id="heading-mistake-9-no-documentation-or-runbooks">Mistake 9: No Documentation or Runbooks</h2>
<h3 id="heading-the-scenario">The Scenario</h3>
<p>The startup's most experienced DevOps engineer takes two weeks of vacation. On day three of their holiday, the staging environment goes down. Nobody else knows how it was built, the engineer set it up manually over six months with no documentation, no Terraform, no notes. The team spends four days trying to reconstruct the environment from memory and guesswork. The engineer gets messages on their vacation every day. When they return, they rebuild the environment in four hours.</p>
<h3 id="heading-the-business-impact">The Business Impact</h3>
<p>Undocumented infrastructure creates single points of failure not in your systems, but in your team. It makes onboarding new engineers take weeks instead of hours. It makes incident response depend on specific people being available. When that person leaves the company, the knowledge walks out with them.</p>
<h3 id="heading-the-fix">The Fix</h3>
<p>Documentation for an engineering team means three specific things:</p>
<ol>
<li><p><strong>Infrastructure as Code is the highest form of documentation.</strong> The Terraform that defines your infrastructure IS the documentation for what exists and how it is configured. If something is not in code, it should not exist in production.</p>
</li>
<li><p><strong>A runbook for every operational task.</strong> A runbook is a step-by-step procedure written well enough that someone in their first week at the company can follow it during an incident:</p>
</li>
</ol>
<pre><code class="language-markdown"># Runbook: Production Database Connection Exhaustion
 
## Symptoms
- Application logs: "too many connections" errors
- 500 error rate spike on database-dependent endpoints
- pg_stat_activity shows max connections reached
 
## Diagnosis
# Check current connection count
psql -h \(DB_HOST -U \)DB_USER -c "SELECT COUNT(*) FROM pg_stat_activity;"
 
# See connections by application
psql -h \(DB_HOST -U \)DB_USER \
  -c "SELECT application_name, COUNT(*) FROM pg_stat_activity GROUP BY 1 ORDER BY 2 DESC;"

## Resolution
1. Identify and restart the service causing the connection leak
2. If immediate relief needed: kill idle connections older than 10 minutes
3. Long-term: review connection pool settings in application config

## Escalation
If unresolved in 30 minutes: page the on-call backend engineer.
</code></pre>
<ol>
<li><strong>An architecture README in every repository.</strong> Every engineer who clones your repository should be able to understand what it does, how to run it locally, how to deploy it, and what it depends on without asking anyone.</li>
</ol>
<h2 id="heading-mistake-10-solving-technical-problems-without-understanding-the-business">Mistake 10: Solving Technical Problems Without Understanding the Business</h2>
<h3 id="heading-the-scenario">The Scenario</h3>
<p>A startup is experiencing slow page loads. A DevOps engineer decides to solve it by migrating to Kubernetes with horizontal pod auto-scaling. The migration takes six weeks. Page loads improve slightly. But 80% of the slowness was caused by unoptimized database queries that had nothing to do with the infrastructure layer. The six-week migration solved 20% of the problem.</p>
<h3 id="heading-the-business-impact">The Business Impact</h3>
<p>Technical solutions to misdiagnosed problems are extraordinarily expensive. Every hour spent building the wrong solution is an hour not spent on the right one. Infrastructure is a tool for delivering business outcomes not an end in itself.</p>
<h3 id="heading-the-fix">The Fix</h3>
<p>Before making any infrastructure decision, answer these four questions:</p>
<ol>
<li><p><strong>What is the actual, measured bottleneck?</strong> Instrument before you act. The bottleneck is almost never where you assumed it was.</p>
</li>
<li><p><strong>What does success look like, and how will you measure it?</strong> "Pages are faster" is not measurable. "p95 page load time drops below 1.2 seconds" is measurable.</p>
</li>
<li><p><strong>What is the full cost of this solution?</strong> Time to implement, ongoing operational burden, team learning curve. Is this cost justified by the measured impact?</p>
</li>
<li><p><strong>Can a simpler solution solve 80% of the problem in 20% of the time?</strong></p>
</li>
</ol>
<p>Always profile and measure before you rebuild:</p>
<pre><code class="language-bash"># Check slow queries in PostgreSQL before any infrastructure changes
psql -h \(DB_HOST -U \)DB_USER -d $DB_NAME -c "
SELECT
  query,
  calls,
  total_exec_time / calls AS avg_ms,
  rows / calls AS avg_rows
FROM pg_stat_statements
ORDER BY avg_ms DESC
LIMIT 10;
"
</code></pre>
<p>Nine times out of ten, slow applications have slow queries, missing indexes, or an N+1 query problem, none of which require a new infrastructure layer to fix.</p>
<h2 id="heading-the-system-thinking-framework-every-devops-engineer-needs">The System Thinking Framework Every DevOps Engineer Needs</h2>
<p>Most of the mistakes above share a common root cause: the engineer was thinking about one component in isolation instead of the full system.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/b33035a6-448f-419b-b293-206b7b775594.jpg" alt="A diagram showing a request flowing through a full system: user → CDN → load balancer → application servers → cache → database → logs/monitoring" style="display: block;" width="544" height="650" loading="lazy">

<p>A system thinker asks six questions before making any change in production:</p>
<table>
<thead>
<tr>
<th>Question</th>
<th>Why You Ask It</th>
</tr>
</thead>
<tbody><tr>
<td><strong>What does this change?</strong></td>
<td>List every configuration, file, or service that will be different.</td>
</tr>
<tr>
<td><strong>What does this depend on?</strong></td>
<td>What must be true upstream for this component to work correctly?</td>
</tr>
<tr>
<td><strong>What depends on this?</strong></td>
<td>What downstream systems are affected if this changes or fails?</td>
</tr>
<tr>
<td><strong>What is the failure mode?</strong></td>
<td>Does this fail loudly (500 errors) or silently (wrong data)?</td>
</tr>
<tr>
<td><strong>What is the rollback path?</strong></td>
<td>How do you reverse this in under five minutes?</td>
</tr>
<tr>
<td><strong>What does healthy look like after the change?</strong></td>
<td>What metrics confirm everything is working correctly?</td>
</tr>
</tbody></table>
<p>This is not a checklist you run through slowly. It is a thinking habit that becomes automatic with practice. Senior engineers do not spend more time on deployments than junior engineers do, they spend their time on different things, and this is one of them.</p>
<h2 id="heading-your-production-readiness-checklist">Your Production Readiness Checklist</h2>
<p>Use this checklist before any production system goes live. Mark each item as done, in progress, or not yet started.</p>
<h3 id="heading-infrastructure">Infrastructure</h3>
<ul>
<li><p>Infrastructure is defined as code (Terraform or CloudFormation) and version-controlled in Git</p>
</li>
<li><p>Separate dev, staging, and production environments exist with separate credentials</p>
</li>
<li><p>All production changes go through an automated CI/CD pipeline, no manual SSH deployments</p>
</li>
<li><p>You can rebuild the entire production environment from code in under two hours</p>
</li>
</ul>
<h3 id="heading-security">Security</h3>
<ul>
<li><p>No secrets, credentials, or API keys exist in any Git repository</p>
</li>
<li><p>All production secrets are in Secrets Manager or SSM Parameter Store</p>
</li>
<li><p>All IAM roles follow the principle of least privilege</p>
</li>
<li><p>S3 buckets have public access blocked by default</p>
</li>
<li><p>Port 22 is not open to <code>0.0.0.0/0</code> on any security group</p>
</li>
<li><p>CloudTrail is enabled in all regions</p>
</li>
<li><p>All IAM users have MFA enabled</p>
</li>
<li><p>AWS Security Hub is enabled and findings are reviewed weekly</p>
</li>
</ul>
<h3 id="heading-observability">Observability</h3>
<ul>
<li><p>Every service has a <code>/health</code> endpoint that monitoring checks continuously</p>
</li>
<li><p>Alerts fire within five minutes of a production error rate spike</p>
</li>
<li><p>Dashboards exist showing latency, error rate, and resource utilization</p>
</li>
<li><p>Logs are centralized and searchable, not scattered across individual servers</p>
</li>
</ul>
<h3 id="heading-reliability">Reliability</h3>
<ul>
<li><p>Production database has Multi-AZ enabled</p>
</li>
<li><p>Backup restoration has been tested in the last 30 days</p>
</li>
<li><p>Written runbooks exist for the three most likely failure scenarios</p>
</li>
<li><p>RTO and RPO requirements are documented and the architecture meets them</p>
</li>
</ul>
<h3 id="heading-documentation">Documentation</h3>
<ul>
<li><p>Every repository has a README explaining what it does and how to deploy it</p>
</li>
<li><p>A new engineer could understand the production architecture from documentation alone</p>
</li>
<li><p>No single engineer holds critical knowledge that lives only in their head</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>None of the mistakes in this article require rare misfortune to experience. They are the predictable result of decisions that feel reasonable under startup pressure but accumulate into real operational risk over time.</p>
<p>The good news is that every single one of them is preventable with the right awareness and the right habits applied early.</p>
<p>You do not need a perfect infrastructure from day one. You need a correct one: version-controlled, automated, observable, secure, and documented. Start with that foundation. Add complexity only when a specific, measured problem requires it. Always connect technical decisions to business outcomes.</p>
<p>The goal of DevOps in a startup is not to build impressive infrastructure. It is to build reliable systems that support product growth safely, efficiently, and sustainably and to make sure that when something does break, you can recover faster than anyone notices.</p>
<h2 id="heading-want-to-go-deeper">Want to Go Deeper?</h2>
<p>If this article resonated with you, <a href="https://coachli.co/tolani-akintayo/PR-H4oQS"><strong>The Startup DevOps Field Guide</strong></a> covers these principles in full depth with complete infrastructure blueprints, security frameworks, CI/CD pipeline templates, and the end-to-end decision-making playbook for engineers building DevOps practices in startup environments from scratch.</p>
<p>It is written specifically for the engineer who wants to do this right from the beginning not the one rebuilding everything after the first major incident.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Migrate to S3 Native State Locking in Terraform ]]>
                </title>
                <description>
                    <![CDATA[ If you've been running Terraform on AWS for any length of time, you know the setup: an S3 bucket for state storage, a DynamoDB table for state locking, and a handful of IAM policies tying them togethe ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-migrate-to-s3-native-state-locking-in-terraform/</link>
                <guid isPermaLink="false">69fd19239f93a850a430069b</guid>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Terraform ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Infrastructure as code ]]>
                    </category>
                
                    <category>
                        <![CDATA[ S3 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tolani Akintayo ]]>
                </dc:creator>
                <pubDate>Thu, 07 May 2026 22:58:43 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9619ad45-15c5-4be7-9221-ed4b76bc2b24.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've been running Terraform on AWS for any length of time, you know the setup: an S3 bucket for state storage, a DynamoDB table for state locking, and a handful of IAM policies tying them together. It works. It has worked for years.</p>
<p>But it has always carried a cost that rarely gets discussed openly. That cost isn't just money, though a DynamoDB table with on-demand billing adds up across multiple teams and environments.</p>
<p>The real cost is complexity. Every new AWS environment needs both resources provisioned before Terraform can manage anything else. Every engineer who sets up their first Terraform backend has to understand why two completely different AWS services are responsible for what is logically one thing: storing and protecting state. And every incident involving a stuck lock has required someone to manually delete a record from DynamoDB to unblock the team.</p>
<p>In November 2024, AWS announced that S3 now supports native object locking for Terraform state files, meaning <strong>DynamoDB is no longer required for state locking</strong>. Terraform 1.10 added support for this feature, and it's now generally available.</p>
<p>In this tutorial, you'll learn:</p>
<ul>
<li><p>What S3 native locking is and how it works</p>
</li>
<li><p>How to set it up from scratch if you're starting a new project</p>
</li>
<li><p>How to migrate an existing S3 + DynamoDB setup to S3 native locking safely</p>
</li>
<li><p>How to verify locking is working and handle edge cases</p>
</li>
</ul>
<p>By the end, you'll have a simpler, cleaner Terraform backend with one fewer AWS resource to manage.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-terraform-state-locking">What Is Terraform State Locking?</a></p>
</li>
<li><p><a href="#heading-what-is-s3-native-state-locking">What Is S3 Native State Locking?</a></p>
</li>
<li><p><a href="#heading-how-s3-native-locking-compares-to-the-s3-dynamodb-approach">How S3 Native Locking Compares to the S3 + DynamoDB Approach</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-part-1-fresh-setup-how-to-configure-s3-native-locking-from-scratch">Part 1: Fresh Setup – How to Configure S3 Native Locking from Scratch</a></p>
<ul>
<li><p><a href="#heading-step-1-create-the-s3-bucket-with-versioning-and-encryption">Step 1: Create the S3 Bucket with Versioning and Encryption</a></p>
</li>
<li><p><a href="#heading-step-2-configure-the-terraform-backend-with-native-locking">Step 2: Configure the Terraform Backend with Native Locking</a></p>
</li>
<li><p><a href="#heading-step-3-initialize-and-verify">Step 3: Initialize and Verify</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-2-migration-how-to-move-from-s3-dynamodb-to-s3-native-locking">Part 2: Migration – How to Move from S3 + DynamoDB to S3 Native Locking</a></p>
<ul>
<li><p><a href="#heading-step-1-verify-your-current-setup">Step 1: Verify Your Current Setup</a></p>
</li>
<li><p><a href="#heading-step-2-enable-object-lock-on-the-existing-s3-bucket">Step 2: Enable Object Lock on the Existing S3 Bucket</a></p>
</li>
<li><p><a href="#heading-step-3-update-the-terraform-backend-configuration">Step 3: Update the Terraform Backend Configuration</a></p>
</li>
<li><p><a href="#heading-step-4-reinitialize-terraform">Step 4: Reinitialize Terraform</a></p>
</li>
<li><p><a href="#heading-step-5-verify-the-migration">Step 5: Verify the Migration</a></p>
</li>
<li><p><a href="#heading-step-6-clean-up-the-dynamodb-table">Step 6: Clean Up the DynamoDB Table</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-how-to-verify-that-locking-is-working">How to Verify That Locking Is Working</a></p>
</li>
<li><p><a href="#heading-how-to-handle-a-stuck-lock">How to Handle a Stuck Lock</a></p>
</li>
<li><p><a href="#heading-rollback-plan-if-something-goes-wrong">Rollback Plan: If Something Goes Wrong</a></p>
</li>
<li><p><a href="#heading-security-best-practices-for-your-state-bucket">Security Best Practices for Your State Bucket</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-references">References</a></p>
</li>
</ul>
<h2 id="heading-what-is-terraform-state-locking">What is Terraform State Locking?</h2>
<p>Before looking at the new approach, it helps to understand what state locking is solving.</p>
<p>Terraform stores everything it knows about your infrastructure in a <strong>state file</strong> – a JSON document that maps your configuration to real AWS resources. When you run <code>terraform apply</code>, Terraform reads this file, calculates the difference between the current state and your configuration, and makes the necessary changes.</p>
<p>The problem arises when two engineers or two CI/CD pipelines run and try to apply changes at the same time. If both read the state file simultaneously, calculate changes independently, and both try to write back, you get a <strong>race condition</strong>. The second write overwrites changes from the first, and your state is now out of sync with reality. This is a serious problem that can cause resources to be untracked, doubled, or destroyed unexpectedly.</p>
<p><strong>State locking</strong> solves this by creating a lock when any operation starts that could modify state. If a lock already exists, Terraform refuses to proceed and reports who holds the lock and when it was acquired. Only one operation can hold the lock at a time. When the operation completes, the lock is released.</p>
<pre><code class="language-plaintext">Terraform Run A                 State File / Lock                Terraform Run B
(User 1)                         (S3/DynamoDB)                   (User 2)

   |                                   |                            |
   |------- 1. Acquire Lock ----------&gt;|                            |
   |                                   |                            |
   |&lt;------ 2. Lock Granted -----------|                            |
   |                                   |                            |
   |                                   |------- 3. Acquire Lock ---&gt;|
   |            [PROCESSING]           |                            |
   |      (Modifying Infrastructure)   |&lt;------ 4. Lock Denied -----|
   |                                   |        (Wait / Retry)      |
   |                                   |                            |
   |------- 5. Release Lock ----------&gt;|                            |
   |                                   |                            |
   |           [COMPLETED]             |&lt;------ 6. Lock Granted ----|
   |                                   |                            |
   |                                   |       [PROCESSING]         |
   |                                   | (Modifying Infrastructure) |              
   |                                   |                            |
</code></pre>
<h2 id="heading-what-is-s3-native-state-locking">What Is S3 Native State Locking?</h2>
<p>Previously, Terraform's S3 backend used a DynamoDB table as the locking mechanism. When a lock was needed, Terraform wrote a record to DynamoDB with a <code>LockID</code> primary key. DynamoDB's conditional writes guaranteed that only one process could create that record, which is what made the locking atomic.</p>
<p>S3 native locking uses <strong>S3 Object Lock</strong> instead. S3 Object Lock is an S3 feature originally designed to enforce WORM (Write Once, Read Many) compliance for regulatory requirements. AWS extended this capability to support Terraform's state locking workflow.</p>
<p>When S3 native locking is enabled in your Terraform backend:</p>
<ol>
<li><p>Terraform writes your state to an <code>.tfstate</code> object in S3 (as before)</p>
</li>
<li><p>To acquire a lock, Terraform uses <strong>S3's conditional write operations</strong> – specifically the <code>if-none-match</code> conditional header to create a lock file atomically</p>
</li>
<li><p>If the lock file already exists, S3 rejects the write, and Terraform reports that a lock is held</p>
</li>
<li><p>When the operation completes, Terraform deletes the lock file to release the lock.</p>
</li>
</ol>
<p>The key difference from DynamoDB: the entire locking mechanism lives inside S3. No second service. No second set of IAM permissions. No second resource to provision.</p>
<p><strong>Note:</strong> This feature requires Terraform version <strong>1.10.0 or later</strong> and an S3 bucket with <strong>Object Lock enabled</strong>. Object Lock must be enabled at bucket creation time. You can't enable it on an existing bucket through the console or CLI. But there is a supported workaround for existing buckets, which we'll cover in Part 2.</p>
<h2 id="heading-how-s3-native-locking-compares-to-the-s3-dynamodb-approach">How S3 Native Locking Compares to the S3 + DynamoDB Approach</h2>
<table>
<thead>
<tr>
<th><strong>Aspect</strong></th>
<th><strong>S3 + DynamoDB (Old)</strong></th>
<th><strong>S3 Native Locking (New)</strong></th>
</tr>
</thead>
<tbody><tr>
<td><strong>AWS services required</strong></td>
<td>S3 + DynamoDB</td>
<td>S3 only</td>
</tr>
<tr>
<td><strong>IAM permissions needed</strong></td>
<td>S3 + DynamoDB permissions</td>
<td>S3 permissions only</td>
</tr>
<tr>
<td><strong>Terraform version</strong></td>
<td>Any</td>
<td>1.10.0 or later</td>
</tr>
<tr>
<td><strong>Setup complexity</strong></td>
<td>Two resources, two IAM scopes</td>
<td>One resource</td>
</tr>
<tr>
<td><strong>Stuck lock resolution</strong></td>
<td>Delete DynamoDB record</td>
<td>Delete S3 lock file</td>
</tr>
<tr>
<td><strong>Cost</strong></td>
<td>S3 storage + DynamoDB on-demand</td>
<td>S3 storage only</td>
</tr>
<tr>
<td><strong>Object Lock requirement</strong></td>
<td>Not required</td>
<td>Required on S3 bucket</td>
</tr>
<tr>
<td><strong>Locking mechanism</strong></td>
<td>DynamoDB conditional writes</td>
<td>S3 conditional writes (<code>if-none-match</code>)</td>
</tr>
<tr>
<td><strong>State versioning</strong></td>
<td>S3 Versioning (recommended)</td>
<td>S3 Versioning (required for full safety)</td>
</tr>
</tbody></table>
<p>The functional behavior from Terraform's perspective is identical. Locking works the same way. The lock information displayed when a lock is held has the same structure. The only difference is what happens under the hood.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have the following in place:</p>
<ul>
<li><strong>Terraform 1.10.0 or later</strong> installed. Check your version:</li>
</ul>
<pre><code class="language-shell">terraform version
</code></pre>
<p>If you need to upgrade, follow the <a href="https://developer.hashicorp.com/terraform/install">official upgrade guide</a>.</p>
<ul>
<li><strong>AWS CLI</strong> installed and configured with credentials that have permission to create and manage S3 buckets.</li>
</ul>
<pre><code class="language-shell">aws --version
aws sts get-caller-identity   # confirm you're authenticated
</code></pre>
<ul>
<li><p><strong>IAM permissions</strong> to perform the following S3 actions:</p>
<ul>
<li><p><code>s3:CreateBucket</code></p>
</li>
<li><p><code>s3:PutBucketVersioning</code></p>
</li>
<li><p><code>s3:PutBucketEncryption</code></p>
</li>
<li><p><code>s3:PutObjectLegalHold</code></p>
</li>
<li><p><code>s3:PutObjectRetention</code></p>
</li>
<li><p><code>s3:GetObject</code></p>
</li>
<li><p><code>s3:PutObject</code></p>
</li>
<li><p><code>s3:DeleteObject</code></p>
</li>
<li><p><code>s3:ListBucket</code></p>
</li>
</ul>
</li>
<li><p>For the <strong>migration path</strong>: access to your existing Terraform project and the S3 bucket and DynamoDB table currently in use.</p>
</li>
</ul>
<h2 id="heading-part-1-fresh-setup-how-to-configure-s3-native-locking-from-scratch">Part 1: Fresh Setup – How to Configure S3 Native Locking from Scratch</h2>
<p>Follow this section if you're starting a new Terraform project and want to use S3 native locking from the beginning.</p>
<h3 id="heading-step-1-create-the-s3-bucket-with-versioning-and-encryption">Step 1: Create the S3 Bucket with Versioning and Encryption</h3>
<p>Object Lock <strong>must be enabled at bucket creation time</strong>. You can't add it afterward through the standard console flow. Create the bucket using the AWS CLI with Object Lock enabled:</p>
<pre><code class="language-shell">aws s3api create-bucket \
  --bucket your-project-terraform-state \
  --region us-east-1 \
  --object-lock-enabled-for-bucket
</code></pre>
<p><strong>Note:</strong> For regions other than <code>us-east-1</code>, add the <code>--create-bucket-configuration</code> flag.</p>
<pre><code class="language-shell">aws s3api create-bucket \
  --bucket your-project-terraform-state \
  --region eu-west-1 \
  --create-bucket-configuration LocationConstraint=eu-west-1 \
  --object-lock-enabled-for-bucket
</code></pre>
<p>Now enable versioning on the bucket. Versioning is required alongside Object Lock and allows Terraform to recover previous state versions if something goes wrong:</p>
<pre><code class="language-shell">aws s3api put-bucket-versioning \
  --bucket your-project-terraform-state \
  --versioning-configuration Status=Enabled
</code></pre>
<p>Enable server-side encryption so your state files are encrypted at rest:</p>
<pre><code class="language-shell">aws s3api put-bucket-encryption \
  --bucket your-project-terraform-state \
  --server-side-encryption-configuration '{
    "Rules": [
      {
        "ApplyServerSideEncryptionByDefault": {
          "SSEAlgorithm": "AES256"
        },
        "BucketKeyEnabled": true
      }
    ]
  }'
</code></pre>
<p>Block all public access to the bucket. A Terraform state file contains resource IDs, IP addresses, and potentially sensitive values. It should never be publicly accessible:</p>
<pre><code class="language-shell">aws s3api put-public-access-block \
  --bucket your-project-terraform-state \
  --public-access-block-configuration \
    "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
</code></pre>
<p>Verify the bucket configuration:</p>
<pre><code class="language-shell"># Confirm Object Lock is enabled
aws s3api get-object-lock-configuration \
  --bucket your-project-terraform-state
 
# Confirm versioning is enabled
aws s3api get-bucket-versioning \
  --bucket your-project-terraform-state
 
# Confirm encryption is configured
aws s3api get-bucket-encryption \
  --bucket your-project-terraform-state
</code></pre>
<p>Expected output for the Object Lock check:</p>
<pre><code class="language-json">{
    "ObjectLockConfiguration": {
        "ObjectLockEnabled": "Enabled"
    }
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/2b2e56cf-687f-4932-a61e-ed7cc33ea6f1.png" alt="Terminal showing AWS CLI verification commands confirming S3 bucket is configured correctly with Object Lock, versioning, and encryption enabled" style="display: block;" width="1120" height="616" loading="lazy">

<h3 id="heading-step-2-configure-the-terraform-backend-with-native-locking">Step 2: Configure the Terraform Backend with Native Locking</h3>
<p>In your Terraform project, create or update your <code>backend.tf</code> file:</p>
<pre><code class="language-hcl">terraform {
  backend "s3" {
    bucket = "your-project-terraform-state"
    key    = "production/terraform.tfstate"
    region = "us-east-1"
 
    # Enable S3 native state locking
    # Requires Terraform 1.10.0+ and a bucket with Object Lock enabled
    use_lockfile = true
 
    # Encryption at rest
    encrypt = true
  }
}
</code></pre>
<p>The critical difference from the old configuration is the <code>use_lockfile = true</code> parameter. Notice what is <strong>absent</strong>: there's no <code>dynamodb_table</code> argument. No DynamoDB table. No second service.</p>
<p>Here's a direct comparison of the old and new configurations:</p>
<p><strong>Old configuration (S3 + DynamoDB):</strong></p>
<pre><code class="language-hcl">terraform {
  backend "s3" {
    bucket         = "your-project-terraform-state"
    key            = "production/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"   # this goes away
  }
}
</code></pre>
<p><strong>New configuration (S3 native locking):</strong></p>
<pre><code class="language-hcl">terraform {
  backend "s3" {
    bucket       = "your-project-terraform-state"
    key          = "production/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true   # this replaces dynamodb_table
  }
}
</code></pre>
<h3 id="heading-step-3-initialize-and-verify">Step 3: Initialize and Verify</h3>
<p>Run <code>terraform init</code> to initialize the backend:</p>
<pre><code class="language-shell">terraform init
</code></pre>
<p>Expected output:</p>
<pre><code class="language-plaintext">Initializing the backend...
 
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
 
Initializing provider plugins...
 
Terraform has been successfully initialized!
</code></pre>
<p>Run a plan to confirm everything is working end-to-end:</p>
<pre><code class="language-shell">terraform plan
</code></pre>
<p>If locking is working, you'll see a brief pause while Terraform acquires the lock before the plan output appears. You'll also see the lock information if you look at the S3 bucket&nbsp;– a <code>.tflock</code> file will appear temporarily alongside your state file during the operation and disappear when it completes.</p>
<h2 id="heading-part-2-migration-how-to-move-from-s3-dynamodb-to-s3-native-locking">Part 2: Migration&nbsp;– How to Move from S3 + DynamoDB to S3 Native Locking</h2>
<p>Follow this section if you have an <strong>existing Terraform setup</strong> using an S3 bucket and DynamoDB table for state locking, and you want to migrate to S3 native locking.</p>
<p><strong>Important:</strong> Migration requires a maintenance window or at minimum a period where no Terraform operations are running. You're changing the backend configuration, which means <strong>all team members and CI/CD pipelines must stop running</strong> <code>terraform plan</code> <strong>or</strong> <code>terraform apply</code> <strong>during the migration</strong>. The migration itself takes under 10 minutes.</p>
<h3 id="heading-step-1-verify-your-current-setup">Step 1: Verify Your Current Setup</h3>
<p>Before making any changes, document your existing backend configuration and confirm the state file is accessible:</p>
<pre><code class="language-shell"># Confirm your state file is in S3
aws s3 ls s3://your-existing-bucket/path/to/terraform.tfstate
 
# Confirm the DynamoDB table exists
aws dynamodb describe-table \
  --table-name your-dynamodb-lock-table \
  --query 'Table.TableStatus'
</code></pre>
<p>Check your current <code>backend.tf</code> and note the exact values:</p>
<pre><code class="language-shell"># Your current backend.tf - note these values before changing anything
terraform {
  backend "s3" {
    bucket         = "your-existing-bucket"       # note this
    key            = "path/to/terraform.tfstate"   # note this
    region         = "us-east-1"                   # note this
    encrypt        = true
    dynamodb_table = "your-dynamodb-lock-table"    # this will be removed
  }
}
</code></pre>
<p>Run one final plan to confirm the current state is clean and there are no unexpected changes pending:</p>
<pre><code class="language-shell">terraform plan
</code></pre>
<p>If the plan shows no changes, you're in a safe state to proceed.</p>
<h3 id="heading-step-2-enable-object-lock-on-the-existing-s3-bucket">Step 2: Enable Object Lock on the Existing S3 Bucket</h3>
<p>This is the most important step in the migration. Object Lock can't normally be enabled on an existing bucket. It's a setting that must be configured at creation time.</p>
<p>But AWS provides a way to enable Object Lock on an existing bucket through a support request or through a direct API call that's not exposed in the standard console UI. AWS has officially documented this path for the Terraform migration use case.</p>
<p>Run the following AWS CLI command to enable Object Lock on your <strong>existing</strong> bucket:</p>
<pre><code class="language-bash">aws s3api put-object-lock-configuration \
  --bucket your-existing-bucket \
  --object-lock-configuration '{"ObjectLockEnabled": "Enabled"}'
</code></pre>
<p><strong>Note:</strong> This command enables Object Lock in <strong>governance mode with no default retention</strong>, meaning it enables the locking capability without setting a default retention period on all objects. This is exactly what Terraform's native locking needs: the ability to create and delete lock files, not permanent object retention.</p>
<p>Verify Object Lock is now enabled:</p>
<pre><code class="language-shell">aws s3api get-object-lock-configuration \
  --bucket your-existing-bucket
</code></pre>
<p>Expected output:</p>
<pre><code class="language-json">{
    "ObjectLockConfiguration": {
        "ObjectLockEnabled": "Enabled"
    }
}
</code></pre>
<p>Also verify that versioning is already enabled (it should be if you are running a production Terraform setup):</p>
<pre><code class="language-shell">aws s3api get-bucket-versioning \
  --bucket your-existing-bucket
</code></pre>
<p>Expected output:</p>
<pre><code class="language-json">{
    "Status": "Enabled"
}
</code></pre>
<p>If versioning isn't enabled, enable it before proceeding:</p>
<pre><code class="language-shell">aws s3api put-bucket-versioning \
  --bucket your-existing-bucket \
  --versioning-configuration Status=Enabled
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/cd17df01-3d0a-4f93-9250-3f51627e91c8.png" alt="Terminal output showing successful Object Lock enablement on an existing S3 bucket using the AWS CLI" style="display: block;" width="1204" height="320" loading="lazy">

<h3 id="heading-step-3-update-the-terraform-backend-configuration">Step 3: Update the Terraform Backend Configuration</h3>
<p>Update your <code>backend.tf</code> to remove the <code>dynamodb_table</code> argument and add <code>use_lockfile = true</code>:</p>
<pre><code class="language-hcl">terraform {
  backend "s3" {
    bucket = "your-existing-bucket"
    key    = "path/to/terraform.tfstate"
    region = "us-east-1"
    encrypt = true
 
    # Add this:
    use_lockfile = true
 
    # Remove this line entirely:
    # dynamodb_table = "your-dynamodb-lock-table"
  }
}
</code></pre>
<p>Your updated <code>backend.tf</code> should look like this:</p>
<pre><code class="language-hcl">terraform {
  backend "s3" {
    bucket       = "your-existing-bucket"
    key          = "path/to/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true
  }
}
</code></pre>
<h3 id="heading-step-4-reinitialize-terraform">Step 4: Reinitialize Terraform</h3>
<p>Run <code>terraform init</code> with the <code>-reconfigure</code> flag. This flag tells Terraform that the backend configuration has changed intentionally and to reinitialize without prompting you to copy state (the state is already in the same bucket):</p>
<pre><code class="language-shell">terraform init -reconfigure
</code></pre>
<p>Expected output:</p>
<pre><code class="language-plaintext">Initializing the backend...
 
Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.
 
Initializing provider plugins...
- Reusing previous version of hashicorp/aws from the dependency lock file
 
Terraform has been successfully initialized!
</code></pre>
<p><strong>If you see an error here:</strong> The most common cause is that Object Lock wasn't successfully enabled on the bucket. Re-run the verification from Step 2 before proceeding.</p>
<h3 id="heading-step-5-verify-the-migration">Step 5: Verify the Migration</h3>
<p>Run a plan to confirm Terraform is working correctly with the new backend configuration:</p>
<pre><code class="language-shell">terraform plan
</code></pre>
<p>The plan should:</p>
<ul>
<li><p>Complete successfully</p>
</li>
<li><p>Show the same result as the plan you ran in Step 1 (no changes, or the same changes as before)</p>
</li>
<li><p>NOT mention DynamoDB anywhere in its output</p>
</li>
</ul>
<p>To confirm that locking is actually using S3 instead of DynamoDB, open a second terminal and run a plan while the first one is running. You should see the second terminal output a lock error that mentions S3, not DynamoDB:</p>
<pre><code class="language-plaintext">╷
│ Error: Error acquiring the state lock
│
│Error message: operation error S3: PutObject, https response       error StatusCode: 409,
│ RequestID: ..., api error Conflict: Object lock already exists for this key.
│
│ Lock Info:
│   ID:        a1b2c3d4-e5f6-7890-abcd-ef1234567890
│   Path:      your-existing-bucket/path/to/terraform.tfstate.tflock
│   Operation: OperationTypePlan
│   Who:       user@hostname
│   Version:   1.10.0
│   Created:   2026-05-06 14:22:01 UTC
│   Info:
╵
</code></pre>
<p>The <code>Path</code> field shows <code>.tfstate.tflock</code>, a file in your S3 bucket, not a DynamoDB record. This confirms that locking is now handled entirely by S3.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/e9abb703-af6e-429c-83bb-2ea2dac43a3a.png" alt="Two terminals showing concurrent terraform plan commands, the second one displays a lock error confirming S3 native locking is working" style="display: block;" width="1264" height="539" loading="lazy">

<h3 id="heading-step-6-clean-up-the-dynamodb-table">Step 6: Clean Up the DynamoDB Table</h3>
<p>Once you've confirmed the migration is working correctly and your team has run at least one successful <code>plan</code> and <code>apply</code> cycle using the new backend, you can remove the DynamoDB table.</p>
<p><strong>Wait at least 24-48 hours before deleting the DynamoDB table</strong> if you have CI/CD pipelines or multiple team members. This gives time to catch any pipeline that wasn't updated with the new backend configuration.</p>
<p>When you're ready, delete the DynamoDB table:</p>
<pre><code class="language-shell">aws dynamodb delete-table \
  --table-name your-dynamodb-lock-table
</code></pre>
<p>Confirm the deletion:</p>
<pre><code class="language-shell">aws dynamodb describe-table \
  --table-name your-dynamodb-lock-table
</code></pre>
<p>Expected output:</p>
<pre><code class="language-plaintext">An error occurred (ResourceNotFoundException) when calling the DescribeTable operation:
Requested resource not found
</code></pre>
<p>This error confirms that the table is gone. The migration is complete.</p>
<p>If you provisioned the DynamoDB table using Terraform (which is the recommended pattern), remove the resource from your Terraform configuration and run <code>terraform apply</code> to destroy it via Terraform rather than the CLI directly. This keeps your state clean:</p>
<pre><code class="language-hcl"># Remove this entire block from your Terraform configuration:
resource "aws_dynamodb_table" "terraform_state_lock" {
  name         = "terraform-state-lock"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"
 
  attribute {
    name = "LockID"
    type = "S"
  }
}
</code></pre>
<p>After removing the block, run:</p>
<pre><code class="language-bash">terraform apply
</code></pre>
<p>Terraform will detect that the DynamoDB table resource has been removed from configuration and will destroy the table.</p>
<h2 id="heading-how-to-verify-that-locking-is-working">How to Verify That Locking Is Working</h2>
<p>After completing either the fresh setup or the migration, use this procedure to independently verify that locking is functioning correctly.</p>
<h3 id="heading-method-1-observe-the-lock-file-during-an-operation">Method 1: Observe the lock file during an operation</h3>
<p>In one terminal, start a long-running plan against a configuration with many resources:</p>
<pre><code class="language-shell">terraform plan
</code></pre>
<p>While it's running, in a second terminal, check for the lock file in S3:</p>
<pre><code class="language-shell">aws s3 ls s3://your-bucket/path/to/ | grep tflock
</code></pre>
<p>You should see a file like:</p>
<pre><code class="language-plaintext">2026-05-06 14:22:01        512 terraform.tfstate.tflock
</code></pre>
<p>After the plan completes, run the same command again. The <code>.tflock</code> file should be gone.</p>
<h3 id="heading-method-2-read-the-lock-file-contents">Method 2: Read the lock file contents</h3>
<p>While a plan is running, download and read the lock file to see its contents:</p>
<pre><code class="language-shell">aws s3 cp \
  s3://your-bucket/path/to/terraform.tfstate.tflock \
  /tmp/current.lock &amp;&amp; cat /tmp/current.lock
</code></pre>
<p>Expected output (formatted for readability):</p>
<pre><code class="language-json">{
  "ID": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "Operation": "OperationTypePlan",
  "Info": "",
  "Who": "tolani@dev-machine",
  "Version": "1.10.0",
  "Created": "2026-05-06T14:22:01.123456789Z",
  "Path": "your-bucket/path/to/terraform.tfstate"
}
</code></pre>
<p>This is the same lock information that Terraform displays when a lock is held. It's now a JSON file in S3 rather than a record in DynamoDB.</p>
<h2 id="heading-how-to-handle-a-stuck-lock">How to Handle a Stuck Lock</h2>
<p>With the DynamoDB backend, resolving a stuck lock meant deleting a record from the DynamoDB table. With S3 native locking, it means deleting the <code>.tflock</code> file from S3.</p>
<p>A lock can get stuck if:</p>
<ul>
<li><p>A <code>terraform apply</code> or <code>plan</code> process was killed mid-execution</p>
</li>
<li><p>A CI/CD pipeline runner crashed during a Terraform operation</p>
</li>
<li><p>A network interruption prevented the lock release from completing</p>
</li>
</ul>
<p>Here's how you can check for a stuck lock:</p>
<pre><code class="language-shell">aws s3 ls s3://your-bucket/path/to/ | grep tflock
</code></pre>
<p>If a <code>.tflock</code> file exists and no Terraform operation is currently running, it is a stuck lock.</p>
<p>You can also read the lock to understand who held it:</p>
<pre><code class="language-shell">aws s3 cp \
  s3://your-bucket/path/to/terraform.tfstate.tflock \
  /tmp/stuck.lock &amp;&amp; cat /tmp/stuck.lock
</code></pre>
<p>This tells you who (<code>Who</code> field) was running the operation, what operation it was (<code>Operation</code> field), and when it was acquired (<code>Created</code> field).</p>
<p>And you can force-unlock using Terraform like this:</p>
<pre><code class="language-shell">terraform force-unlock LOCK-ID
</code></pre>
<p>Replace <code>LOCK-ID</code> with the <code>ID</code> value from the lock file contents. For example:</p>
<pre><code class="language-shell">terraform force-unlock a1b2c3d4-e5f6-7890-abcd-ef1234567890
</code></pre>
<p>Terraform will confirm:</p>
<pre><code class="language-plaintext">Do you really want to force-unlock?
  Terraform will remove the lock on the remote state.
  This will allow local Terraform commands to modify this state, even though it
  may be still be in use. Only 'yes' will be accepted to confirm.
 
  Enter a value: yes
 
Terraform state has been successfully unlocked!
</code></pre>
<p>An alternative is to delete the lock file directly via CLI. If <code>terraform force-unlock</code> doesn't work (for example, because you are running in a CI environment without Terraform available), delete the lock file directly:</p>
<pre><code class="language-shell">aws s3 rm s3://your-bucket/path/to/terraform.tfstate.tflock
</code></pre>
<p><strong>Only delete the lock file if you are certain no Terraform operation is currently running.</strong> Deleting a lock that is actively held by a running operation will allow a second concurrent operation to start, which is exactly the race condition locking is designed to prevent.</p>
<h2 id="heading-rollback-plan-if-something-goes-wrong">Rollback Plan: If Something Goes Wrong</h2>
<p>If you encounter problems after migrating, you can roll back to the S3 + DynamoDB setup with these steps.</p>
<p><strong>Step 1: Stop all Terraform operations</strong> in your team and CI/CD pipelines.</p>
<p><strong>Step 2: Recreate the DynamoDB table</strong> if you already deleted it:</p>
<pre><code class="language-shell">aws dynamodb create-table \
  --table-name terraform-state-lock \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST
</code></pre>
<p><strong>Step 3: Revert</strong> <code>backend.tf</code> to the previous configuration:</p>
<pre><code class="language-hcl">terraform {
  backend "s3" {
    bucket         = "your-existing-bucket"
    key            = "path/to/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"   # restored
    # Remove: use_lockfile = true
  }
}
</code></pre>
<p><strong>Step 4: Reinitialize:</strong></p>
<pre><code class="language-shell">terraform init -reconfigure
</code></pre>
<p><strong>Step 5: Verify:</strong></p>
<pre><code class="language-shell">terraform plan
</code></pre>
<p>The state file hasn't moved, so there's no data loss during a rollback. The only change is which locking mechanism Terraform uses.</p>
<p><strong>Note:</strong> Object Lock being enabled on the S3 bucket doesn't prevent the rollback. Object Lock and DynamoDB locking can coexist, Object Lock simply adds a capability to the bucket. Using <code>dynamodb_table</code> in your backend config tells Terraform to use DynamoDB regardless of whether Object Lock is enabled on the bucket.</p>
<h2 id="heading-security-best-practices-for-your-state-bucket">Security Best Practices for Your State Bucket</h2>
<p>Migrating to S3 native locking is a good opportunity to review the overall security configuration of your state bucket. Here are the practices every production Terraform state bucket should implement:</p>
<h3 id="heading-enable-versioning-required">Enable Versioning (Required)</h3>
<p>Versioning is a hard requirement for S3 native locking to work safely. It ensures that if a state file is accidentally overwritten or corrupted, you can restore a previous version.</p>
<pre><code class="language-shell">aws s3api put-bucket-versioning \
  --bucket your-state-bucket \
  --versioning-configuration Status=Enabled
</code></pre>
<h3 id="heading-block-all-public-access-non-negotiable">Block All Public Access (Non-Negotiable)</h3>
<p>Your state file contains resource ARNs, IP addresses, and may contain sensitive values passed through Terraform variables. It must never be publicly accessible.</p>
<pre><code class="language-shell">aws s3api put-public-access-block \
  --bucket your-state-bucket \
  --public-access-block-configuration \
    "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
</code></pre>
<h3 id="heading-enable-server-side-encryption">Enable Server-Side Encryption</h3>
<p>Always encrypt state files at rest. AES256 is the minimum. If your organization requires KMS key management:</p>
<pre><code class="language-shell">aws s3api put-bucket-encryption \
  --bucket your-state-bucket \
  --server-side-encryption-configuration '{
    "Rules": [
      {
        "ApplyServerSideEncryptionByDefault": {
          "SSEAlgorithm": "aws:kms",
          "KMSMasterKeyID": "arn:aws:kms:us-east-1:123456789012:key/your-kms-key-id"
        },
        "BucketKeyEnabled": true
      }
    ]
  }'
</code></pre>
<h3 id="heading-apply-least-privilege-iam-permissions">Apply Least-Privilege IAM Permissions</h3>
<p>The role or user that Terraform uses to access the state bucket should have only the permissions it needs. Here's a minimal IAM policy for S3 native locking:</p>
<pre><code class="language-json">{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "TerraformStateAccess",
      "Effect": "Allow",
      "Action": [
        "s3:ListBucket",
        "s3:GetObject",
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": [
        "arn:aws:s3:::your-state-bucket",
        "arn:aws:s3:::your-state-bucket/*"
      ]
    },
    {
      "Sid": "TerraformStateLocking",
      "Effect": "Allow",
      "Action": [
        "s3:GetObjectLegalHold",
        "s3:PutObjectLegalHold",
        "s3:GetObjectRetention",
        "s3:PutObjectRetention"
      ],
      "Resource": "arn:aws:s3:::your-state-bucket/*.tflock"
    }
  ]
}
</code></pre>
<p>Notice what is absent: there are no DynamoDB permissions. This is a cleaner, smaller permission set than the old approach required.</p>
<h3 id="heading-enable-access-logging">Enable Access Logging</h3>
<p>Log all access to your state bucket in CloudTrail or S3 server access logs. This gives you an audit trail of every time state was read, written, or locked:</p>
<pre><code class="language-shell">aws s3api put-bucket-logging \
  --bucket your-state-bucket \
  --bucket-logging-status '{
    "LoggingEnabled": {
      "TargetBucket": "your-logging-bucket",
      "TargetPrefix": "terraform-state-access/"
    }
  }'
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>AWS S3 native state locking removes the need for a DynamoDB table from your Terraform backend setup. The result is simpler infrastructure, a smaller IAM permission surface, and one fewer service to provision, monitor, and pay for across every environment your team manages.</p>
<p>Here's a summary of what you accomplished:</p>
<ul>
<li><p>Understood what state locking is and why it's required for safe Terraform operations</p>
</li>
<li><p>Compared S3 native locking to the existing S3 + DynamoDB approach</p>
</li>
<li><p>Set up a fresh Terraform backend using S3 native locking with correct bucket configuration</p>
</li>
<li><p>Migrated an existing backend from S3 + DynamoDB to S3 native locking safely</p>
</li>
<li><p>Learned how to verify locking, handle stuck locks, and roll back if needed</p>
</li>
<li><p>Applied security best practices to the state bucket</p>
</li>
</ul>
<p>This pattern – using S3 native locking – is the recommended approach for all new Terraform projects on AWS going forward. If you're managing a large estate with multiple Terraform backends, consider automating the migration using a script or Terraform module that applies the pattern across all your state buckets.</p>
<p><em>If you are building or optimizing cloud infrastructure for a startup and want a complete reference for production-ready Terraform modules, CI/CD pipeline patterns, and infrastructure runbooks, check out</em> <a href="https://coachli.co/tolani-akintayo/PR-H4oQS">The Startup DevOps Field Guide</a><em>. It covers the full lifecycle of AWS infrastructure from initial setup to production reliability.</em></p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://developer.hashicorp.com/terraform/language/backend/s3#use_lockfile">HashiCorp - S3 Backend Configuration: use_lockfile</a></p>
</li>
<li><p><a href="https://github.com/hashicorp/terraform/releases/tag/v1.10.0">HashiCorp: Terraform 1.10 Release Notes</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-lock.html">AWS Docs: S3 Object Lock Overview</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/AmazonS3/latest/API/API_PutObjectLockConfiguration.html">AWS Docs: PutObjectLockConfiguration API</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/conditional-requests.html">AWS Docs: S3 Conditional Writes</a></p>
</li>
<li><p><a href="https://developer.hashicorp.com/terraform/language/state/locking">HashiCorp: Backend State Locking</a></p>
</li>
<li><p><a href="https://developer.hashicorp.com/terraform/cli/commands/force-unlock">HashiCorp: terraform force-unlock Command</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/manage-versioning-examples.html">AWS Docs: Enabling S3 Versioning</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/serv-side-encryption.html">AWS Docs: S3 Server-Side Encryption</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Land Your First Cloud or DevOps Role: What Hiring Managers Actually Look For ]]>
                </title>
                <description>
                    <![CDATA[ You've completed three AWS courses. You have notes from a dozen Docker tutorials. You know what Kubernetes is, what CI/CD means, and you can explain Infrastructure as Code without hesitating. And yet  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-land-your-first-cloud-or-devops-role-what-hiring-managers-actually-look-for/</link>
                <guid isPermaLink="false">69f3683c909e64ad07e3b0fc</guid>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Career ]]>
                    </category>
                
                    <category>
                        <![CDATA[ jobs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tolani Akintayo ]]>
                </dc:creator>
                <pubDate>Thu, 30 Apr 2026 14:33:32 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/374e807b-a67f-4f04-a639-dfa230b0ba5f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You've completed three AWS courses. You have notes from a dozen Docker tutorials. You know what Kubernetes is, what CI/CD means, and you can explain Infrastructure as Code without hesitating.</p>
<p>And yet the applications go out, and nothing comes back.</p>
<p>This is one of the most frustrating experiences in tech. You're genuinely learning, genuinely putting in the time, and you have nothing to show for it in terms of results. You start to wonder if the market is too competitive, if you need one more certification, or if there's some hidden door everyone else found that you're missing.</p>
<p>The truth is simpler and more actionable than any of that: <strong>hiring managers can't see your YouTube watch history. They can see your GitHub.</strong> Most beginners optimize for learning. Hired candidates optimize for proof.</p>
<p>In this guide, you'll get an honest breakdown of the nine factors hiring managers actually evaluate when they look at a junior cloud or DevOps candidate and a concrete 90-day plan to address each one. By the end, you'll know exactly where you stand and exactly what to do next.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-the-three-patterns-that-keep-beginners-stuck">The Three Patterns That Keep Beginners Stuck</a></p>
<ul>
<li><p><a href="#heading-pattern-1-the-tutorial-loop">Pattern 1: The Tutorial Loop</a></p>
</li>
<li><p><a href="#heading-pattern-2--the-theorypractice-gap">Pattern 2: The Theory-Practice Gap</a></p>
</li>
<li><p><a href="#pattern-3-silent-learning">Pattern 3: Silent Learning</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-what-hiring-managers-are-actually-evaluating">What Hiring Managers Are Actually Evaluating</a></p>
</li>
<li><p><a href="#heading-factor-1-proof-of-work-the-non-negotiable">Factor 1: Proof of Work (The Non-Negotiable)</a></p>
<ul>
<li><a href="#heading-the-three-projects-that-cover-everything">The Three Projects That Cover Everything</a></li>
</ul>
</li>
<li><p><a href="#heading-factor-2-system-level-thinking">Factor 2: System-Level Thinking</a></p>
</li>
<li><p><a href="#heading-factor-3-software-engineering-fundamentals">Factor 3: Software Engineering Fundamentals</a></p>
</li>
<li><p><a href="#heading-factor-4-communication-skills">Factor 4: Communication Skills</a></p>
</li>
<li><p><a href="#heading-factor-5-consistency-over-intensity">Factor 5: Consistency Over Intensity</a></p>
</li>
<li><p><a href="#heading-factor-6-networking-and-visibility">Factor 6: Networking and Visibility</a></p>
</li>
<li><p><a href="#heading-factor-7-ownership-mindset">Factor 7: Ownership Mindset</a></p>
</li>
<li><p><a href="#heading-factor-8--business-awareness">Factor 8: Business Awareness</a></p>
</li>
<li><p><a href="#heading-factor-9-learning-agility">Factor 9: Learning Agility</a></p>
</li>
<li><p><a href="#heading-your-90-day-action-plan">Your 90-Day Action Plan</a></p>
</li>
<li><p><a href="#heading-honest-self-assessment-where-do-you-stand">Honest Self-Assessment: Where Do You Stand?</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
<li><p><a href="#heading-references-and-recommended-resources">References and Recommended Resources</a></p>
</li>
</ul>
<h2 id="heading-the-three-patterns-that-keep-beginners-stuck">The Three Patterns That Keep Beginners Stuck</h2>
<h3 id="heading-pattern-1-the-tutorial-loop">Pattern 1: The Tutorial Loop</h3>
<p>Week 1: You watch eight hours of Docker content. Week 2: You start an AWS course and get 70% through. Week 3: A Kubernetes series looks interesting, so you start that instead. Week 4: You open LinkedIn and wonder why you're not getting callbacks.</p>
<p>Watching tutorials feels like progress. It's comfortable, passive, and has no failure state. Nothing breaks. Nothing goes wrong.</p>
<p>The problem is that it produces nothing a hiring manager can evaluate. Courses and certifications tell an employer what you've been exposed to. Your GitHub tells them what you can actually do.</p>
<h3 id="heading-pattern-2-the-theory-practice-gap">Pattern 2: The Theory-Practice Gap</h3>
<p>You can explain CI/CD fluently. You've read the Kubernetes documentation. You understand the conceptual difference between a container and a virtual machine.</p>
<p>But you've never taken a simple application, containerized it, connected it to a pipeline, and deployed it to a cloud server with a real URL that someone can visit.</p>
<p>In an interview, "I understand how it works" and "I have built this and here is the link" are not equivalent answers. Hiring managers hear the first version from hundreds of candidates. The second version gets callbacks.</p>
<h3 id="heading-pattern-3-silent-learning">Pattern 3: Silent Learning</h3>
<p>This one is perhaps the most painful pattern because the learning is real. You're putting in the work every day but nobody knows. No GitHub activity. No LinkedIn posts. No community presence. Just cold applications sent from job boards to ATS systems that filter you out before a human ever sees your name.</p>
<p>The hard truth: people get hired through people. A hiring manager who has seen your LinkedIn post about a problem you solved is significantly more likely to give your résumé serious attention than a stranger who applied through a portal.</p>
<h2 id="heading-what-hiring-managers-are-actually-evaluating">What Hiring Managers Are Actually Evaluating</h2>
<p>I've grouped the nine factors that follow into three buckets: <strong>Mindset</strong>, <strong>Execution</strong>, and <strong>Visibility</strong>. The order matters: mindset shapes how you execute, and execution is what powers visibility.</p>
<table>
<thead>
<tr>
<th>Bucket</th>
<th>Covers</th>
<th>Factors</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Mindset</strong></td>
<td>How you think about problems and your career</td>
<td>Factors 2, 7, 8, 9</td>
</tr>
<tr>
<td><strong>Execution</strong></td>
<td>What you actually build and demonstrate</td>
<td>Factors 1, 3</td>
</tr>
<tr>
<td><strong>Visibility</strong></td>
<td>Whether the right people know you exist</td>
<td>Factors 4, 5, 6</td>
</tr>
</tbody></table>
<p>Let's go through each one.</p>
<h2 id="heading-factor-1-proof-of-work-the-non-negotiable">Factor 1: Proof of Work (The Non-Negotiable)</h2>
<p>If there's one thing to take from this entire article, it's this: <strong>no portfolio means no serious consideration.</strong> The most technically capable candidate in the applicant pool is invisible without proof of work.</p>
<p>This isn't about impressing anyone with complexity. It's about demonstrating that you can take a system from zero to deployed, documented, and working.</p>
<p>Here's the checklist every portfolio project should meet before you consider it done:</p>
<ul>
<li><p><strong>It's deployed</strong>: there's a real URL you can share, not "it works on my machine"</p>
</li>
<li><p><strong>It has a CI/CD pipeline</strong>: code changes are automatically tested and deployed</p>
</li>
<li><p><strong>Infrastructure is defined as code</strong>: not manually clicked together in the AWS console</p>
</li>
<li><p><strong>It has monitoring and alerting</strong>: you know when it breaks before users tell you</p>
</li>
<li><p><strong>It's documented</strong>: a README explains what it does, how to run it, and how it works</p>
</li>
<li><p><strong>It's on GitHub publicly</strong>: with real commit history showing iterative work</p>
</li>
</ul>
<p>If your project meets all six criteria, you have proof of work. If it meets four of six, you have a project in progress. Finish it before you start applying.</p>
<h3 id="heading-the-three-projects-that-cover-everything">The Three Projects That Cover Everything</h3>
<p>You don't need ten projects. You need two to three projects that together demonstrate the full range of DevOps skills.</p>
<h4 id="heading-project-1-the-full-stack-deploy-pipeline">Project 1 : The Full-Stack Deploy Pipeline</h4>
<p>This is the foundational DevOps project every beginner should build first.</p>
<p>Take any simple web application – a Python Flask app, a Node.js API, or even a static site. Containerize it with Docker. Write a CI/CD pipeline that runs tests, builds the Docker image, and deploys to a cloud server automatically on every push to the main branch. You can also set up Nginx as a reverse proxy and add an uptime monitor (UptimeRobot has a free tier).</p>
<p>Tools: GitHub Actions, Docker, AWS EC2 or <a href="http://Render.com">Render.com</a>, Nginx.</p>
<p>Why it matters to a hiring manager: it proves you can automate a full deployment workflow end-to-end. The hiring manager can visit your URL, see it running, and inspect your pipeline history.</p>
<p>This single project puts you ahead of most applicants who only have course completion screenshots.</p>
<h4 id="heading-project-2-infrastructure-as-code-with-terraform">Project 2: Infrastructure as Code with Terraform</h4>
<p>Write Terraform code that provisions a complete environment: a VPC, public and private subnets, an EC2 instance with properly scoped security group rules, and an S3 bucket for remote state. Destroy it and recreate it from scratch to prove the code actually works. Add a GitHub Actions workflow that runs <code>terraform plan</code> on pull requests and <code>terraform apply</code> on merge to main.</p>
<p>Tools: Terraform, AWS (or Azure/GCP), GitHub Actions.</p>
<p>Why it matters: Infrastructure as Code with Terraform is a required skill at almost every company running cloud infrastructure. Showing you can write, version-control, and automate Terraform demonstrates a core professional competency.</p>
<h4 id="heading-project-3-monitoring-and-observability-stack">Project 3: Monitoring and Observability Stack</h4>
<p>Deploy a monitoring stack using Docker Compose: Prometheus scraping metrics from your application and the host, Grafana dashboards showing CPU, memory, request rates, and error rates, and Alertmanager configured to send alerts to Slack or email when thresholds are crossed. Connect this to your Project 1 application so the pipeline deploys and the monitoring watches it.</p>
<p>Tools: Prometheus, Grafana, Alertmanager, Node Exporter, Docker Compose.</p>
<p>Why it matters: most beginner portfolios have zero observability work. This project immediately signals that you understand production engineering, not just deployment. Any senior DevOps engineer or SRE reviewing your application will notice it and it will set you apart.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/da9e25be-9b59-48c8-9cf0-9cfdb050c277.png" alt="GitHub profile showing three pinned DevOps portfolio repositories with descriptive names " style="display: block;" width="1353" height="584" loading="lazy">

<h2 id="heading-factor-2-system-level-thinking">Factor 2: System-Level Thinking</h2>
<p>This is the mindset that separates a DevOps engineer from someone who just knows a collection of tools. System-level thinking means you can see the whole picture, not just the part you happen to be working on at any given moment.</p>
<p>Here's the mental test hiring managers are running throughout your interview: <em>can you trace a user request from the moment they click a button to the moment they see a response, and explain what happens at every layer in between?</em></p>
<p>Here's the full journey of a web request, the map of modern infrastructure every DevOps engineer needs to understand:</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>Layer</th>
<th>What's happening and what can go wrong</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>User's Browser</td>
<td>The user types a URL. The browser needs to find the server.</td>
</tr>
<tr>
<td>2</td>
<td>DNS Resolution</td>
<td>The domain is translated into an IP address. DNS misconfigurations mean users can't reach you at all.</td>
</tr>
<tr>
<td>3</td>
<td>CDN / Edge Network</td>
<td>Traffic hits a CDN (Cloudflare, CloudFront) first. Static assets are served from the nearest edge. SSL terminates here.</td>
</tr>
<tr>
<td>4</td>
<td>Load Balancer</td>
<td>Routes the request to an available application server. If all targets are unhealthy, users get 502/503 errors.</td>
</tr>
<tr>
<td>5</td>
<td>Compute / Application Servers</td>
<td>The application code runs here in containers, on VMs, or in server-less functions. Business logic executes.</td>
</tr>
<tr>
<td>6</td>
<td>Database Layer</td>
<td>The application reads from or writes to a database. Slow queries or a full disk causes slow responses or outages.</td>
</tr>
<tr>
<td>7</td>
<td>Cache Layer</td>
<td>Redis or Memcached caches frequently-read data. Cache misses cause extra database load.</td>
</tr>
<tr>
<td>8</td>
<td>Response Returns</td>
<td>The response travels back through the stack and the user sees the result.</td>
</tr>
<tr>
<td>9</td>
<td>Logging and Monitoring</td>
<td>Every step above should emit logs and metrics. Good monitoring alerts you before users notice a problem.</td>
</tr>
</tbody></table>
<p>Why does this matter in an interview? Consider two candidates answering the question: <em>"Tell me about a time something broke in production."</em></p>
<p>Candidate A: "The website was down."</p>
<p>Candidate B: "The load balancer health checks were failing because the app containers were running out of memory due to a memory leak introduced in the previous deploy. We identified it via memory metrics in Grafana, rolled back, and added a memory limit to the container spec."</p>
<p>Same incident. Completely different answer. System-level thinking is what makes the difference.</p>
<h2 id="heading-factor-3-software-engineering-fundamentals">Factor 3: Software Engineering Fundamentals</h2>
<p>Many beginners rush to learn Kubernetes and Terraform before mastering the foundations that make those tools make sense. This creates a knowledge structure that looks impressive but has no solid base underneath it.</p>
<p>Here are the fundamentals that actually matter and what to do if you have a gap in any of them:</p>
<h3 id="heading-1-linux-and-the-command-line">1. Linux and the Command Line</h3>
<p>DevOps tools run on Linux. CI/CD jobs run in Linux containers. SSH is the front door to every server. If the terminal makes you uncomfortable, you're not ready for a production environment. This is not a preference, it's a prerequisite.</p>
<p>Start with daily Linux practice. The <a href="https://training.linuxfoundation.org/training/introduction-to-linux/">Linux Foundation's free introductory materials</a> are a solid starting point. And here's a <a href="https://www.freecodecamp.org/news/learn-the-basics-of-the-linux-operating-system/">solid freeCodeCamp course on Linux basics.</a></p>
<h3 id="heading-2-networking-fundamentals">2. Networking Fundamentals</h3>
<p>DNS, TCP/IP, HTTP/HTTPS, load balancing, firewalls, VPCs, subnets these concepts appear in every cloud architecture. Without them, Terraform and Kubernetes are magic boxes. Study the request flow in Factor 2 above until you can draw it from memory without looking.</p>
<p>Here's a <a href="https://www.freecodecamp.org/news/computer-networking-fundamentals/">computer networking fundamentals course</a> to get you started.</p>
<h3 id="heading-3-scripting-bash-and-python">3. Scripting: Bash and Python</h3>
<p>CI/CD pipelines are scripts. Automation is scripting. If you cannot write a Bash script that reads a config file, calls an API, and handles errors gracefully your automation ceiling is very low. Fix this by writing one small, useful script every week. Solve real problems with code.</p>
<p>Here's a helpful tutorial on <a href="https://www.freecodecamp.org/news/shell-scripting-crash-course-how-to-write-bash-scripts-in-linux/">shell scripting in Linux for beginners</a>.</p>
<h3 id="heading-4-git-and-version-control">4. Git and Version Control</h3>
<p>Not just <code>git commit</code> and <code>git push</code>. Branching strategies, pull requests, merge conflicts, rebasing, and tagging releases are all standard practice in professional DevOps teams. Use Git for everything including your personal learning notes. Practice branching workflows intentionally.</p>
<p>Here's a <a href="https://www.freecodecamp.org/news/gitting-things-done-book/">full book on all the Git basics</a> (and some more advanced topics, too) you need to know.</p>
<h3 id="heading-5-docker-and-containers">5. Docker and Containers</h3>
<p>Docker is the universal packaging format for modern software. Understanding layers, multi-stage builds, volumes, networking, and container security is the floor not the ceiling. Every project you build should be containerized. Write your Dockerfiles by hand instead of copying them.</p>
<p>Here's a course on <a href="https://www.freecodecamp.org/news/learn-docker-and-kubernetes-hands-on-course/">Docker and Kubernetes</a> to get you started,</p>
<h2 id="heading-factor-4-communication-skills">Factor 4: Communication Skills</h2>
<p>Technical skills set your ceiling. Communication skills determine how fast you reach it. This is the most consistently underestimated factor among beginner DevOps candidates.</p>
<p>Two candidates with identical technical ability will have very different career outcomes based on how clearly they communicate. Here's what that looks like in practice:</p>
<p><strong>Architecture explanation</strong>: Can you describe how your project works to someone who has never seen it? Can you draw the architecture on a whiteboard and walk someone through your design decisions and the trade-offs you made?</p>
<p><strong>Trade-off articulation</strong>: <em>"I chose X over Y because..."</em> is one of the most powerful phrases in a technical interview. It shows you understand that every decision has pros and cons and you made a conscious, reasoned choice rather than just copying a tutorial.</p>
<p><strong>Written documentation</strong>: A README is your project's cover letter. A well-written README with clear setup instructions, an architecture diagram, and documented decisions demonstrates engineering maturity that most beginners don't show.</p>
<p>Here's a quick test: open your most recent project on GitHub and read the README as if you're a hiring manager seeing it for the first time. Does it answer these questions?</p>
<ul>
<li><p>What does this project do, and why did you build it?</p>
</li>
<li><p>What does the architecture look like?</p>
</li>
<li><p>How do I run this locally, and how do I deploy it?</p>
</li>
<li><p>What decisions did you make, and why?</p>
</li>
<li><p>What would you improve if you continued working on it?</p>
</li>
</ul>
<p>If you answered "no" to more than two of those rewrite the README before applying anywhere. This single action will meaningfully improve your response rate.</p>
<p><strong>Interview communication</strong>: Hiring managers assess communication throughout the entire interview not just your answers. Thinking out loud, structuring your responses, and admitting uncertainty honestly are all evaluated.</p>
<h2 id="heading-factor-5-consistency-over-intensity">Factor 5: Consistency Over Intensity</h2>
<p>Hiring managers are pattern recognition machines. They look at your GitHub contribution graph, your LinkedIn activity, and your learning trajectory and form an impression before reading a single word on your résumé.</p>
<p>A binge-learning approach, 10-hour weekends followed by weeks of nothing produces a GitHub graph that tells the wrong story. Thirty minutes of focused daily practice for six months beats a monthly 10-hour binge. At the six-month mark, the daily practitioner has 90 hours of focused work. The binge learner has 60 with significantly worse retention.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/1315bb8d-9e4e-4f84-836f-4e02b83c75ce.webp" alt="GitHub contribution graph showing 12 months of consistent activity with regular commits across the year" style="display: block;" width="1080" height="273" loading="lazy">

<p>Here's how to build consistency in practice:</p>
<ul>
<li><p>Pick a time slot in your day that you will protect. Thirty minutes is enough to make progress.</p>
</li>
<li><p>Define a four-week learning sprint with a specific goal, not "learn Terraform" but "build and deploy a VPC with Terraform and write the README."</p>
</li>
<li><p>Keep a private learning journal: date, what you studied, what you built, what confused you.</p>
</li>
<li><p>When the sprint ends, evaluate what you built and plan the next one.</p>
</li>
</ul>
<p>What to avoid: declaring publicly on LinkedIn that you're "grinding DevOps full time" and then disappearing for six weeks. The absence is noticed. Only commit publicly to what you will actually sustain.</p>
<h2 id="heading-factor-6-networking-and-visibility">Factor 6: Networking and Visibility</h2>
<p>This is the factor most beginners resist most, and the one that makes the biggest practical difference in time-to-hire.</p>
<p>Most DevOps jobs are filled through people referrals, community connections, LinkedIn conversations. A warm introduction from someone who has seen your work outweighs fifty cold applications every time.</p>
<p>Here are three ways to build visibility without it feeling performative:</p>
<h3 id="heading-community-engagement">Community Engagement</h3>
<p>Join communities where DevOps engineers actually talk: AWS User Groups, local DevOps meetups, DevOps Discord servers, Reddit communities like r/devops and r/kubernetes. You don't need to be the expert. Ask specific questions, answer what you genuinely know, and show up consistently. After three to six months, people will recognize your name.</p>
<h3 id="heading-linkedin-content">LinkedIn Content</h3>
<p>Post once per week about something you learned, built, or got stuck on. Not marketing – documentation. A post that says <em>"This week I configured Prometheus alerting for a Docker Compose stack. Here's what tripped me up and how I solved it"</em> attracts recruiters, leads to conversations, and builds a searchable record of your growth over time.</p>
<h3 id="heading-asking-good-questions-in-public">Asking Good Questions in Public</h3>
<p>When you get stuck and figure it out, write it up. Post the solution in the same community where you asked the question. Answer someone else's version of the same question later. You position yourself as a helpful, engaged learner, exactly who hiring managers want to hire.</p>
<p>Here's a concrete three-month visibility sprint to follow:</p>
<table>
<thead>
<tr>
<th>Timeframe</th>
<th>Action</th>
</tr>
</thead>
<tbody><tr>
<td>Week 1-2</td>
<td>Update your LinkedIn headline: "Cloud / DevOps Engineer in Training │ Building with AWS, Docker, Terraform". Connect with 20 people in DevOps engineers, recruiters, hiring managers. Add a short personal note when connecting.</td>
</tr>
<tr>
<td>Week 3-4</td>
<td>Write your first LinkedIn post. Document something you built or learned this week. Keep it honest and specific. 150–200 words is enough.</td>
</tr>
<tr>
<td>Month 2</td>
<td>Join one community. Introduce yourself. Answer one question per week.</td>
</tr>
<tr>
<td>Month 3</td>
<td>Post consistently once per week. Engage with others' posts. Start appearing in recruiter searches.</td>
</tr>
</tbody></table>
<p>By month three, recruiters searching for "DevOps" in your location will encounter your activity. Some of the best entry-level DevOps opportunities come from exactly this kind of low-pressure visibility.</p>
<h2 id="heading-factor-7-ownership-mindset">Factor 7: Ownership Mindset</h2>
<p>This factor is less about personality type and more about observable behavior. Hiring managers are looking for evidence that you finish what you start not just that you start things.</p>
<p>Here's what the contrast looks like:</p>
<table>
<thead>
<tr>
<th>What hiring managers frequently see</th>
<th>What hiring managers want to see</th>
</tr>
</thead>
<tbody><tr>
<td>"I started a Kubernetes project and encountered a lot of issues"</td>
<td>"Here is a complete project. It deploys to AWS, has a CI/CD pipeline, is monitored, and you can access it at this URL right now."</td>
</tr>
<tr>
<td>"I was working through a Terraform course, learnt a lot about XYZ."</td>
<td>"I finished it, documented it, and wrote a post about what I learned."</td>
</tr>
</tbody></table>
<p>Ownership mindset has three components. First, finish things: a complete, simple project is worth ten times more than ten incomplete complex ones. Second, take responsibility without blame when something breaks: ownership means identifying the cause, fixing it, and adding monitoring so it doesn't happen again. Third, self-direct your learning you don't wait for someone to tell you what to learn next. You see a gap, identify how to close it, and close it. This is what "junior who can work independently" actually means in job descriptions.</p>
<h2 id="heading-factor-8-business-awareness">Factor 8: Business Awareness</h2>
<p>Technical skill gets you in the door. Business awareness keeps you there and accelerates your career.</p>
<p>The core question hiring managers are testing is: <em>can you connect your technical decisions to cost, uptime, and user impact?</em> Infrastructure decisions are business decisions. Cloud costs are typically the second-largest engineering expense at most companies after salaries. A misconfigured auto-scaling group or a forgotten large EC2 instance can burn thousands of dollars overnight.</p>
<p>Here are a few benchmark questions worth being able to answer comfortably:</p>
<ul>
<li><p>If your company has a 99.9% SLA, how many minutes of downtime per month is that? (About 43 minutes.)</p>
</li>
<li><p>If you move workloads from on-demand EC2 instances to Reserved Instances, what's the approximate cost saving? (Around 40–60%.)</p>
</li>
<li><p>If your CI/CD pipeline takes 45 minutes per build and you run 20 builds per day, how much developer wait time does that represent weekly?</p>
</li>
</ul>
<p>Most junior candidates can't answer these fluently in an interview. Candidates who can stand out immediately not because the questions are hard, but because so few people bother to connect infrastructure and business.</p>
<p>The simple habit to build: whenever you describe a technical decision in your project documentation or in an interview, add the business dimension. "I configured auto-scaling" becomes "I configured auto-scaling to handle traffic spikes, which eliminated the cost of over-provisioning and reduced our estimated monthly cloud spend by approximately $X."</p>
<h2 id="heading-factor-9-learning-agility">Factor 9: Learning Agility</h2>
<p>Everyone claims to be a fast learner. It's the most overused phrase in technology job applications. Here's how to make it actually mean something.</p>
<p>Saying "I'm a fast learner" in an interview is table stakes. The question is whether you can prove it. Proof sounds like this: <em>"I had never used GitHub Actions before. I needed a CI/CD pipeline for a project I was building. In 48 hours, I had a working pipeline that runs tests, builds a Docker image, and deploys to AWS."</em></p>
<p>What makes that credible: it names a specific tool, a specific timeframe, and a specific outcome. There is a GitHub repository with a commit history and a working pipeline that a hiring manager can actually look at.</p>
<p>Learning agility is not about knowing many tools shallowly. It's about picking up new tools quickly because you deeply understand the underlying concepts. Tool names change every few years. Concepts networking, automation, observability, reliability do not.</p>
<p>To build a concrete track record of learning agility: once a month, pick one tool you haven't used. Follow its quick-start guide. Build something small. Document what was difficult. Post about it. This is your learning agility portfolio visible, dated, and specific.</p>
<h2 id="heading-your-90-day-action-plan">Your 90-Day Action Plan</h2>
<p>Here is a concrete, sequential plan that takes you from where you are now to your first DevOps interview-ready state.</p>
<h3 id="heading-month-1-build-your-foundation">Month 1: Build Your Foundation</h3>
<p>Focus entirely on Project 1 from the Proof of Work section. Build it completely. Deploy it. Get the live URL. Don't start Project 2 until Project 1 meets all six checklist criteria.</p>
<p>Alongside the build: 30 minutes of Linux and Bash scripting practice daily. This isn't optional, it's the foundation everything else runs on.</p>
<h3 id="heading-month-2-expand-your-execution-and-start-your-visibility">Month 2: Expand Your Execution and Start Your Visibility</h3>
<p>Begin Project 2 (Terraform IaC). Write your first LinkedIn post, it doesn't need to be polished, it needs to be specific. Join one community and introduce yourself.</p>
<h3 id="heading-month-3-complete-the-portfolio-and-document-everything">Month 3: Complete the Portfolio and Document Everything</h3>
<p>Finish all three projects to full checklist standard. Polish every README. Add architecture diagrams. Optimize your GitHub profile, pin your three best repos, write a profile README that describes who you are and what you build, and add links to your live project URLs.</p>
<h3 id="heading-month-4-onward-apply-with-strategy">Month 4 Onward: Apply with Strategy</h3>
<p>Don't start applying before month four. Apply with real proof of work in hand. Target five to ten quality applications per week rather than spraying a hundred. Include your GitHub and your best project's live URL in every application. For roles at companies where you have a community connection, reach out to that person before applying.</p>
<p>Track every application in a spreadsheet: company, role, date applied, status, outcome, notes. After thirty applications, you'll have enough data to see what's working and what isn't.</p>
<p>Here's the full 90-day breakdown:</p>
<table>
<thead>
<tr>
<th>Timeframe</th>
<th>Focus</th>
<th>Milestone</th>
</tr>
</thead>
<tbody><tr>
<td>Week 1-2</td>
<td>Linux fundamentals. Set up GitHub profile. Start Project 1.</td>
<td>Foundation</td>
</tr>
<tr>
<td>Week 3-4</td>
<td>Complete Project 1 CI/CD pipeline. Deploy. Get live URL. Write README.</td>
<td>First Proof of Work</td>
</tr>
<tr>
<td>Month 2</td>
<td>Begin Project 2. First LinkedIn post. Join one community.</td>
<td>Visibility begins</td>
</tr>
<tr>
<td>Month 2-3</td>
<td>Complete Project 2. Scaffold monitoring (Project 3). Post weekly on LinkedIn.</td>
<td>Building momentum</td>
</tr>
<tr>
<td>Month 3</td>
<td>Finish all 3 projects to checklist standard. Polish READMEs and GitHub profile.</td>
<td>Portfolio complete</td>
</tr>
<tr>
<td>Month 4+</td>
<td>Apply strategically. Continue posting and community engagement.</td>
<td>Active job search</td>
</tr>
</tbody></table>
<h2 id="heading-honest-self-assessment-where-do-you-stand">Honest Self-Assessment: Where Do You Stand?</h2>
<p>Go through each statement below. Be completely honest: this is for you, not anyone else.</p>
<table>
<thead>
<tr>
<th>Statement</th>
<th>Action if the answer is No</th>
</tr>
</thead>
<tbody><tr>
<td>I can explain a web request end-to-end (DNS → load balancer → compute → database → logs)</td>
<td>Study Factor 2 until you can draw this from memory</td>
</tr>
<tr>
<td>I have at least one deployed project with a live URL</td>
<td>This is Priority 1. Nothing else matters more right now.</td>
</tr>
<tr>
<td>My best project has a CI/CD pipeline that auto-deploys on push</td>
<td>Add this to your existing project this week</td>
</tr>
<tr>
<td>I have written infrastructure as code (Terraform or CloudFormation)</td>
<td>Project 2 is your next build target</td>
</tr>
<tr>
<td>My projects have READMEs that explain architecture and decisions</td>
<td>Spend one hour today rewriting your README</td>
</tr>
<tr>
<td>I have posted about my learning on LinkedIn in the last 30 days</td>
<td>Post something today, document what you built last week</td>
</tr>
<tr>
<td>I am part of at least one DevOps community</td>
<td>Join r/devops or an AWS Discord server this week</td>
</tr>
<tr>
<td>I can write a Bash script that solves a real automation problem</td>
<td>30 minutes of daily scripting practice for the next 30 days</td>
</tr>
<tr>
<td>I can explain what I built, why I made each decision, and what I'd change</td>
<td>Practice saying this out loud about each project until it's fluent</td>
</tr>
</tbody></table>
<p>Count your "no" answers. Each one is a specific, actionable gap, not a vague sense of being behind. That's the difference between this self-assessment and the anxious feeling of "I'm not ready yet." You're not behind. You just have a prioritized list of what to build next.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Here's what you know now that most beginners still don't:</p>
<p>The gap between you and a DevOps job isn't a gap in certifications, a gap in courses completed, or a gap in the number of tools you've heard about. It's a gap in proof of work, visibility, and the consistency with which you execute.</p>
<p>Hiring managers aren't looking for someone who has watched everything. They're looking for someone who has built something, documented it, deployed it, monitored it, and can clearly explain every decision they made along the way.</p>
<p>The path isn't secret. It's just work. Build two to three complete projects that meet the full checklist. Document everything. Show up consistently in communities and on LinkedIn. Apply with strategy. Iterate based on feedback.</p>
<p>If you want a production-grade reference to support your DevOps journey complete with real Terraform modules, CI/CD workflow templates, infrastructure runbooks, and platform engineering patterns used in real startup environments <a href="https://coachli.co/tolani-akintayo/PR-H4oQS">The Startup DevOps Field Guide</a> was built for exactly this stage of your career.</p>
<p>The information gap between you and your first DevOps role is smaller than you think. The execution gap is where the work is. Start today.</p>
<h2 id="heading-references-and-recommended-resources">References and Recommended Resources</h2>
<ul>
<li><p><a href="https://roadmap.sh/devops">roadmap.sh/devops</a>: The community-maintained DevOps learning roadmap. Use this to sequence what you learn next and avoid random jumps between topics.</p>
</li>
<li><p><a href="https://dora.dev">DORA State of DevOps Report</a>: Free annual report on what DevOps practices actually improve software delivery performance. Gives you the vocabulary hiring managers speak.</p>
</li>
<li><p><a href="https://training.linuxfoundation.org/training/introduction-to-linux/">Linux Foundation - Introduction to Linux</a>: Free introductory Linux course. If the terminal still makes you nervous, start here.</p>
</li>
<li><p><a href="https://itrevolution.com/product/the-phoenix-project/">The Phoenix Project</a>: A business novel about DevOps transformation. Teaches core concepts through story. Gives you vocabulary for business-aware conversations.</p>
</li>
<li><p><a href="http://ExplainShell.com">ExplainShell.com</a>: Paste any command you find online and see exactly what every part does. Use this constantly while building your projects.</p>
</li>
<li><p><a href="https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-readmes">GitHub - How to Write a Good README</a>: Official GitHub guidance on repository documentation.</p>
</li>
<li><p><a href="https://prometheus.io/docs/introduction/overview/">Prometheus Documentation</a>: Official docs for the monitoring tool used in Project 3.</p>
</li>
<li><p><a href="https://developer.hashicorp.com/terraform/tutorials/aws-get-started">Terraform Getting Started - AWS</a>: Official step-by-step guide for Project 2.</p>
</li>
<li><p><a href="https://docs.github.com/en/actions">GitHub Actions Documentation</a>: Complete reference for building CI/CD pipelines in Project 1.</p>
</li>
<li><p><a href="https://www.freecodecamp.org/news/learn-linux-for-beginners-book-basic-to-advanced/">freeCodeCamp - Learn Linux for Beginners</a>: Comprehensive Linux guide available on freeCodeCamp.</p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Deploy a Full-Stack Next.js App on Cloudflare Workers with GitHub Actions CI/CD ]]>
                </title>
                <description>
                    <![CDATA[ I typically build my projects using Next.js 14 (App Router) and Supabase for authentication along with Postgres. The default deployment choice for a Next.js app is usually Vercel, and for good reason: ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-deploy-a-full-stack-next-js-app-on-cloudflare-workers-with-github-actions-ci-cd/</link>
                <guid isPermaLink="false">69f2145e6e0124c05e1a5b6e</guid>
                
                    <category>
                        <![CDATA[ Next.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloudflare ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub Actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Md Tarikul Islam ]]>
                </dc:creator>
                <pubDate>Wed, 29 Apr 2026 14:23:26 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/cbb9e559-baa7-452c-992a-3416041712ad.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I typically build my projects using Next.js 14 (App Router) and Supabase for authentication along with Postgres. The default deployment choice for a Next.js app is usually Vercel, and for good reason: it provides an excellent developer experience.</p>
<p>But after running the same project on both platforms for about a week, I started exploring Cloudflare Workers as an alternative. I noticed improvements in latency (lower TTFB) and found the free tier to be more flexible for my use case.</p>
<p>Deploying Next.js apps on Cloudflare used to be challenging. Earlier solutions like Cloudflare Pages had limitations with full Next.js features, and tools like <code>next-on-pages</code> often lagged behind the latest releases.</p>
<p>That changed with the introduction of <a href="https://opennext.js.org/cloudflare"><code>@opennextjs/cloudflare</code></a>. It allows you to compile a standard Next.js application into a Cloudflare Worker, supporting features like SSR, ISR, middleware, and the Image component – all without requiring major code changes.</p>
<p>In this guide, I’ll walk you through the exact steps I used to deploy my full-stack Next.js + Supabase application to Cloudflare Workers.</p>
<p>This article is the runbook I wish I had when I started.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-choose-cloudflare-workers-over-vercel">Why Choose Cloudflare Workers Over Vercel?</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-stack">The Stack</a></p>
</li>
<li><p><a href="#heading-step-1-install-the-cloudflare-adapter">Step 1 — Install the Cloudflare Adapter</a></p>
</li>
<li><p><a href="#heading-step-2-wire-opennext-into-next-dev">Step 2 — Wire OpenNext into next dev</a></p>
</li>
<li><p><a href="#heading-step-3-local-environment-setup-with-devvars">Step 3— Local Environment Setup with .dev.vars</a></p>
</li>
<li><p><a href="#heading-step-4-deploy-your-app-from-your-local-machine">Step 4 — Deploy Your App from Your Local Machine</a></p>
</li>
<li><p><a href="#heading-step-5-push-your-secrets-to-the-worker">Step 5 — Push your secrets to the Worker</a></p>
</li>
<li><p><a href="#heading-step-6-set-up-continuous-deployment-with-github-actions">Step 6 — Set Up Continuous Deployment with GitHub Actions</a></p>
</li>
<li><p><a href="#heading-step-7-updating-the-project-the-daily-workflow">Step 7 — Updating the project (the daily workflow)</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final thoughts</a></p>
</li>
</ul>
<h2 id="heading-why-choose-cloudflare-workers-over-vercel">Why Choose Cloudflare Workers Over Vercel?</h2>
<p>When deploying a Next.js application, Vercel is often the default choice. It offers a smooth developer experience and tight integration with Next.js.</p>
<p>But Cloudflare Workers provides a compelling alternative, especially when you care about global performance and cost efficiency.</p>
<p>Here’s a high-level comparison (at the time of writing):</p>
<table>
<thead>
<tr>
<th>Concern</th>
<th>Vercel (Hobby)</th>
<th>Cloudflare Workers (Free Tier)</th>
</tr>
</thead>
<tbody><tr>
<td>Requests</td>
<td>Fair usage limits</td>
<td>Millions of requests per day</td>
</tr>
<tr>
<td>Cold starts</td>
<td>~100–300 ms (region-based)</td>
<td>Near-zero (V8 isolates)</td>
</tr>
<tr>
<td>Edge locations</td>
<td>Limited regions for SSR</td>
<td>300+ global edge locations</td>
</tr>
<tr>
<td>Bandwidth</td>
<td>~100 GB/month (soft cap)</td>
<td>Generous / no strict cap on free tier</td>
</tr>
<tr>
<td>Custom domains</td>
<td>Supported</td>
<td>Supported</td>
</tr>
<tr>
<td>Image optimization</td>
<td>Counts toward usage</td>
<td>Available via <code>IMAGES</code> binding</td>
</tr>
<tr>
<td>Pricing beyond free</td>
<td>Starts at ~$20/month</td>
<td>Low-cost, usage-based pricing</td>
</tr>
</tbody></table>
<h3 id="heading-key-takeaways">Key Takeaways</h3>
<ul>
<li><p><strong>Lower latency globally</strong>: Cloudflare runs your app across hundreds of edge locations, reducing response time for users worldwide.</p>
</li>
<li><p><strong>Minimal cold starts</strong>: Thanks to V8 isolates, functions start almost instantly.</p>
</li>
<li><p><strong>Cost efficiency</strong>: The free tier is generous enough for portfolios, blogs, and many small-to-medium apps.</p>
</li>
</ul>
<h3 id="heading-trade-offs-to-consider">Trade-offs to Consider</h3>
<p>Cloudflare Workers use a V8 isolate runtime, not a full Node.js environment. That means:</p>
<ul>
<li><p>Some Node.js APIs like <code>fs</code> or <code>child_process</code> aren't available</p>
</li>
<li><p>Native binaries or certain libraries may not work</p>
</li>
</ul>
<p>That said, for most modern stacks –&nbsp;like Next.js + Supabase + Stripe + Resend – this limitation is rarely an issue.</p>
<p>In short, choose <strong>Vercel</strong> if you want the simplest, plug-and-play Next.js deployment. Choose <strong>Cloudflare Workers</strong> if you want better edge performance and more flexible scaling.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before getting started, make sure you have the following set up. Most of these take only a few minutes:</p>
<ul>
<li><p><strong>Node.js 18+</strong> and <strong>pnpm 9+</strong> (you can also use npm or yarn, but this guide uses pnpm.)</p>
</li>
<li><p>A <strong>Cloudflare account</strong> 👉 <a href="https://dash.cloudflare.com/sign-up">https://dash.cloudflare.com/sign-up</a></p>
</li>
<li><p>A <strong>Supabase account</strong> (if your app uses a database) 👉 <a href="https://supabase.com">https://supabase.com</a></p>
</li>
<li><p>A <strong>GitHub repository</strong> for your project (required later for CI/CD setup)</p>
</li>
<li><p>A <strong>domain name</strong> (optional) – You’ll get a free <code>*.workers.dev</code> URL by default.</p>
</li>
</ul>
<h3 id="heading-install-wrangler-cloudflare-cli">Install Wrangler (Cloudflare CLI)</h3>
<p>We’ll use Wrangler to build and deploy the application:</p>
<pre><code class="language-bash">pnpm add -D wrangler
</code></pre>
<h2 id="heading-the-stack">The Stack</h2>
<p>Here’s the tech stack used in this project:</p>
<ul>
<li><p><strong>Next.js (v14.2.x):</strong> Using the App Router with Edge runtime for both public and dashboard routes</p>
</li>
<li><p><strong>Supabase:</strong> Handles authentication, Postgres database, and Row-Level Security (RLS)</p>
</li>
<li><p><strong>Tailwind CSS</strong> + UI utilities: For styling, along with lightweight animation using Framer Motion</p>
</li>
<li><p><strong>Cloudflare Workers:</strong> Deployment powered by <code>@opennextjs/cloudflare</code> and <code>wrangler</code></p>
</li>
<li><p><strong>GitHub Actions:</strong> Used to automate CI/CD and deployments</p>
</li>
</ul>
<p><strong>Note:</strong> If you're using Next.js <strong>15 or later</strong>, you can remove the<br><code>--dangerouslyUseUnsupportedNextVersion</code> flag from the build script, as it's only required for certain Next.js 14 setups.</p>
<h2 id="heading-step-1-install-the-cloudflare-adapter">Step 1 — Install the Cloudflare Adapter</h2>
<p>From inside your existing Next.js project, install the OpenNext adapter along with Wrangler (Cloudflare’s CLI tool):</p>
<pre><code class="language-bash">pnpm add @opennextjs/cloudflare
pnpm add -D wrangler
</code></pre>
<p>Then add the deploy scripts to <code>package.json</code>:</p>
<pre><code class="language-jsonc">{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start",
    "lint": "next lint",

    "cloudflare-build": "opennextjs-cloudflare build --dangerouslyUseUnsupportedNextVersion",
    "preview":          "pnpm cloudflare-build &amp;&amp; opennextjs-cloudflare preview",
    "deploy":           "pnpm cloudflare-build &amp;&amp; wrangler deploy",
    "upload":           "pnpm cloudflare-build &amp;&amp; opennextjs-cloudflare upload",
    "cf-typegen":       "wrangler types --env-interface CloudflareEnv cloudflare-env.d.ts"
  }
}
</code></pre>
<p>What each script does:</p>
<table>
<thead>
<tr>
<th>Script</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>pnpm cloudflare-build</code></td>
<td>Compiles your Next app into <code>.open-next/</code> (the Worker bundle). No upload.</td>
</tr>
<tr>
<td><code>pnpm preview</code></td>
<td>Builds and runs the Worker locally with <code>wrangler dev</code>. Closest thing to prod.</td>
</tr>
<tr>
<td><code>pnpm deploy</code></td>
<td>Builds and uploads to Cloudflare. <strong>This ships to production.</strong></td>
</tr>
<tr>
<td><code>pnpm upload</code></td>
<td>Builds and uploads a <em>new version</em> without promoting it (for staged rollouts).</td>
</tr>
<tr>
<td><code>pnpm cf-typegen</code></td>
<td>Regenerates <code>cloudflare-env.d.ts</code> types after editing <code>wrangler.jsonc</code>.</td>
</tr>
</tbody></table>
<p><strong>Heads up:</strong> the Pages-based <code>@cloudflare/next-on-pages</code> is a different tool. We are <strong>not</strong> using Pages — we're deploying as a real Worker. Don't mix the two.</p>
<h2 id="heading-step-2-wire-opennext-into-next-dev">Step 2 — Wire OpenNext into <code>next dev</code></h2>
<p>So that <code>pnpm dev</code> can read your Cloudflare bindings (env vars, R2, KV, D1, …) the same way production will, edit <code>next.config.mjs</code>:</p>
<pre><code class="language-js">/** @type {import('next').NextConfig} */
const nextConfig = {};

if (process.env.NODE_ENV !== "production") {
  const { initOpenNextCloudflareForDev } = await import(
    "@opennextjs/cloudflare"
  );
  initOpenNextCloudflareForDev();
}

export default nextConfig;
</code></pre>
<p>We only call it in development so <code>next build</code> stays fast and CI doesn't spin up a Miniflare instance for nothing.</p>
<h2 id="heading-step-3-local-environment-setup-with-devvars">Step 3 — Local Environment Setup with <code>.dev.vars</code></h2>
<p>When working with Cloudflare Workers locally, Wrangler uses a file called <code>.dev.vars</code> to store environment variables (instead of <code>.env.local</code> used by Next.js).</p>
<p>A simple and reliable approach is to keep an example file in your repo and ignore the real one.</p>
<h3 id="heading-example-devvarsexample-committed">Example: <code>.dev.vars.example</code> (committed)</h3>
<pre><code class="language-bash">NEXT_PUBLIC_SUPABASE_URL="https://YOUR-PROJECT-ref.supabase.co"
NEXT_PUBLIC_SUPABASE_ANON_KEY="YOUR-ANON-KEY"
NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL="admin@example.com"
</code></pre>
<h3 id="heading-set-up-your-local-environment">Set Up Your Local Environment</h3>
<p>Run the following commands:</p>
<pre><code class="language-plaintext">cp .dev.vars.example .dev.vars
cp .dev.vars .env.local
</code></pre>
<ul>
<li><p><code>.dev.vars</code> is used by Wrangler (<code>wrangler dev</code>)</p>
</li>
<li><p><code>.env.local</code> is used by Next.js (<code>next dev</code>)</p>
</li>
</ul>
<h3 id="heading-why-use-both-files">Why Use Both Files?</h3>
<ul>
<li><p><code>next dev</code> reads from <code>.env.local</code></p>
</li>
<li><p><code>wrangler dev</code> (used in <code>pnpm preview</code>) reads from <code>.dev.vars</code></p>
</li>
</ul>
<p>Keeping both files in sync ensures your app behaves consistently in development and when running in the Cloudflare runtime.</p>
<h3 id="heading-update-gitignore">Update <code>.gitignore</code></h3>
<p>Make sure these files are ignored:</p>
<pre><code class="language-plaintext">.dev.vars
.env*.local
.open-next
.wrangler
</code></pre>
<h2 id="heading-step-4-deploy-your-app-from-your-local-machine">Step 4 — Deploy Your App from Your Local Machine</h2>
<p>Once <code>pnpm preview</code> is working correctly, you're ready to deploy your application:</p>
<pre><code class="language-bash">pnpm deploy
</code></pre>
<p>Under the hood that runs:</p>
<pre><code class="language-bash">pnpm cloudflare-build &amp;&amp; wrangler deploy
</code></pre>
<p>The first time, Wrangler will:</p>
<ol>
<li><p>Compile your app to <code>.open-next/worker.js</code>.</p>
</li>
<li><p>Upload the script + assets to Cloudflare.</p>
</li>
<li><p>Print your live URL, e.g. <code>https://porfolio.&lt;your-account&gt;.workers.dev</code>.</p>
</li>
</ol>
<p>Open it in a browser. Congratulations — you're on Cloudflare's edge in 330+ cities. The page should be served in <strong>&lt;100 ms</strong> TTFB from anywhere.  </p>
<p><a href="https://portfolio.tarikuldev.workers.dev/">Here's the live version of my own portfolio deployed this way</a></p>
<h2 id="heading-step-5-push-your-secrets-to-the-worker">Step 5 — Push Your Secrets to the Worker</h2>
<p>Local <code>.dev.vars</code> is <strong>not</strong> uploaded by <code>wrangler deploy</code>. You have to push secrets explicitly:</p>
<pre><code class="language-bash">wrangler secret put NEXT_PUBLIC_SUPABASE_URL
wrangler secret put NEXT_PUBLIC_SUPABASE_ANON_KEY
wrangler secret put NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL
</code></pre>
<p>Each command prompts you for the value and stores it encrypted on Cloudflare. Or do it visually:</p>
<blockquote>
<p>Cloudflare Dashboard → <strong>Workers &amp; Pages</strong> → your worker → <strong>Settings</strong> → <strong>Variables and Secrets</strong> → <strong>Add</strong>.</p>
</blockquote>
<p>Important: <code>NEXT_PUBLIC_*</code> vars are inlined into the client bundle at build time, so they also need to be available when pnpm cloudflare-build runs (locally, that's your .env.local; in CI, see Step 10).</p>
<h2 id="heading-step-6-set-up-continuous-deployment-with-github-actions">Step 6 — Set Up Continuous Deployment with GitHub Actions</h2>
<p>Once your local deployment is working, the next step is automating deployments so every push to the <code>main</code> branch updates production automatically.</p>
<p>With this workflow:</p>
<ul>
<li><p>Pull requests will run validation checks</p>
</li>
<li><p>Production deploys only happen after successful builds</p>
</li>
<li><p>Broken code never reaches your live site</p>
</li>
</ul>
<p>Create the following file inside your project:</p>
<p><code>.github/workflows/deploy.yml</code></p>
<pre><code class="language-yaml">name: CI / Deploy to Cloudflare Workers

on:
  push:
    branches: [main]
  pull_request:
    branches: [main]
  workflow_dispatch:

concurrency:
  group: cloudflare-deploy-${{ github.ref }}
  cancel-in-progress: true

jobs:
  verify:
    name: Lint and Build
    runs-on: ubuntu-latest
    timeout-minutes: 10

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 10

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm

      - run: pnpm install --frozen-lockfile
      - run: pnpm lint
      - run: pnpm build
        env:
          NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
          NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
          NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL }}

  deploy:
    name: Deploy to Cloudflare Workers
    needs: verify
    if: github.event_name == 'push' &amp;&amp; github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    timeout-minutes: 15

    steps:
      - uses: actions/checkout@v4

      - uses: pnpm/action-setup@v4
        with:
          version: 10

      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      - name: Build and Deploy
        run: pnpm run deploy
        env:
          CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
          NEXT_PUBLIC_SUPABASE_URL: ${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
          NEXT_PUBLIC_SUPABASE_ANON_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
          NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL: ${{ secrets.NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL }}
</code></pre>
<h3 id="heading-required-github-repo-secrets">Required GitHub repo secrets</h3>
<p>Go to GitHub repo → Settings → Secrets and variables → Actions → New repository secret and add:</p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Where to get it</th>
</tr>
</thead>
<tbody><tr>
<td><code>CLOUDFLARE_API_TOKEN</code></td>
<td><a href="https://dash.cloudflare.com/profile/api-tokens">https://dash.cloudflare.com/profile/api-tokens</a> → "Edit Cloudflare Workers" template</td>
</tr>
<tr>
<td><code>CLOUDFLARE_ACCOUNT_ID</code></td>
<td>Cloudflare dashboard → right sidebar, "Account ID"</td>
</tr>
<tr>
<td><code>CLOUDFLARE_ACCOUNT_SUBDOMAIN</code></td>
<td>Your <code>*.workers.dev</code> subdomain (used only for the deployment URL link)</td>
</tr>
<tr>
<td><code>NEXT_PUBLIC_SUPABASE_URL</code></td>
<td>Supabase project settings</td>
</tr>
<tr>
<td><code>NEXT_PUBLIC_SUPABASE_ANON_KEY</code></td>
<td>Supabase project settings</td>
</tr>
<tr>
<td><code>NEXT_PUBLIC_DASHBOARD_DEFAULT_EMAIL</code></td>
<td>Email pre-filled on <code>/dashboard/login</code></td>
</tr>
</tbody></table>
<p>That's it. Push it to <code>main</code> and it'll go live in about 90 seconds. PRs run lint and build only, so broken code never reaches production.</p>
<h2 id="heading-step-7-updating-the-project-the-daily-workflow">Step 7 — Updating the Project (the Daily Workflow)</h2>
<p>After the initial setup, the loop is boringly simple — which is the whole point. Here's what I actually do day-to-day:</p>
<h3 id="heading-code-change">Code Change</h3>
<pre><code class="language-bash">git checkout -b feat/new-section
# ...edit files...
pnpm dev                # iterate locally
pnpm preview            # final smoke test on the Worker runtime
git commit -am "feat: add new section"
git push origin feat/new-section
</code></pre>
<p>Open a PR and the <strong>verify</strong> that the job runs. Then review, merge, and the deploy it. The job ships to Cloudflare automatically.</p>
<h3 id="heading-updating-env-vars-secrets">Updating env Vars / Secrets</h3>
<pre><code class="language-bash"># Local
nano .dev.vars

# Production
wrangler secret put NEXT_PUBLIC_SUPABASE_URL
# ...etc.
</code></pre>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>When I started this migration, I was nervous about leaving Vercel — the Next.js DX there is genuinely excellent. But the moment you push beyond a hobby site, Cloudflare's economics and edge performance are not close.</p>
<p>With <code>@opennextjs/cloudflare</code>, the developer experience has also caught up: my <code>pnpm dev</code> loop is identical, my <code>pnpm preview</code> mimics production, and <code>git push</code> deploys globally in ~90 seconds.</p>
<p>If you've been holding off because the old Cloudflare Pages + Next.js story was rough, that era is over. Try this runbook on a side project this weekend and see for yourself.</p>
<p>If you found this useful, the full repo is <a href="./">here</a> — feel free to clone it as a starter.</p>
<p>Happy shipping.</p>
<p>— <em>Tarikul</em></p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
