<?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[ Devops - 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[ Devops - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Thu, 20 Aug 2026 07:23:13 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/devops/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Production-Ready DevSecOps Platform from Homelab to AWS [Full Book] ]]>
                </title>
                <description>
                    <![CDATA[ In this book, you'll build a fintech transaction ledger from scratch and progressively transform it into a production-ready DevSecOps platform. You'll also deploy it on AWS. The app processes credit a ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-production-ready-devsecops-platform-from-homelab-to-aws-full-book/</link>
                <guid isPermaLink="false">6a67a3c3a26e578cabe00161</guid>
                
                    <category>
                        <![CDATA[ DevSecOps ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ book ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Osomudeya Zudonu ]]>
                </dc:creator>
                <pubDate>Mon, 27 Jul 2026 18:30:27 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/f545c4cf-df83-4c56-a196-7b57458de9da.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In this book, you'll build a fintech transaction ledger from scratch and progressively transform it into a production-ready DevSecOps platform. You'll also deploy it on AWS.</p>
<p>The app processes credit and debit transactions, fires compliance alerts, and stores everything in a database. You'll build the infrastructure around it yourself: automation, scanning, policy enforcement, secrets management, threat detection, and observability.</p>
<p>By the time you're finished, you'll be able to talk through every decision in an interview because you made each one.</p>
<p>This guide doesn't hand you a pre-built solution. It makes you feel out. and understand each problem before introducing the tool that solves it.</p>
<p>All the code, manifests, scripts, and stage-by-stage READMEs live in the companion repository. Clone it before you start:</p>
<pre><code class="language-bash">git clone https://github.com/Osomudeya/clearledger.git
cd clearledger
</code></pre>
<p>Everything in this book refers to files inside that repo.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You'll need these tools installed on your machine before Stage 0:</p>
<ul>
<li><p><strong>Multipass:</strong> creates a lightweight Ubuntu VM so Kubernetes has enough resources</p>
</li>
<li><p><strong>kubectl:</strong> talks to your Kubernetes cluster from your terminal</p>
</li>
<li><p><strong>Helm:</strong> installs apps into Kubernetes</p>
</li>
<li><p><strong>Docker Desktop:</strong> builds container images</p>
</li>
<li><p><strong>jq:</strong> formats JSON output so it's readable</p>
</li>
</ul>
<p>You'll also need free accounts on GitHub and Docker Hub.</p>
<p>And you should be comfortable with the following knowledge and skills:</p>
<ul>
<li><p><strong>Basic Linux command line:</strong> navigating directories, reading files, running scripts</p>
</li>
<li><p><strong>Git:</strong> clone, commit, push</p>
</li>
<li><p>What a container is and roughly how Docker builds one</p>
</li>
</ul>
<p>You don't need prior Kubernetes, security, or cloud experience. This guide builds that from Stage 0.</p>
<p>Your machine needs at least 24 GB of RAM, 6 CPU cores, and 80 GB of free disk space. See <a href="#heading-how-to-set-up-your-machine">How to Set Up Your Machine</a> for the exact install commands.</p>
<p>The companion repo is at <a href="https://github.com/Osomudeya/clearledger">github.com/Osomudeya/clearledger</a>. Star it, clone it, then continue.</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-what-you-are-building">What You Are Building</a></p>
</li>
<li><p><a href="#heading-how-to-work-through-this-lab">How to Work Through This Lab</a></p>
</li>
<li><p><a href="#heading-tools-you-will-use">Tools You Will Use</a></p>
</li>
<li><p><a href="#heading-how-to-choose-your-path">How to Choose Your Path</a></p>
</li>
<li><p><a href="#heading-how-to-save-your-progress">How to Save Your Progress</a></p>
</li>
<li><p><a href="#heading-who-this-is-for">Who This Is For</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-your-machine">How to Set Up Your Machine</a></p>
</li>
<li><p><a href="#heading-how-to-start-the-lab">How to Start the Lab</a></p>
</li>
<li><p><a href="#heading-how-to-manage-disk-space">How to Manage Disk Space</a></p>
</li>
<li><p><a href="#heading-how-to-try-the-app-without-kubernetes">How to Try the App Without Kubernetes</a></p>
</li>
<li><p><a href="#heading-how-to-configure-local-domain-names">How to Configure Local Domain Names</a></p>
</li>
<li><p><a href="#heading-stage-0-the-running-system">Stage 0 — The Running System</a></p>
</li>
<li><p><a href="#heading-stage-1-ci-pipeline-github-actions-self-hosted-runner">Stage 1 — CI Pipeline (GitHub Actions + Self-Hosted Runner)</a></p>
</li>
<li><p><a href="#heading-stage-2-gitops-with-argocd">Stage 2 — GitOps with ArgoCD</a></p>
</li>
<li><p><a href="#heading-stage-3-security-gates">Stage 3 — Security Gates</a></p>
</li>
<li><p><a href="#heading-stage-4-admission-control-kyverno">Stage 4 — Admission Control (Kyverno)</a></p>
</li>
<li><p><a href="#heading-stage-5-secrets-management-vault">Stage 5: Secrets Management (Vault)</a></p>
</li>
<li><p><a href="#heading-stage-6-runtime-security-falco">Stage 6 — Runtime Security (Falco)</a></p>
</li>
<li><p><a href="#heading-stage-65-chaos-engineering-optional">Stage 6.5 — Chaos Engineering (Optional)</a></p>
</li>
<li><p><a href="#heading-stage-7-security-observability">Stage 7 — Security Observability</a></p>
</li>
<li><p><a href="#heading-stage-75-opentelemetry-optional">Stage 7.5 — OpenTelemetry (Optional)</a></p>
</li>
<li><p><a href="#heading-stage-8-aws-migration">Stage 8 — AWS Migration</a></p>
</li>
<li><p><a href="#heading-troubleshooting-see-troubleshootingmd">Troubleshooting (see troubleshooting.md)</a></p>
</li>
<li><p><a href="#heading-compliance-reference">Compliance Reference</a></p>
</li>
<li><p><a href="#heading-interview-preparation">Interview Preparation</a></p>
</li>
<li><p><a href="#heading-aws-cost-reference">AWS Cost Reference</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-you-are-building">What You Are Building</h2>
<p>ClearLedger is a fintech transaction ledger built with three FastAPI microservices, PostgreSQL, Redis, and a web frontend.</p>
<p>Users can register, sign in, record credit and debit transactions, view their account balance, and receive compliance alerts whenever a transaction exceeds a predefined threshold.</p>
<p>The application is intentionally simple. Its purpose isn't to teach fintech. It gives you a realistic system that you'll secure and operate like a production platform.</p>
<p>The project consists of four components:</p>
<ul>
<li><p><strong>auth-service:</strong> Handles user registration, login, and JWT authentication.</p>
</li>
<li><p><strong>ledger-service:</strong> Processes transactions, maintains account balances, and stores transaction history.</p>
</li>
<li><p><strong>notification-service:</strong> Listens for large transactions through Redis and generates compliance alerts.</p>
</li>
<li><p><strong>frontend:</strong> A web interface for logging in, viewing balances, submitting transactions, and reviewing alerts.</p>
</li>
</ul>
<p>By the end of this book, every one of these services will still exist. What changes is how they are built, deployed, secured, and operated.</p>
<p>The application is simply the vehicle. DevSecOps is the destination.</p>
<h3 id="heading-how-the-platform-evolves">How the Platform Evolves</h3>
<p>You won't install every tool on day one. Instead, the platform grows the same way production systems usually do: a problem appears first, then a solution is introduced.</p>
<p>You'll begin with a manually deployed Kubernetes application. From there, each stage solves one real operational problem.</p>
<p><strong>Stage 0: Raw Kubernetes</strong></p>
<p>You'll deploy and run the application manually, which lets you understand the system before introducing automation.</p>
<p><strong>Stage 1: Continuous Integration</strong></p>
<p>Building container images becomes automatic whenever code is pushed, eliminating manual build steps.</p>
<p><strong>Stage 2: GitOps</strong></p>
<p>Deployments are no longer done with kubectl. Git becomes the single source of truth, preventing configuration drift.</p>
<p><strong>Stage 3: Security Gates</strong></p>
<p>Every commit passes through security scanning so vulnerable code, secrets, and misconfigurations are stopped before deployment.</p>
<p><strong>Stage 4: Admission Control</strong></p>
<p>Even if something bypasses the pipeline, Kubernetes policies prevent insecure workloads from entering the cluster.</p>
<p><strong>Stage 5: Secrets Management</strong></p>
<p>Application credentials move out of Kubernetes Secrets into Vault, removing sensitive data from Git and cluster storage.</p>
<p><strong>Stage 6: Runtime Security</strong></p>
<p>Falco continuously watches running containers and detects suspicious behavior after deployment.</p>
<p><strong>Stage 6.5 (Optional): Chaos Engineering</strong></p>
<p>Failures are introduced deliberately to verify that the platform can recover instead of simply detecting problems.</p>
<p><strong>Stage 7: Observability</strong></p>
<p>Metrics, logs, and dashboards provide visibility into the health, performance, and security of the platform.</p>
<p><strong>Stage 7.5 (Optional): OpenTelemetry</strong></p>
<p>Distributed tracing follows requests across every service, revealing how a single transaction moves through the system.</p>
<p><strong>Stage 8: AWS Migration</strong></p>
<p>The same architecture is deployed on AWS using EKS, ECR, RDS, and an Application Load Balancer without changing how the application itself works.</p>
<p>If you simply want to explore the application before touching Kubernetes, an optional Docker Compose stack lets you run everything locally on your machine.</p>
<p>The guiding principle of this book is simple: every stage makes you feel the problem before introducing the tool that solves it.</p>
<h2 id="heading-how-to-work-through-this-lab">How to Work Through This Lab</h2>
<p>Throughout this process of understanding each problem before introducing the tool that solves it, three habits will carry you through every stage.</p>
<ol>
<li><p><strong>Read first before you run:</strong> The paragraphs before each command explain <em>why</em> you're running it. Skipping them means you can reproduce the steps but not explain them, and explaining them is what gets you hired. The commands are proof you understand.</p>
</li>
<li><p><strong>Choose with a reason:</strong> Every tool here solves a specific problem. Why use Vault instead of Kubernetes Secrets? Why split code and manifests into two repos? Don't just follow the steps: ask <em>what breaks if we skip this?</em> If you understand the problem, you'll remember the solution.</p>
</li>
<li><p><strong>Go in order and verify every checkpoint:</strong> Each stage depends on the one before it. When you hit an issue, read the error. Getting stuck and debugging is part of the learning: employers want to hear "I hit X error and fixed it by doing Y."</p>
</li>
</ol>
<p>At every ✋ Hands-on checkpoint:</p>
<ol>
<li><p>Run the command.</p>
</li>
<li><p>Compare your output with Expected.</p>
</li>
<li><p>If it doesn't match, fix it before continuing.</p>
</li>
<li><p>When <code>make check-N</code> passes: <code>make snapshot STAGE=N &amp;&amp; make snapshots</code>. Only continue after you see <code>clearledger.stageN</code>.</p>
</li>
</ol>
<p>Avoid these mistakes:</p>
<ul>
<li><p>Don't skip a checkpoint because it passed before.</p>
</li>
<li><p>Don't run <code>make restore</code> without checking available snapshots first.</p>
</li>
<li><p>Replace <code>your-username</code> with your real Docker Hub or GitHub username everywhere it appears.</p>
</li>
<li><p>Run runner commands inside the VM (prompt shows <code>ubuntu@clearledger</code>), not on your Mac.</p>
</li>
</ul>
<p>Take screenshots at each <strong>portfolio checkpoint</strong>. These moments become your evidence: proof that the platform runs, detects, blocks, syncs, and observes real activity.</p>
<h2 id="heading-tools-you-will-use">Tools You Will Use</h2>
<p>Come back to the below table when a new name appears and you wonder <em>why now</em>. Each entry is one line: what it does and when it appears.</p>
<p><strong>On your laptop:</strong></p>
<ul>
<li><p>Multipass creates the Ubuntu VM.</p>
</li>
<li><p>Docker builds images.</p>
</li>
<li><p><code>make</code> wraps long commands into <code>make setup</code> / <code>make check-N</code>.</p>
</li>
<li><p><code>/etc/hosts</code> entries like <code>clearledger.local</code> let your browser reach the cluster.</p>
</li>
</ul>
<p><strong>The app:</strong></p>
<ul>
<li><p>Three Python APIs (auth, ledger, notifications) + a web frontend.</p>
</li>
<li><p>Postgres stores data</p>
</li>
<li><p>Redis lets ledger publish alerts without calling notification directly</p>
</li>
<li><p>nginx ingress routes browser traffic to the right service.</p>
</li>
</ul>
<table>
<thead>
<tr>
<th>Tool</th>
<th>One-line role</th>
<th>Stage</th>
</tr>
</thead>
<tbody><tr>
<td>MicroK8s / kubectl</td>
<td>Kubernetes cluster inside the VM: <code>kubectl</code> talks to it.</td>
<td>0</td>
</tr>
<tr>
<td><code>clearledger</code> (repo)</td>
<td>App code + CI workflow: what you build.</td>
<td>1</td>
</tr>
<tr>
<td><code>clearledger-infra</code> (repo)</td>
<td>Kubernetes YAML only: what the cluster should run. CI updates it, ArgoCD deploys it.</td>
<td>1</td>
</tr>
<tr>
<td>GitHub Actions + self-hosted runner</td>
<td>Builds images and updates infra repo on every push. Runner lives in the VM to reach the local cluster.</td>
<td>1</td>
</tr>
<tr>
<td>ArgoCD</td>
<td>Watches <code>clearledger-infra</code>, syncs the cluster to match Git, reverts unauthorized changes.</td>
<td>2</td>
</tr>
<tr>
<td>Gitleaks</td>
<td>Blocks commits that contain secrets (API keys, tokens).</td>
<td>3</td>
</tr>
<tr>
<td>Semgrep</td>
<td>SAST: catches unsafe Python patterns (injection, hardcoded credentials).</td>
<td>3</td>
</tr>
<tr>
<td>Checkov</td>
<td>IaC scanning: misconfigs in Dockerfiles and Kubernetes YAML.</td>
<td>3</td>
</tr>
<tr>
<td>Trivy</td>
<td>Image scanning: known CVEs in pip/npm packages and the built container.</td>
<td>3</td>
</tr>
<tr>
<td>Syft + Grype</td>
<td>SBOM generation and vulnerability check on the artifact itself.</td>
<td>3</td>
</tr>
<tr>
<td>Cosign</td>
<td>Signs container images: Stage 4 rejects unsigned ones at deploy time.</td>
<td>3</td>
</tr>
<tr>
<td>Kyverno</td>
<td>Admission control: blocks non-compliant pods at the cluster gate (root containers, missing limits, unsigned images).</td>
<td>4</td>
</tr>
<tr>
<td>Vault</td>
<td>Stores credentials outside Git and etcd: injects them into pods via a sidecar at startup.</td>
<td>5</td>
</tr>
<tr>
<td>Falco</td>
<td>eBPF runtime detection: alerts when a shell starts or a sensitive file is read inside a running container.</td>
<td>6</td>
</tr>
<tr>
<td>Network policies</td>
<td>Kubernetes firewall between pods: limits blast radius if one service is compromised.</td>
<td>6</td>
</tr>
<tr>
<td>LitmusChaos</td>
<td>Kills pods deliberately to prove the app recovers (optional).</td>
<td>6.5</td>
</tr>
<tr>
<td>Prometheus / Grafana / Loki</td>
<td>Metrics, dashboards, and log search: turns security events into evidence.</td>
<td>7</td>
</tr>
<tr>
<td>OpenTelemetry + Tempo</td>
<td>Distributed traces: shows where one request spent its time across services (optional).</td>
<td>7.5</td>
</tr>
<tr>
<td>Terraform / EKS / ECR / RDS</td>
<td>Infrastructure as code for the AWS migration: same app, cloud-managed backing services.</td>
<td>8</td>
</tr>
</tbody></table>
<p>Each stage adds a new security layer. The tools aren't interchangeable: scanners check your code and images before deployment, ArgoCD keeps the cluster synced to Git, Vault handles secrets, Kyverno blocks unsafe workloads before they run, and Falco watches for suspicious behavior after they're running.</p>
<p>That's why the order matters: you're building defense in depth, one layer at a time.</p>
<h2 id="heading-how-to-choose-your-path">How to Choose Your Path</h2>
<p>Pick one path from your host RAM before you provision a cluster. Switching mid-lab after OOM kills or disk pressure wastes a day, so choose upfront.</p>
<table>
<thead>
<tr>
<th>Your situation</th>
<th>Path</th>
<th>What you get</th>
</tr>
</thead>
<tbody><tr>
<td><strong>8 GB RAM</strong>, or unsure this laptop can carry the lab</td>
<td><strong>Docker Compose first</strong></td>
<td>The real app: register, post a transaction, see the compliance alert fire. Then decide on a cluster. <code>make integration-up</code> · <a href="#heading-how-to-try-the-app-without-kubernetes">Local integration stack</a></td>
</tr>
<tr>
<td><strong>16 GB RAM</strong> on the host</td>
<td><strong>Lite local cluster</strong> (Stages 0–5)</td>
<td><strong>Running on one VM:</strong> This setup includes Kubernetes, CI/CD, GitOps, security checks, admission control, and Vault. To use fewer resources, edit <code>scripts/setup-cluster.local.env</code> before running <code>make setup</code>.</td>
</tr>
<tr>
<td><strong>Under 16 GB</strong> host RAM and you need Kubernetes, or you want all 8 stages</td>
<td><strong>Cloud VM</strong></td>
<td>Provision a remote machine (4–8 vCPU, 16–32 GB RAM), clone the repo, run the lab there, <code>make teardown</code> when done. Stages 6.5 / 7 / 7.5 (chaos + full observability) need 24 GB on the host, use this path if your laptop cannot spare that.</td>
</tr>
</tbody></table>
<p>The default path in this guide assumes 24 GB+ RAM and the full local VM (Before You Start). If that's not you, start from the row that matches your machine.</p>
<h2 id="heading-how-to-save-your-progress">How to Save Your Progress</h2>
<p><strong>Mac + Multipass only:</strong> <code>make snapshot</code> and <code>make restore</code> require Multipass. If you're using Linux without Multipass, skip snapshots and use Path B if something goes wrong.</p>
<p>This lab takes several days to complete.</p>
<p>Your source code lives on your computer, so rebuilding or deleting the VM doesn't delete your Git repository, commits, manifests, or configuration files.</p>
<p>The VM stores your running environment, including deployed pods, Vault secrets, Postgres data, and Grafana dashboards.</p>
<h3 id="heading-save-your-progress">Save Your Progress</h3>
<p>After completing each stage, create a snapshot before moving on. For example:</p>
<pre><code class="language-bash">make snapshot STAGE=7
make snapshots
</code></pre>
<p>Always run <code>make snapshots</code> to confirm the snapshot was created.</p>
<h3 id="heading-restore-your-progress">Restore Your Progress</h3>
<p>If the VM becomes unusable after a while, restore the latest working snapshot:</p>
<pre><code class="language-bash">make snapshots
make restore STAGE=7

export KUBECONFIG=~/.kube/clearledger-config
make check-7
</code></pre>
<h3 id="heading-what-happens-if-the-vm-breaks">What Happens If the VM Breaks?</h3>
<p>You keep:</p>
<ul>
<li><p>Your Git repository</p>
</li>
<li><p>Your commits</p>
</li>
<li><p><code>.env</code></p>
</li>
<li><p><code>setup-cluster.local.env</code></p>
</li>
<li><p><code>clearledger-infra</code> on GitHub</p>
</li>
</ul>
<p>You lose anything stored inside the VM after your last snapshot, including:</p>
<ul>
<li><p>Running pods</p>
</li>
<li><p>Vault secrets</p>
</li>
<li><p>Postgres data</p>
</li>
<li><p>Grafana and Loki data</p>
</li>
</ul>
<p>That's why it's a good idea to create a snapshot after every completed stage.</p>
<h4 id="heading-path-a-you-have-a-snapshot-recommended">Path A: You Have a Snapshot (Recommended)</h4>
<p>Restore the latest working snapshot and continue from that stage.</p>
<pre><code class="language-bash">make snapshots
make restore STAGE=6

export KUBECONFIG=~/.kube/clearledger-config
make check-6
</code></pre>
<h4 id="heading-path-b-no-snapshot">Path B: No Snapshot</h4>
<p>Rebuild the lab.</p>
<pre><code class="language-bash">make teardown
make setup

export KUBECONFIG=~/.kube/clearledger-config
</code></pre>
<p>Your Git repositories are still intact, but the Kubernetes cluster starts empty. Continue the book from the stage you had reached and rebuild the platform from there.</p>
<p>If you run into problems such as disk space issues, failed snapshots, Mac sleep or restart problems, Vault authentication errors, or pods stuck in CrashLoopBackOff, see <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md</a> for detailed recovery steps.</p>
<h2 id="heading-who-this-is-for">Who This Is For</h2>
<p><strong>Junior DevOps (0–2 yrs):</strong> do every stage in order. Don't skip the pain point sections. Expect Stage 0–2 to take a full day each, Stages 3–7 half a day each, Stage 8 a few hours. That's normal, so don't rush.</p>
<p><strong>Mid-level DevOps (2–4 yrs):</strong> skim Stages 0–2 to understand the app, focus time on Stages 3–7 where the security layers are.</p>
<p><strong>Interview preparation:</strong> complete through Stage 4, then read <code>docs/interview-prep.md</code>. The questions are based on exactly what's in this lab.</p>
<h2 id="heading-how-to-set-up-your-machine">How to Set Up Your Machine</h2>
<p>Requirements are in <a href="#heading-prerequisites">Prerequisites</a> above. Confirm 24 GB RAM, 6 CPU cores, and 80 GB free disk before installing.</p>
<h3 id="heading-install-the-required-tools">Install the Required Tools</h3>
<table>
<thead>
<tr>
<th>Tool</th>
<th>What it does</th>
<th>macOS</th>
<th>Linux</th>
<th>Windows</th>
</tr>
</thead>
<tbody><tr>
<td>Multipass</td>
<td>Creates lightweight Ubuntu VMs on your laptop</td>
<td><code>brew install --cask multipass</code></td>
<td><code>sudo snap install multipass</code></td>
<td><a href="https://multipass.run/install">multipass.run/install</a></td>
</tr>
<tr>
<td>kubectl</td>
<td>Talks to your Kubernetes cluster from your terminal</td>
<td><code>brew install kubectl</code></td>
<td><code>sudo snap install kubectl --classic</code></td>
<td><code>winget install Kubernetes.kubectl</code></td>
</tr>
<tr>
<td>Helm</td>
<td>Package manager for Kubernetes (like apt/brew but for cluster apps)</td>
<td><code>brew install helm</code></td>
<td><code>sudo snap install helm --classic</code></td>
<td><code>winget install Helm.Helm</code></td>
</tr>
<tr>
<td>Docker Desktop</td>
<td>Builds container images on your machine</td>
<td><a href="https://docs.docker.com/desktop/">docker.com</a></td>
<td><a href="https://docs.docker.com/engine/install/">docker.com</a></td>
<td><a href="https://docs.docker.com/desktop/">docker.com</a></td>
</tr>
<tr>
<td>jq</td>
<td>Formats JSON output so you can read it</td>
<td><code>brew install jq</code></td>
<td><code>sudo apt install jq</code></td>
<td><code>winget install jqlang.jq</code></td>
</tr>
</tbody></table>
<p><strong>Windows users:</strong> Run all commands inside WSL2 Ubuntu. Don't use PowerShell for this lab because the setup uses <code>make</code> and Bash scripts.</p>
<p>Verify everything before continuing:</p>
<pre><code class="language-bash">multipass --version
kubectl version --client
helm version
docker --version
jq --version
</code></pre>
<p>If any command fails, install the missing tool before continuing.</p>
<h2 id="heading-how-to-start-the-lab">How to Start the Lab</h2>
<p>The main lab path starts at <a href="#heading-stage-0-the-running-system">Stage 0: the Running System</a>.</p>
<p>After you have run the setup once step by step, you can use this shortcut next time:</p>
<pre><code class="language-bash">make setup
export KUBECONFIG=~/.kube/clearledger-config
kubectl get nodes
</code></pre>
<p>Expected: one node named <code>clearledger</code> with STATUS <code>Ready</code>.</p>
<p><code>make setup</code> provisions the Multipass VM, installs MicroK8s, applies disk-safety caps, and updates <code>/etc/hosts</code>. Takes 3–5 minutes.</p>
<h2 id="heading-how-to-manage-disk-space">How to Manage Disk Space</h2>
<p>The lab runs on a single-node MicroK8s VM with a fixed disk (80 GB by default). Over days or weeks (especially after CI builds, Helm upgrades, and Stage 7 observability) container images, logs, and journald can fill the root filesystem. Pods then fail with <code>Evicted</code>, <code>ImagePullBackOff</code>, or mysterious <code>Pending</code> states.</p>
<p><code>make setup</code> applies preventive caps automatically (log rotation, image GC thresholds, journald cap). See <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">Disk health in troubleshooting.md</a> for the full table and recovery steps.</p>
<p><strong>Check disk health:</strong></p>
<pre><code class="language-bash">make doctor    # PASS / WARN / FAIL + PVC and Prometheus TSDB sizes
</code></pre>
<p><strong>Clean up unused files inside the VM without deleting app data:</strong></p>
<pre><code class="language-bash">make reclaim
</code></pre>
<p>If <code>make doctor</code> still reports FAIL after reclaim, you may need <code>make teardown &amp;&amp; make setup</code> and restore from a snapshot. Full guidance: <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md. VM disk full</a>.</p>
<h2 id="heading-how-to-try-the-app-without-kubernetes">How to Try the App Without Kubernetes</h2>
<p>If your machine doesn't have enough resources for Kubernetes, you can run ClearLedger with Docker Compose.</p>
<pre><code class="language-bash">docker compose -f docker-compose.integration.yml up --build -d
</code></pre>
<p>Open <a href="http://localhost:3000">http://localhost:3000</a>.</p>
<p>When you're ready, stop the stack and continue with Stage 0.</p>
<pre><code class="language-bash">docker compose -f docker-compose.integration.yml down
</code></pre>
<h3 id="heading-how-to-sign-in-for-the-first-time">How to Sign In for the First Time</h3>
<p>First, you'll need to register. The database starts empty after each fresh <code>up</code> (or <code>down -v</code>). Use a real-looking email (Pydantic rejects <code>@*.local</code>), for example <code>test@clearledger.io</code> for an email and <code>SecurePass123</code> for a password.</p>
<p>Then sign in with the same credentials.</p>
<p>Wrong password shows <em>Incorrect email or password</em>. If you see a stale error, hard-refresh or run <code>localStorage.removeItem('cl_token')</code> in the browser console.</p>
<h3 id="heading-how-to-run-the-demo-flow">How to Run the Demo Flow</h3>
<p>First, register and sign in at <a href="http://localhost:3000">http://localhost:3000</a>. Submit a few credits and debits (for example, Salary +$5000, Rent −$1200).</p>
<p>Then confirm the balance updates and history lists entries.</p>
<p>Now submit a transaction <strong>≥ $10,000</strong>: the Alerts panel should show <code>LARGE_TRANSACTION</code>.</p>
<p>Here's an optional smoke test against the same base URL:</p>
<pre><code class="language-bash">BASE_URL=http://localhost:3000 bash scripts/dast/smoke.sh
</code></pre>
<h2 id="heading-how-to-configure-local-domain-names">How to Configure Local Domain Names</h2>
<p>Add the ClearLedger hostnames to your hosts file.</p>
<h3 id="heading-macos-or-linux-with-multipass">macOS or Linux with Multipass</h3>
<p>Run:</p>
<pre><code class="language-bash">sudo bash scripts/setup-hosts.sh
</code></pre>
<p>Or do it manually:</p>
<pre><code class="language-bash">VMIP=$(multipass info clearledger | grep IPv4 | awk '{print $2}')

echo "$VMIP  clearledger.local argocd.local grafana.local vault.local falco.local litmus.local" | sudo tee -a /etc/hosts
</code></pre>
<p>Verify after Stage 0:</p>
<pre><code class="language-bash">curl -s -o /dev/null -w "%{http_code}\n" http://clearledger.local/auth/health
</code></pre>
<p>Expected: <code>200</code>.</p>
<h3 id="heading-wsl2">WSL2</h3>
<p>Find your WSL IP:</p>
<pre><code class="language-bash">ip -4 addr show eth0 | grep inet
</code></pre>
<p>Use the IP shown (or <code>127.0.0.1</code> if it works on your machine), then add it to <code>/etc/hosts</code>:</p>
<pre><code class="language-bash">LAB_IP=&lt;YOUR_IP&gt;

echo "$LAB_IP  clearledger.local argocd.local grafana.local vault.local falco.local litmus.local" | sudo tee -a /etc/hosts
</code></pre>
<p>If you use Chrome or Edge on Windows instead of inside WSL, add the same line to:</p>
<p><code>C:\Windows\System32\drivers\etc\hosts</code></p>
<p>Verify:</p>
<pre><code class="language-bash">curl http://clearledger.local/auth/health
</code></pre>
<h2 id="heading-stage-0-the-running-system">Stage 0 — The Running System</h2>
<p><strong>Starting point:</strong> Nothing is deployed yet, so you're about to build a Kubernetes cluster and deploy ClearLedger manually.</p>
<p><strong>Goal:</strong> By the end of this stage, ClearLedger will be running on Kubernetes. You'll be able to register a user, submit transactions, and see compliance alerts, all deployed by hand, with no automation.</p>
<p>Every deployment, update, and fix is manual. That's intentional. Before automating a platform, you need to understand how it works without automation.</p>
<h3 id="heading-01-provision-the-cluster">0.1: Provision the Cluster</h3>
<p>Next you'll be creating a virtual machine on your laptop that runs its own Kubernetes cluster. Think of it as a miniature data center inside your computer.</p>
<p>Multipass creates lightweight Ubuntu VMs. MicroK8s is a minimal Kubernetes distribution that runs inside that VM. Together they give you a real cluster without needing cloud resources.</p>
<p><strong>Recommended: one command (do this):</strong></p>
<pre><code class="language-bash">make setup
export KUBECONFIG=~/.kube/clearledger-config
kubectl get nodes
</code></pre>
<p>Expected:</p>
<pre><code class="language-plaintext">NAME          STATUS   ROLES    AGE   VERSION
clearledger   Ready    &lt;none&gt;   2m    v1.29.x
</code></pre>
<p><code>make setup</code> runs <code>scripts/setup-cluster.sh</code> (VM + MicroK8s + disk-safety caps) and <code>scripts/setup-hosts.sh</code> (<code>/etc/hosts</code> entries). It takes 3–5 minutes.</p>
<p>Disk-safety (log rotation, image GC thresholds, journald cap) is configured automatically. See Disk health in <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md</a> for more info.</p>
<p>If STATUS is <code>NotReady</code>, wait 60 seconds and try again.</p>
<p>Here's the manual setup (only if <code>make setup</code> failed and you need to debug step by step):</p>
<pre><code class="language-bash">multipass launch \
  --name clearledger \
  --cpus 6 --memory 12G --disk 80G \
  22.04
</code></pre>
<p>Get the VM IP (needed for <code>/etc/hosts</code>):</p>
<pre><code class="language-bash">multipass info clearledger | grep IPv4
</code></pre>
<p>Add hosts entries. See the <a href="#heading-how-to-configure-local-domain-names">Domain Names</a> section above, or run <code>sudo bash scripts/setup-hosts.sh</code>.</p>
<pre><code class="language-bash">multipass shell clearledger
</code></pre>
<p>Inside the VM:</p>
<pre><code class="language-bash">sudo snap install microk8s --classic --channel=1.29/stable
sudo usermod -aG microk8s ubuntu &amp;&amp; newgrp microk8s
microk8s enable dns ingress storage helm3 rbac
echo "alias kubectl='microk8s kubectl'" &gt;&gt; ~/.bashrc
echo "alias helm='microk8s helm3'" &gt;&gt; ~/.bashrc
source ~/.bashrc
kubectl get nodes
exit   # back to your host machine
</code></pre>
<p>Connect kubectl from your host:</p>
<pre><code class="language-bash">multipass exec clearledger -- microk8s config &gt; ~/.kube/clearledger-config
export KUBECONFIG=~/.kube/clearledger-config
kubectl get nodes
</code></pre>
<h3 id="heading-02-understand-the-application-before-deploying-it">0.2: Understand the Application Before Deploying it</h3>
<p>Open these files before running a single <code>kubectl</code> command. Reading the code first builds context that makes everything else make sense.</p>
<table>
<thead>
<tr>
<th>File</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><a href="../app/auth-service/main.py"><code>app/auth-service/main.py</code></a></td>
<td>Register, login, verify JWT</td>
</tr>
<tr>
<td><a href="../app/ledger-service/main.py"><code>app/ledger-service/main.py</code></a></td>
<td>Transactions, balance, calls auth-service to verify every request</td>
</tr>
<tr>
<td><a href="../app/notification-service/main.py"><code>app/notification-service/main.py</code></a></td>
<td>Subscribes to Redis, fires alerts when amount ≥ $10,000</td>
</tr>
<tr>
<td><a href="../app/frontend/src/app.js"><code>app/frontend/src/app.js</code></a></td>
<td>SPA: calls the same API as the curl commands</td>
</tr>
<tr>
<td><a href="../app/auth-service/Dockerfile"><code>app/auth-service/Dockerfile</code></a></td>
<td>Non-root user, pinned base image, HEALTHCHECK</td>
</tr>
</tbody></table>
<p>Notice this line in every Dockerfile: <code>USER appuser</code>. It means the image is designed to run as a normal user instead of root. The Kubernetes manifests also set <code>runAsNonRoot: true</code>. Later, in Stage 4, Kyverno enforces that rule and rejects pods that don't declare they run as non-root. Your app is prepared early so it passes that policy later.</p>
<p>Also look at <a href="../infra/manifests/auth-service/secret.yaml"><code>infra/manifests/auth-service/secret.yaml</code></a>. The database password is <code>changeme-stage0</code> encoded in base64. Decode it:</p>
<pre><code class="language-bash">echo "Y2hhbmdlbWUtc3RhZ2Uw" | base64 -d
# changeme-stage0
</code></pre>
<p>That password is sitting in a YAML file anyone with repo access can read. base64 is encoding, not encryption. It's trivially reversible. Remember this moment. It's why Stage 5 exists.</p>
<h3 id="heading-03-docker-hub-setup">0.3: Docker Hub Setup</h3>
<p>You need a container registry: a place to store the built images so the cluster can pull them. Docker Hub is the simplest option. You'll replace it with a private registry (ECR) in Stage 8.</p>
<p>Create four public repositories on Docker Hub (free account, hub.docker.com):</p>
<ol>
<li><p>Go to <a href="http://hub.docker.com"><code>hub.docker.com</code></a></p>
</li>
<li><p>Click <strong>Create repository</strong></p>
</li>
<li><p>Choose your Docker Hub username as the namespace</p>
</li>
<li><p>Enter one repository name from the list below</p>
</li>
<li><p>Set visibility to <strong>Public</strong></p>
</li>
<li><p>Click <strong>Create</strong></p>
</li>
<li><p>Repeat for all four services</p>
</li>
</ol>
<pre><code class="language-plaintext">YOUR_USERNAME/clearledger-auth-service
YOUR_USERNAME/clearledger-ledger-service
YOUR_USERNAME/clearledger-notification-service
YOUR_USERNAME/clearledger-frontend
</code></pre>
<p>Next, generate an access token. Go to hub.docker.com, then Account Settings, Security, and New Access Token (Read/Write/Delete). Save it. You won't see it again.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/85562991-e4e7-4d17-8d91-4c4ec2f60114.png" alt="image screenshot guide describing where and how to create access token" style="display:block;margin:0 auto" width="302" height="888" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ad8a99a9-c6e4-41ac-a1c0-6799f731782c.png" alt="Image screenshot showing how to create access token" style="display:block;margin:0 auto" width="1039" height="593" loading="lazy">

<pre><code class="language-bash">docker login
# Username: your Docker Hub username
# Password: the access token (NOT your account password)
</code></pre>
<p>Build and push all four services:</p>
<pre><code class="language-bash"># Replace your-username with your Docker Hub username, the same string everywhere in this lab
export DOCKER_USERNAME=your-username
echo "Using DOCKER_USERNAME=$DOCKER_USERNAME"
</code></pre>
<p><strong>✋ Hands-on checkpoint: Docker Hub username</strong></p>
<pre><code class="language-bash"># Must print your real username, not the literal text "your-username"
echo "$DOCKER_USERNAME"
</code></pre>
<p>Expected: one line with your Docker Hub name (for example, <code>veeno-demo</code>). If you see <code>your-username</code> instead, stop and fix <code>export</code> before building.</p>
<p>Build and push all four services:</p>
<pre><code class="language-bash">docker build -t $DOCKER_USERNAME/clearledger-auth-service:v0.1.0 ./app/auth-service
docker build -t $DOCKER_USERNAME/clearledger-ledger-service:v0.1.0 ./app/ledger-service
docker build -t $DOCKER_USERNAME/clearledger-notification-service:v0.1.0 ./app/notification-service
docker build -t $DOCKER_USERNAME/clearledger-frontend:v0.1.0 ./app/frontend

# Push

docker push $DOCKER_USERNAME/clearledger-auth-service:v0.1.0
docker push $DOCKER_USERNAME/clearledger-ledger-service:v0.1.0
docker push $DOCKER_USERNAME/clearledger-notification-service:v0.1.0
docker push $DOCKER_USERNAME/clearledger-frontend:v0.1.0
</code></pre>
<p><strong>✋ Hands-on checkpoint: images on Docker Hub</strong></p>
<p>Open hub.docker.com and go to your profile, then <strong>Repositories</strong>. Then confirm that all four <code>clearledger-*</code> repos exist and each shows tag <code>v0.1.0</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/fb07dc5f-9db2-4819-810c-89b76b01a0e1.png" alt="screenshot image confirming what docker image repo looks like when done" style="display:block;margin:0 auto" width="776" height="397" loading="lazy">

<p>On your laptop, run:</p>
<pre><code class="language-bash">docker pull $DOCKER_USERNAME/clearledger-auth-service:v0.1.0
</code></pre>
<p>Expected: <code>Status: Downloaded newer image</code> or <code>Image is up to date</code>, not <code>repository does not exist</code> or <code>denied</code>.</p>
<h3 id="heading-04-look-at-the-manifests-before-applying-them">0.4: Look at the Manifests Before Applying Them</h3>
<p>Kubernetes uses <strong>manifest</strong> files (YAML) to describe the resources it should create. Instead of clicking buttons, you declare the desired state, and Kubernetes creates it.</p>
<p>Before deploying ClearLedger, take a quick look at these manifests:</p>
<ul>
<li><p><code>infra/manifests/namespace.yaml</code>: Creates the <code>clearledger</code> namespace.</p>
</li>
<li><p><code>infra/manifests/postgres/</code>: Deploys PostgreSQL.</p>
</li>
<li><p><code>infra/manifests/redis/redis.yaml</code>: Deploys Redis.</p>
</li>
<li><p><code>infra/manifests/auth-service/</code>: Deploys the authentication service.</p>
</li>
<li><p><code>infra/manifests/ledger-service/</code>: Deploys the ledger service.</p>
</li>
<li><p><code>infra/manifests/notification-service/</code>: Deploys the notification service.</p>
</li>
<li><p><code>infra/manifests/frontend/</code>: Deploys the web application.</p>
</li>
<li><p><code>infra/manifests/ingress.yaml</code>: Makes the application available at <code>clearledger.local</code>.</p>
</li>
<li><p><code>infra/manifests/rbac/rbac.yaml</code>: Defines who may do what inside the cluster.</p>
</li>
</ul>
<p>You don't need to understand every field yet. The goal is simply to see how the application is described before Kubernetes creates it.</p>
<p>You'll understand how Ingress routing and RBAC work in the two optional sections after §0.6. For now, just see how the app is described before Kubernetes creates it.</p>
<h3 id="heading-05-deploy-clearledger-layer-by-layer">0.5: Deploy ClearLedger (Layer by Layer)</h3>
<p>Deploy in <strong>six layers</strong>. Finish each layer before starting the next. Run <code>kubectl get pods -n clearledger</code> after layers 2, 3, and 6 to confirm progress.</p>
<p>Set a short path variable and confirm your username is still set:</p>
<pre><code class="language-bash">export DOCKER_USERNAME=your-username   # skip if already set in §0.3
STAGE0=stages/stage-0-raw-kubernetes/infra/manifests
</code></pre>
<h4 id="heading-051-layer-1-namespace-and-rbac">0.5.1 — Layer 1: Namespace and RBAC</h4>
<p>Nothing else can be created until the namespace exists. RBAC also must exist before workloads reference ServiceAccounts.</p>
<pre><code class="language-bash">kubectl apply -f infra/manifests/namespace.yaml
kubectl apply -f infra/manifests/rbac/rbac.yaml
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get namespace clearledger
kubectl get serviceaccount -n clearledger
# Expected: auth-service, ledger-service, notification-service, clearledger-viewer
</code></pre>
<h4 id="heading-052-layer-2-postgresql">0.5.2 — Layer 2: PostgreSQL</h4>
<p>Database must be running before auth-service or ledger-service start. Both services connect to Postgres on startup to run migrations and serve requests, and they'll crash-loop if the database isn't there yet.</p>
<pre><code class="language-bash">kubectl apply -f infra/manifests/postgres/postgres-secret.yaml
kubectl apply -f infra/manifests/postgres/postgres.yaml

kubectl wait --for=condition=ready pod -l app=postgres \
  -n clearledger --timeout=120s
</code></pre>
<p>Expected after <code>kubectl apply</code>:</p>
<pre><code class="language-plaintext">secret/postgres-secret created
persistentvolumeclaim/postgres-pvc created
statefulset.apps/postgres created
service/postgres created
</code></pre>
<p>Expected when <code>kubectl wait</code> succeeds: the command exits with no output (exit code 0). If it times out, see <strong>If Postgres stays Pending</strong> below before continuing.</p>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pods -n clearledger -l app=postgres
kubectl get pvc -n clearledger
</code></pre>
<p>Expected:</p>
<pre><code class="language-plaintext">NAME         READY   STATUS    RESTARTS   AGE
postgres-0   1/1     Running   0          45s

NAME           STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS        AGE
postgres-pvc   Bound    pvc-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx   5Gi        RWO            microk8s-hostpath   45s
</code></pre>
<p><strong>If Postgres stays Pending</strong> (<code>kubectl wait</code> times out, pod shows <code>0/1 Pending</code>, PVC shows <code>Pending</code>):</p>
<p>Postgres needs a <strong>PersistentVolumeClaim</strong>: disk space on the cluster. MicroK8s provides that through the <code>hostpath-storage</code> addon. If <code>make setup</code> was interrupted or you used manual setup without <code>microk8s enable storage</code>, the PVC has nothing to bind to and the pod never schedules.</p>
<p>Check the events: you'll usually see something like:</p>
<pre><code class="language-plaintext">Warning  FailedScheduling  ...  pod has unbound immediate PersistentVolumeClaims
Normal   FailedBinding     ...  no persistent volumes available for this claim and no storage class is set
</code></pre>
<p>Fix it on the VM, then restart the postgres pod. Run this <strong>from your host</strong>: the same command on macOS, Linux, or Windows PowerShell (Multipass is installed on the host. It executes inside the VM for you):</p>
<pre><code class="language-bash"># Enable storage (and ingress/rbac if make setup skipped them)
multipass exec clearledger -- microk8s enable storage ingress rbac

# Confirm a default StorageClass exists
kubectl get storageclass
# Expected: microk8s-hostpath (default)

# Kick the pod so it reschedules against the new storage class
kubectl delete pod postgres-0 -n clearledger

kubectl wait --for=condition=ready pod -l app=postgres \
  -n clearledger --timeout=120s
kubectl get pods -n clearledger -l app=postgres
# Expected: postgres-0   1/1   Running
</code></pre>
<p>Don't continue to auth-service or ledger-service until Postgres is <code>Running</code>. They will crash-loop without a database.</p>
<h4 id="heading-053-layer-3-redis">0.5.3 — Layer 3: Redis</h4>
<p><strong>Why Redis is here (a quick scenario):</strong> Imagine a customer posts a $15,000 debit. Ledger-service saves it to Postgres, then publishes a message to Redis: <em>"large transaction, user X, amount 15000."</em> Notification-service is listening on that channel. It picks up the message and records a compliance alert: the one you'll see in the UI later when you curl <code>/notifications/alerts</code>.</p>
<p>Ledger-service and notification-service don't call each other directly. Redis sits in the middle as a <strong>message bus</strong>: ledger publishes, notification subscribes. That's why Redis must be running before you deploy notification-service (and why you deploy it now, alongside Postgres, before the app layer).</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/f099d734-8b73-483d-a28b-16cd7703703b.png" alt="flow daigram image explaining how redis works" style="display:block;margin:0 auto" width="1536" height="1024" loading="lazy">

<pre><code class="language-bash">kubectl apply -f infra/manifests/redis/redis.yaml
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pods -n clearledger -l app=redis
</code></pre>
<p>Expected:</p>
<pre><code class="language-plaintext">NAME                     READY   STATUS    RESTARTS   AGE
redis-xxxxxxxxxx-xxxxx   1/1     Running   0          30s
</code></pre>
<h4 id="heading-054-layer-4-application-secrets">0.5.4 — Layer 4: Application secrets</h4>
<p>Credentials live in Kubernetes Secrets for Stage 0 (Stage 5 moves them to Vault).</p>
<pre><code class="language-bash">kubectl apply -f infra/manifests/auth-service/secret.yaml
kubectl apply -f infra/manifests/ledger-service/secret.yaml
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get secrets -n clearledger | grep -E 'auth-service|ledger-service'
</code></pre>
<p>Expected (AGE will differ, <strong>DATA</strong> counts must match):</p>
<pre><code class="language-plaintext">auth-service-secret     Opaque   2      64s
ledger-service-secret   Opaque   1      8s
</code></pre>
<p><code>auth-service-secret</code> holds two keys (<code>database_url</code>, <code>jwt_secret</code>). <code>ledger-service-secret</code> holds one (<code>database_url</code>). Stage 5 replaces these with Vault, for now they live in the cluster as Kubernetes Secrets.</p>
<h4 id="heading-055-layer-5-application-workloads">0.5.5 — Layer 5: Application workloads</h4>
<p>You're about to start the four app services: auth, ledger, notification, and frontend. Postgres, Redis, and the Secrets from the last two layers are already in place. Now Kubernetes needs to pull your Docker Hub images and run them as pods.</p>
<p><strong>Two files per service (mostly):</strong> A Deployment tells Kubernetes <em>which container image to run</em> and <em>how many copies</em>. A <strong>Service</strong> gives that app a stable name inside the cluster (for example, <code>auth-service</code> so ledger can find auth without knowing pod IP addresses). You apply the Deployment first, then the Service.</p>
<p>So why are we using the <code>sed</code> command below? The deployment YAML files in Git contain a placeholder: literally the text <code>DOCKER_USERNAME</code>, because everyone's Docker Hub username is different. You already set yours in §0.3 (<code>export DOCKER_USERNAME=YOUR_DOCKERHUB_USERNAME</code>). The <code>sed</code> line swaps that placeholder for your real username on the fly, as the manifest is sent to Kubernetes. You never edit the file in Git. If you skip <code>sed</code> and apply the raw file, Kubernetes tries to pull an image called <code>DOCKER_USERNAME/clearledger-auth-service</code>, which doesn't exist.</p>
<p>Why do we use the Stage 0 folder? This repo has more than one copy of the Kubernetes manifests. For this manual deployment, use <code>stages/stage-0-raw-kubernetes/infra/manifests/</code>. Those files are prepared for Stage 0 and contain the <code>DOCKER_USERNAME</code> placeholder that the commands below replace. Don't use <code>infra/manifests/</code> yet, as those files are for the GitOps stages later.</p>
<p>Deploy each service in order. Run these from the repo root with <code>DOCKER_USERNAME</code> still exported:</p>
<p><strong>1. auth-service</strong>: login and registration</p>
<pre><code class="language-bash">sed "s|DOCKER_USERNAME|${DOCKER_USERNAME}|g" \
  "$STAGE0/auth-service/deployment.yaml" | kubectl apply -f -
kubectl apply -f infra/manifests/auth-service/service.yaml
</code></pre>
<p><strong>2. ledger-service</strong>: transactions and balance (needs Postgres + the secret you created in §0.5.4)</p>
<pre><code class="language-bash">sed "s|DOCKER_USERNAME|${DOCKER_USERNAME}|g" \
  "$STAGE0/ledger-service/deployment.yaml" | kubectl apply -f -
kubectl apply -f infra/manifests/ledger-service/service.yaml
</code></pre>
<p><strong>3. notification-service</strong>: listens on Redis for large-transaction alerts (no database secret in this one)</p>
<pre><code class="language-bash">sed "s|DOCKER_USERNAME|${DOCKER_USERNAME}|g" \
  "$STAGE0/notification-service/deployment.yaml" | kubectl apply -f -
kubectl apply -f infra/manifests/notification-service/service.yaml
</code></pre>
<p><strong>4. frontend</strong>: the web UI (Deployment and Service are in one file here)</p>
<pre><code class="language-bash">sed "s|DOCKER_USERNAME|${DOCKER_USERNAME}|g" \
  "$STAGE0/frontend/deployment.yaml" | kubectl apply -f -
</code></pre>
<p><strong>Verify</strong> (all app pods should reach <code>Running</code>: auth and ledger may take ~30s while they connect to Postgres):</p>
<pre><code class="language-bash">kubectl get pods -n clearledger
</code></pre>
<p>Expected. You should see Postgres and Redis from earlier layers plus new pods for each app (exact pod names vary):</p>
<pre><code class="language-plaintext">NAME                                      READY   STATUS    RESTARTS   AGE
postgres-0                                1/1     Running   0          15m
redis-xxxxxxxxxx-xxxxx                    1/1     Running   0          10m
auth-service-xxxxxxxxxx-xxxxx             1/1     Running   0          45s
auth-service-xxxxxxxxxx-xxxxx             1/1     Running   0          45s
ledger-service-xxxxxxxxxx-xxxxx           1/1     Running   0          40s
ledger-service-xxxxxxxxxx-xxxxx           1/1     Running   0          40s
notification-service-xxxxxxxxxx-xxxxx     1/1     Running   0          35s
frontend-xxxxxxxxxx-xxxxx                 1/1     Running   0          30s
</code></pre>
<p>If auth-service or ledger-service is <code>CrashLoopBackOff</code>, check the logs:</p>
<pre><code class="language-bash">kubectl logs -n clearledger deploy/auth-service --tail=20
</code></pre>
<p><strong>Common cause:</strong> you applied <code>infra/manifests/*/deployment.yaml</code> instead of the Stage 0 files above: logs may show <code>DATABASE_URL is not set</code>. Re-run the <code>sed</code> + <code>kubectl apply</code> commands in this section.</p>
<p><strong>✋ Hands-on checkpoint: workloads before ingress</strong></p>
<pre><code class="language-bash">kubectl get deployment -n clearledger
kubectl get pods -n clearledger --field-selector=status.phase!=Running
</code></pre>
<p>Expected: four Deployments (<code>auth-service</code>, <code>ledger-service</code>, <code>notification-service</code>, <code>frontend</code>) with <code>READY</code> matching desired replicas (auth and ledger show <code>2/2</code>). The second command prints <strong>nothing</strong>: no pods stuck in Pending or CrashLoopBackOff.</p>
<h4 id="heading-056-layer-6-ingress">0.5.6 — Layer 6: Ingress</h4>
<p>Exposes the cluster to <code>http://clearledger.local</code>.</p>
<pre><code class="language-bash">kubectl apply -f infra/manifests/ingress.yaml
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get ingress -n clearledger
curl -s -o /dev/null -w "%{http_code}\n" http://clearledger.local/
# Expected: 200
</code></pre>
<h4 id="heading-057-watch-until-stable">0.5.7: Watch until stable</h4>
<pre><code class="language-bash">kubectl get pods -n clearledger -w
</code></pre>
<p>Expected final state (press Ctrl+C to stop watching once all pods show <code>Running</code>):</p>
<pre><code class="language-plaintext">NAME                                  READY   STATUS    RESTARTS
auth-service-xxx                      1/1     Running   0
auth-service-yyy                      1/1     Running   0
frontend-xxx                          1/1     Running   0
ledger-service-xxx                    1/1     Running   0
ledger-service-yyy                    1/1     Running   0
notification-service-xxx              1/1     Running   0
postgres-0                            1/1     Running   0
redis-xxx                             1/1     Running   0
</code></pre>
<p>Pod stuck in <code>Pending</code> or <code>CrashLoopBackOff</code>? These two commands show you what went wrong:</p>
<pre><code class="language-bash">kubectl describe pod POD_NAME -n clearledger
kubectl logs POD_NAME -n clearledger --previous
</code></pre>
<h3 id="heading-06-verify-the-running-system">0.6: Verify the Running System</h3>
<p>Use <strong>one test account</strong> for both browser and curl so nothing conflicts:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td>Email</td>
<td><code>test@clearledger.io</code></td>
</tr>
<tr>
<td>Password</td>
<td><code>SecurePass123</code></td>
</tr>
</tbody></table>
<p>If you already registered in the browser with a <strong>different</strong> password, either sign in with that password or pick a new email: the curl commands below must use the <strong>same</strong> email and password you actually registered with.</p>
<h4 id="heading-browser-verification-recommended">Browser verification (recommended):</h4>
<p>Open <code>http://clearledger.local</code> in your browser. You should see the ClearLedger login screen.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ba678edb-ed82-4063-b2e3-304bfe31e27c.png" alt="clearledger login screen UI screenshot" style="display:block;margin:0 auto" width="1140" height="1106" loading="lazy">

<p>Click <strong>Register</strong> and create an account with <code>test@clearledger.io</code> / <code>SecurePass123</code> (same as the curl block below. Pydantic rejects obviously fake emails like <code>test@test.com</code>).</p>
<p>Sign in with that email and password. On first login the dashboard auto-seeds demo transactions. Wait a few seconds for them to appear:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/4bf4bd15-ad3b-466c-ba46-8539c2e1536c.png" alt="screenshot of clearledger UI after login" style="display:block;margin:0 auto" width="1141" height="933" loading="lazy">

<p>Look at the <strong>Current Balance</strong> card. It should show a dollar amount with a sparkline chart.</p>
<p>Look at <strong>Transaction History</strong>. You should see entries like "Salary (Acme Corp", "Rent) May 2026", and so on.</p>
<p>And look at the <strong>Alerts</strong> panel at the bottom. You should see <code>LARGE_TRANSACTION</code> alerts with a red badge. Two of the demo transactions exceed $10,000, which triggers the compliance alert automatically.</p>
<p>Then submit your own transaction over $10,000 and watch the alert count increase in real time.</p>
<p><strong>What to look for:</strong></p>
<ul>
<li><p>Balance updates immediately after each transaction</p>
</li>
<li><p>Credits show as green <code>+$</code> amounts, debits show as red <code>−$</code> amounts</p>
</li>
<li><p>The Alerts badge count increases when you submit a transaction ≥ $10,000</p>
</li>
<li><p>Each alert shows the amount, direction, and timestamp</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/05690dde-58de-4453-9a3f-8eee09b33293.png" alt="screenshot of clearledger UI after login and making transactions" style="display:block;margin:0 auto" width="1473" height="1269" loading="lazy">

<p><strong>Take a screenshot of the dashboard showing transactions and at least one alert.</strong> This is the first piece of your portfolio.</p>
<p><strong>Alternatively via curl</strong> (same account: useful if the browser is not cooperating):</p>
<pre><code class="language-bash"># Register (skip if you already registered in the browser with the same email)
curl -s -X POST http://clearledger.local/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"test@clearledger.io","password":"SecurePass123"}' | jq .
</code></pre>
<p>Expected: <code>{"user_id":"...","email":"test@clearledger.io"}</code>, or an error that the email is already registered (fine if you used the browser first).</p>
<pre><code class="language-bash"># Login — save the token (must match the password you registered with)
TOKEN=$(curl -s -X POST http://clearledger.local/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@clearledger.io","password":"SecurePass123"}' \
  | jq -r .access_token)
echo "Token: ${TOKEN:0:30}..."
</code></pre>
<p>If <code>TOKEN</code> is empty or login returns <code>401</code>, your browser password doesn't match: re-register with the table above or use your actual password in the <code>-d</code> JSON.</p>
<pre><code class="language-bash"># Create a large transaction (triggers notification alert)
curl -s -X POST http://clearledger.local/ledger/transactions \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"amount":15000,"direction":"debit","description":"Property payment"}' | jq .
</code></pre>
<p>Expected: a transaction object with <code>id</code>, <code>amount: 15000</code>, <code>direction: "debit"</code>:</p>
<pre><code class="language-bash"># Check balance
curl -s http://clearledger.local/ledger/balance \
  -H "Authorization: Bearer $TOKEN" | jq .
</code></pre>
<pre><code class="language-bash"># Confirm the notification alert fired
curl -s http://clearledger.local/notifications/alerts | jq .
</code></pre>
<p>Expected (curl-only path, no browser demo seed): at least one alert for the $15,000 transaction, for example, <code>{"total":1,"alerts":[{"type":"LARGE_TRANSACTION","amount":15000,...}]}</code>. If you already used the browser, <code>total</code> may be <strong>3 or more</strong> (two demo alerts plus yours), that is also correct.</p>
<p><strong>If you see</strong> <code>{"detail":"Unauthorized"}</code><strong>:</strong> your token has expired. JWTs are short-lived for security. This is intentional. Re-run the login command above to get a fresh token, then retry the failed command.</p>
<p>This only affects the <code>$TOKEN</code> variable in your current terminal session. If you open a new terminal, you need to run the login command again because <code>$TOKEN</code> doesn't persist across sessions.</p>
<pre><code class="language-bash">make check-0
</code></pre>
<h3 id="heading-understanding-ingress-optional">Understanding Ingress (Optional)</h3>
<p>Read this after §0.6 if you want to understand how <code>clearledger.local</code> reaches your pods.</p>
<p>Your cluster runs four application services: frontend, auth-service, ledger-service, and notification-service. Each has an internal <strong>Service</strong> address inside the cluster, but none are reachable from your browser until an <strong>Ingress</strong> routes external traffic.</p>
<p>The Ingress is the front door. When a request hits <code>clearledger.local</code>, Kubernetes looks at the URL path and forwards to the right service. Requests to <code>/auth</code> go to auth-service, <code>/ledger</code> to ledger-service, <code>/notifications</code> to notification-service, and <code>/</code> to the frontend.</p>
<p>Open <a href="./infra/manifests/ingress.yaml"><code>infra/manifests/ingress.yaml</code></a> and read the comments. The API paths use a <strong>rewrite:</strong> <code>/auth/login</code> becomes <code>/login</code> before it reaches auth-service, so backend routes stay simple.</p>
<p>You'll add more hostnames later (<code>grafana.local</code>, <code>argocd.local</code>, and so on): each gets its own Ingress manifest in a later stage. This file is only the ClearLedger app.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/3307ca55-75d4-41be-a161-e741d2349b5e.png" alt="flow diagram explaining ingress, how it works." style="display:block;margin:0 auto" width="1677" height="938" loading="lazy">

<h3 id="heading-understanding-rbac-optional">Understanding RBAC (Optional)</h3>
<p>Ingress controls traffic coming from outside the cluster. RBAC controls permissions inside the cluster.</p>
<p>This file creates identities and permissions for the <code>clearledger</code> namespace.</p>
<p>Open <a href="../infra/manifests/rbac/rbac.yaml"><code>infra/manifests/rbac/rbac.yaml</code></a>. The comments at the top mirror this walkthrough.</p>
<p>A <strong>ServiceAccount</strong> is an identity for a pod. For example, <code>auth-service</code>, <code>ledger-service</code>, and <code>notification-service</code> each get their own identity.</p>
<p>A <strong>Role</strong> says what that identity is allowed to do. In your repo, the app roles are very limited: they can only <code>get</code> and <code>list</code> Kubernetes Endpoints. They can't read Secrets, delete pods, create resources, or access other namespaces.</p>
<p>A <strong>RoleBinding</strong> connects the identity to the permissions. Without the RoleBinding, the Role exists but no pod receives those permissions.</p>
<p>The <code>clearledger-viewer</code> ServiceAccount is for read-only debugging. It can inspect pods, services, endpoints, events, and configmaps, but it can't read Secrets.</p>
<p>The default ServiceAccount is bound to a role with zero permissions. That way, if a pod forgets to set <code>serviceAccountName</code>, it falls back to an identity that can do nothing.</p>
<p>The point is least privilege: even if a pod is compromised, Kubernetes doesn't hand it broad cluster access.</p>
<h3 id="heading-07-why-manual-deploys-cant-be-trusted">0.7: Why Manual Deploys Can't Be Trusted</h3>
<p>In Stage 0, you built and deployed the app by hand. Now you'll make one small code change and deploy it again. This shows the problem with manual deployments: they're hard to track, hard to roll back, and hard to prove. Stages 1 and 2 fix that with CI and GitOps.</p>
<h4 id="heading-step-1-make-a-visible-change">Step 1: Make a visible change.</h4>
<p>Open <code>app/auth-service/main.py</code> and find the <code>/health</code> endpoint. Change the return value so you can tell the new version is running:</p>
<pre><code class="language-python"># Before
return {"status": "ok", "service": settings.service_name}

# After — add a version field
return {"status": "ok", "service": settings.service_name, "version": "0.2.0"}
</code></pre>
<p>Save the file. This simulates a developer shipping a small fix.</p>
<h4 id="heading-step-2-build-push-and-deploy-by-hand">Step 2: Build, push, and deploy by hand.</h4>
<pre><code class="language-bash">docker build -t $DOCKER_USERNAME/clearledger-auth-service:v0.2.0 ./app/auth-service

docker push $DOCKER_USERNAME/clearledger-auth-service:v0.2.0
kubectl set image deployment/auth-service \
  auth-service=$DOCKER_USERNAME/clearledger-auth-service:v0.2.0 \
  -n clearledger
</code></pre>
<p>Wait about 30 seconds for Kubernetes to pull the new image and restart the pods:</p>
<pre><code class="language-bash">kubectl rollout status deployment/auth-service -n clearledger
</code></pre>
<h4 id="heading-step-3-verify-your-change-is-live">Step 3: Verify your change is live.</h4>
<pre><code class="language-bash">curl -s http://clearledger.local/auth/health | jq .
</code></pre>
<p>Expected: <code>{"status":"ok","service":"auth-service","version":"0.2.0"}</code></p>
<p>If you still see the old response without <code>"version"</code>, wait a few more seconds and retry. Kubernetes is still rolling out the new pods.</p>
<h4 id="heading-step-4-notice-what-manual-deploy-doesnt-give-you">Step 4: Notice what manual deploy doesn't give you.</h4>
<p>You deployed a change. It works. But think about what just happened:</p>
<ul>
<li><p><strong>Who deployed this?</strong> There's no record. You ran <code>kubectl</code> from your laptop. If three people have cluster access, no one knows who changed what.</p>
</li>
<li><p><strong>What changed?</strong> The only evidence is the Docker Hub tag <code>v0.2.0</code>. Nothing links that tag to a specific commit or code review.</p>
</li>
<li><p><strong>What if</strong> <code>v0.2.0</code> <strong>is broken?</strong> You would need to remember the previous tag, then run <code>kubectl set image</code> again to roll back. What if you don't remember the tag? What if the previous image was deleted?</p>
</li>
<li><p><strong>What if someone else runs</strong> <code>kubectl apply</code> <strong>with</strong> <code>v0.1.0</code> <strong>while you're pushing</strong> <code>v0.2.0</code><strong>?</strong> The cluster silently reverts to the old version. No error. No notification. You think your fix is live, but it's not.</p>
</li>
<li><p><strong>Where is the audit trail?</strong> Nowhere. In a regulated environment (banking, healthcare, government), you need proof of who deployed what and when. Right now you have nothing.</p>
</li>
</ul>
<p>Manual deploys can work for a demo. But they don't hold up for a team or a regulated environment. Keep these gaps in mind. They're why the next stages exist.</p>
<h4 id="heading-step-5-revert-your-change-before-continuing">Step 5: Revert your change before continuing.</h4>
<p>Undo the health endpoint change in <code>app/auth-service/main.py</code> (remove <code>"version": "0.2.0"</code>). Don't rebuild: the cluster will keep running <code>v0.2.0</code> for now, and Stage 1 will take over image management.</p>
<p>Stage 1 automates the build. Stage 2 fixes the deployment.</p>
<h3 id="heading-what-you-learned-in-stage-0">What You Learned in Stage 0</h3>
<ul>
<li><p>How to provision a local Kubernetes cluster with Multipass and MicroK8s</p>
</li>
<li><p>How Kubernetes manifests describe the desired state of your system</p>
</li>
<li><p>How an Ingress routes external traffic to internal services</p>
</li>
<li><p>How to build, push, and deploy container images manually</p>
</li>
<li><p><strong>Why manual deploys can't be trusted</strong>: no audit trail, no rollback, no consistency</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Deployed a multi-service application to Kubernetes by hand: namespace, RBAC, a StatefulSet database, Deployments, Services, and path-based Ingress routing, and can explain why each layer deploys in that order.</p>
</blockquote>
<p><code>make snapshot STAGE=0 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage0</code>. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<h2 id="heading-stage-1-ci-pipeline-github-actions-self-hosted-runner">Stage 1 — CI Pipeline (GitHub Actions + Self-Hosted Runner)</h2>
<p>In Stage 0 you built and deployed by hand. Stage 1 automates the build: a <code>git push</code> runs a pipeline that builds images, scans them, pushes to Docker Hub, and records the new tag in <code>clearledger-infra</code>.</p>
<p><strong>Goal:</strong> every push to GitHub automatically builds images, pushes them to Docker Hub, and updates image tags in <code>clearledger-infra</code>.</p>
<p><strong>Am I ready for Stage 1?</strong></p>
<p>Run these <strong>yourself</strong> before §1.1:</p>
<pre><code class="language-plaintext">make check-0
echo "$DOCKER_USERNAME"    # must not be empty or "your-username"
curl -s -o /dev/null -w "%{http_code}" http://clearledger.local/auth/health
</code></pre>
<p>Expected: health check green, <code>echo</code> prints your Docker Hub user, and curl prints <code>200</code>.</p>
<p>What you'll need for this section:</p>
<ul>
<li><p>Docker Hub account with four clearledger- repositories (see QUICKSTART.md §1b)</p>
</li>
<li><p>GitHub account: you can create repos and personal access tokens</p>
</li>
<li><p>~2–4 hours for runner install + first green pipeline (this is the hardest stage for beginners)</p>
</li>
<li><p>Done when: make check-1 passes and you manually confirmed the five items in §1.7 below. Then save: make snapshot STAGE=1 → make snapshots (confirm clearledger.stage1).</p>
</li>
</ul>
<h3 id="heading-what-you-need-to-know-first">What You Need to Know First</h3>
<p>In Stage 0, your laptop was the deployment system.</p>
<p>You typed <code>docker build</code>, <code>docker push</code>, and <code>kubectl set image</code> yourself. That worked for a demo, but it's not how teams should ship software.</p>
<p>Manual builds create too many unanswered questions:</p>
<ul>
<li><p>Did this image come from the latest code?</p>
</li>
<li><p>Did someone build it from a dirty working tree?</p>
</li>
<li><p>Did the build work the same way on another machine?</p>
</li>
<li><p>Which commit produced the image currently running?</p>
</li>
<li><p>Who pushed the image, and when?</p>
</li>
</ul>
<p><strong>CI (Continuous Integration)</strong> fixes the build side of that problem. It means that every time code is pushed, an automated system builds, checks, and packages it the same way.</p>
<p>Think of CI as a factory line:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/5a9a4283-a77c-493e-85c4-e457b6ab00c9.png" alt="flow diagram explain gihub ci flow" style="display:block;margin:0 auto" width="1024" height="1536" loading="lazy">

<pre><code class="language-text">Developer pushes code
        ↓
GitHub detects the push
        ↓
GitHub Actions starts the pipeline
        ↓
Runner executes the jobs
        ↓
Docker images are built and pushed
        ↓
Infra manifests are updated with the new image tags  (in clearledger-infra — §1.3)
</code></pre>
<p>The important idea is that the build no longer depends on your laptop. Your laptop writes code and the pipeline produces the release artifact.</p>
<p>A CI system has three parts:</p>
<ol>
<li><p><strong>Pipeline host</strong>: the control plane. It notices a push and decides which workflow to run. In this lab, that's <strong>GitHub Actions</strong>.</p>
</li>
<li><p><strong>Pipeline file</strong>: the instructions. It's a YAML file at <code>.github/workflows/ci.yaml</code> that says what jobs to run.</p>
</li>
<li><p><strong>Runner</strong>: the worker machine. It actually executes the commands in the pipeline.</p>
</li>
</ol>
<p>GitHub Actions normally uses GitHub-hosted runners in the cloud. In this lab, that's not enough. Your Kubernetes cluster lives inside a local Multipass VM and GitHub's cloud runner can't reach it. You also need the runner inside the VM to build Docker images using the local Docker daemon.</p>
<p>So you install a self-hosted runner inside the VM. It connects outbound to GitHub, waits for work, then executes pipeline jobs locally where it can reach everything.</p>
<p>Two repos, <code>clearledger</code> (code + CI) and <code>clearledger-infra</code> (Kubernetes YAML only). You'll create the second in §1.3.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/f7c69215-31c8-4806-b899-15caeace9485.png" alt="flow chart demonstrating self hosted github flow" style="display:block;margin:0 auto" width="1024" height="1536" loading="lazy">

<pre><code class="language-text">GitHub — clearledger (app repo)
  stores your code
  starts the workflow on git push
        ↓
Self-hosted runner (inside Multipass VM)
  builds Docker images
  pushes images to Docker Hub
  updates image tags in clearledger-infra  ← you create this in §1.3
        ↓
GitHub — clearledger-infra (infra repo)
  stores Kubernetes YAML with the new image tags
  ArgoCD watches this repo in Stage 2 (not yet)
</code></pre>
<p>For Stages 1–7, the lab uses <code>.github/workflows/ci.yaml</code> with your self-hosted runner. It builds images, pushes them to Docker Hub, and updates <code>clearledger-infra</code>. Stage 8 adds a separate AWS workflow, <code>.github/workflows/ci-aws.yaml</code>, which pushes to ECR instead. You don't need to configure the AWS workflow until you reach Stage 8.</p>
<h3 id="heading-11-push-the-app-repo-to-github-not-clearledger-infra-yet">1.1: Push the App Repo to GitHub (Not <code>clearledger-infra</code> Yet)</h3>
<p>This step is <strong>repo #1,</strong> <code>clearledger</code> (application code + CI workflow). You're pushing the clone on your laptop: the same folder where you ran Stage 0 (<code>make setup</code>, <code>kubectl apply</code>, and so on).</p>
<p><code>clearledger-infra</code> comes later in §1.3. That second repo holds Kubernetes manifests only. Don't create it here.</p>
<p>First, put the application repo somewhere GitHub Actions can see it.</p>
<p>Go to GitHub and then New Repository:</p>
<ul>
<li><p>Repository name: <code>clearledger</code> (exact name, not <code>clearledger-infra</code>)</p>
</li>
<li><p>Visibility: <strong>Public or Private</strong>. Both work with the self-hosted runner and GitHub Actions. ArgoCD never reads this repo (see <a href="#heading-private-repos-what-syncs-where">Private repos: what syncs where</a> in §1.3).</p>
</li>
<li><p>Do <strong>not</strong> initialize with a README or <code>.gitignore</code></p>
</li>
</ul>
<p>The repo already has those files locally. If GitHub creates its own, your first push may fail because the histories don't match.</p>
<p>Run from your <strong>local</strong> <code>clearledger</code> <strong>project root</strong> on your laptop (where <code>app/</code>, <code>infra/</code>, and <code>.github/workflows/ci.yaml</code> live):</p>
<pre><code class="language-bash">cd ~/Desktop/clearledger   # your clone path
git remote add origin https://github.com/YOUR_USERNAME/clearledger.git
git branch -M main
git push -u origin main
</code></pre>
<p>If <code>git remote add</code> fails because <code>origin</code> already exists:</p>
<pre><code class="language-bash">git remote -v
git remote set-url origin https://github.com/YOUR_USERNAME/clearledger.git
git push -u origin main
</code></pre>
<p>Verify in the browser: <code>https://github.com/YOUR_USERNAME/clearledger</code>.</p>
<p>You should see <code>app/</code>, <code>infra/manifests/</code>, <code>docs/</code>, and <code>.github/workflows/ci.yaml</code>. That confirms GitHub can trigger the pipeline on your next push.</p>
<p><strong>What you proved:</strong> the <strong>app repo</strong> is on GitHub. CI will run from here. Deployment manifests for GitOps land in <code>clearledger-infra</code> in §1.3.</p>
<h3 id="heading-12-install-the-self-hosted-runner-inside-the-vm">1.2: Install the Self-Hosted Runner Inside the VM</h3>
<p>The workflow file tells GitHub <em>what</em> to run. The runner is <em>where</em> it runs.</p>
<p>This lab uses a self-hosted runner because your infrastructure is local. GitHub's cloud servers can't reach your MicroK8s cluster or Docker daemon inside the Multipass VM. The runner solves that by living inside the VM. It connects to GitHub to pick up jobs, then executes everything locally.</p>
<p>If the runner is missing or offline, the pipeline can't execute. The workflow may sit queued, or it may fail because no matching runner is available.</p>
<h4 id="heading-step-1-open-githubs-runner-setup-page-keep-this-tab-open">Step 1: Open GitHub’s runner setup page (keep this tab open)</h4>
<p>GitHub gives you a full copy-paste install guide on one page. Use it: don’t hunt for URLs or tokens elsewhere.</p>
<ol>
<li><p>Open <code>https://github.com/YOUR_USERNAME/clearledger</code></p>
</li>
<li><p>Go to Settings, Actions, Runners, and New self-hosted runner</p>
</li>
<li><p>Select Linux and x64</p>
</li>
</ol>
<p>The page title should look like: <strong>Add new self-hosted runner · YOUR_USERNAME/clearledger</strong>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/e0aa537c-a2b2-4ed7-8b48-f686a012ea8d.png" alt="e0aa537c-a2b2-4ed7-8b48-f686a012ea8d" style="display:block;margin:0 auto" width="1251" height="1267" loading="lazy">

<p>That page has three sections you'll use:</p>
<table>
<thead>
<tr>
<th>Section on GitHub</th>
<th>What to do with it</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Download</strong></td>
<td>Copy the <code>mkdir</code>, <code>curl</code>, and <code>tar</code> commands into the VM in Step 4 (same versions as below)</td>
</tr>
<tr>
<td><strong>Configure</strong></td>
<td>Copy the <strong>token</strong> from the <code>./config.sh ... --token ...</code> line: do <strong>not</strong> run GitHub’s <code>./config.sh</code> as-is</td>
</tr>
<tr>
<td><strong>Using your self-hosted runner</strong></td>
<td>Ignore for now, the lab workflow needs the <code>clearledger</code> label (Step 4)</td>
</tr>
</tbody></table>
<p>Scroll to <strong>Configure</strong>. You'll see something like:</p>
<pre><code class="language-bash">./config.sh --url https://github.com/YOUR_USERNAME/clearledger --token AXXXXXXXXXXXXXXXXXXXXXXXXX
./run.sh
</code></pre>
<p>The token is the long string after <code>--token</code> (starts with <code>A</code>, about 26 characters). Copy only that string.</p>
<p>Keep this tab open until Step 4 finishes: the token expires in about <strong>1 hour</strong>. If it expires, click New self-hosted runner again for a fresh token.</p>
<h4 id="heading-step-2-enter-the-vm">Step 2: Enter the VM</h4>
<p><code>multipass shell clearledger</code></p>
<p>After this command, your prompt should look like <code>ubuntu@clearledger:~$</code>. That means you are inside the Ubuntu VM. If your prompt still shows your Mac username or MacBook name, you're still on your host machine and the runner setup will fail.</p>
<p>Continue only when your prompt shows <code>ubuntu@clearledger</code>.</p>
<p>Everything from Step 3 onwards runs inside the VM, not on your Mac.</p>
<h4 id="heading-step-3-install-docker-inside-the-vm">Step 3: Install Docker inside the VM</h4>
<p>The runner will build Docker images. That means Docker must exist where the runner runs.</p>
<pre><code class="language-bash">curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker ubuntu
newgrp docker

docker --version
</code></pre>
<p>Expected: Docker prints a version number (for example, <code>Docker version 29.x.x</code>).</p>
<p><strong>Verify Docker works for the</strong> <code>ubuntu</code> <strong>user now</strong>: the runner doesn't exist yet (Step 4 creates <code>~/actions-runner</code>):</p>
<pre><code class="language-bash">docker ps
</code></pre>
<p>Expected: a table header (CONTAINER ID, IMAGE, …), even if no containers are listed. <strong>Not</strong> <code>permission denied while trying to connect to the Docker API</code>.</p>
<p>If <code>docker ps</code> fails with permission denied, the <code>docker</code> group has not applied yet. Run <code>newgrp docker</code> again, or log out of the VM (<code>exit</code>) and <code>multipass shell clearledger</code> back in, then retry <code>docker ps</code>.</p>
<p><strong>What you proved:</strong> the VM can run Docker without Docker Desktop on your Mac. Continue to Step 4 to install the runner.</p>
<h4 id="heading-step-4-install-and-register-the-runner">Step 4: Install and register the runner</h4>
<p>Still inside the VM (<code>ubuntu@clearledger</code> prompt):</p>
<p><strong>Download:</strong> you can copy the commands from the <strong>Download</strong> section on GitHub’s runner page (Step 1), or run the block below. They should match. Paste into the VM, not your Mac.</p>
<p><strong>Configure:</strong> use the lab command below, not GitHub’s <code>./config.sh</code> line. Paste your token from Step 1 and replace <code>YOUR_USERNAME</code>.</p>
<pre><code class="language-bash">mkdir -p ~/actions-runner &amp;&amp; cd ~/actions-runner

curl -o actions-runner-linux-x64-2.335.1.tar.gz -L \
  https://github.com/actions/runner/releases/download/v2.335.1/actions-runner-linux-x64-2.335.1.tar.gz

tar xzf ./actions-runner-linux-x64-2.335.1.tar.gz

./config.sh \
  --url https://github.com/YOUR_USERNAME/clearledger \
  --token YOUR_RUNNER_TOKEN \
  --name clearledger-runner \
  --labels clearledger,self-hosted,linux \
  --work _work \
  --unattended

sudo ./svc.sh install
sudo ./svc.sh start
</code></pre>
<p>Do <strong>not</strong> run GitHub’s <code>./run.sh</code> for day-to-day use: the lab uses <code>sudo ./svc.sh</code> so the runner survives VM reboots. GitHub shows <code>./run.sh</code> for a quick test only.</p>
<p>Expected after <code>./config.sh</code>: <code>Runner successfully added</code> (or similar). If you see Invalid token or Expired token, go back to Step 1 in the browser and copy a fresh token.</p>
<p>The <code>clearledger</code> label is required GitHub’s default <code>./config.sh</code> on the setup page doesn't add it. The workflow uses:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/f8fa25ff-81da-4759-b021-293c244add7c.png" alt="image showing where to add the label in github ui for the runner" style="display:block;margin:0 auto" width="938" height="252" loading="lazy">

<pre><code class="language-yaml">runs-on: [self-hosted, clearledger]
</code></pre>
<p>GitHub schedules jobs by runner labels, not by runner name. A runner named <code>clearledger</code> without the <code>clearledger</code> label will stay online but jobs will remain queued with <code>Waiting for a runner to pick up this job</code>.</p>
<p>What those last two commands mean:</p>
<pre><code class="language-text">sudo ./svc.sh install
  Registers the runner with systemd inside the VM.
  Without this, `sudo ./svc.sh status` says: not installed.

sudo ./svc.sh start
  Starts the runner service in the background.
  After this, it keeps running even when you close the terminal.
</code></pre>
<p>Check it locally from the same folder, still inside the VM:</p>
<pre><code class="language-bash">cd ~/actions-runner
sudo ./svc.sh status
</code></pre>
<p>Expected: the service is installed and running.</p>
<p>If <code>docker ps</code> worked in Step 3 but a CI job later fails with Docker socket permission denied, the runner probably started before the <code>docker</code> group applied. Restart it after Step 4 (only when <code>~/actions-runner</code> exists):</p>
<pre><code class="language-bash">cd ~/actions-runner
sudo ./svc.sh stop
sudo ./svc.sh start
docker ps    # must work without sudo
</code></pre>
<p>Or, if you started the runner manually with <code>./run.sh</code> instead of systemd:</p>
<pre><code class="language-bash">cd ~/actions-runner
pkill -f "Runner.Listener|Runner.Worker|./run.sh" || true
nohup ./run.sh &gt; _diag/manual-runner.log 2&gt;&amp;1 &amp;
docker ps
</code></pre>
<p>If you see this:</p>
<pre><code class="language-text">not installed
</code></pre>
<p>then <code>sudo ./svc.sh install</code> didn't run successfully. Run:</p>
<pre><code class="language-bash">cd ~/actions-runner
sudo ./svc.sh install
sudo ./svc.sh start
sudo ./svc.sh status
</code></pre>
<p>If <code>install</code> fails, rerun <code>./config.sh</code> with a fresh GitHub runner token, then run the install/start commands again.</p>
<h4 id="heading-step-5-exit-the-vm">Step 5: Exit the VM</h4>
<pre><code class="language-bash">exit
</code></pre>
<h4 id="heading-step-6-verify-the-runner-is-connected">Step 6: Verify the runner is connected</h4>
<p>Go to github.com/YOUR_USERNAME/clearledger then to Settings, Actions, and Runners.</p>
<p>You should see <code>clearledger-runner</code> with a green dot and status <strong>Idle</strong>. Open the runner details and confirm the labels include:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/f47d23ff-fecf-4789-b8ee-3be4191c1c3a.png" alt="screenshot image of github ui shpwing runner status as &quot;idle&quot; green" style="display:block;margin:0 auto" width="816" height="589" loading="lazy">

<pre><code class="language-text">self-hosted
Linux
X64
clearledger
</code></pre>
<p>If <code>clearledger</code> is missing, add it in the runner settings before rerunning the workflow. The runner name alone is not enough.</p>
<p><strong>✋ Hands-on checkpoint: runner ready for jobs</strong></p>
<p>Still on GitHub, Settings, Actions, and Runners, confirm:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Expected</th>
</tr>
</thead>
<tbody><tr>
<td>Status</td>
<td><strong>Idle</strong> (green)</td>
</tr>
<tr>
<td>Labels</td>
<td>includes <code>self-hosted</code> <strong>and</strong> <code>clearledger</code></td>
</tr>
<tr>
<td>OS</td>
<td>Linux</td>
</tr>
</tbody></table>
<p>Then trigger a dry run from your laptop:</p>
<pre><code class="language-bash">git commit --allow-empty -m "test: verify runner picks up jobs"
git push
</code></pre>
<p>Open <code>https://github.com/YOUR_USERNAME/clearledger/actions</code>. Within 30 seconds a workflow run should show Queued then In progress, not stuck on “Waiting for a runner.” If it waits more than 2 minutes, the labels are wrong. Edit the runner on GitHub and add <code>clearledger</code>.</p>
<p><strong>If it shows Offline:</strong></p>
<pre><code class="language-bash">multipass exec clearledger -- sudo systemctl status actions.runner.*.service
multipass exec clearledger -- journalctl -u actions.runner.*.service --lines=50
</code></pre>
<p><strong>What you proved:</strong> GitHub can now send work into your local lab environment.</p>
<h3 id="heading-13-create-the-infra-repo-on-github">1.3: Create the Infra Repo on GitHub</h3>
<p>Now separate <strong>application code</strong> from <strong>deployment state</strong>. Stage 1 introduces a second GitHub repository alongside the <code>clearledger</code> app repo you pushed in §1.1.</p>
<p>You'll use two repositories for the rest of the lab:</p>
<table>
<thead>
<tr>
<th>Repo</th>
<th>What lives there</th>
<th>Who changes it</th>
<th>Why it exists</th>
</tr>
</thead>
<tbody><tr>
<td><code>clearledger</code></td>
<td>App source code, Dockerfiles, tests, <code>.github/workflows/ci.yaml</code>, lab docs</td>
<td>You, the developer</td>
<td>This is where code changes start</td>
</tr>
<tr>
<td><code>clearledger-infra</code></td>
<td>Kubernetes manifests only: <code>deployment.yaml</code>, <code>service.yaml</code>, ingress, secrets templates</td>
<td>The CI pipeline, then ArgoCD reads it</td>
<td>This is the desired state of the cluster</td>
</tr>
</tbody></table>
<p>Think of <code>clearledger</code> as the question <em>“What is the application?”</em>. Python services, Dockerfiles, tests, and the CI workflow. Think of <code>clearledger-infra</code> as <em>“What exact version should be running in Kubernetes right now?”</em>. Deployments, Services, ingress rules, and the image tags that point at Docker Hub.</p>
<p>Teams split these on purpose. If you edit <code>README.md</code> in <code>clearledger</code>, that is a documentation change. It shouldn't trigger a deployment.<br>If you change <code>auth-service</code> code, the pipeline builds a new image (for example tag <code>abc123</code>) and, only after scans pass, records that tag in <code>clearledger-infra</code>:</p>
<pre><code class="language-yaml">image: $DOCKER_USERNAME/clearledger-auth-service:abc123
</code></pre>
<p>That line is a deployment contract: Git now says the cluster <em>should</em> run <code>abc123</code>. In Stage 1, the cluster doesn't change yet (and you'll prove that in §1.6).<br>In Stage 2, ArgoCD watches <code>clearledger-infra</code>, compares Git to what is running, and syncs the cluster when they differ. The app repo is where work begins. The infra repo is what production is supposed to look like.</p>
<h4 id="heading-private-repos-what-syncs-where">Private repos: what syncs where</h4>
<p>This lab uses two GitHub repos. <code>clearledger</code> is your main project repo: app code, CI pipeline, docs, policies, and lab files. This repo can be private.</p>
<p><code>clearledger-infra</code> contains only Kubernetes manifests. ArgoCD watches this repo and uses it to deploy the app. For beginners, make this repo public so ArgoCD can read it without extra authentication.</p>
<p>The flow looks like this:</p>
<pre><code class="language-text">clearledger
app code + infra/manifests/
        ↓
CI copies infra/manifests/
        ↓
clearledger-infra
Kubernetes manifests only
        ↓
ArgoCD syncs from this repo
        ↓
Kubernetes cluster
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/4aa15b2c-c746-4711-aa64-704a9d3eada2.png" alt="flow chart explain how both repos work" style="display:block;margin:0 auto" width="1165" height="1350" loading="lazy">

<p>ArgoCD doesn't read the main <code>clearledger</code> repo. It only reads <code>clearledger-infra</code>. If <code>clearledger</code> is private, that is fine. If <code>clearledger-infra</code> is private, you must give ArgoCD GitHub credentials later. If you do not, ArgoCD may show <code>ComparisonError</code>.</p>
<p>Create the infra repo on GitHub:</p>
<ol>
<li><p>Go to GitHub and then <strong>New Repository</strong></p>
</li>
<li><p>Name it <code>clearledger-infra</code></p>
</li>
<li><p>Choose <strong>Public</strong></p>
</li>
<li><p>Don't add a README</p>
</li>
<li><p>Click <strong>Create</strong></p>
</li>
</ol>
<p>Later, the CI pipeline will update <code>clearledger-infra</code> automatically. In Stage 1, the pipeline doesn't run <code>kubectl apply</code> – it updates Git. In Stage 2, ArgoCD reads that Git repo and applies it to the cluster.</p>
<p><strong>Before pushing:</strong> set your Docker Hub username in Kustomize (image tags are resolved here, not in deployment YAML):</p>
<pre><code class="language-bash"># Replace YOUR_DOCKERHUB_USERNAME with the same value as $DOCKER_USERNAME from §0.3
sed -i.bak "s/YOUR_DOCKERHUB_USERNAME/${DOCKER_USERNAME}/g" infra/manifests/kustomization.yaml
rm -f infra/manifests/kustomization.yaml.bak
</code></pre>
<p>Push only the Kubernetes manifests from <code>infra/manifests/</code> (not everything under <code>infra/</code>):</p>
<pre><code class="language-bash">mkdir -p /tmp/clearledger-infra
cp -r infra/manifests /tmp/clearledger-infra/
cd /tmp/clearledger-infra
git init
git remote add origin https://github.com/YOUR_USERNAME/clearledger-infra.git
git add . &amp;&amp; git commit -m "feat: initial manifests" &amp;&amp; git push -u origin main
cd -
</code></pre>
<p><strong>✋ Hands-on checkpoint: infra repo on GitHub (do this before §1.4)</strong></p>
<p>On your laptop:</p>
<pre><code class="language-bash">grep "docker.io/${DOCKER_USERNAME}/" infra/manifests/kustomization.yaml | wc -l
grep YOUR_DOCKERHUB_USERNAME infra/manifests/kustomization.yaml || echo "OK: placeholder replaced"
</code></pre>
<p>Expected: first command prints <code>4</code> (four image lines). Second prints <code>OK: placeholder replaced</code>, not four lines still saying <code>YOUR_DOCKERHUB_USERNAME</code>.</p>
<p>In the browser, open <code>https://github.com/YOUR_USERNAME/clearledger-infra/tree/main/manifests</code> and confirm <strong>with your eyes</strong>:</p>
<table>
<thead>
<tr>
<th>File / folder</th>
<th>Must exist</th>
</tr>
</thead>
<tbody><tr>
<td><code>kustomization.yaml</code></td>
<td>Yes. Open it: <code>newName:</code> lines use <strong>your</strong> Docker Hub user</td>
</tr>
<tr>
<td><code>auth-service/secret.yaml</code></td>
<td>Yes. Stages 2–4 need this until Stage 5</td>
</tr>
<tr>
<td><code>ledger-service/secret.yaml</code></td>
<td>Yes</td>
</tr>
<tr>
<td><code>auth-service/deployment.yaml</code></td>
<td>Yes. Open it: must contain <code>secretKeyRef</code>, <strong>not</strong> <code>vault.hashicorp.com</code></td>
</tr>
<tr>
<td><code>netpol/</code></td>
<td><strong>No</strong>. If present, delete the folder on GitHub before Stage 2</td>
</tr>
<tr>
<td><code>vault/</code></td>
<td><strong>No</strong>. Vault rotation is Stage 5 only</td>
</tr>
</tbody></table>
<p><strong>Which folders matter?</strong> You only pushed <code>infra/manifests/</code> to GitHub, that's correct. Everything else in this repo stays local for now.</p>
<p>Some manifests for later stages (network policies, Vault extras) live under <code>infra/deferred-by-stage/</code> in the <code>clearledger</code> repo. You'll apply those by hand when you reach that stage. Do <strong>not</strong> copy that folder into <code>clearledger-infra</code>, or ArgoCD will deploy things too early.</p>
<p>You might notice <code>stages/stage-1-ci-pipeline/</code> has no copy of the manifests. That is normal: the lab doesn't duplicate YAML there. The canonical copy is <code>infra/manifests/</code> in this repo, and the live GitOps copy is <code>clearledger-infra</code> on GitHub.</p>
<p><strong>What you proved:</strong> Kubernetes config now has its own repo and Git history, separate from application code. CI will update <code>clearledger-infra</code> after each build, and your app repo stays for code and the pipeline file.</p>
<h3 id="heading-14-set-up-github-secrets">1.4: Set up GitHub Secrets</h3>
<p>Go to <code>github.com/YOUR_USERNAME/clearledger</code> and then Settings, Secrets and variables, Actions, and New repository secret.</p>
<p>The workflow needs credentials for Docker Hub, GitHub, and image signing:</p>
<ul>
<li><p>Docker Hub, so it can push images.</p>
</li>
<li><p>GitHub, so it can push image tag updates into <code>clearledger-infra</code>.</p>
</li>
<li><p>Cosign, so it can sign the images after pushing them.</p>
</li>
</ul>
<p>Do <strong>not</strong> paste these values into YAML files. Store them as GitHub Actions secrets.</p>
<h4 id="heading-secret-1-dockerusername">Secret 1, <code>DOCKER_USERNAME</code></h4>
<p>This is just your Docker Hub username.</p>
<p>Example:</p>
<pre><code class="language-text">veeno-demo
</code></pre>
<p>Get it from Docker Hub: hub.docker.com, profile menu, Account Settings.</p>
<h4 id="heading-secret-2-dockerpassword">Secret 2, <code>DOCKER_PASSWORD</code></h4>
<p>This should be a Docker Hub <strong>access token</strong>, not your normal Docker Hub password.</p>
<p>Create it here:</p>
<pre><code class="language-text">hub.docker.com
→ Account Settings
→ Security
→ New Access Token
→ Description: clearledger-github-actions
→ Access permissions: Read, Write, Delete or Read/Write
→ Generate
</code></pre>
<p>Copy the token immediately. Docker Hub only shows it once.</p>
<h4 id="heading-secret-3-infrarepotoken">Secret 3, <code>INFRA_REPO_TOKEN</code></h4>
<p>This is a GitHub Personal Access Token (PAT). The pipeline uses it to push commits to the second repo, <code>clearledger-infra</code>.</p>
<p>Create it here:</p>
<pre><code class="language-text">GitHub profile settings
→ Settings
→ Developer settings
→ Personal access tokens
→ Tokens (classic)
→ Click "Generate new token"
→ Choose "Generate new token (classic)"
→ If GitHub asks for your password or 2FA, complete it
→ Note: clearledger-infra-ci
→ Expiration: choose a lab-friendly value
→ Select scope: repo
   This allows the pipeline to push to clearledger-infra.
→ Generate token
</code></pre>
<p>Copy the token immediately. GitHub only shows it once.</p>
<p>For this lab, <code>repo</code> scope is the simplest option. In production, you would use tighter permissions, such as a fine-grained token limited to only <code>clearledger-infra</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/5a0d06bf-570a-4360-aadb-038b7ae7ed4e.png" alt="screenshot of docker ui showing where to set up PAT" style="display:block;margin:0 auto" width="302" height="888" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/b9e7f0c8-882c-4d15-b67b-d2ff38289836.png" alt="screenshot of docker ui showing where to set up token scope" style="display:block;margin:0 auto" width="1039" height="593" loading="lazy">

<h4 id="heading-secrets-4-and-5-cosignprivatekey-and-cosignpassword">Secrets 4 and 5, <code>COSIGN_PRIVATE_KEY</code> and <code>COSIGN_PASSWORD</code></h4>
<p>Cosign signs container images after the pipeline pushes them to Docker Hub. Later, Stage 4 uses the public key with Kyverno so the cluster can verify that images came from your trusted pipeline.</p>
<p>Generate the key pair on your host machine, not inside the Multipass VM:</p>
<pre><code class="language-bash"># macOS: brew install cosign
# Linux/WSL2: curl -sSL -o cosign https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64 &amp;&amp; chmod +x cosign &amp;&amp; sudo mv cosign /usr/local/bin/
cosign generate-key-pair
</code></pre>
<p>This creates:</p>
<pre><code class="language-text">cosign.key   # private key — never commit this
cosign.pub   # public key — keep for later Kyverno verification
</code></pre>
<p>When Cosign asks for a password, enter one and save it in your password manager. If you already generated a key without a password, regenerate it with a password for this lab.</p>
<p>Add these five secrets to the <code>clearledger</code> repo, not <code>clearledger-infra</code>:</p>
<table>
<thead>
<tr>
<th>Secret name</th>
<th>Value</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>DOCKER_USERNAME</code></td>
<td>Your Docker Hub username</td>
<td>Pipeline logs in to push images</td>
</tr>
<tr>
<td><code>DOCKER_PASSWORD</code></td>
<td>Your Docker Hub access token</td>
<td>Pipeline authenticates with Docker Hub</td>
</tr>
<tr>
<td><code>INFRA_REPO_TOKEN</code></td>
<td>The GitHub PAT from above</td>
<td>Pipeline pushes image tag updates to clearledger-infra</td>
</tr>
<tr>
<td><code>COSIGN_PRIVATE_KEY</code></td>
<td>Contents of <code>cosign.key</code></td>
<td>Pipeline signs pushed container images</td>
</tr>
<tr>
<td><code>COSIGN_PASSWORD</code></td>
<td>Password used when creating the Cosign key</td>
<td>Unlocks the private key during signing</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/638009a9-844b-4bcb-9701-6312ec18d5c6.png" alt="screenshot of github UI showing my repository secrets" style="display:block;margin:0 auto" width="980" height="338" loading="lazy">

<p><strong>Repository variables (not secrets)</strong> (optional) toggles for later stages. Add under <strong>Settings, Secrets and variables, Actions, Variables</strong>:</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Stage 1</th>
<th>When to enable</th>
</tr>
</thead>
<tbody><tr>
<td><code>ENABLE_ARGOCD_SYNC</code></td>
<td>Leave <strong>unset</strong></td>
<td><strong>Stage 2</strong> — after ArgoCD’s first sync is healthy (see <a href="#heading-how-to-enable-the-ci-to-argocd-handoff">Enable CI → ArgoCD handoff</a>)</td>
</tr>
<tr>
<td><code>ENABLE_DAST</code></td>
<td>Leave <strong>unset</strong></td>
<td><strong>Stage 3</strong> — after the app is live at <code>clearledger.local</code> (see <a href="#heading-enable-dast-optional-after-stage-2">Enable DAST</a>)</td>
</tr>
</tbody></table>
<p>Don't add either variable in Stage 1. If you set them now, CI will try to refresh ArgoCD or run ZAP before the cluster is ready, and the pipeline output gets harder to read. The guide calls out the exact moment to turn each one on – you only need to remember that both exist.</p>
<p><strong>What you proved:</strong> the pipeline can authenticate to external systems without hardcoding credentials in the repo.</p>
<h3 id="heading-15-understand-the-pipeline-before-activating-it">1.5: Understand the Pipeline Before Activating it</h3>
<p>Don't treat the workflow file as magic. Open <code>.github/workflows/ci.yaml</code> and read it before you run it.</p>
<p>The pipeline has two responsibilities:</p>
<ol>
<li><p>Prove the code and images are safe enough to publish.</p>
</li>
<li><p>Update the infra repo with the new image tags.</p>
</li>
</ol>
<p>Here's the security flow first:</p>
<pre><code class="language-text">Developer pushes code to GitHub
        ↓
GitHub Actions starts workflow
        ↓
Self-hosted runner inside the Multipass VM picks up the job
        ↓
1. Scan secrets (Gitleaks)
        ↓
2. Run code security scans (Semgrep) + IaC scan (Checkov) — parallel
        ↓
3. Prepare scanners (install Trivy/Syft/Grype/Cosign once; refresh Trivy DB once)
        ↓
4. BUILD: docker build all four services (local tags only; nothing hits Docker Hub yet)
        ↓
5. SCAN: Trivy on all images; Syft + Grype SBOM on auth-service; upload evidence
        ↓
6. PUBLISH: push to Docker Hub + Cosign sign (only if scan passed)
        ↓
7. UPDATE MANIFESTS: commit new image tags to clearledger-infra
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/0cea04bf-e166-4fd3-9eb5-1a664e427206.png" alt="visual image of the cicd security flow pattern" style="display:block;margin:0 auto" width="1024" height="1536" loading="lazy">

<h4 id="heading-build-scan-publish-prod-style-gates">Build, scan, publish (prod-style gates)</h4>
<p>Real teams never push first and scan later. The pipeline separates three concerns into three jobs in <code>.github/workflows/ci.yaml</code>:</p>
<table>
<thead>
<tr>
<th>Job</th>
<th>What it does</th>
<th>If it fails…</th>
</tr>
</thead>
<tbody><tr>
<td><code>build-images</code></td>
<td><code>docker build</code> all services with tag <code>${{ github.sha }}</code></td>
<td>No registry pollution, images never left the runner</td>
</tr>
<tr>
<td><code>scan-images</code></td>
<td>Trivy (all 4 images); Syft + Grype (auth only)</td>
<td>Publish is skipped: bad images never reach Docker Hub</td>
</tr>
<tr>
<td><code>publish-images</code></td>
<td>Runs <code>scripts/ci-publish-image.sh</code> tag, push, Cosign sign</td>
<td>Only runs after scan passes</td>
</tr>
</tbody></table>
<p>You do <strong>not</strong> run <code>scripts/ci-publish-image.sh</code> yourself before pushing code. GitHub Actions checks out the repo and calls it inside <code>publish-images</code>.</p>
<p><strong>Why can</strong> <code>build-images</code> <strong>and</strong> <code>scan-images</code> <strong>be separate jobs?</strong> Each job is a fresh checkout on GitHub-hosted runners. They don't share a disk. On <strong>your</strong> self-hosted runner, all three jobs run on the <strong>same Multipass VM</strong> and use the <strong>same Docker engine</strong>.</p>
<p>Job 1 runs <code>docker build</code> and leaves the images on that machine. Job 2 runs Trivy against those same local images: no upload, no download. Job 3 pushes to Docker Hub only if the scan passed.</p>
<p>That's a practical lab setup: one persistent build machine with Docker installed, like a dedicated CI worker in a real office. In <strong>Stage 8 (AWS)</strong>, the pipeline uses GitHub-hosted runners instead: there, <code>build-images</code> saves the images to a file (<code>images.tar</code>) and passes that file to the next job as a workflow artifact, because those runners are throwaway VMs with no shared Docker cache.</p>
<p>Then comes the GitOps handoff:</p>
<pre><code class="language-text">Secure images now exist in Docker Hub
        ↓
Runner checks out clearledger-infra from GitHub
        ↓
Deployment YAML image tags are updated
        ↓
Runner commits and pushes back to clearledger-infra
        ↓
Stage 1 ends here
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/3be226da-a7d5-4231-8110-c37f1b8bfdce.png" alt="visual image of the cicd security flow pattern and github handoff journey" style="display:block;margin:0 auto" width="1024" height="1536" loading="lazy">

<p><strong>Here's how the image tag ties to your code:</strong> every pipeline run is triggered by a git commit. GitHub gives that commit a unique ID called the <strong>SHA</strong> (a long hex string like <code>a1b2c3d4e5f6789…</code>). The workflow sets <code>IMAGE_TAG</code> to that SHA and uses it everywhere:</p>
<ol>
<li><p><strong>Build:</strong> <code>docker build -t clearledger-auth-service:a1b2c3d4…</code></p>
</li>
<li><p><strong>Publish:</strong> push to Docker Hub as <code>YOUR_DOCKERHUB_USERNAME/clearledger-auth-service:a1b2c3d4…</code></p>
</li>
<li><p><strong>Update manifests:</strong> <code>kustomize edit set image …:a1b2c3d4…</code> in <code>clearledger-infra</code></p>
</li>
<li><p><strong>Commit message:</strong> <code>ci: deploy a1b2c3d4… — all gates passed</code></p>
</li>
</ol>
<p>If production is running <code>YOUR_DOCKERHUB_USERNAME/clearledger-auth-service:a1b2c3d4</code>, you can copy that <code>a1b2c3d4</code> tag, open GitHub, and instantly find the exact commit that built that image. There's no guessing and no wondering if <code>latest</code> changed. Every deployed image points back to one specific version of the code, making rollbacks and debugging much easier.</p>
<h4 id="heading-the-kustomize-placeholder">The Kustomize placeholder</h4>
<p><code>auth-service/deployment.yaml</code> uses a label instead of a real image address:</p>
<pre><code class="language-yaml">image: clearledger/auth-service:gitops
</code></pre>
<p>That label isn't on Docker Hub. It tells Kustomize where to substitute. The real address lives in <code>kustomization.yaml</code>:</p>
<pre><code class="language-yaml">images:
  - name: clearledger/auth-service          # matches the label above
    newName: docker.io/YOUR_DOCKERHUB_USERNAME/clearledger-auth-service
    newTag: abc123def456…                   # real commit SHA — CI writes this
</code></pre>
<p>When ArgoCD deploys, <code>kustomize build</code> swaps the label for the full address.</p>
<p>You edit <code>kustomization.yaml</code> once in §1.3 to set your Docker Hub username in <code>newName:</code>. After that, CI writes <code>newTag:</code> automatically on every green push. You never touch it by hand.</p>
<h4 id="heading-stage-1-ci-updates-github-not-the-cluster">Stage 1: CI updates GitHub, not the cluster</h4>
<p>After a green pipeline run, three things are true:</p>
<ul>
<li><p>New images exist on Docker Hub</p>
</li>
<li><p><code>clearledger-infra</code> on GitHub has new SHAs in <code>kustomization.yaml</code></p>
</li>
<li><p>Your Kubernetes cluster is <strong>unchanged</strong>. Still running whatever Stage 0 left there</p>
</li>
</ul>
<p>CI never runs <code>kubectl apply</code>. It only commits to <code>clearledger-infra</code>. That's the whole Stage 1 lesson: build and scan are automated, but <strong>deploy</strong> is not: yet. Stage 2 installs ArgoCD, which reads <code>clearledger-infra</code> and updates the cluster for you.</p>
<p><strong>Kubernetes Checkov</strong> runs in Stage 1 but does <strong>not</strong> block the pipeline. It uploads findings so you can see hardening work ahead. Stage 4 turns those kinds of rules into cluster enforcement with Kyverno.</p>
<p>Jobs run on your self-hosted runner (<code>runs-on: [self-hosted, clearledger]</code>). Both <code>ENABLE_ARGOCD_SYNC</code> and <code>ENABLE_DAST</code> are unset in Stage 1. See §1.4 for when each gets flipped.</p>
<p>If a job fails, start with <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md"><code>docs/troubleshooting.md</code></a> before editing the workflow.</p>
<h4 id="heading-stage-1-security-posture-what-blocks-vs-what-waits">Stage 1 security posture: what blocks vs what waits</h4>
<p>Note that stage 1 is not “security off.” Some gates stop the pipeline while others run for evidence and tighten in later stages.</p>
<p><strong>Blocks the pipeline today:</strong></p>
<ul>
<li><p>Gitleaks (secrets in Git)</p>
</li>
<li><p>Semgrep (SAST on Python)</p>
</li>
<li><p>Checkov on Dockerfiles</p>
</li>
<li><p>Trivy (fixable HIGH/CRITICAL CVEs in images)</p>
</li>
<li><p>Grype on auth-service SBOM (fixable HIGH+)</p>
</li>
<li><p>Manifest update to <code>clearledger-infra</code> (must succeed)</p>
</li>
</ul>
<p><strong>Runs but doesn't block yet:</strong></p>
<ul>
<li><p>Checkov on Kubernetes manifests – enforced in <strong>Stage 4</strong> (Kyverno)</p>
</li>
<li><p>Cosign sign + SLSA attest – enforced in <strong>Stage 4</strong> (unsigned images rejected)</p>
</li>
<li><p>Syft SBOM generation – supply-chain evidence. You'll purposely break gates in <strong>Stage 3.</strong></p>
</li>
<li><p>ArgoCD refresh – <strong>Stage 2</strong> (<code>ENABLE_ARGOCD_SYNC=true</code>)</p>
</li>
<li><p>DAST / ZAP – <strong>Stage 3</strong> (<code>ENABLE_DAST=true</code>)</p>
</li>
</ul>
<p><strong>If you forget which stage fixes what</strong>, search this guide for “Stage 1 security posture” or follow the stage order: Stage 3 breaks gates on purpose, Stage 4 connects Checkov findings to Kyverno, Stage 5 moves secrets off Git, Stage 6 adds runtime detection, Stage 7 adds monitoring dashboards.</p>
<p>Run <code>make check-3</code> and <code>make check-4</code> after those stages to confirm hardening landed.</p>
<p><strong>Design intent:</strong> Stage 1 proves CI can build, scan, push, and update Git without you touching Docker manually. Later stages turn evidence into enforcement. The relaxations here are deliberate.</p>
<h3 id="heading-16-activate-the-pipeline">1.6: Activate the Pipeline</h3>
<p><strong>Run this in the</strong> <code>clearledger</code> <strong>app repo, not</strong> <code>clearledger-infra</code><strong>.</strong></p>
<p>§1.3 created <code>clearledger-infra</code> with only Kubernetes manifests. It has no <code>.github/workflows/</code> and no pipeline. If your shell prompt says <code>clearledger-infra</code>, or you used <code>/tmp/clearledger-infra</code>, you're in the wrong place.</p>
<pre><code class="language-bash">cd /path/to/clearledger    # the app repo you pushed in §1.1

git remote -v              # must show .../clearledger.git — NOT clearledger-infra

ls .github/workflows/ci.yaml   # must exist before you commit
</code></pre>
<p>The pipeline file already lives at <code>.github/workflows/ci.yaml</code>. Push any small change to <code>clearledger</code> on <code>main</code>:</p>
<pre><code class="language-bash">echo "# Pipeline activated $(date)" &gt;&gt; README.md
git add README.md
git commit -m "ci: activate GitHub Actions pipeline"
git push origin main
</code></pre>
<p>Watch the run at: <code>https://github.com/YOUR_USERNAME/clearledger/actions</code> (app repo Actions tab, not the infra repo).</p>
<p>When the pipeline succeeds, it updates <code>clearledger-infra</code> for you. You don't need to push anything to the infra repo by hand for this step.</p>
<p>Expected Output: all jobs green in about 8 minutes.</p>
<pre><code class="language-plaintext">✓ Build + Scan auth-service
✓ Build + Scan ledger-service
✓ Build + Scan notification-service
✓ Build + Scan frontend
✓ Update manifests → GitHub
</code></pre>
<p>DAST and the ArgoCD refresh step show as <strong>skipped</strong>: this is expected, because Both toggles are unset until later (see §1.4).</p>
<p><strong>Note:</strong> this lab includes <code>.gitleaksignore</code> because some intentional demo secrets are already present in Git history. Gitleaks still runs normally. The ignore file only suppresses known lab fingerprints. Don't add new findings to it unless you've confirmed they're intentional test data.</p>
<p>Click into the job logs and look for the story. Don't just wait for green:</p>
<ul>
<li><p>Docker login succeeded</p>
</li>
<li><p>Each service image built and pushed to Docker Hub</p>
</li>
<li><p><code>clearledger-infra</code> was checked out</p>
</li>
<li><p>Deployment YAMLs were updated with the new SHA tag</p>
</li>
<li><p>A commit was pushed back to <code>clearledger-infra</code></p>
</li>
</ul>
<p>After the pipeline succeeds, open <code>https://github.com/YOUR_USERNAME/clearledger-infra</code> and look at the deployment manifests. The image tags should now use the current commit SHA.</p>
<p>Now check the cluster:</p>
<pre><code class="language-bash">kubectl get deployment auth-service -n clearledger \
  -o jsonpath='{.spec.template.spec.containers[0].image}' &amp;&amp; echo
</code></pre>
<p>You may still see the old image. That's expected. This is the most important learning in Stage 1:</p>
<pre><code class="language-text">GitHub pipeline succeeded.
Docker Hub has new images.
clearledger-infra has new image tags.
The Kubernetes cluster did not update automatically.
</code></pre>
<p>That's not a failure. It's the deployment gap. Stage 1 automated the build, but no controller is watching the infra repo yet. Stage 2 installs ArgoCD to close that gap.</p>
<h3 id="heading-17-hands-on-checkpoint-prove-stage-1-is-really-done">1.7 — Hands-on Checkpoint: Prove Stage 1 is Really Done</h3>
<p>Don't rely on a green workflow badge alone. Run each check yourself:</p>
<h4 id="heading-1-infra-repo-still-has-app-secrets-critical-for-stage-2">1. Infra repo still has app secrets (critical for Stage 2)</h4>
<p>Open <code>https://github.com/YOUR_USERNAME/clearledger-infra/tree/main/manifests/auth-service</code>, <code>secret.yaml</code> must be visible.</p>
<p>On your laptop:</p>
<pre><code class="language-bash">git clone --depth 1 https://github.com/YOUR_USERNAME/clearledger-infra.git /tmp/verify-infra
grep secretKeyRef /tmp/verify-infra/manifests/auth-service/deployment.yaml
grep secret.yaml /tmp/verify-infra/manifests/kustomization.yaml
rm -rf /tmp/verify-infra
</code></pre>
<p>Expected: <code>secretKeyRef</code> in deployment output. kustomization lists <code>auth-service/secret.yaml</code> and <code>ledger-service/secret.yaml</code>. If secrets are missing, re-push §1.3 manifests before Stage 2.</p>
<h4 id="heading-2-kustomize-image-tags-updated-by-ci">2. Kustomize image tags updated by CI</h4>
<pre><code class="language-bash">git clone --depth 1 https://github.com/YOUR_USERNAME/clearledger-infra.git /tmp/verify-infra
grep newTag /tmp/verify-infra/manifests/kustomization.yaml
rm -rf /tmp/verify-infra
</code></pre>
<p>Expected: <code>newTag</code> is a 40-character git SHA (or your commit hash), not still <code>v0.1.0</code> only: unless you haven't pushed since §0.3.</p>
<h4 id="heading-3-docker-hub-has-signed-images-from-this-pipeline">3. Docker Hub has signed images from this pipeline</h4>
<p>Open hub.docker.com then <code>clearledger-auth-service</code> then <strong>Tags</strong>. The latest tag should match the SHA from step 2.</p>
<h4 id="heading-4-cluster-unchanged-deployment-gap-intentional">4. (Cluster unchanged (deployment gap) intentional)</h4>
<pre><code class="language-bash">kubectl get deployment auth-service -n clearledger \
  -o jsonpath='{.spec.template.spec.containers[0].image}' &amp;&amp; echo
</code></pre>
<p>Expected: still your <strong>Stage 0</strong> tag (for example, <code>veeno-demo/clearledger-auth-service:v0.1.0</code>), not the new SHA. That proves CI didn't touch the cluster.</p>
<h4 id="heading-5-runner-still-idle">5. Runner still idle</h4>
<p>GitHub, Settings, Actions, Runners, <code>clearledger-runner</code>, <strong>Idle</strong>.</p>
<pre><code class="language-bash">make check-1
</code></pre>
<p>All five pass, onto Stage 2.</p>
<h3 id="heading-what-you-learned-in-stage-1">What You Learned in Stage 1</h3>
<ul>
<li><p><strong>CI removes your laptop from the build process.</strong> Builds become repeatable, visible, and tied to Git commits.</p>
</li>
<li><p><strong>A runner is the worker, not the pipeline itself.</strong> GitHub schedules the job, the self-hosted runner executes it inside your VM.</p>
</li>
<li><p><strong>Artifacts and desired state are different things.</strong> Docker Hub stores built images. <code>clearledger-infra</code> on GitHub stores the Kubernetes manifests that say which image should run.</p>
</li>
<li><p><strong>Good pipelines don't secretly mutate clusters.</strong> This pipeline updates Git instead of running <code>kubectl</code>.</p>
</li>
<li><p><strong>The gap that remains:</strong> the infra repo changed, but the cluster didn't. Someone still has to apply the change manually. Stage 2 fixes that with GitOps.</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Built a CI pipeline on a self-hosted GitHub Actions runner that builds and pushes container images on every push, and can debug a workflow that fails before any job is created.</p>
</blockquote>
<p><code>make snapshot STAGE=1 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage1</code>. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<h2 id="heading-stage-2-gitops-with-argocd">Stage 2 — GitOps with ArgoCD</h2>
<p>From this point on, Git is in charge. Whatever is written in the infrastructure repository is what should be running. If someone changes the cluster by hand, ArgoCD notices the difference and changes it back to match Git.</p>
<p><strong>Goal:</strong> Install ArgoCD so it watches <code>clearledger-infra</code> and deploys changes to the cluster. The CI pipeline only updates the Git repository, it never connects to Kubernetes or runs <code>kubectl</code> commands.</p>
<p>Here's a more conversational, compressed version:</p>
<h3 id="heading-am-i-ready-for-stage-2">Am I ready for Stage 2?</h3>
<p>Before moving on, finish <strong>§1.6</strong>, then run:</p>
<pre><code class="language-bash">make check-1

grep secretKeyRef infra/manifests/auth-service/deployment.yaml

grep vault.hashicorp infra/manifests/auth-service/deployment.yaml &amp;&amp; echo "STOP: Vault annotations present" || echo "OK"
</code></pre>
<p>You should see:</p>
<ul>
<li><p><code>check-1</code> passes</p>
</li>
<li><p><code>secretKeyRef</code> is present</p>
</li>
<li><p><code>OK</code> (no Vault annotations yet)</p>
</li>
</ul>
<p>Quick checklist:</p>
<ul>
<li><p><code>clearledger-infra</code> contains <code>auth-service/secret.yaml</code> and <code>ledger-service/secret.yaml</code></p>
</li>
<li><p>Your self-hosted runner is <strong>Idle</strong> with the <code>clearledger</code> label</p>
</li>
<li><p><code>ENABLE_ARGOCD_SYNC</code> isn't set yet (you'll enable it after installing ArgoCD)</p>
</li>
</ul>
<p>You're done with Stage 2 when <code>make check-2</code> passes and <a href="http://argocd.local"><code>http://argocd.local</code></a> shows ArgoCD syncing <code>clearledger</code>.</p>
<p>Finally, save your progress:</p>
<pre><code class="language-bash">make snapshot STAGE=2
make snapshots
</code></pre>
<p>Confirm that <code>clearledger.stage2</code> appears in the snapshot list.</p>
<h3 id="heading-what-you-need-to-know-first">What You Need to Know First</h3>
<p><strong>The gap from Stage 1:</strong> CI already builds images and updates <code>clearledger-infra</code>. The cluster didn't change until someone ran <code>kubectl</code>. This stage closes that last step.</p>
<table>
<thead>
<tr>
<th>Who</th>
<th>Job</th>
</tr>
</thead>
<tbody><tr>
<td><strong>CI</strong> (Stage 1)</td>
<td>Build → scan → push images → update image tags in <code>clearledger-infra</code></td>
</tr>
<tr>
<td><strong>ArgoCD</strong> (Stage 2)</td>
<td>Watch <code>clearledger-infra</code> → apply manifests → cluster runs what Git says</td>
</tr>
</tbody></table>
<pre><code class="language-text">push code → CI updates clearledger-infra → ArgoCD syncs cluster
</code></pre>
<h3 id="heading-pre-sync-checklist-run-before-argocd-app-sync">Pre-sync Checklist: Run Before <code>argocd app sync</code></h3>
<p>ArgoCD applies whatever is in <code>clearledger-infra</code>. Wrong content causes red pods. Re-run the §1.7 checkpoint table to confirm GitHub-side content is still correct, then verify the laptop side:</p>
<pre><code class="language-bash"># Application manifest must point at YOUR infra repo
grep repoURL stages/stage-2-gitops/argocd/clearledger-app.yaml

# Stage 0 workloads still healthy before ArgoCD takes over
kubectl get pods -n clearledger
curl -s -o /dev/null -w "%{http_code}" http://clearledger.local/auth/health
</code></pre>
<p>Expected: <code>repoURL</code> contains your GitHub username, all app pods <code>Running</code>, curl <code>200</code>. Only when both pass should you install ArgoCD and sync below.</p>
<pre><code class="language-bash">kubectl create namespace argocd 2&gt;/dev/null || true

kubectl apply -n argocd --server-side --force-conflicts -f \
  https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

kubectl wait --for=condition=ready pod \
  -l app.kubernetes.io/name=argocd-server -n argocd --timeout=180s
</code></pre>
<p><strong>Why</strong> <code>--server-side --force-conflicts</code><strong>?</strong> Argo CD ships a very large <code>applicationsets.argoproj.io</code> CRD. A normal <code>kubectl apply</code> tries to stash the whole thing in an annotation, hits a 256 KiB limit, and errors with <code>metadata.annotations: Too long</code>. Server-side apply avoids that. It's <a href="https://argo-cd.readthedocs.io/en/stable/operator-manual/installation/">how Argo CD expects you to install</a>.</p>
<p>Get the admin password:</p>
<pre><code class="language-bash">kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath="{.data.password}" | base64 -d &amp;&amp; echo
</code></pre>
<h4 id="heading-configure-argo-cd-for-your-nginx-ingress">Configure Argo CD for your NGINX ingress</h4>
<p>The browser talks HTTPS to ingress and ingress talks plain HTTP to the Argo CD server. Without this, the UI often breaks with <code>503</code> or <code>ERR_TOO_MANY_REDIRECTS</code> on live-update URLs (<code>/api/v1/stream/*</code>).</p>
<pre><code class="language-bash">kubectl apply -f stages/stage-2-gitops/infra/argocd-cmd-params.yaml

kubectl apply -f stages/stage-2-gitops/infra/argocd-ingress.yaml

kubectl rollout restart deployment/argocd-server -n argocd

kubectl rollout status deployment/argocd-server -n argocd --timeout=180s
</code></pre>
<p><strong>Expected in</strong> <code>argocd-cmd-params-cm</code><strong>:</strong> <code>server.insecure: "true"</code>, <code>server.grpc.web: "true"</code>, <code>server.url: https://argocd.local</code>.</p>
<p>Open <code>https://argocd.local</code>. Login: <code>admin</code> and the password from above. Accept the self-signed certificate warning if the browser shows one.</p>
<p><strong>Expected:</strong> The Applications page loads. In the browser console (F12 Console), you shouldn't see <code>401</code> or <code>ERR_HTTP2_PROTOCOL_ERROR</code>. If the UI looks fine in a normal window, you're done: incognito isn't required.</p>
<p><strong>If login fails with</strong> <code>401 Unauthorized</code> (often after a config change or a bad earlier login), try a private/incognito window or clear site data for <code>argocd.local</code>, then log in again. Still stuck? See <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md. ArgoCD</a>.</p>
<p>Connect ArgoCD to the infra repo and apply the Application manifest:</p>
<h4 id="heading-1-edit-stagesstage-2-gitopsargocdclearledger-appyaml">1. Edit <code>stages/stage-2-gitops/argocd/clearledger-app.yaml</code></h4>
<p>Set <code>spec.source.repoURL</code> to your infra repo (your GitHub username, not <code>git config user.name</code>).</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/56020fa9-77fa-4d60-bcc5-bbd06b6c809f.png" alt="photo of manifest file pointing to what to change." style="display:block;margin:0 auto" width="705" height="161" loading="lazy">

<h4 id="heading-2-connect-argocd-to-your-infrastructure-repository">2. Connect ArgoCD to your infrastructure repository:</h4>
<p>This gives ArgoCD permission to watch <code>clearledger-infra</code> for new commits. Whenever the deployment manifests change, ArgoCD will update the cluster automatically.</p>
<pre><code class="language-bash"># macOS: brew install argocd
argocd login argocd.local --username admin --password YOUR_PASSWORD --insecure --grpc-web

# Public repo
argocd repo add https://github.com/YOUR_USERNAME/clearledger-infra.git --grpc-web

# Private repo — PAT from Stage 1 §1.4 (you saved it as GitHub secret INFRA_REPO_TOKEN)
export INFRA_REPO_TOKEN='ghp_...'   # paste here; GitHub only shows it once at creation
argocd repo add https://github.com/YOUR_USERNAME/clearledger-infra.git \
  --username git --password "$INFRA_REPO_TOKEN" --grpc-web
</code></pre>
<p><strong>Verify that Argo CD can reach the repo</strong> (do this before applying the Application):</p>
<pre><code class="language-bash">argocd repo list --grpc-web
</code></pre>
<p>Look for your <code>clearledger-infra</code> URL with <strong>TYPE</strong> <code>git</code> and connection Successful. If it shows Failed or the repo is missing, Argo CD can't sync. Fix credentials before Stage 4 or any stage that depends on GitOps.</p>
<p>After a VM restore or Argo CD reinstall, you may need to run <code>argocd repo add</code> again (credentials are stored in the cluster, not in Git).</p>
<h4 id="heading-3-apply-and-sync">3. Apply and sync:</h4>
<pre><code class="language-bash">kubectl apply -f stages/stage-2-gitops/argocd/clearledger-app.yaml

argocd app sync clearledger --grpc-web
</code></pre>
<h3 id="heading-how-to-read-the-argo-cd-ui">How to Read the Argo CD UI</h3>
<p>After sync, open the <strong>clearledger</strong> application in the tree view. Three badges at the top tell you almost everything:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/2db73788-ee70-450d-b5ed-00835d50d180.png" alt="screenshot shwoing argocd UI" style="display:block;margin:0 auto" width="1127" height="1275" loading="lazy">

<p><strong>APP HEALTH: Healthy</strong>. Kubernetes thinks the workloads are running. Pods are up (or still starting if it says Progressing).</p>
<p><strong>SYNC STATUS: Synced</strong>: the cluster matches <code>clearledger-infra</code> on GitHub at the commit shown (for example, <code>main (2c88aa1)</code>). Git is the source of truth and Argo CD applied it.</p>
<p><strong>LAST SYNC: Succeeded</strong>: the most recent apply from Git worked. If this failed, click it for the error.</p>
<p>The resource tree below is the same app broken into pieces: namespace, secrets, services, deployments, ingress, and so on Green checkmarks = applied from Git. Click any box (for example, <code>deploy/auth-service</code>) then <strong>Live Manifest</strong> vs <strong>Desired</strong> to see what Argo CD thinks should run.</p>
<p><strong>Quick "is the app actually working?" test</strong> (outside Argo CD):</p>
<pre><code class="language-bash">curl -s -o /dev/null -w "%{http_code}\n" http://clearledger.local/auth/health
</code></pre>
<p><code>200</code> = the app is reachable end-to-end, not only "green in Argo CD."</p>
<p><strong>When something is wrong:</strong> HEALTH goes <strong>Degraded</strong> or <strong>Progressing</strong> for a long time, SYNC goes <strong>OutOfSync</strong>, and a resource in the tree turns <strong>red</strong>. Click that resource and then <strong>Events</strong> or <strong>Logs</strong>. The kubectl checks below double-check the same thing from the terminal.</p>
<p>Confirm ArgoCD is watching all workloads (not only ingress):</p>
<pre><code class="language-bash">argocd app resources clearledger --grpc-web | grep Deployment
</code></pre>
<p><strong>Pass looks like your output:</strong></p>
<pre><code class="language-text">apps    Deployment    clearledger    auth-service            No
apps    Deployment    clearledger    frontend                No
apps    Deployment    clearledger    ledger-service          No
apps    Deployment    clearledger    notification-service  No
apps    Deployment    clearledger    redis                   No
</code></pre>
<p>This command shows the Deployments that ArgoCD is managing for ClearLedger. You should see <code>auth-service</code>, <code>ledger-service</code>, <code>notification-service</code>, <code>frontend</code>, and <code>redis</code>. That means ArgoCD reads the full <code>kustomization.yaml</code> from <code>clearledger-infra/manifests</code>, not just one file.</p>
<p>The last column is <code>ORPHANED</code>. <code>No</code> is good. It means ArgoCD knows this resource belongs to the ClearLedger app. You only need to worry if one of the Deployments is missing, or if ArgoCD shows <code>OutOfSync</code>, <code>Degraded</code>, or red resources in the UI.</p>
<p><strong>✋ Hands-on checkpoint: first sync healthy</strong></p>
<p>Run these four checks. Pass looks like this:</p>
<pre><code class="language-bash">kubectl get pods -n clearledger
# Every app pod 1/1 Running (postgres/redis may show older RESTARTS from VM reboots — OK)

kubectl get application clearledger -n argocd \
  -o jsonpath='sync={.status.sync.status} health={.status.health.status}{"\n"}'
# sync=Synced health=Healthy

curl -s -o /dev/null -w "%{http_code}\n" http://clearledger.local/auth/health
# 200

kubectl logs -n clearledger deploy/auth-service --tail=5 2&gt;/dev/null | head -3
# Lines like: GET /health HTTP/1.1" 200 OK
# Bad sign: DATABASE_URL is not set
</code></pre>
<p>If all four pass then, Stage 2 first sync is done. Continue to Enable CI, and ArgoCD handoff below, then <code>make check-2</code> and <code>make snapshot STAGE=2</code>.</p>
<h3 id="heading-how-to-enable-the-ci-to-argocd-handoff">How to Enable the CI to ArgoCD Handoff</h3>
<p>In Stage 1, the pipeline updated <code>clearledger-infra</code>, but it didn't update the cluster. That was intentional.</p>
<p>Now ArgoCD is installed, so you can let the pipeline tell ArgoCD to check for changes after each successful run.</p>
<p>In GitHub, open your <code>clearledger</code> repo and go to Settings, Secrets and variables, Actions, Variables, and then New repository variable.</p>
<p>Add:</p>
<table>
<thead>
<tr>
<th><strong>Name</strong></th>
<th><strong>Value</strong></th>
</tr>
</thead>
<tbody><tr>
<td><code>ENABLE_ARGOCD_SYNC</code></td>
<td><code>true</code></td>
</tr>
</tbody></table>
<p>From now on, a green pipeline does two things:</p>
<ol>
<li><p>Updates <code>clearledger-infra</code> with the new image tag</p>
</li>
<li><p>Asks ArgoCD to sync the cluster</p>
</li>
</ol>
<p>If the pipeline can't trigger ArgoCD immediately, that's usually okay. ArgoCD checks <code>clearledger-infra</code> on its own every few minutes, so it should still pick up the new Git change.</p>
<p>Leave <code>ENABLE_DAST</code> unset for now. You enable that in Stage 3 after the app is stable at <code>clearledger.local</code>.</p>
<h3 id="heading-if-the-argocd-ui-shows-red-pods-or-progressing-read-this-before-the-screenshot">If the Argocd UI Shows Red Pods or "Progressing" (Read This Before the Screenshot)</h3>
<p>This is a common first-sync surprise, not a broken install.</p>
<h4 id="heading-why-it-happens-in-stage-2">Why it happens in Stage 2</h4>
<p>ArgoCD syncs whatever is in <code>clearledger-infra</code>. Deployments must use <code>secretKeyRef</code> (Stages 2–4), not Vault injection. If your infra repo has Vault annotations from an older lab copy, auth/ledger crash with <code>DATABASE_URL is not set</code> until Stage 5.</p>
<p>Network policies belong to Stage 6. In the main <code>clearledger</code> repo, they live in <code>infra/deferred-by-stage/stage-6-runtime-security/netpol/</code>, not in <code>infra/manifests/</code>. Don't copy them into <code>clearledger-infra</code> during Stage 2.</p>
<p>If <code>manifests/netpol/</code> is still in your <code>clearledger-infra</code> repo on GitHub (from an older copy of the lab), ArgoCD will keep applying it. Those policies use <strong>default-deny</strong> and break DNS for new pods, so you see red <strong>0/1</strong> pods and <strong>Progressing</strong> health.</p>
<h4 id="heading-fix-for-stage-2">Fix for Stage 2</h4>
<p>Do <strong>both</strong> steps. Deleting only in the cluster is not enough: ArgoCD recreates policies from Git on the next sync.</p>
<p><strong>Step 1: remove from</strong> <code>clearledger-infra</code> <strong>on GitHub</strong></p>
<p>Delete the folder <code>manifests/netpol/</code> and commit: <code>chore: defer network policies to Stage 6</code>.</p>
<p><strong>Step 2: sync and restart</strong></p>
<pre><code class="language-bash">argocd app sync clearledger --grpc-web
kubectl delete networkpolicy -n clearledger --all   # safe once Git no longer has netpol
kubectl rollout restart deployment/auth-service deployment/ledger-service -n clearledger
argocd app get clearledger --grpc-web | grep -E "Sync Status|Health Status"
</code></pre>
<p>Network policies stay in <code>clearledger</code> under <code>infra/deferred-by-stage/</code> until you apply them in Stage 6.</p>
<p>When that looks good, continue below.</p>
<p>When ArgoCD finishes syncing, open the <code>clearledger</code> app in the ArgoCD UI. You should see green <code>Healthy</code> and <code>Synced</code> badges. The app should point to your <code>clearledger-infra</code> repo, use the <code>manifests</code> path, and deploy into the <code>clearledger</code> namespace.</p>
<p>Then open the app tile. The resource tree should show your deployments, services, and ingress with no red resources.</p>
<p>You can confirm the same thing from the terminal:</p>
<p><code>argocd app get clearledger --grpc-web</code></p>
<p>Look for <code>Sync Status: Synced</code> and <code>Health Status: Healthy</code>.</p>
<h3 id="heading-argocd-stuck-outofsync">ArgoCD stuck OutOfSync</h3>
<p><strong>Normal path:</strong> CI copies full manifests + updates Kustomize tags, ArgoCD auto-syncs within ~3 minutes.</p>
<p><strong>If still OutOfSync after 10+ minutes:</strong></p>
<pre><code class="language-bash">make fix-argocd
</code></pre>
<p>This re-syncs canonical manifests to <code>clearledger-infra</code> (Kustomize SHAs preserved), re-applies the Application, and triggers a hard refresh. <strong>Don't</strong> <code>kubectl apply</code> deployments: fix Git, let ArgoCD sync.</p>
<pre><code class="language-bash">kubectl annotate application clearledger -n argocd 

argocd.argoproj.io/refresh=hard --overwrite

argocd app sync clearledger --grpc-web --prune

kubectl get application clearledger -n argocd -o jsonpath='sync={.status.sync.status} health={.status.health.status}{"\n"}'
</code></pre>
<p><strong>Take a screenshot of that view</strong>: the app tile or the resource tree is fine. That’s your portfolio proof that GitOps is actually running.</p>
<h3 id="heading-prove-argocd-self-healing">Prove ArgoCD Self-Healing</h3>
<p>Now prove that Git is the source of truth.</p>
<p>In this demo, you'll change the running cluster by hand. You will <strong>not</strong> change Git. ArgoCD should notice that the cluster no longer matches <code>clearledger-infra</code>, then change it back.</p>
<p>Before you start, make sure the app is healthy and ArgoCD is managing the deployments:</p>
<pre><code class="language-bash">argocd app resources clearledger --grpc-web | grep Deployment
</code></pre>
<p>Manually change the auth-service image in the cluster:</p>
<pre><code class="language-bash"># Manually change the image in the cluster only (Git stays the same)
kubectl set image deployment/auth-service \
  auth-service=$DOCKER_USERNAME/clearledger-auth-service:fake-tag \
  -n clearledger
</code></pre>
<p>Check ArgoCD:</p>
<pre><code class="language-bash"># ArgoCD should flip to OutOfSync within a minute or two
argocd app get clearledger --grpc-web | grep -E "Sync Status|Health Status"
</code></pre>
<p>Wait for ArgoCD to fix the cluster. The fake image tag may briefly cause an image pull error. That's expected in this demo.</p>
<pre><code class="language-bash"># Wait for selfHeal (default sync interval is ~3 minutes)
sleep 180
</code></pre>
<p>Confirm the image was changed back to the Git version:</p>
<pre><code class="language-bash"># Cluster image should match clearledger-infra again — Git was never edited
kubectl get deployment auth-service -n clearledger \
  -o jsonpath='{.spec.template.spec.containers[0].image}'
</code></pre>
<p>If the image changed back, ArgoCD self-healing worked. You changed the cluster by hand, but ArgoCD restored it to match <code>clearledger-infra</code>.</p>
<p>That's GitOps: Git says what should run, and ArgoCD keeps the cluster matching Git.</p>
<pre><code class="language-bash">make check-2
</code></pre>
<h3 id="heading-how-to-roll-back-a-bad-deploy">How to Roll Back a Bad Deploy</h3>
<p>You just proved that ArgoCD reverts unauthorized cluster changes. Now flip it: <strong>what if you pushed a bad commit yourself?</strong> GitOps rollback isn't a button. It's a Git operation. This section explains why, shows you both methods, and has you practice each one before you need them under pressure.</p>
<h4 id="heading-how-you-know-you-need-to-roll-back">How you know you need to roll back</h4>
<p>These symptoms appearing within minutes of a push to <code>clearledger-infra</code> point at a bad commit:</p>
<ul>
<li><p>Pods stuck in <code>CrashLoopBackOff</code> or <code>Error</code>. Check with <code>kubectl get pods -n clearledger</code>.</p>
</li>
<li><p><code>kubectl logs &lt;pod&gt; -n clearledger --previous</code> shows startup errors that weren't there before.</p>
</li>
<li><p>ArgoCD health flips from <code>Healthy</code> to <code>Degraded</code> or stays on <code>Progressing</code>. Check with <code>argocd app get clearledger --grpc-web</code>.</p>
</li>
<li><p>The app returns 5xx errors or login stops working. Check with <code>curl -I http://clearledger.local/health</code>.</p>
</li>
</ul>
<p>If this happens right after a push, roll back first. Once the app is stable again, investigate the bad commit.</p>
<h4 id="heading-why-argocd-rollback-isnt-just-a-button">Why ArgoCD rollback isn't just a button</h4>
<p>ArgoCD has a rollback button in the UI and an <code>argocd app rollback</code> command. Both work. But only if you understand the interaction with <code>selfHeal</code>.</p>
<p>Your Application (<code>stages/stage-2-gitops/argocd/clearledger-app.yaml</code>) is configured with:</p>
<pre><code class="language-yaml">syncPolicy:
  automated:
    selfHeal: true
</code></pre>
<p>ArgoCD keeps the cluster matched to Git. In this lab, Git means <code>clearledger-infra</code>.</p>
<p>If someone changes the cluster by hand, ArgoCD treats that as drift and changes it back to match Git.</p>
<p>This also affects rollback. The ArgoCD UI rollback changes the cluster, but it doesn't change Git. If <code>clearledger-infra</code> still points to the bad version, and self-heal can bring the bad version back.</p>
<p>The safer GitOps rollback is to change Git with <code>git revert</code> in <code>clearledger-infra</code>. Then ArgoCD syncs the cluster to the reverted, good version.</p>
<p>If you need an emergency UI rollback, turn off auto-sync first, roll back in ArgoCD, then fix Git afterward.</p>
<h4 id="heading-method-1-git-revert-preferred-always-try-this-first">Method 1: Git revert (preferred, always try this first)</h4>
<p>This is the GitOps way. You don't touch the cluster. You change Git, and ArgoCD syncs the fix.</p>
<p><strong>When to use:</strong> You have a few minutes and can identify the bad commit in <code>clearledger-infra</code>.</p>
<p><strong>How it works:</strong></p>
<pre><code class="language-plaintext">Bad commit pushed to clearledger-infra
        ↓
ArgoCD auto-synced it (cluster is now broken)
        ↓
You run: git revert &lt;bad-commit&gt; &amp;&amp; git push
        ↓
ArgoCD auto-syncs the revert (cluster is fixed, selfHeal works with you)
        ↓
Git history shows the bad deploy AND the revert, full audit trail
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/2371fab2-b05d-4453-ac28-80465a95a88f.png" alt="demo showing how argocd works and the flow" style="display:block;margin:0 auto" width="1024" height="1536" loading="lazy">

<p><strong>Step-by-step:</strong></p>
<pre><code class="language-bash"># 1. Go to your clearledger-infra repo (wherever you cloned it)
cd ~/clearledger-infra  # adjust path if you cloned elsewhere
git pull                # make sure you are up to date

# 2. Find the bad commit
git log --oneline -10

# Output looks like:
# abc1234 update ledger-service image to v1.4.0   ← this broke prod
# def5678 update auth-service image to v1.3.1     ← was fine
# 9a1b2c3 add vault rotation cronjob

# 3. Revert it — this creates a NEW commit, it does not delete history
git revert abc1234 --no-edit

# 4. Push — ArgoCD picks it up automatically within ~3 minutes
git push

# 5. Confirm the cluster recovered
kubectl get pods -n clearledger
argocd app get clearledger --grpc-web | grep -E "Sync Status|Health Status"
# Expected: Sync Status: Synced, Health Status: Healthy
</code></pre>
<p>This method is preferred because it fixes the source of truth: <code>clearledger-infra</code>.</p>
<p>After you push the revert, ArgoCD sees the new Git state and syncs the cluster to it. Nothing fights you because Git and the cluster are supposed to match.</p>
<p>It also leaves a clear history. Git shows the bad deploy, the revert, who made both changes, and when they happened. That's easier to debug, easier to review, and better for compliance.</p>
<h4 id="heading-method-2-emergency-argocd-rollback-when-the-cluster-is-on-fire">Method 2: Emergency ArgoCD rollback (when the cluster is on fire)</h4>
<p>Use this if the cluster is broken right now and you don't have time to push a Git fix. It pins the cluster to a previous known good deployment immediately. You'll still fix Git afterward. This isn't a permanent fix.</p>
<p><strong>When to use:</strong> Incident in progress. Pods are crashing, users are affected, and you need the cluster back to a known good state in under 30 seconds.</p>
<p><strong>Before you start:</strong> confirm your ArgoCD CLI session is still valid. If it expired, re-login first: an expired session will silently fail every command below.</p>
<blockquote>
<pre><code class="language-bash">argocd account get-user-info --grpc-web
# If you see "Unauthenticated", re-login:
ARGOCD_PASSWORD=$(kubectl -n argocd get secret argocd-initial-admin-secret \
  -o jsonpath="{.data.password}" | base64 -d)

argocd login argocd.local --username admin --password "$ARGOCD_PASSWORD" \
  --insecure --grpc-web
</code></pre>
</blockquote>
<p><strong>Step 1: Disable auto-sync</strong> (critical). Skip this and selfHeal will undo your rollback within 3 minutes.</p>
<pre><code class="language-bash">argocd app set clearledger --sync-policy none --grpc-web
# Confirm: automated sync is now off
argocd app get clearledger --grpc-web | grep "Sync Policy"
# Expected: Sync Policy: &lt;none&gt;
</code></pre>
<p><strong>Step 2: Find the last known-good deployment ID</strong></p>
<pre><code class="language-bash">argocd app history clearledger --grpc-web

# Output looks like:
# ID   DATE                           REVISION
# 9    2026-06-05 10:12:00 +0000 UTC  abc1234  ← bad deploy (current)
# 8    2026-06-04 14:46:06 +0000 UTC  def5678  ← known good
# 7    2026-06-01 20:53:19 +0000 UTC  9a1b2c3

# Or check via kubectl (no argocd CLI needed):
kubectl get application clearledger -n argocd \
  -o jsonpath='{range .status.history[*]}{.id}{"\t"}{.deployedAt}{"\t"}{.revision}{"\n"}{end}'
</code></pre>
<p>Use the ID (the number on the left), not the SHA.</p>
<p><strong>Step 3: Roll back to the good ID</strong></p>
<pre><code class="language-bash">argocd app rollback clearledger 8 --grpc-web
</code></pre>
<p><strong>Step 4: Confirm the cluster is stable</strong></p>
<pre><code class="language-bash">kubectl get pods -n clearledger
# All pods should be Running

argocd app get clearledger --grpc-web | grep -E "Sync Status|Health Status"
# Sync Status:   OutOfSync  ← expected — cluster is at rev 8, Git is still at the bad HEAD
# Health Status: Healthy    ← this is what matters right now
</code></pre>
<p><code>OutOfSync</code> is correct and expected at this point. The cluster is running the old good revision. Git still has the bad commit. You'll fix that next.</p>
<p><strong>Step 5: Fix Git (don't leave it broken)</strong></p>
<pre><code class="language-bash">cd ~/clearledger-infra
git pull
git revert &lt;bad-commit-sha&gt; --no-edit
git push
</code></pre>
<p><strong>Step 6: Re-enable auto-sync</strong></p>
<pre><code class="language-bash">argocd app set clearledger \
  --sync-policy automated \
  --self-heal \
  --auto-prune \
  --grpc-web

# Trigger an immediate sync so you do not wait for the next auto-check
argocd app sync clearledger --grpc-web

# Confirm everything is clean
argocd app get clearledger --grpc-web | grep -E "Sync Status|Health Status"
# Expected: Sync Status: Synced, Health Status: Healthy
</code></pre>
<p><strong>Never leave auto-sync disabled longer than the incident.</strong> It's your drift-detection and tamper-evidence mechanism: without it, unauthorized <code>kubectl</code> changes go undetected. Re-enable it the moment you push the Git fix.</p>
<h4 id="heading-practise-the-rollback-now-before-you-need-it-under-pressure">Practise the rollback now (before you need it under pressure)</h4>
<p>Don't wait for a real incident to run this for the first time. The steps below simulate a bad image tag deploy and walk you through Method 1 (the preferred path).</p>
<p><strong>Step 1: Push a bad image tag to</strong> <code>clearledger-infra</code></p>
<pre><code class="language-bash">cd ~/clearledger-infra
git pull

# Edit manifests/notification-service/deployment.yaml
# Change the image tag to a tag that does not exist, e.g.:
#   image: docker.io/$DOCKER_USERNAME/clearledger-notification-service:broken-tag

# Commit and push it
git add manifests/notification-service/deployment.yaml
git commit -m "test: simulate bad deploy with nonexistent image tag"
git push
</code></pre>
<p><strong>Step 2: Watch ArgoCD sync the bad state</strong></p>
<pre><code class="language-bash"># Give ArgoCD ~3 minutes to pick it up, or trigger immediately:
argocd app sync clearledger --grpc-web

# Watch the notification-service pod fail
kubectl get pods -n clearledger -w
# You will see: notification-service pod stuck in ImagePullBackOff or ErrImagePull
</code></pre>
<p><strong>Step 3: Roll back using Method 1</strong></p>
<pre><code class="language-bash">cd ~/clearledger-infra

# Revert the bad commit
git revert HEAD --no-edit
git push

# ArgoCD will auto-sync — or trigger it:
argocd app sync clearledger --grpc-web

# Watch pods recover
kubectl get pods -n clearledger -w
# notification-service should return to Running
</code></pre>
<p><strong>Step 4: Verify</strong></p>
<pre><code class="language-bash">argocd app get clearledger --grpc-web | grep -E "Sync Status|Health Status"
# Expected: Sync Status: Synced, Health Status: Healthy

kubectl get pods -n clearledger
# All pods Running, no ImagePullBackOff
</code></pre>
<p>You have now practised a rollback end-to-end. The <code>git revert</code> commit is permanently in the infra repo's history: a real audit record of a simulated recovery.</p>
<h4 id="heading-quick-reference">Quick reference</h4>
<p><strong>Use Method 1 (git revert) when:</strong></p>
<ul>
<li><p>A bad image tag or manifest was pushed to <code>clearledger-infra</code> and you have a few minutes</p>
</li>
<li><p>Any config change in the infra repo caused pods to break</p>
</li>
<li><p>This is almost always the right answer. It's fast, safe, and leaves a clean audit trail.</p>
</li>
</ul>
<p><strong>Use Method 2 (emergency ArgoCD rollback) when:</strong></p>
<ul>
<li><p>The cluster is broken right now, users are affected, and you need it stable in under 30 seconds</p>
</li>
<li><p>You're not yet sure which commit caused the problem and need time to investigate: roll back to stabilise, then use <code>git log</code> to find the culprit, then fix forward with Method 1</p>
</li>
</ul>
<p><strong>Neither method applies</strong> when a pod is crashing but nothing was pushed to the infra repo recently. This isn't a rollback problem. Check <code>kubectl logs</code>, Vault connectivity, and network policies instead.</p>
<p><code>revisionHistoryLimit: 10</code> in <code>stages/stage-2-gitops/argocd/clearledger-app.yaml</code> means ArgoCD always has 10 previous deployments available for emergency rollback. Increase it if your release cadence is high.</p>
<h3 id="heading-what-you-learned-in-stage-2">What You Learned in Stage 2</h3>
<ul>
<li><p>What GitOps means: Git is the single source of truth, and a tool enforces it</p>
</li>
<li><p>What ArgoCD does: watches Git, compares it to the cluster, corrects drift automatically</p>
</li>
<li><p>How the full flow works now: push code, CI builds image, CI updates infra repo, and ArgoCD syncs cluster.</p>
</li>
<li><p>No one runs <code>kubectl</code> to deploy anymore. The pipeline updates Git, ArgoCD does the rest.</p>
</li>
<li><p><strong>How to roll back safely:</strong> <code>git revert</code> in the infra repo is the correct answer, while ArgoCD emergency rollback is the break-glass option. You must disable auto-sync first or selfHeal will silently undo it.</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Implemented GitOps with ArgoCD so cluster state is driven from Git, with drift detection, auto-sync, and a Git-based rollback of a bad deploy.</p>
</blockquote>
<p><code>make snapshot STAGE=2 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage2</code>. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<h2 id="heading-stage-3-security-gates">Stage 3 — Security Gates</h2>
<p>Every push runs security checks. Some failures stop the pipeline right away. Others you learn from now and enforce in the cluster later (Stage 4).</p>
<p><strong>Goal:</strong> understand six scanners: what each one looks at, what it catches, and how to read a failure. You'll break each gate on purpose (§3.4) so a failed CI job isn't a surprise.</p>
<p><strong>Ready for Stage 3?</strong></p>
<ul>
<li><p><code>make check-2</code> passes</p>
</li>
<li><p><code>ENABLE_ARGOCD_SYNC=true</code> on GitHub (you set this in Stage 2)</p>
</li>
<li><p><code>ENABLE_DAST</code> still <strong>unset</strong> (turn on later in this stage if you want)</p>
</li>
<li><p>Argo CD at <code>http://argocd.local</code> shows <strong>Synced</strong></p>
</li>
<li><p>Optional: skim <a href="#heading-stage-1-security-posture-what-blocks-vs-what-waits">Stage 1 security posture</a>. Stage 1 already ran many of these tools</p>
</li>
</ul>
<p><strong>Done when:</strong> <code>make check-3</code> passes and you triggered each gate once (§3.4). Then <code>make snapshot STAGE=3</code> and <code>make snapshots</code>.</p>
<h3 id="heading-what-you-need-to-know-first">What You Need to Know First</h3>
<p>One tool isn't enough. Each scanner guards a different layer:</p>
<ul>
<li><p><strong>Gitleaks</strong>: secrets in code or Git history (API keys, tokens)</p>
</li>
<li><p><strong>Semgrep (SAST)</strong>: bugs in your Python/JS source (injection, unsafe patterns)</p>
</li>
<li><p><strong>Trivy (SCA + images)</strong>: finds known security vulnerabilities (called CVEs) in your Python/Node.js packages and Docker images. A CVE (Common Vulnerabilities and Exposures) is a publicly tracked software security flaw with a unique identifier.</p>
</li>
<li><p><strong>Checkov (IaC)</strong>: misconfigurations in Dockerfiles, Kubernetes manifests, and Stage 8 Terraform.</p>
</li>
<li><p><strong>Cosign</strong>: proves images were built and signed by your pipeline</p>
</li>
</ul>
<p>What blocks CI today: secrets, bad code (SAST), vulnerable images, Dockerfile issues on production images.</p>
<p>What waits for later: Some Kubernetes issues are only reported in Stage 1. They show you what still needs hardening. In Stage 4, Kyverno turns the important rules into real cluster enforcement, so unsafe workloads are blocked before they run.</p>
<p>When you <code>git commit</code>, hooks on your laptop can scan first (pre-commit). When you <code>git push</code>, GitHub Actions scans again on the runner. Same idea twice: catch mistakes before they waste a 10-minute pipeline. Pre-commit is optional to install, as CI always runs on push either way.</p>
<h3 id="heading-enable-dast-optional-after-stage-2">Enable DAST (Optional After Stage 2)</h3>
<p>DAST (Dynamic Application Security Testing) scans the <strong>running</strong> app at <code>http://clearledger.local</code>. It was off in Stages 1–2 on purpose: Stage 1 never deployed to the cluster, and Stage 2 was about getting GitOps healthy first.</p>
<p>If <code>make check-2</code> passes and <code>curl http://clearledger.local/auth/health</code> returns <code>200</code>, you can turn DAST on:</p>
<p>Go to GitHub and into your <code>clearledger</code> repo. Then go to <strong>Settings, Secrets and variables, Actions, Variables</strong>, and <strong>New repository variable</strong>:</p>
<table>
<thead>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><code>ENABLE_DAST</code></td>
<td><code>true</code></td>
</tr>
</tbody></table>
<p>Push a small commit (or re-run the last workflow on <code>main</code>). The <strong>DAST (OWASP ZAP + fintech API tests)</strong> job should run instead of <strong>skipped</strong>. A failed ZAP scan is a real finding to investigate. Skipped before this step only means the toggle was off.</p>
<h3 id="heading-31-install-pre-commit-hooks">3.1: Install Pre-commit Hooks</h3>
<pre><code class="language-bash"># macOS (Homebrew — avoids PEP 668 "externally-managed-environment" from pip3):
brew install pre-commit

# Linux/WSL2:
# sudo apt install -y pre-commit
# or: python3 -m pip install --user pre-commit

pre-commit install
pre-commit run --all-files
</code></pre>
<p>If a hook fails, read the error first. Gitleaks and Ruff should pass before you commit. Some YAML or Terraform hook issues may come from later-stage files. If that happens, continue with the stage instructions and use <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md"><code>troubleshooting.md</code></a> for Gitleaks or CI scanner failures.</p>
<p>Test it catches secrets locally before CI does:</p>
<pre><code class="language-bash">echo 'AWS_SECRET = "'$(printf '%s%s' 'AKIA' 'IOSFODNN7EXAMPLE')'"' &gt;&gt; app/auth-service/main.py
git add app/auth-service/main.py &amp;&amp; git commit -m "test"

# Gitleaks fires and blocks the commit — see "What you should see" below

git restore --staged app/auth-service/main.py

git checkout app/auth-service/main.py
</code></pre>
<p>The commit was blocked before it even reached Git. If the pre-commit hook wasn't installed, that fake AWS key would be in your Git history permanently (even if you delete the line later, Git remembers).</p>
<p><strong>If you already did Cosign in Stage 1,</strong> that counts. Stage 3 doesn't require regenerating keys. Confirm that <code>infra/cosign.pub</code> exists and GitHub has <code>COSIGN_PRIVATE_KEY</code> + <code>COSIGN_PASSWORD</code>. Stage 4 turns signing into <strong>enforcement</strong> at the cluster gate.</p>
<p><strong>✋ Hands-on checkpoint: pre-commit actually blocks a secret</strong></p>
<p>Installed-but-not-wired is the classic silent failure. Prove the hooks fire:</p>
<pre><code class="language-bash">echo 'AWS_SECRET='"$(printf '%s%s' 'AKIA' 'IOSFODNN7EXAMPLE')" &gt; leak-test.env

git add leak-test.env

pre-commit run --all-files; echo "exit=$?"

git reset leak-test.env &gt;/dev/null; rm -f leak-test.env
</code></pre>
<p><strong>Expected:</strong> the secret-scanning hook <strong>fails</strong> the run (<code>exit=1</code>) and flags <code>leak-test.env</code>. If <code>exit=0</code>, your hooks are installed but not catching anything: re-run <code>pre-commit install</code> and confirm <code>.git/hooks/pre-commit</code> exists.</p>
<p>If you skip this, commits sail through unscanned and you'll believe Stage 3 is protecting you when it's not.</p>
<h3 id="heading-32-generate-cosign-keys">3.2: Generate Cosign Keys</h3>
<p>If you created Cosign keys in Stage 1 (§1.4), skip generation: go straight to inserting <code>cosign.pub</code> into the Kyverno policy and adding the GitHub secrets below.</p>
<p><strong>Cosign</strong> signs your Docker images with a cryptographic key. When you deploy to the cluster, Kyverno (Stage 4) can verify the signature and reject any image that wasn't signed by your pipeline. This prevents someone from pushing a malicious image to your Docker Hub and having the cluster run it.</p>
<pre><code class="language-bash"># macOS: brew install cosign

# Linux/WSL2: curl -O -L https://github.com/sigstore/cosign/releases/download/v2.2.4/cosign-linux-amd64 &amp;&amp; chmod +x cosign-linux-amd64 &amp;&amp; sudo mv cosign-linux-amd64 /usr/local/bin/cosign

cosign generate-key-pair   # enter a password when prompted
</code></pre>
<p>This creates two files: <code>cosign.key</code> (private, used by the pipeline to sign) and <code>cosign.pub</code> (public, used by Kyverno to verify).</p>
<p>Insert your public key into the Kyverno policy (replace the placeholder block in <code>infra/policies/require-signed-images.yaml</code> with the contents of <code>cosign.pub</code>).</p>
<p>Add secrets to GitHub (github.com/YOUR_USERNAME/clearledger → Settings → Secrets and variables → Actions):</p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><code>COSIGN_PRIVATE_KEY</code></td>
<td>Contents of <code>cosign.key</code></td>
</tr>
<tr>
<td><code>COSIGN_PASSWORD</code></td>
<td>The password you entered when generating keys</td>
</tr>
</tbody></table>
<p><strong>✋ Hands-on checkpoint: Cosign keys are ready</strong></p>
<p>Stage 4 uses <a href="http://cosign.pub"><code>cosign.pub</code></a> to verify signed images. Before you continue, confirm the key files exist and the private key isn't tracked by Git:</p>
<pre><code class="language-bash">test -f cosign.key &amp;&amp; echo "private key present"
test -f cosign.pub &amp;&amp; echo "public key present"
grep -q "BEGIN PUBLIC KEY" cosign.pub &amp;&amp; echo "public key valid"
git check-ignore cosign.key &amp;&amp; echo "private key correctly ignored"
</code></pre>
<p><strong>Expected:</strong> all four lines should print.</p>
<p>If <code>git check-ignore cosign.key</code> prints nothing, add <code>cosign.key</code> to <code>.gitignore</code> before committing anything. The private key must stay out of Git.</p>
<p>Don't skip this check. Stage 4 needs the public key for the Kyverno image-signing policy, and the private key must remain local.</p>
<h3 id="heading-33-activate-the-full-security-pipeline">3.3: Activate the Full Security Pipeline</h3>
<p>The security gates are already in <code>.github/workflows/ci.yaml</code>. Push any change to trigger the full pipeline:</p>
<pre><code class="language-bash">git add . &amp;&amp; git commit -m "ci: full DevSecOps pipeline" &amp;&amp; git push origin main
</code></pre>
<h3 id="heading-34-break-each-gate-on-purpose">3.4: Break Each Gate on Purpose</h3>
<p>For each gate, you'll want to break something on purpose, read how the tool reports it, revert, and confirm green again. Try the local command first, then push once if you want a screenshot on GitHub Actions.</p>
<pre><code class="language-bash"># 1. Break it   2. Run locally or push   3. Read the failure
# 4. git checkout -- path/to/file   5. pre-commit run --all-files (optional)   6. git push
</code></pre>
<p>Start with <strong>Gate 1</strong> end-to-end before the others.</p>
<h4 id="heading-gate-1-gitleaks-secrets">Gate 1: Gitleaks (secrets)</h4>
<p><strong>Inject:</strong> hardcoded AWS key in any Python file.</p>
<p>The goal is to prove the secret scanner works.</p>
<p>This command adds a fake AWS-looking key to <code>app/auth-service/main.py</code>:</p>
<pre><code class="language-bash">echo 'AWS_KEY = "'$(printf '%s%s' 'AKIA' 'IOSFODNN7EXAMPLE')'"' &gt;&gt; app/auth-service/main.py

git add app/auth-service/main.py &amp;&amp; git commit -m "test: trigger gitleaks"
# pre-commit blocks this commit locally — that is the test.
# For a CI screenshot only: git commit --no-verify -m "test: trigger gitleaks" &amp;&amp; git push
</code></pre>
<p><strong>Done looks like this (terminal: pre-commit):</strong></p>
<pre><code class="language-text">🔑 Secrets scan (Gitleaks)...............................................Failed
- hook id: gitleaks
- exit code: 1

Finding:     AWS_KEY = "REDACTED"
RuleID:      aws-access-token
File:        app/auth-service/main.py
Line:        316
</code></pre>
<p><strong>Expected:</strong> the commit should fail. Gitleaks should report one secret finding in <code>app/auth-service/</code><a href="http://main.py"><code>main.py</code></a>.</p>
<p>That failure is good. It means the local pre-commit hook caught the secret before it reached Git.</p>
<p><strong>Revert:</strong></p>
<pre><code class="language-bash">git restore --staged app/auth-service/main.py 2&gt;/dev/null
git checkout app/auth-service/main.py
pre-commit run gitleaks --all-files   # → Passed
</code></pre>
<h4 id="heading-gate-2-semgrep-sast">Gate 2: Semgrep (SAST)</h4>
<p><strong>Local dry-run</strong> (no repo change):</p>
<pre><code class="language-bash">python3 -m venv /tmp/sec-gates-venv &amp;&amp; /tmp/sec-gates-venv/bin/pip install semgrep
cat &gt; /tmp/semgrep-bad.py &lt;&lt; 'EOF'
import subprocess
from fastapi import Request
def bad(request: Request):
    subprocess.run(request.query_params.get("cmd"), shell=True)
EOF
/tmp/sec-gates-venv/bin/semgrep \
  --config=p/python --config=p/security-audit --config=p/owasp-top-ten --error \
  /tmp/semgrep-bad.py
</code></pre>
<p><strong>Break CI</strong>: add a temporary file Semgrep will scan, commit, and push:</p>
<pre><code class="language-bash">cat &gt; app/auth-service/gate_test_semgrep.py &lt;&lt; 'EOF'
import subprocess
from fastapi import Request
def bad(request: Request):
    subprocess.run(request.query_params.get("cmd"), shell=True)
EOF

git add app/auth-service/gate_test_semgrep.py &amp;&amp; git commit -m "test: trigger semgrep" &amp;&amp; git push
</code></pre>
<p><strong>Expected result:</strong> Semgrep reports <code>subprocess-shell-true</code> as <code>Blocking</code>. The <code>SAST (Semgrep)</code> job turns red, and the image build jobs don't run.</p>
<p><strong>Revert:</strong></p>
<pre><code class="language-bash">rm -f app/auth-service/gate_test_semgrep.py
git add -A &amp;&amp; git commit -m "revert: semgrep gate test" &amp;&amp; git push
</code></pre>
<h4 id="heading-gate-3-checkov-iac-dockerfile">Gate 3: Checkov (IaC / Dockerfile)</h4>
<p>Checkov scans Dockerfiles and Kubernetes manifests for unsafe configuration.</p>
<p>First, run a local demo. This removes the <code>HEALTHCHECK</code> from a copied Dockerfile and shows how Checkov reports it:</p>
<pre><code class="language-bash">python3 -m venv /tmp/sec-gates-venv &amp;&amp; /tmp/sec-gates-venv/bin/pip install checkov
sed '/^HEALTHCHECK/,+1d' app/auth-service/Dockerfile &gt; /tmp/Dockerfile-nohc
mkdir -p /tmp/checkov-demo/app/auth-service
cp /tmp/Dockerfile-nohc /tmp/checkov-demo/app/auth-service/Dockerfile
/tmp/sec-gates-venv/bin/checkov --directory /tmp/checkov-demo --framework dockerfile
</code></pre>
<p>Now trigger a Checkov finding in CI by exposing SSH port <code>22</code> in the auth-service Dockerfile:</p>
<pre><code class="language-bash">echo 'EXPOSE 22' &gt;&gt; app/auth-service/Dockerfile
git add app/auth-service/Dockerfile &amp;&amp; git commit -m "test: trigger checkov" &amp;&amp; git push
</code></pre>
<p><strong>Expected result:</strong> the Checkov log or artifact should show <code>CKV_DOCKER_1</code>, which means an SSH port was exposed.</p>
<p>The <code>IaC Scan (Checkov)</code> job may or may not turn red, depending on the severity Checkov assigns. That's okay for this exercise. The goal is to find and understand the Checkov result.</p>
<p>If you need a screenshot of a failed GitHub Actions job, use Gate 1, Gate 2, or Gate 4. Those are designed to turn the workflow red. Checkov is mainly for reading the finding, so it may stay green.</p>
<p><strong>Revert:</strong></p>
<pre><code class="language-bash">git checkout app/auth-service/Dockerfile
git commit -am "revert: checkov gate test" &amp;&amp; git push
</code></pre>
<h4 id="heading-gate-4-trivy-image-cves">Gate 4: Trivy (image CVEs)</h4>
<p><strong>Local dry-run</strong>: scan an old base image (no build):</p>
<pre><code class="language-bash">trivy image --exit-code 1 --severity CRITICAL,HIGH --ignore-unfixed python:3.8-slim
</code></pre>
<p><strong>Break CI</strong>: pin an old base in the Dockerfile, push, wait for <code>Scan images</code>:</p>
<pre><code class="language-bash">sed -i.bak 's/FROM python:3.13-slim/FROM python:3.8-slim/' app/auth-service/Dockerfile
git add app/auth-service/Dockerfile &amp;&amp; git commit -m "test: trigger trivy" &amp;&amp; git push
</code></pre>
<p><strong>Pass:</strong> <code>Scan images</code> → <strong>Trivy scan all images</strong> exits 1 with a CVE table (<code>HIGH</code> / <code>CRITICAL</code>). <code>Publish images</code> and <code>Update Manifests</code> are skipped.</p>
<p><strong>Revert:</strong></p>
<pre><code class="language-bash">git checkout app/auth-service/Dockerfile
git commit -am "revert: trivy gate test" &amp;&amp; git push
</code></pre>
<h3 id="heading-35-when-a-scan-fails-on-a-cve-you-didnt-inject">3.5: When a Scan Fails on a CVE You Didn't Inject</h3>
<p>§3.4 is deliberate. This section is for the other case where you push normal code, but the image scan fails because a new vulnerability was found.<br>That's normal. CVE databases update all the time. Don't weaken the scan. Fix the vulnerable package or image.</p>
<p>First, find the real CVE. In GitHub Actions, open <strong>Scan images</strong> then go to <strong>Trivy scan all images</strong> and look for the table with:</p>
<ul>
<li><p>Package</p>
</li>
<li><p>CVE</p>
</li>
<li><p>Installed version</p>
</li>
<li><p>Fixed version You can also download the artifact:</p>
</li>
</ul>
<p><strong>Ignore this red herring</strong> at the bottom of the log:</p>
<pre><code class="language-text">Version 0.71.2 of Trivy is now available
Error: Process completed with exit code 1.
</code></pre>
<p>The version notice doesn't fail the job. A fixable HIGH/CRITICAL CVE does. Don't add <code>--skip-version-check</code> to “fix” it.</p>
<p><strong>Instead, fix it with:</strong></p>
<ul>
<li><p><strong>pip package</strong>: bump to the Fixed Version in <code>requirements.txt</code> (example: <code>python-multipart==0.0.30</code> for CVE-2026-53539). Apply the same bump to sibling services if they share that pin.</p>
</li>
<li><p><strong>OS package</strong>: newer base image or a targeted <code>apt</code>/<code>apk</code> upgrade in the Dockerfile.</p>
</li>
<li><p><strong>No stable fix yet</strong> documented exception only: add the CVE to <code>.trivyignore</code> and <code>.grype.yaml</code> with a comment (see <code>CVE-2026-7210</code>).</p>
</li>
</ul>
<p>Don't remove <code>--exit-code 1</code>, lower the severity rule, or disable scanning. For help, see <a href="troubleshooting.md#trivy-version-x-is-now-available-notice-not-a-scan-failure">Trivy version notice</a> and <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">Trivy blocks Python service images</a>.</p>
<h3 id="heading-finish-stage-3">Finish Stage 3</h3>
<p>For screenshots, use one clear failed gate:</p>
<ul>
<li><p>Gitleaks: <code>Secrets Scan</code></p>
</li>
<li><p>Semgrep: <code>SAST</code></p>
</li>
<li><p>Trivy: <code>Scan images</code></p>
</li>
<li><p>Checkov: look for <code>CKV_*</code> in the log or artifact. The job may stay green</p>
</li>
</ul>
<p>After each test in §3.4, undo the test change, push the revert, and confirm the workflow is green again. One red GitHub Actions screenshot is enough for your portfolio.</p>
<p><strong>Run the stage check:</strong></p>
<pre><code class="language-bash">make check-3   # must end: All checks passed. Ready for the next stage.
</code></pre>
<p><strong>Expected:</strong> <code>All checks passed. Ready for the next stage.</code></p>
<p>You should also have triggered at least one gate in §3.4. A local Gitleaks failure counts.</p>
<p><code>ENABLE_DAST=true</code> is optional. You only need it if you want to run ZAP later.</p>
<p><strong>Not required yet:</strong> Checkov blocking Kubernetes manifests or Cosign blocking deployments. Stage 4 turns those into cluster enforcement with Kyverno.</p>
<p>Next, save your progress:</p>
<pre><code class="language-bash">make snapshot STAGE=3 &amp;&amp; make snapshots
</code></pre>
<h2 id="heading-stage-4-admission-control-kyverno">Stage 4 — Admission Control (Kyverno)</h2>
<p>Even if CI passes, the cluster can still refuse.</p>
<p>CI scans your code and images before they reach GitOps, but it can't watch everything that happens inside the cluster. Someone with <code>kubectl</code> access could apply a manifest directly.</p>
<p>A Helm chart you install might create pods that violate your security standards. Those paths never hit the pipeline, which is why Stage 4 adds admission control: a checkpoint built into Kubernetes itself.</p>
<p>Every time something tries to create or update a resource, the request passes through admission webhooks before it takes effect. If a webhook rejects the request, the resource is never created.</p>
<p><strong>Kyverno</strong> is a Kubernetes-native policy engine that uses those webhooks. You write policies as YAML files (not application code), and Kyverno enforces them on every matching resource in the cluster, for example, rejecting any pod that runs as root or requiring CPU and memory limits on every container.</p>
<p>The difference from CI is timing: CI scans <em>before</em> code ships, while Kyverno enforces at the <em>cluster gate</em>. Together they give you two layers of defense.</p>
<p>Your goal in this stage is to install Kyverno, apply the policies in <code>infra/policies/</code>, and prove in §4.4 that non-compliant pods are denied before the container runtime ever sees them.</p>
<p>Before you start, make sure the foundation from earlier stages is still solid: <code>make check-3</code> should pass (pre-commit hooks and CI security gates are active), <code>infra/cosign.pub</code> should exist from Stage 3, and ArgoCD should still be syncing so the app responds at <code>http://clearledger.local</code>. If any of those are red, fix them first: Kyverno sits on top of a healthy cluster, not a broken one.</p>
<p>You're done with Stage 4 when all three break-it scenarios in §4.4 are denied and <code>make check-4</code> passes.</p>
<p><strong>What changes from Stage 3 is enforcement, not scanning.</strong> In CI, Checkov reported Kubernetes misconfigurations but didn't block the pipeline. Kyverno now stops those same classes of problems at the cluster gate.</p>
<p>Cosign has been signing your images since Stage 1. Kyverno now <em>requires</em> that signature before a ClearLedger image can deploy. This is where <a href="#heading-stage-1-security-posture-what-blocks-vs-what-waits">Stage 1 evidence becomes enforcement</a>. See that section if you want the full map of what blocked in Stage 1 versus what waited for Stage 4.</p>
<p>Start with §4.1 to install Kyverno. If the install, policies, break-it scenarios, or <code>make check-4</code> fail, read <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md"><code>troubleshooting.md</code></a> and <strong>Stage 4: Admission Control (Kyverno)</strong> before changing Helm values or policy YAML.</p>
<h3 id="heading-what-kyverno-enforces">What Kyverno Enforces</h3>
<p>All policy files live in <code>infra/policies/</code>. Kyverno itself is installed via Helm using <code>stages/stage-4-admission-control/infra/kyverno/values.yaml</code>.</p>
<table>
<thead>
<tr>
<th>Policy</th>
<th>What it enforces</th>
<th>Framework</th>
</tr>
</thead>
<tbody><tr>
<td><code>disallow-root-containers</code></td>
<td><code>runAsNonRoot: true</code></td>
<td>CIS K8s 5.2.6</td>
</tr>
<tr>
<td><code>require-resource-limits</code></td>
<td>CPU/memory requests and limits</td>
<td>CIS K8s 5.2.4</td>
</tr>
<tr>
<td><code>disallow-privilege-escalation</code></td>
<td><code>allowPrivilegeEscalation: false</code></td>
<td>CIS K8s 5.2.5</td>
</tr>
<tr>
<td><code>drop-all-capabilities</code></td>
<td><code>capabilities.drop: [ALL]</code></td>
<td>CIS K8s 5.2.7</td>
</tr>
<tr>
<td><code>require-signed-images</code></td>
<td>Cosign signature on ClearLedger images</td>
<td>SLSA Level 2</td>
</tr>
</tbody></table>
<h3 id="heading-platform-stability-from-stage-4-onward">Platform Stability: From Stage 4 Onward</h3>
<p>From Stage 4 on, you're running more controllers on a single-node VM. Kyverno, storage provisioners, and later Prometheus and Loki. A pod can show <code>Running</code> while it's actually crash-looping in the background.</p>
<p>When platform pods (Kyverno controllers, <code>hostpath-provisioner</code>, the Prometheus operator, and similar) accumulate high <code>RESTARTS</code>, the API server starts timing out, <code>kubectl</code> feels flaky, and you can waste days debugging the wrong component because the app pods look fine.</p>
<p>After every stage from here on, give the cluster about ten minutes to settle, then run the stage health check:</p>
<pre><code class="language-bash">bash scripts/health-check.sh &lt;stage&gt;    # for example, 4, 7, 7.5
# or the Makefile shortcut:
make check-4
</code></pre>
<p>The script ends with a Platform stability section that flags pods with suspicious restart counts. You can also scan the worst offenders yourself. This lists the fifteen pods with the highest restart counts cluster-wide, which is useful when something feels slow but you're not sure which namespace is struggling:</p>
<pre><code class="language-bash">kubectl get pods -A --sort-by='.status.containerStatuses[0].restartCount' \
  -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount' \
  | tail -15
</code></pre>
<p><strong>The gate:</strong> Kyverno controllers and other platform pods should show <strong>RESTARTS under 5</strong> after the stage settles. If any platform pod is climbing past 10, stop and fix it with the documented Helm values or <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md</a>. Don't <code>kubectl patch</code> around it and move on. A stable platform layer is a prerequisite for every stage that follows.</p>
<h3 id="heading-41-install-kyverno">4.1: Install Kyverno</h3>
<pre><code class="language-bash">helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update

helm upgrade --install kyverno kyverno/kyverno \
  --version 3.2.8 \
  --namespace kyverno \
  --create-namespace \
  -f stages/stage-4-admission-control/infra/kyverno/values.yaml \
  --wait --timeout=600s
</code></pre>
<p>The values file does three important things for the lab:</p>
<ol>
<li><p><strong>Disables cleanup CronJobs</strong>: older Kyverno charts pull <code>bitnami/kubectl</code>, which was removed from Docker Hub and causes <code>ImagePullBackOff</code> on cleanup pods.</p>
</li>
<li><p><strong>Points Helm hooks at</strong> <code>bitnamilegacy/kubectl</code>, so future <code>helm uninstall</code> doesn't hang on a missing image.</p>
</li>
<li><p><strong>Extends liveness probe timeouts</strong>: the default <code>timeoutSeconds: 5, failureThreshold: 2</code> is too tight for a loaded single-node VM. Under CPU pressure, the health endpoint can take &gt;5s to respond, which triggers a restart cascade that saturates the node and makes the API server intermittently unreachable. The values file sets <code>timeoutSeconds: 30, failureThreshold: 5</code> so Kyverno survives load spikes without crash-looping.</p>
</li>
</ol>
<p><strong>What you should see:</strong></p>
<pre><code class="language-yaml">Release "kyverno" does not exist. Installing it now.
NAME: kyverno
NAMESPACE: kyverno
STATUS: deployed
...
Kyverno version: v1.12.6
</code></pre>
<p>Verify all four controllers are running (first pull can take several minutes on a slow connection):</p>
<pre><code class="language-markdown">kubectl get pods -n kyverno
</code></pre>
<pre><code class="language-plaintext">NAME                                             READY   STATUS    RESTARTS   AGE
kyverno-admission-controller-bd685cd4b-f6kl6     1/1     Running   0          2m
kyverno-background-controller-66fcfc6d87-59wgt   1/1     Running   0          2m
kyverno-cleanup-controller-5c5bf8bc6b-7kspq      1/1     Running   0          2m
kyverno-reports-controller-5cdd6f4c48-qf5wc      1/1     Running   0          2m
</code></pre>
<p>If pods stay in <code>ContainerCreating</code> for a long time, the node is still pulling images from <code>ghcr.io/kyverno</code>. Wait. Don't start a second Helm install on top of a partial one.</p>
<h4 id="heading-stability-gate-kyverno-install-only-before-42">Stability gate: Kyverno install only (before §4.2):</h4>
<p>Before continuing, make sure the Kyverno pods are healthy:</p>
<pre><code class="language-bash">kubectl get pods -n kyverno
</code></pre>
<p><strong>Expected:</strong> the Kyverno controller pods show <code>1/1 Running</code>, with low restart counts such as <code>0</code>, <code>1</code>, or <code>2</code>, and the restart count isn't increasing.</p>
<p>Don't run <code>make check-4</code> yet. That check also looks for the policies you apply later in §4.3, so it may fail at this point even if Kyverno installed correctly.</p>
<h3 id="heading-42-confirm-your-cosign-public-key-is-in-the-policy">4.2: Confirm your Cosign Public Key is in the Policy</h3>
<p>Stage 3 created <code>infra/cosign.pub</code>. Kyverno uses that same key to verify image signatures when a pod is created. The policy file ships with a placeholder. You must replace it with your key before applying policies in §4.3.</p>
<h4 id="heading-step-1-show-your-key-run-from-the-repo-root-on-the-vm">Step 1: Show your key (run from the repo root on the VM)</h4>
<pre><code class="language-bash">cd ~/clearledger    # or wherever you cloned the repo
cat infra/cosign.pub
</code></pre>
<p>You should see three lines: <code>-----BEGIN PUBLIC KEY-----</code>, a long base64 line, and <code>-----END PUBLIC KEY-----</code>. Copy that whole block (you'll paste it in the next step).</p>
<h4 id="heading-step-2-paste-the-key-into-the-policy">Step 2: Paste the key into the policy</h4>
<p>Open <code>infra/policies/require-signed-images.yaml</code> in your editor (<code>nano</code>, <code>vim</code>, or VS Code).</p>
<p>Find this line:</p>
<pre><code class="language-yaml">                      PASTE_YOUR_COSIGN_PUBLIC_KEY_HERE
</code></pre>
<p>Delete <strong>only</strong> that placeholder line and paste the three lines from <code>cosign.pub</code> in its place. The result should look like this (your base64 line will differ):</p>
<pre><code class="language-yaml">                - keys:
                    publicKeys: |-
                      -----BEGIN PUBLIC KEY-----
                     JFkwEwYHKoZIzj0CAQYIKoFIzj0DAQcDQgZEI...
                      -----END PUBLIC KEY-----
</code></pre>
<p>Save the file. Keep the pasted key indented under <code>publicKeys: |-</code>. The <code>BEGIN PUBLIC KEY</code> and <code>END PUBLIC KEY</code> lines should have spaces before them, just like the base64 line between them.</p>
<h4 id="heading-step-3-verify-three-quick-checks">Step 3: Verify (three quick checks)</h4>
<p>Run these one at a time from the repo root:</p>
<pre><code class="language-bash"># Check A — placeholder must be gone
grep PASTE_YOUR_COSIGN_PUBLIC_KEY_HERE infra/policies/require-signed-images.yaml \
  &amp;&amp; echo "❌ FAIL: placeholder still in file — edit and save again" \
  || echo "✓ OK: placeholder removed"
</code></pre>
<pre><code class="language-bash"># Check B — key block must be present exactly once
grep -c "BEGIN PUBLIC KEY" infra/policies/require-signed-images.yaml
</code></pre>
<p>Expected output for Check B: <code>1</code> (if you see <code>0</code>, the key was not pasted. If <code>2</code>, you pasted it twice).</p>
<pre><code class="language-bash"># Check C — policy key must match cosign.pub byte-for-byte
diff infra/cosign.pub \
  &lt;(sed -n '/-----BEGIN PUBLIC KEY-----/,/-----END PUBLIC KEY-----/p' \
      infra/policies/require-signed-images.yaml | sed 's/^[[:space:]]*//')
</code></pre>
<p>Expected output for Check C: <strong>nothing</strong>. No diff lines means the keys match. If <code>diff</code> prints differences, open the policy file and fix the paste.</p>
<p>If all three passed, continue to §4.3.</p>
<p><strong>If you skip this</strong>, Scenario 3 in §4.4 fails in a confusing way: unsigned images may slip through, or signed pods may be rejected because Kyverno is checking against the wrong key.</p>
<h3 id="heading-43-apply-the-five-core-policies">4.3: Apply the Five Core Policies</h3>
<p>Now you'll apply the five policies that map to CIS controls. Don't apply <code>verify-slsa-provenance.yaml</code> yet. It's an optional SLSA attestation policy (Audit mode) for a later enhancement.</p>
<p>Stage 4 applies <code>infra/policies/require-signed-images.yaml</code>. This policy uses <code>failurePolicy: Fail</code>, so if Kyverno can't verify an image signature, the pod is blocked instead of allowed. The ECR policy with <code>failurePolicy: Ignore</code> is for Stage 8, not this step.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/67a638f5-65b8-41d2-be68-babe6c7b8c99.png" alt="screenshot image showing infra policy Yaml file failurePolicy as &quot;Fail&quot;" style="display:block;margin:0 auto" width="721" height="193" loading="lazy">

<pre><code class="language-bash">kubectl apply \
  -f infra/policies/disallow-root.yaml \
  -f infra/policies/disallow-privilege-escalation.yaml \
  -f infra/policies/drop-all-capabilities.yaml \
  -f infra/policies/require-resource-limits.yaml \
  -f infra/policies/require-signed-images.yaml
</code></pre>
<p>Wait a few seconds, then confirm all policies show <code>READY: True</code> and <code>VALIDATE ACTION: Enforce</code>:</p>
<pre><code class="language-bash">kubectl get clusterpolicy
</code></pre>
<pre><code class="language-plaintext">NAME                            ADMISSION   BACKGROUND   VALIDATE ACTION   READY   AGE
disallow-privilege-escalation   true        true         Enforce           True    10s
disallow-root-containers        true        true         Enforce           True    10s
drop-all-capabilities           true        true         Enforce           True    10s
require-resource-limits         true        true         Enforce           True    10s
require-signed-images           true        false        Enforce           True    10s
</code></pre>
<p>If <code>READY</code> stays empty, check Kyverno logs: <code>kubectl logs -n kyverno -l app.kubernetes.io/component=admission-controller --tail=50</code>.</p>
<h3 id="heading-44-breaking-it-on-purpose">4.4: Breaking it on Purpose</h3>
<p>Now you'll test the policies by trying to create bad pods.</p>
<p>These pods are supposed to fail. That's the point.</p>
<p>CI tools like Checkov warn you in a report. Kyverno goes further: it blocks unsafe pods before Kubernetes runs them.</p>
<p>For each test, read the error message, as it should tell you which policy blocked the pod and what field was wrong. That error message is your proof that admission control is working.</p>
<table>
<thead>
<tr>
<th>Scenario</th>
<th>What you simulate</th>
<th>Policy under test</th>
<th>Success looks like</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Attacker applies a bare pod (no hardening)</td>
<td>Root, caps, privilege, limits</td>
<td>Four policies fire, pod <code>NotFound</code></td>
</tr>
<tr>
<td>2</td>
<td>Developer fixes securityContext but forgets limits</td>
<td>Resource limits only</td>
<td>One policy fires, pod <code>NotFound</code></td>
</tr>
<tr>
<td>3</td>
<td>Attacker pushes unsigned image to Docker Hub</td>
<td>Cosign signature</td>
<td><code>require-signed-images</code> denies, pod <code>NotFound</code></td>
</tr>
</tbody></table>
<h4 id="heading-scenario-1-root-container-no-securitycontext">Scenario 1: root container (no securityContext)</h4>
<p><strong>What you're simulating:</strong> Someone with <code>kubectl</code> access bypasses CI and applies a minimal pod: no <code>securityContext</code>, no resource limits.</p>
<p>This is exactly what Stage 1 Checkov flagged as evidence. Stage 4 now blocks it.</p>
<p><strong>What's wrong with this manifest:</strong> The container has only a name and image. It will run as root by default, keep all Linux capabilities, and has no CPU/memory bounds.</p>
<pre><code class="language-bash">cat &lt;&lt;EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: root-test
  namespace: clearledger
spec:
  containers:
    - name: test
      image: nginx:alpine
EOF
</code></pre>
<p><strong>What you should see:</strong></p>
<pre><code class="language-yaml">Error from server: error when creating "STDIN": admission webhook "validate.kyverno.svc-fail" denied the request:

resource Pod/clearledger/root-test was blocked due to the following policies

disallow-privilege-escalation:
  check-allowPrivilegeEscalation: 'validation error: allowPrivilegeEscalation must
    be set to false. rule check-allowPrivilegeEscalation failed at path /spec/containers/0/securityContext/'
disallow-root-containers:
  check-runAsNonRoot: |-
    validation error: Root containers are blocked in the clearledger namespace. Set securityContext.runAsNonRoot: true on the pod or container.
    . rule check-runAsNonRoot failed at path /spec/containers/0/securityContext/
drop-all-capabilities:
  check-capabilities: 'validation error: All containers must drop ALL capabilities.
    rule check-capabilities failed at path /spec/containers/0/securityContext/'
require-resource-limits:
  check-resources: 'validation error: Resource requests and limits are required for
    all containers. rule check-resources failed at path /spec/containers/0/resources/limits/'
</code></pre>
<p><strong>How to read this output:</strong></p>
<p>The important line is:</p>
<pre><code class="language-text">resource Pod/clearledger/root-test was blocked due to the following policies
</code></pre>
<p>That means Kyverno stopped the pod before it was created.</p>
<p>Under that line, Kyverno lists every policy the pod failed. For example:</p>
<pre><code class="language-text">disallow-root-containers:
  check-runAsNonRoot:
</code></pre>
<p>This means the pod failed the <code>disallow-root-containers</code> policy, specifically the <code>check-runAsNonRoot</code> rule. The fix is also shown in the message:</p>
<pre><code class="language-text">Set securityContext.runAsNonRoot: true
</code></pre>
<p>The same pattern applies to the other policies:</p>
<ul>
<li><p><code>disallow-privilege-escalation</code> means the pod didn't set <code>allowPrivilegeEscalation: false</code></p>
</li>
<li><p><code>drop-all-capabilities</code> means the pod didn't drop Linux capabilities with <code>capabilities.drop: [ALL]</code></p>
</li>
<li><p><code>require-resource-limits</code> means the pod didn't set CPU and memory requests/limits</p>
</li>
</ul>
<p>The <code>path</code> part tells you where Kubernetes expected the missing setting. For example, <code>/spec/containers/0/securityContext/</code> means: look inside the pod spec, then the first container, then its <code>securityContext</code>.</p>
<p>And <code>/spec/containers/0/resources/limits/</code> means: look inside the first container's resource limits.</p>
<p>So this one bad pod failed four controls at once. That's the lesson: Kyverno doesn't just say "no." It tells you which policy failed and where to fix the YAML.</p>
<p><strong>Verify enforcement worked:</strong></p>
<pre><code class="language-bash">kubectl get pod root-test -n clearledger
# Error from server (NotFound): pods "root-test" not found
</code></pre>
<p>If you see a pod in <code>Running</code> or <code>Pending</code>, policies aren't enforcing: re-check that <code>kubectl get clusterpolicy</code> shows all five <code>READY: True</code>.</p>
<p><strong>Take a screenshot.</strong> This is portfolio evidence for CIS Kubernetes Benchmark 5.2.6: enforced, not just configured.</p>
<h4 id="heading-scenario-2-missing-resource-limits">Scenario 2: missing resource limits</h4>
<p><strong>What you're simulating:</strong> A developer who read the securityContext requirements and fixed root/caps/privilege. But skipped resource limits.</p>
<p>This is common in real teams: “we hardened the container” but forgot CPU/memory bounds.</p>
<p><strong>What's wrong with this manifest:</strong> <code>securityContext</code> is correct, but there's no <code>resources.requests</code> or <code>resources.limits</code>. A container without limits can starve other workloads on the node.</p>
<pre><code class="language-bash">cat &lt;&lt;EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: nolimits-test
  namespace: clearledger
spec:
  containers:
    - name: test
      image: nginx:alpine
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        allowPrivilegeEscalation: false
        capabilities:
          drop: [ALL]
EOF
</code></pre>
<p><strong>What you should see:</strong></p>
<pre><code class="language-plaintext">Error from server: error when creating "STDIN": admission webhook "validate.kyverno.svc-fail" denied the request:

resource Pod/clearledger/nolimits-test was blocked due to the following policies

require-resource-limits:
  check-resources: 'validation error: Resource requests and limits are required for
    all containers. rule check-resources failed at path /spec/containers/0/resources/limits/'
</code></pre>
<p><strong>Key observation:</strong> Only one policy fires this time: the securityContext fields satisfied the other four rules. Kyverno evaluates rules independently. Each container property is a separate gate.</p>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pod nolimits-test -n clearledger
# Error from server (NotFound): pods "nolimits-test" not found
</code></pre>
<h4 id="heading-scenario-3-unsigned-clearledger-image">Scenario 3: unsigned ClearLedger image</h4>
<p><strong>What you're simulating:</strong> A supply-chain attack: someone pushes a malicious image to Docker Hub under your repo name (<code>clearledger-auth-service</code>) without going through your signed CI pipeline. Stage 3 made Cosign signing possible, while Stage 4 makes it mandatory at the cluster gate.</p>
<p><strong>Why this setup is needed:</strong> Kyverno checks image signatures against the image in Docker Hub, not against images on your laptop. The test image tag must exist in Docker Hub first.</p>
<p>If you use a fake tag like <code>:unsigned</code> that was never pushed, Kubernetes may fail later with <code>ImagePullBackOff</code>. That only means the image can't be pulled; it doesn't prove Kyverno blocked an unsigned image.</p>
<h4 id="heading-step-1-push-a-deliberately-unsigned-test-image-one-time">Step 1: push a deliberately unsigned test image (one-time):</h4>
<pre><code class="language-bash">export DOCKER_USERNAME=your-dockerhub-username

docker pull nginx:alpine
docker tag nginx:alpine ${DOCKER_USERNAME}/clearledger-auth-service:unsigned-test
docker push ${DOCKER_USERNAME}/clearledger-auth-service:unsigned-test

# Must fail — proves the image has no Cosign signature from your pipeline key:
cosign verify --key infra/cosign.pub \
  index.docker.io/${DOCKER_USERNAME}/clearledger-auth-service:unsigned-test
# Error: no signatures found
</code></pre>
<h4 id="heading-step-2-try-to-deploy-it-with-a-compliant-pod-spec">Step 2: try to deploy it with a compliant pod spec:</h4>
<p>The pod manifest is fully hardened (securityContext + limits) so only the signature policy can fail. Use <code>index.docker.io/</code> in the image URL: on Kyverno 1.12, <code>docker.io/...</code> may not trigger <code>verifyImages</code> matching.</p>
<pre><code class="language-bash">cat &lt;&lt;EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: unsigned-test
  namespace: clearledger
spec:
  containers:
    - name: test
      image: index.docker.io/${DOCKER_USERNAME}/clearledger-auth-service:unsigned-test
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        allowPrivilegeEscalation: false
        capabilities:
          drop: [ALL]
      resources:
        requests:
          memory: "64Mi"
          cpu: "50m"
        limits:
          memory: "128Mi"
          cpu: "200m"
EOF
</code></pre>
<p><strong>What you should see:</strong></p>
<pre><code class="language-plaintext">Error from server: error when creating "STDIN": admission webhook "mutate.kyverno.svc-fail" denied the request:

resource Pod/clearledger/unsigned-test was blocked due to the following policies

require-signed-images:
  verify-cosign-signature: 'failed to verify image index.docker.io/veeno-demo/clearledger-auth-service:unsigned-test:
    .attestors[0].entries[0].keys: no signatures found'
</code></pre>
<p><strong>How to read this output:</strong></p>
<ul>
<li><p>Note the webhook name is <code>mutate.kyverno.svc-fail</code>, not <code>validate</code>: image verification runs in Kyverno’s mutate pass (digest + signature check) before the pod is admitted.</p>
</li>
<li><p><code>no signatures found</code> means Kyverno reached Docker Hub, found the image, and confirmed it was <strong>not</strong> signed with your <code>infra/cosign.pub</code> key.</p>
</li>
<li><p>The pod never exists: the attacker can't get a shell even if the image is pullable.</p>
</li>
</ul>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pod unsigned-test -n clearledger
# Error from server (NotFound): pods "unsigned-test" not found
</code></pre>
<p><strong>What you should NOT see</strong> (these mean the test didn't prove signature enforcement):</p>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>What went wrong</th>
</tr>
</thead>
<tbody><tr>
<td>Pod created, then <code>ImagePullBackOff</code></td>
<td>Tag does not exist on Docker Hub, complete Step 1 first</td>
</tr>
<tr>
<td>Pod created and <code>Running</code></td>
<td>Image used <code>docker.io/...</code> instead of <code>index.docker.io/...</code></td>
</tr>
<tr>
<td>No <code>require-signed-images</code> in the error</td>
<td>Policy not applied, or <code>cosign.pub</code> not embedded in the policy YAML</td>
</tr>
</tbody></table>
<h4 id="heading-contrast-signed-image-is-allowed">Contrast: signed image is allowed:</h4>
<p>The previous test used an unsigned image, so Kyverno blocked it.</p>
<p>Your real ClearLedger images should be signed by the CI pipeline. If the pod also follows the security rules, Kyverno allows it to run.</p>
<p>You can check the image currently used by <code>auth-service</code>:</p>
<pre><code class="language-bash"># Your deployed tag (signed in CI) should start if spec is compliant:
kubectl get deployment auth-service -n clearledger \
  -o jsonpath='{.spec.template.spec.containers[0].image}'
# docker.io/veeno-demo/clearledger-auth-service:v0.1.0
</code></pre>
<p><strong>Example output:</strong></p>
<p><code>docker.io/veeno-demo/clearledger-auth-service:v0.1.0</code></p>
<p>Pods that were already running before the policies were applied will keep running. The important test is what happens when Kubernetes creates a new pod. New pods using signed ClearLedger images should pass Kyverno verification.</p>
<p>Take a screenshot of the Scenario 3 denial. It proves the cluster blocks unsigned images, not just that CI signs images.</p>
<h3 id="heading-45-verify-clearledger-still-works">4.5: Verify ClearLedger Still Works</h3>
<p>Kyverno enforces on new pod creation. Existing deployments that already passed admission (or were synced before policies existed) keep running. Confirm your app pods are healthy:</p>
<pre><code class="language-bash">kubectl get pods -n clearledger
</code></pre>
<pre><code class="language-plaintext">NAME                                    READY   STATUS    RESTARTS   AGE
auth-service-...                        1/1     Running   0          ...
frontend-...                            1/1     Running   0          ...
ledger-service-...                      1/1     Running   0          ...
notification-service-...                1/1     Running   0          ...
postgres-0                              1/1     Running   0          ...
redis-...                               1/1     Running   0          ...
</code></pre>
<p>If ingress is configured:</p>
<pre><code class="language-bash">curl -s http://clearledger.local/auth/health | jq .
# {"status": "ok", "service": "auth-service"}
</code></pre>
<p>ArgoCD should still show <strong>Synced</strong> and <strong>Healthy</strong>: GitOps and admission control work together, not against each other.</p>
<h3 id="heading-46-policy-exceptions-when-a-legitimate-workload-needs-a-bypass">4.6: Policy Exceptions (When a Legitimate Workload Needs a Bypass)</h3>
<p>Kyverno blocks every pod that violates a policy. But what happens when a legitimate workload needs to bypass a specific rule?</p>
<p>PostgreSQL is the example. The official Postgres Alpine image uses a specific internal user (UID 70) to manage its data directory. The <code>disallow-root-containers</code> policy requires every pod to set <code>runAsNonRoot: true</code>.</p>
<p>Postgres does set that. But if Kyverno is configured to also check specific UID ranges, or if the pod's security context doesn't satisfy the rule for any reason, Kyverno blocks it. The database can't start, and the entire application fails.</p>
<p>You can't weaken the policy cluster-wide to accommodate one database. That would let every pod bypass the rule. Instead, you create a <strong>PolicyException</strong>: a targeted exemption for exactly the pods that need it.</p>
<p>Open <a href="../infra/policies/exceptions/postgres-root-exception.yaml"><code>infra/policies/exceptions/postgres-root-exception.yaml</code></a> and read the comments. Here's what each section does:</p>
<p><strong>The</strong> <code>spec.exceptions</code> <strong>block</strong> identifies which policy and rule to bypass:</p>
<pre><code class="language-yaml">exceptions:
  - policyName: disallow-root-containers
    ruleNames:
      - check-runAsNonRoot
</code></pre>
<p>This says: "skip only the <code>check-runAsNonRoot</code> rule from the <code>disallow-root-containers</code> policy." Every other rule in that policy (and every other policy in the cluster) still enforces normally.</p>
<p><strong>The</strong> <code>spec.match</code> <strong>block</strong> limits which resources get the exception:</p>
<pre><code class="language-yaml">match:
  any:
    - resources:
        kinds:
          - Pod
        namespaces:
          - clearledger
        names:
          - postgres-*
</code></pre>
<p>Only pods named <code>postgres-*</code> (matching <code>postgres-0</code>, <code>postgres-1</code>, and so on), only in the <code>clearledger</code> namespace, only for the <code>Pod</code> resource kind. Everything else in the cluster still follows the strict policy.</p>
<p><strong>The annotations</strong> are documentation for your team and auditors:</p>
<pre><code class="language-yaml">annotations:
  reason: "Postgres alpine image requires UID 70 for data directory ownership"
  approved-by: "platform-team"
  review-date: "2026-01-01"
</code></pre>
<p>These have no technical effect: Kyverno ignores them. They exist so that six months from now, when someone asks "why does Postgres bypass this rule?", the answer is right there in the file.</p>
<p><strong>The rules for safe exceptions:</strong></p>
<ol>
<li><p><strong>Scope narrowly</strong>: target the exact resource that needs it, nothing more</p>
</li>
<li><p><strong>Commit to Git</strong>: the exception is reviewed in a pull request, tracked in version history, and auditable</p>
</li>
<li><p><strong>Never weaken the policy itself</strong>: the rule stays strict for everything else</p>
</li>
<li><p><strong>Review periodically</strong>: exceptions should be temporary if possible, and re-evaluated on a schedule</p>
</li>
</ol>
<p>Apply the exception <strong>only if</strong> Kyverno blocks your Postgres pods:</p>
<pre><code class="language-bash">kubectl apply -f infra/policies/exceptions/postgres-root-exception.yaml
</code></pre>
<p>Verify Kyverno still blocks other non-compliant pods (same denial as Scenario 1):</p>
<pre><code class="language-bash">cat &lt;&lt;EOF | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: another-root-test
  namespace: clearledger
spec:
  containers:
    - name: test
      image: nginx:alpine
EOF
</code></pre>
<h3 id="heading-47-cis-benchmark-evidence-kube-bench">4.7: CIS Benchmark Evidence <code>kube-bench</code>)</h3>
<p>You already installed Kyverno and proved it blocks unsafe pods.</p>
<p>This step is different. <code>kube-bench</code> doesn't block pods and doesn't change the cluster. It only checks the Kubernetes node against the CIS benchmark and saves evidence.</p>
<p>Think of the difference like this:</p>
<table>
<thead>
<tr>
<th>Tool</th>
<th>What it checks</th>
<th>Question it answers</th>
</tr>
</thead>
<tbody><tr>
<td>Kyverno</td>
<td>Pods and workloads</td>
<td>"Is this pod allowed to run?"</td>
</tr>
<tr>
<td>kube-bench</td>
<td>Kubernetes node settings</td>
<td>"Is this Kubernetes node hardened?"</td>
</tr>
</tbody></table>
<p>Both are useful, but only Kyverno blocks workloads in this lab.</p>
<p>Run kube-bench:</p>
<pre><code class="language-bash">bash stages/stage-4-admission-control/scripts/run-kube-bench.sh
</code></pre>
<p>The script runs kube-bench as a Kubernetes Job and saves the report here:</p>
<pre><code class="language-text">stages/stage-4-admission-control/scripts/kube-bench-report.json
</code></pre>
<p>It also compares the result against this baseline:</p>
<pre><code class="language-text">stages/stage-4-admission-control/scripts/kube-bench-baseline.json
</code></pre>
<p>On MicroK8s, you'll see many <code>FAIL</code> and <code>WARN</code> lines. That's expected. The lab isn't asking you to fix every CIS warning on a single-node local VM.</p>
<p>What matters is the final result.</p>
<p>Pass looks like this:</p>
<pre><code class="language-text">kube-bench: 1 FAIL control(s) present (documented in baseline — no regressions).
kube-bench: no regressions vs baseline.
</code></pre>
<p>That means the known MicroK8s issues are documented, and your cluster didn't get worse.</p>
<p>If you see <code>REGRESSION</code> or <code>make check-4</code> fails on kube-bench, stop and investigate before Stage 5.</p>
<p>Optional: confirm the report file exists:</p>
<pre><code class="language-bash">ls -la stages/stage-4-admission-control/scripts/kube-bench-report.json
</code></pre>
<p>In production, you would either fix the CIS failures or document approved exceptions. In this lab, the baseline records the expected MicroK8s state.</p>
<h3 id="heading-48-health-check">4.8: Health Check</h3>
<pre><code class="language-bash">make check-4
</code></pre>
<p><strong>What you should see:</strong></p>
<pre><code class="language-plaintext">▶ Stage 4 — Admission Control (Kyverno)
  ✓ Kyverno is running
  ✓ Policy disallow-root-containers — Enforce mode
  ✓ Policy require-resource-limits — Enforce mode
  ✓ Policy require-signed-images — Enforce mode
  ✓ Policy disallow-privilege-escalation — Enforce mode
  ✓ Policy drop-all-capabilities — Enforce mode
  ✓ Kyverno correctly rejects pods without securityContext
  ✓ kube-bench baseline exists (...)

All checks passed. Ready for the next stage.
</code></pre>
<p>If kube-bench reports regressions, run the script manually and update the baseline after reviewing. That diff is audit evidence.</p>
<p>If Kyverno install, policies, break-it scenarios, or <code>make check-4</code> fail, see <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md. Stage 4</a>.</p>
<h3 id="heading-stage-4-complete-done-checklist-move-to-stage-5">Stage 4 Complete: Done Checklist (Move to Stage 5)</h3>
<p>You're <strong>done with Stage 4</strong> when all of these are true:</p>
<table>
<thead>
<tr>
<th>#</th>
<th>Check</th>
<th>How to verify</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Kyverno running</td>
<td><code>kubectl get pods -n kyverno</code> — four controllers <code>Running</code></td>
</tr>
<tr>
<td>2</td>
<td>Policies applied</td>
<td><code>kubectl get clusterpolicy</code> — five policies, <code>READY: True</code>, <code>Enforce</code></td>
</tr>
<tr>
<td>3</td>
<td>Root pod blocked</td>
<td>Scenario 1 denial in terminal (screenshot for portfolio)</td>
</tr>
<tr>
<td>4</td>
<td>Unsigned image blocked</td>
<td>Scenario 3 denial — push <code>unsigned-test</code> tag first, use <code>index.docker.io/</code></td>
</tr>
<tr>
<td>5</td>
<td>App still healthy</td>
<td><code>kubectl get pods -n clearledger</code> — all app pods <code>Running</code></td>
</tr>
<tr>
<td>6</td>
<td>Health check green</td>
<td><code>make check-4</code> ends with <code>All checks passed. Ready for the next stage.</code></td>
</tr>
</tbody></table>
<p><strong>Portfolio screenshots (optional):</strong> root-pod denial (§4.4 Scenario 1), unsigned-image denial (§4.4 Scenario 3), and <code>kubectl get clusterpolicy</code> showing five <code>Enforce</code> policies.</p>
<p>Not yet: SLSA attestation (optional), Vault secrets (Stage 5), network policies (Stage 6). Passwords still live in Kubernetes Secrets. Stage 5 moves them into Vault.</p>
<h3 id="heading-what-you-learned-in-stage-4">What You Learned in Stage 4</h3>
<ul>
<li><p>The difference between CI scanning (before merge) and admission control (at the cluster gate)</p>
</li>
<li><p>What Kyverno is: a policy engine that intercepts every Kubernetes API request</p>
</li>
<li><p>That enforcement means the bad resource never exists, not "we detected it after the fact"</p>
</li>
<li><p>How to read a Kyverno denial: policy name → rule name → JSON path that failed</p>
</li>
<li><p>How to write and apply cluster-wide security policies as YAML</p>
</li>
<li><p>How to scope a PolicyException without weakening the policy for everyone else</p>
</li>
<li><p>That operational issues (Helm, image pulls, registry URL format) affect whether controls actually fire</p>
</li>
<li><p><strong>Why both CI and admission control are needed:</strong> CI catches problems in your code while Kyverno catches everything else that touches the cluster</p>
</li>
<li><p><strong>Evidence beats configuration:</strong> a policy file in Git means nothing: the break-it denials are proof CIS controls are enforced, not just documented.</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Enforced admission control with Kyverno: blocking root containers, privilege escalation, unsigned images, and missing resource limits at deploy time: mapped to CIS Kubernetes benchmarks.</p>
</blockquote>
<p><code>make snapshot STAGE=4 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage4</code>. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<h2 id="heading-stage-5-secrets-management-vault">Stage 5: Secrets Management (Vault)</h2>
<p>By the end of this stage, sensitive values no longer live in Git or in etcd-backed Kubernetes Secrets: Vault holds them centrally and injects them into pods only when they start.</p>
<p><strong>Your goal:</strong> remove <code>auth-service-secret</code> and <code>ledger-service-secret</code> from the cluster.</p>
<p>Login and API calls must still work because Vault injects credentials at pod startup. That's the moment secrets management clicks.</p>
<p><strong>Before you start</strong>, confirm Stage 4 is solid: <code>make check-4</code> passes, all five Kyverno policies are enforcing, and the app responds at <code>http://clearledger.local</code>. Fix any crash-looping pods before installing Vault.</p>
<h3 id="heading-what-changes-in-this-stage">What Changes in This Stage</h3>
<p>Right now, database passwords and JWT keys sit in <code>secret.yaml</code> files on GitHub and in Kubernetes Secrets inside the cluster. In Stage 5 you move those values into <strong>HashiCorp Vault</strong> and teach the app to read them a different way.</p>
<p>When an auth or ledger pod starts, the <strong>Vault agent injector</strong> adds a small sidecar container. That sidecar logs into Vault using the pod’s own service account, fetches the password and JWT, and writes them as files under <code>/vault/secrets/</code>.</p>
<p>Your app already knows how to read those paths. It's the same data that used to arrive via <code>secretKeyRef</code>, just delivered at runtime instead of pulled from a Kubernetes Secret object.</p>
<p>Once migration is complete, sensitive values live in <strong>Vault</strong> (the long-term store) and briefly on the <strong>pod filesystem</strong> while the container runs. They're not in Git anymore. You remove <code>secret.yaml</code> from <code>clearledger-infra</code> and ArgoCD syncs deployments that point at Vault instead.</p>
<p>To load Vault the first time, you copy a template to a local <code>.env</code> file (§5.1). That file is gitignored. You run <code>seed-vault-secrets.sh</code> once to copy those values into Vault.</p>
<p>Real secret values aren't written into committed scripts. The scripts read secrets from your local <code>.env</code> file or from your terminal, so passwords and tokens stay out of Git.</p>
<h3 id="heading-do-the-steps-in-this-order">Do the Steps in This Order</h3>
<p>Each step depends on the one before it. Skipping ahead is the most common way to get red auth/ledger pods that look like a broken app but really mean “Vault is not ready yet.”</p>
<ol>
<li><p><strong>§5.1</strong>: copy <code>stages/stage-5-secrets-management/.env.example</code> to <code>.env</code>, then fill it with your cluster passwords</p>
</li>
<li><p><strong>§5.2</strong>: install Vault and the agent injector with Helm</p>
</li>
<li><p><strong>§5.3</strong>. Run <code>setup.sh</code>, then <code>seed-vault-secrets.sh</code> (passwords now live in Vault)</p>
</li>
<li><p><strong>§5.4</strong>: push Vault-enabled deployments to <code>clearledger-infra</code>. Let ArgoCD sync.</p>
</li>
<li><p><strong>§5.5</strong>. Wait for <strong>2/2</strong> pods (app + Vault sidecar), then delete the old Kubernetes Secrets</p>
</li>
<li><p><strong>§5.5b</strong>: ArgoCD <strong>Synced / Healthy</strong> (after secret delete. OutOfSync before delete is normal)</p>
</li>
<li><p><strong>§5.6</strong>. Confirm login works and credentials appear under <code>/vault/secrets/</code> inside the pod</p>
</li>
</ol>
<p>Start at <strong>§5.1</strong>. If anything fails, read <code>troubleshooting.md.</code> before changing manifests.</p>
<h3 id="heading-51-create-env-local-only-never-commit">5.1: Create <code>.env</code> (Local Only, Never Commit)</h3>
<p>This file holds two things: a dev Vault root token for Helm (§5.2), and the passwords you'll load into Vault in §5.3.</p>
<p>It stays on your machine only. Never commit it. The <code>SEED_*</code> values must match what the app uses today so login still works after you delete Kubernetes Secrets later.</p>
<p>Two different files. <strong>Don't mix them up:</strong></p>
<table>
<thead>
<tr>
<th>File</th>
<th>What it is</th>
</tr>
</thead>
<tbody><tr>
<td><code>stages/stage-5-secrets-management/.env.example</code></td>
<td>Blank template in the repo (empty fields). Copy this in step 1.</td>
</tr>
<tr>
<td><code>stages/stage-5-secrets-management/.env</code></td>
<td>Your real file (gitignored). You create it and fill it in steps 2–3.</td>
</tr>
</tbody></table>
<p>The sample block at the bottom of this section is only a picture of what a completed <code>.env</code> looks like: don't copy those placeholder passwords unless they happen to match your cluster.</p>
<h4 id="heading-step-1-copy-the-template-to-env">Step 1: copy the template to <code>.env</code></h4>
<pre><code class="language-bash">cp stages/stage-5-secrets-management/.env.example \
   stages/stage-5-secrets-management/.env
</code></pre>
<p>That gives you a file with empty <code>VAULT_TOKEN=</code> and <code>SEED_*=</code> lines. Open it in your editor for steps 2–3.</p>
<h4 id="heading-step-2-read-the-current-passwords-from-the-cluster">Step 2: read the current passwords from the cluster</h4>
<p>Run these from the repo root. Each command prints one value: copy the output into <code>.env</code> in step 3.</p>
<pre><code class="language-bash"># → paste as SEED_AUTH_DATABASE_URL
kubectl get secret auth-service-secret -n clearledger \
  -o jsonpath='{.data.database_url}' | base64 -d; echo

# → paste as SEED_AUTH_JWT_SECRET
kubectl get secret auth-service-secret -n clearledger \
  -o jsonpath='{.data.jwt_secret}' | base64 -d; echo

# → paste as SEED_LEDGER_DATABASE_URL
kubectl get secret ledger-service-secret -n clearledger \
  -o jsonpath='{.data.database_url}' | base64 -d; echo
</code></pre>
<h4 id="heading-step-3-fill-in-env">Step 3: fill in <code>.env</code></h4>
<table>
<thead>
<tr>
<th>Variable</th>
<th>What to put</th>
</tr>
</thead>
<tbody><tr>
<td><code>VAULT_TOKEN</code></td>
<td>Any dev-only string you choose (for example, <code>my-dev-root-token</code>): same value in §5.2 Helm install</td>
</tr>
<tr>
<td><code>SEED_AUTH_DATABASE_URL</code></td>
<td>Output of first command above</td>
</tr>
<tr>
<td><code>SEED_AUTH_JWT_SECRET</code></td>
<td>Output of second command</td>
</tr>
<tr>
<td><code>SEED_LEDGER_DATABASE_URL</code></td>
<td>Output of third command</td>
</tr>
</tbody></table>
<p><strong>Sample only: shape of a completed</strong> <code>.env</code> (use your kubectl output from step 2, not these example strings unless they match):</p>
<pre><code class="language-text">VAULT_TOKEN=my-dev-root-token
SEED_AUTH_DATABASE_URL=postgresql://clearledger:changeme-stage0@postgres:5432/clearledger
SEED_AUTH_JWT_SECRET=stage0-jwt-secret-change-in-production
SEED_LEDGER_DATABASE_URL=postgresql://clearledger:changeme-stage0@postgres:5432/clearledger
</code></pre>
<p>If <code>auth-service-secret</code> is already deleted (you skipped ahead: recover like this):</p>
<pre><code class="language-bash"># Database URL from Postgres bootstrap secret (lab default password is often changeme-stage0)
PG_PASS=$(kubectl get secret postgres-secret -n clearledger \
  -o jsonpath='{.data.password}' | base64 -d)
echo "postgresql://clearledger:${PG_PASS}@postgres:5432/clearledger"
# Use that line for both SEED_AUTH_DATABASE_URL and SEED_LEDGER_DATABASE_URL

# JWT: same value you used at Stage 0, or read from Vault if you already seeded:
kubectl exec -n vault vault-0 -- vault kv get -field=jwt_secret clearledger/auth-service 2&gt;/dev/null \
  || echo "(set SEED_AUTH_JWT_SECRET manually — must match tokens already issued)"
</code></pre>
<p>Continue to <strong>§5.2</strong> once <code>.env</code> has all four variables set.</p>
<h3 id="heading-52-install-vault-and-the-agent-injector">5.2: Install Vault and the Agent Injector</h3>
<pre><code class="language-bash">set -a &amp;&amp; source stages/stage-5-secrets-management/.env &amp;&amp; set +a

helm repo add hashicorp https://helm.releases.hashicorp.com &amp;&amp; helm repo update

# First install:
helm install vault hashicorp/vault \
  --namespace vault --create-namespace \
  --set server.dev.enabled=true \
  --set server.dev.devRootToken="${VAULT_TOKEN}" \
  --set ui.enabled=true \
  --set injector.enabled=true

# If helm install fails with "cannot re-use a name", use upgrade instead:
# helm upgrade --install vault hashicorp/vault \
#   --namespace vault --create-namespace \
#   --set server.dev.enabled=true \
#   --set server.dev.devRootToken="${VAULT_TOKEN}" \
#   --set ui.enabled=true \
#   --set injector.enabled=true

kubectl wait --for=condition=ready pod \
  -l app.kubernetes.io/name=vault -n vault --timeout=120s
kubectl wait --for=condition=ready pod \
  -l app.kubernetes.io/name=vault-agent-injector -n vault --timeout=120s

kubectl apply -f stages/stage-5-secrets-management/infra/vault-ingress.yaml
</code></pre>
<p>Open <a href="http://vault.local"><code>http://vault.local</code></a> in your browser. Log in with the value you set as <code>VAULT_TOKEN</code> in <code>stages/stage-5-secrets-management/.env</code>. For example, if your <code>.env</code> has <code>VAULT_TOKEN=my-dev-root-token</code>, use <code>my-dev-root-token</code> as the Vault login token.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/8a2b0002-2ee3-4b04-89f0-e43da18fc9b9.png" alt="screenshot showing vault UI" style="display:block;margin:0 auto" width="1301" height="696" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ec5fdb0b-7258-4e41-aeb5-adaa8754dbf2.png" alt="screenshot showing vault UI" style="display:block;margin:0 auto" width="1283" height="703" loading="lazy">

<p><strong>Verify: list Vault pods:</strong></p>
<pre><code class="language-bash">kubectl get pods -n vault
</code></pre>
<p><strong>Expected: Vault pods:</strong></p>
<pre><code class="language-text">NAME                                   READY   STATUS    RESTARTS   AGE
vault-0                                1/1     Running   0          1m
vault-agent-injector-8d6b668b4-xxxxx   1/1     Running   0          1m
</code></pre>
<p><strong>If</strong> <code>helm install</code> <strong>fails with “cannot re-use a name”</strong>: Vault is already installed. Use the <code>helm upgrade --install</code> block above.</p>
<h3 id="heading-53-configure-vault-platform-seed-kv">5.3: Configure Vault (Platform + Seed KV)</h3>
<p>Run both scripts in order. Each reads <code>VAULT_TOKEN</code> from your <code>.env</code>.</p>
<pre><code class="language-bash">bash stages/stage-5-secrets-management/infra/vault/setup.sh
bash stages/stage-5-secrets-management/infra/vault/seed-vault-secrets.sh
</code></pre>
<p><code>setup.sh</code>: prepares Vault for the cluster: Kubernetes auth, the KV secret store, policies, and roles so auth/ledger pods <em>can</em> fetch secrets later. It doesn't write your database passwords yet and nothing goes to Git.</p>
<p><code>seed-vault-secrets.sh</code>: takes the <code>SEED_*</code> lines from <code>.env</code> and stores them in Vault at <code>clearledger/data/auth-service</code> and <code>clearledger/data/ledger-service</code>. It doesn't echo those values to the terminal.</p>
<p>Re-running either script is safe for the lab.</p>
<p><strong>Expected,</strong> <code>setup.sh</code> <strong>(tail):</strong></p>
<pre><code class="language-text">==&gt; Enabling Kubernetes auth method...
==&gt; Configuring Kubernetes auth...
==&gt; Enabling KV secrets engine...
==&gt; Creating Vault policies...
==&gt; Creating Kubernetes auth roles...
==&gt; Applying RBAC + ServiceAccounts...

✓ Vault platform setup complete (no secrets written yet).
  Next: bash stages/stage-5-secrets-management/infra/vault/seed-vault-secrets.sh
</code></pre>
<p><strong>Expected,</strong> <code>seed-vault-secrets.sh</code><strong>:</strong></p>
<pre><code class="language-text">==&gt; Logging into Vault...
==&gt; Writing secrets to Vault KV (values are not printed)...
======== Secret Path ========
clearledger/data/auth-service
======= Metadata =======
Key                Value
---                -----
created_time       2026-06-01T15:31:53.538991153Z
version            1
✓ Secrets stored at clearledger/data/auth-service and clearledger/data/ledger-service
</code></pre>
<p><strong>Verify metadata only</strong> (no secret values printed):</p>
<pre><code class="language-bash">kubectl exec -n vault vault-0 -- vault kv metadata get clearledger/auth-service
</code></pre>
<pre><code class="language-text">Key                     Value
---                     -----
cas_required            false
created_time            2026-06-01T15:31:53.538991153Z
current_version         1
delete_version_after    0s
max_versions            0
oldest_version          0
updated_time            2026-06-01T15:31:53.538991153Z
</code></pre>
<h3 id="heading-54-gitops-update-clearledger-infra-fixes-argocd-outofsync">5.4: GitOps: Update <code>clearledger-infra</code> (Fixes ArgoCD OutOfSync)</h3>
<p>ArgoCD deploys from your <code>clearledger-infra</code> GitHub repo, not from the main <code>clearledger</code> app repo where you're working now. You edit manifests here first, then copy the same changes to <code>clearledger-infra</code> so ArgoCD can sync them. Work slowly and verify after each sub-step.</p>
<h4 id="heading-54a-update-manifests-in-the-app-repo-clearledger">5.4a. Update manifests in the app repo (<code>clearledger</code>)</h4>
<pre><code class="language-bash">cp stages/stage-5-secrets-management/infra/manifests/auth-service/deployment.yaml \
   infra/manifests/auth-service/deployment.yaml

cp stages/stage-5-secrets-management/infra/manifests/ledger-service/deployment.yaml \
   infra/manifests/ledger-service/deployment.yaml\

mkdir -p infra/manifests/vault

cp infra/deferred-by-stage/stage-5-secrets-management/vault/rotation-cronjob.yaml \
   infra/manifests/vault/rotation-cronjob.yaml

rm -f infra/manifests/auth-service/secret.yaml infra/manifests/ledger-service/secret.yaml
</code></pre>
<h4 id="heading-54b-edit-inframanifestskustomizationyaml-by-hand">5.4b. Edit <code>infra/manifests/kustomization.yaml</code> by hand</h4>
<p>Open the file in your editor. In the <code>resources:</code> list:</p>
<ul>
<li><p><strong>Remove</strong> the app secret entries: delete these two lines, or comment them out with <code>#</code> (both work, as Kustomize ignores <code>#</code> lines):</p>
<pre><code class="language-yaml">- auth-service/secret.yaml
- ledger-service/secret.yaml
</code></pre>
</li>
<li><p><strong>Add</strong> this line (with the other resources):</p>
<pre><code class="language-yaml">- vault/rotation-cronjob.yaml
</code></pre>
</li>
</ul>
<p>Leave <code>postgres/postgres-secret.yaml</code>, that is Postgres bootstrap only, not app credentials.</p>
<p>Save. Verify:</p>
<pre><code class="language-bash"># Active (uncommented) app secret lines must be gone — postgres-secret is OK
grep -E '^[[:space:]]*-[[:space:]]+(auth-service|ledger-service)/secret\.yaml' \
  infra/manifests/kustomization.yaml &amp;&amp; echo "STOP: app secrets still active" || echo "OK"

grep vault/rotation-cronjob.yaml infra/manifests/kustomization.yaml
grep vault.hashicorp infra/manifests/auth-service/deployment.yaml | head -1
kustomize build infra/manifests &gt;/dev/null &amp;&amp; echo "OK: kustomize build"
</code></pre>
<p>Expected: <code>OK</code>, rotation cronjob listed, first line shows <code>vault.hashicorp.com/agent-inject</code>, kustomize build succeeds.</p>
<p>Commit in the <strong>app</strong> repo when ready: <code>git add infra/manifests &amp;&amp; git commit -m "feat(stage-5): Vault deployments in canonical manifests"</code>.</p>
<h4 id="heading-54c-push-the-same-changes-to-clearledger-infra">5.4c. Push the same changes to <code>clearledger-infra</code></h4>
<pre><code class="language-bash">git clone https://github.com/YOUR_USERNAME/clearledger-infra.git /tmp/clearledger-infra
</code></pre>
<p>If clone fails with <code>destination path '/tmp/clearledger-infra' already exists</code> (you cloned in §1.3 or an earlier step), reuse that folder. Don't clone again:</p>
<pre><code class="language-bash">cd /tmp/clearledger-infra &amp;&amp; git pull &amp;&amp; cd -
</code></pre>
<p>Or start fresh: <code>rm -rf /tmp/clearledger-infra</code> then run <code>git clone</code> again.</p>
<p><strong>Run the</strong> <code>cp</code> <strong>commands from the main</strong> <code>clearledger</code> <strong>app repo</strong>, not from <code>/tmp/clearledger-infra</code>. Your shell prompt should say <code>clearledger</code>, not <code>clearledger-infra</code>. The source path <code>infra/manifests/...</code> only exists in the app repo.</p>
<pre><code class="language-bash">cd ~/clearledger    # main app repo — adjust path if yours differs

cp infra/manifests/auth-service/deployment.yaml /tmp/clearledger-infra/manifests/auth-service/
cp infra/manifests/ledger-service/deployment.yaml /tmp/clearledger-infra/manifests/ledger-service/
mkdir -p /tmp/clearledger-infra/manifests/vault
cp infra/manifests/vault/rotation-cronjob.yaml /tmp/clearledger-infra/manifests/vault/
cp infra/manifests/kustomization.yaml /tmp/clearledger-infra/manifests/kustomization.yaml
rm -f /tmp/clearledger-infra/manifests/auth-service/secret.yaml
rm -f /tmp/clearledger-infra/manifests/ledger-service/secret.yaml

cd /tmp/clearledger-infra
git add -A
git status
git commit -m "feat(stage-5): Vault injection; remove app secrets from GitOps"
git push
cd -
</code></pre>
<p><strong>✋ Hands-on checkpoint. Stage 5 GitOps landed</strong></p>
<pre><code class="language-bash">git clone --depth 1 https://github.com/YOUR_USERNAME/clearledger-infra.git /tmp/verify-s5
test ! -f /tmp/verify-s5/manifests/auth-service/secret.yaml &amp;&amp; echo "OK: app secret removed from Git"
grep vault.hashicorp /tmp/verify-s5/manifests/auth-service/deployment.yaml | head -1
grep vault/rotation-cronjob.yaml /tmp/verify-s5/manifests/kustomization.yaml
rm -rf /tmp/verify-s5
</code></pre>
<p>Expected: <code>OK</code>, Vault annotation present, rotation job in kustomization.</p>
<p><strong>Expected,</strong> <code>git status</code> <strong>before commit (step 5.4c):</strong></p>
<pre><code class="language-text">modified:   manifests/auth-service/deployment.yaml
modified:   manifests/ledger-service/deployment.yaml
modified:   manifests/kustomization.yaml
new file:   manifests/vault/rotation-cronjob.yaml
deleted:    manifests/auth-service/secret.yaml
deleted:    manifests/ledger-service/secret.yaml
</code></pre>
<p>After <code>git push</code>, ArgoCD will roll out Vault-enabled deployments automatically. <strong>Continue to §5.5</strong>. Don't expect <strong>Synced</strong> yet, as app secrets are still in the cluster until you delete them there.</p>
<p><strong>Common rollout failures:</strong></p>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td><code>Duplicate value: "vault-secrets"</code></td>
<td>Do <strong>not</strong> declare a <code>vault-secrets</code> volume in <code>deployment.yaml</code>: the injector creates it</td>
</tr>
<tr>
<td><code>Service appeared 2 times</code></td>
<td>Keep <code>Service</code> only in <code>service.yaml</code>, not at the bottom of <code>deployment.yaml</code></td>
</tr>
<tr>
<td>Kyverno <code>containers/0</code> <code>runAsNonRoot</code></td>
<td>Add <code>runAsNonRoot: true</code> on the <strong>app</strong> container <code>securityContext</code>, not only on <code>spec.securityContext</code></td>
</tr>
<tr>
<td>Pods stuck <code>1/1</code> (no sidecar)</td>
<td>Confirm <code>injector.enabled=true</code> and deployment has <code>vault.hashicorp.com/agent-inject: "true"</code></td>
</tr>
<tr>
<td><code>permission denied</code> in vault-agent-init</td>
<td>Run <code>setup.sh</code> : K8s auth role not bound to service account</td>
</tr>
<tr>
<td>ArgoCD <strong>Sync failed</strong> on <code>CronJob/vault-secret-rotation</code></td>
<td>Kyverno blocked the job: <code>infra/manifests/vault/rotation-cronjob.yaml</code> must include <code>runAsNonRoot</code>, <code>allowPrivilegeEscalation: false</code>, <code>capabilities.drop: [ALL]</code>, and CPU/memory limits. Push fix to <code>clearledger-infra</code>.</td>
</tr>
</tbody></table>
<h3 id="heading-55-wait-for-vault-injected-pods-then-delete-k8s-app-secrets">5.5: Wait for Vault-injected Pods, Then Delete K8s App Secrets</h3>
<p><strong>Wait until auth/ledger show Vault sidecars</strong> (<code>READY 2/2</code> = app + vault-agent):</p>
<pre><code class="language-bash">kubectl get pods -n clearledger -l app=auth-service
kubectl get pods -n clearledger -l app=ledger-service
</code></pre>
<p><strong>Expected:</strong></p>
<pre><code class="language-text">NAME                            READY   STATUS    RESTARTS   AGE
auth-service-5756d9fcb9-bmdlr   2/2     Running   0          2m
auth-service-5756d9fcb9-jtgss   2/2     Running   0          2m
</code></pre>
<p>Inspect sidecar pulled secrets (init container logs):</p>
<pre><code class="language-bash">kubectl logs -n clearledger \
  $(kubectl get pod -n clearledger -l app=auth-service -o name | head -1) \
  -c vault-agent-init
# ... Authentication successful, rendering templates ...
</code></pre>
<p><strong>Only after pods are 2/2</strong>, delete app Secrets:</p>
<pre><code class="language-bash">kubectl delete secret auth-service-secret ledger-service-secret -n clearledger
</code></pre>
<p><strong>Expected: secrets remaining:</strong></p>
<pre><code class="language-bash">kubectl get secret -n clearledger
</code></pre>
<pre><code class="language-text">NAME              TYPE     DATA   AGE
postgres-secret   Opaque   2      6d
</code></pre>
<p><code>postgres-secret</code> is Postgres bootstrap only, not app credentials. That stays until you harden Postgres separately.</p>
<p>If delete says <code>NotFound</code>: secrets were already removed. Continue to §5.6.</p>
<h3 id="heading-55b-argocd-should-be-synced-after-secret-delete">5.5b: ArgoCD Should Be Synced After Secret Delete</h3>
<p>Run this after §5.5, not right after §5.4. Before you delete app Secrets, OutOfSync is normal. Git no longer lists <code>auth-service-secret</code> / <code>ledger-service-secret</code>, but they still exist in the cluster until you delete them in the step above.</p>
<pre><code class="language-bash">kubectl get application clearledger -n argocd \
  -o jsonpath='sync={.status.sync.status} health={.status.health.status}{"\n"}'
</code></pre>
<p><strong>Before secret delete:</strong> expect <code>sync=OutOfSync health=Healthy</code> or <code>Progressing</code> while Vault pods roll out. That's fine if auth/ledger are <strong>2/2</strong>.</p>
<p><strong>After secret delete</strong>, hard-refresh and sync if still OutOfSync:</p>
<pre><code class="language-bash">kubectl annotate application clearledger -n argocd argocd.argoproj.io/refresh=hard --overwrite
argocd app sync clearledger --grpc-web --prune
</code></pre>
<p>If sync says <strong>another operation is already in progress</strong>, wait a minute: ArgoCD auto-sync is already running.</p>
<p>Wait until:</p>
<pre><code class="language-bash">kubectl get application clearledger -n argocd \
  -o jsonpath='{.status.sync.status} {.status.health.status}{"\n"}'
# Synced Healthy
</code></pre>
<p>Don't update app deployments with <code>kubectl apply</code> after ArgoCD is managing them. ArgoCD keeps the cluster matched to <code>clearledger-infra</code>. If you change a deployment by hand, ArgoCD may revert it. For Stage 5, update the manifests in Git and let ArgoCD sync the Vault-enabled deployments.</p>
<h3 id="heading-56-login-and-injected-files">5.6: Login and Injected Files</h3>
<pre><code class="language-bash">kubectl exec -n clearledger \
  $(kubectl get pod -n clearledger -l app=auth-service -o name | head -1) \
  -c auth-service -- ls /vault/secrets/
</code></pre>
<pre><code class="language-text">database_url
jwt_secret
</code></pre>
<pre><code class="language-bash">curl -s -X POST http://clearledger.local/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@clearledger.io","password":"SecurePass123"}' | jq .
</code></pre>
<p><strong>Expected:</strong></p>
<pre><code class="language-json">{
  "access_token": "&lt;jwt-returned-by-auth-service&gt;",
  "token_type": "bearer"
}
</code></pre>
<p><strong>Take a screenshot:</strong> working login JSON + <code>kubectl get secret -n clearledger</code> showing no <code>auth-service-secret</code> / <code>ledger-service-secret</code>.</p>
<h3 id="heading-57-health-check">5.7: Health Check</h3>
<pre><code class="language-bash">make check-5
</code></pre>
<p><strong>What you should see:</strong></p>
<blockquote>
<p><code>make check-5</code> re-runs Stage 4 checks first, that is expected. Look for the Stage 5 block below to confirm Vault is working.</p>
</blockquote>
<pre><code class="language-text">▶ Stage 4: Admission Control (Kyverno)
  ✓ Kyverno is running
  ✓ Policy disallow-root-containers — Enforce mode
  ...
  ✓ kube-bench matches baseline (no new FAIL regressions)

▶ Stage 5: Secrets Management (Vault)
  ✓ Vault pod is running
  ✓ Vault agent injector is running
  ✓ Vault is unsealed
  ✓ Vault Kubernetes auth method is enabled
  ✓ auth-service-secret removed — Vault is the secret source
  ✓ Vault injected /vault/secrets/database_url into auth-service

All checks passed. Ready for the next stage.
</code></pre>
<p>If Vault injection or ArgoCD sync fails, see <code>troubleshooting.md</code>.</p>
<h3 id="heading-stage-5-is-done-checklist-before-moving-to-stage-6">Stage 5 is Done: Checklist Before Moving to Stage 6</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>Check</th>
<th>How to verify</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Secrets in Vault only</td>
<td><code>vault kv metadata get clearledger/auth-service</code> shows <code>current_version &gt;= 1</code></td>
</tr>
<tr>
<td>2</td>
<td>No app secrets in infra Git</td>
<td><code>secret.yaml</code> absent from <code>clearledger-infra/manifests/auth-service/</code> and <code>ledger-service/</code></td>
</tr>
<tr>
<td>3</td>
<td>ArgoCD synced</td>
<td><code>Synced Healthy</code> on Application <code>clearledger</code></td>
</tr>
<tr>
<td>4</td>
<td>K8s app secrets deleted</td>
<td><code>kubectl get secret -n clearledger</code> no auth/ledger app secrets</td>
</tr>
<tr>
<td>5</td>
<td>Injection works</td>
<td>Auth pods <code>2/2</code>; <code>ls /vault/secrets/</code> shows <code>database_url</code>, <code>jwt_secret</code></td>
</tr>
<tr>
<td>6</td>
<td>App works</td>
<td>Login curl returns <code>access_token</code></td>
</tr>
<tr>
<td>7</td>
<td>Health check</td>
<td><code>make check-5</code> ends with <code>All checks passed. Ready for the next stage.</code></td>
</tr>
</tbody></table>
<p>Stage 5 moves app credentials out of Git and Kubernetes Secrets. It doesn't make Vault production-grade yet. This lab still uses Vault dev mode, not HA or auto-unseal.</p>
<p>Also, a running pod can still read the files under <code>/vault/secrets/</code> because the app needs those credentials to work. That's normal. Stage 6 adds Falco so you can detect suspicious runtime access.</p>
<h3 id="heading-what-you-learned-in-stage-5">What You Learned in Stage 5</h3>
<ul>
<li><p>Kubernetes Secrets aren't enough for real secret management.</p>
</li>
<li><p>Vault now stores the app credentials.</p>
</li>
<li><p><code>.env</code> was only used locally to load the first secrets into Vault. It's never committed.</p>
</li>
<li><p>Vault injects secrets into the pod when the app starts.</p>
</li>
<li><p><code>clearledger-infra</code> must stop storing <code>secret.yaml</code>, because ArgoCD deploys from that repo.</p>
</li>
<li><p>The order matters: install Vault, seed secrets, update GitOps, wait for healthy pods, then delete old Kubernetes Secrets.</p>
</li>
</ul>
<p><strong>What you can now say in an interview:</strong></p>
<blockquote>
<p>I replaced Kubernetes Secrets with HashiCorp Vault agent injection, removed app credentials from Git and Kubernetes Secrets, and verified the app still worked after Vault injected the credentials at runtime.</p>
</blockquote>
<p>Save your progress:</p>
<pre><code class="language-bash">make snapshot STAGE=5 &amp;&amp; make snapshots
</code></pre>
<p>Confirm <code>clearledger.stage5</code> appears in the snapshot list.</p>
<h2 id="heading-stage-6-runtime-security-falco">Stage 6 — Runtime Security (Falco)</h2>
<p>Stages 1–5 secured what gets deployed and how secrets are stored. Stage 6 watches what happens inside running containers after they start.</p>
<p>Your goal is to learn what runtime security catches and why it matters, then prove it by triggering a Falco alert and reading it the way an on-call engineer would.</p>
<p>CI, Kyverno, and Vault all act before or at pod startup. Falco fills the gap they leave open. It watches what running software actually does inside the container. That's the layer incident response and forensics care about, not just another chart to install.</p>
<p><strong>Before you start Stage 6:</strong></p>
<ul>
<li><p><code>make check-5</code> passes</p>
</li>
<li><p>Login and transactions still work at <code>http://clearledger.local</code></p>
</li>
<li><p>Platform pods have low restart counts</p>
</li>
</ul>
<p>You're done with Stage 6 when:</p>
<ul>
<li><p>You trigger at least one Falco alert</p>
</li>
<li><p>You apply the network policies</p>
</li>
<li><p><code>make check-6</code> passes</p>
</li>
</ul>
<p>Then save your VM:</p>
<pre><code class="language-bash">make snapshot STAGE=6
make snapshots
</code></pre>
<h3 id="heading-do-the-steps-in-this-order">Do the Steps in This Order</h3>
<p>Each step depends on the one before it. Don't run <code>make check-6</code> until §6.4. It checks network policies you haven't applied yet.</p>
<ol>
<li><p><strong>§6.1:</strong> <code>bash stages/stage-6-runtime-security/scripts/install-falco.sh</code>. Confirm <code>falco-*</code> pods <code>2/2 Running</code> and custom rules loaded.</p>
</li>
<li><p><strong>§6.2:</strong> <code>make demo-6</code>: read <code>✓ Runtime detection confirmed</code> in the terminal</p>
</li>
<li><p><strong>§6.3</strong> (optional) manual break-it scenarios (skip if <code>make demo-6</code> already worked)</p>
</li>
<li><p><strong>§6.4:</strong> <code>kubectl apply -f infra/deferred-by-stage/stage-6-runtime-security/netpol/network-policies.yaml</code>. Confirm <code>curl http://clearledger.local/</code> returns 200.</p>
</li>
<li><p><strong>§6.6:</strong> <code>make check-6</code></p>
</li>
</ol>
<p>Start at <strong>§6.1</strong>. If anything fails, see <code>troubleshooting.md</code>.</p>
<p><strong>Optional reading:</strong> <a href="#heading-how-stage-6-fits-the-full-stack-optional-reading">How Stage 6 fits the full stack</a>: why Falco and netpol exist and how they differ from Stages 3–5.</p>
<h3 id="heading-if-you-get-stuck-in-stage-6">If You Get Stuck in Stage 6</h3>
<p>Stage 6 has three jobs:</p>
<ol>
<li><p>Install Falco</p>
</li>
<li><p>Trigger one test alert</p>
</li>
<li><p>Apply network policies</p>
</li>
</ol>
<p>Don't worry about every row in the Falco UI. The UI may show noise. You pass the Falco part when you can find one alert from your demo, either in the terminal or in the UI.</p>
<p>For the portfolio screenshot, open:</p>
<p><code>http://falco.local</code></p>
<p>Login:</p>
<ul>
<li><p>Username: <code>admin</code></p>
</li>
<li><p>Password: <code>admin</code></p>
</li>
</ul>
<p>Take a screenshot only after your demo alert appears.</p>
<p><strong>Common stuck points</strong></p>
<table>
<thead>
<tr>
<th>You think…</th>
<th>What is actually true</th>
</tr>
</thead>
<tbody><tr>
<td>“The UI shows 200+ Critical alerts, maybe I broke something”</td>
<td>No. <code>postgres-0</code> reads <code>/etc/passwd</code> on a loop and Falco flags it. Ignore those rows.</td>
</tr>
<tr>
<td>“I can't find my demo alert”</td>
<td>Search the UI with <strong>Cmd+F →</strong> <code>Shell Spawned</code>, or use the <strong>terminal grep</strong> in step 4 above. If grep shows <code>auth-service</code> + <code>id &amp;&amp; exit</code>, you passed.</td>
</tr>
<tr>
<td>“<code>make check-6</code> failed on NetworkPolicy”</td>
<td>You ran the check <strong>before §6.4</strong>. Apply netpol first, then re-run.</td>
</tr>
<tr>
<td>“§6.3 vs §6.2 — which do I run?”</td>
<td>Run <code>make demo-6</code> <strong>(§6.2)</strong> only. §6.3 is the same attacks as manual commands. Skip it if demo-6 already worked.</td>
</tr>
<tr>
<td>“What is Shell Spawned?”</td>
<td>Falco saw a <code>sh</code> <strong>process start</strong> inside <code>auth-service</code>. That's suspicious in production. In the lab, <strong>you</strong> caused it on purpose. See §6.2.</td>
</tr>
<tr>
<td>“Scenario 4 hangs or exit 137”</td>
<td>Old <code>wget</code> command + <strong>Terminating</strong> pod. Skip Scenario 4 or use the <strong>python3</strong> command in §6.4. Checkpoint + <code>make check-6</code> is enough.</td>
</tr>
</tbody></table>
<h3 id="heading-61-install-falco-and-falcosidekick-ui">6.1: Install Falco and Falcosidekick UI</h3>
<pre><code class="language-bash">bash stages/stage-6-runtime-security/scripts/install-falco.sh
</code></pre>
<p>This runs <code>helm upgrade --install</code> with <code>modern_ebpf</code>, enables Falcosidekick + Web UI, enables the <strong>k8s-metacollector</strong> (<code>collectors.kubernetes.enabled: true</code>) so custom rules can match <code>k8smeta.ns.name = clearledger</code>, loads rules from <code>infra/falco/clearledger-rules-content.yaml</code>, applies the rules ConfigMap and ingress.</p>
<p>If Falco is already installed, the script is safe to re-run (upgrade).</p>
<p><strong>Verify Falco pods:</strong></p>
<pre><code class="language-bash">kubectl get pods -n falco
</code></pre>
<p><strong>Expected output:</strong></p>
<pre><code class="language-text">NAME                                      READY   STATUS    RESTARTS   AGE
falco-w4fh6                               2/2     Running   0          2m
falco-falcosidekick-...                   1/1     Running   0          2m
falco-falcosidekick-ui-...                1/1     Running   0          2m
falco-falcosidekick-ui-redis-0            1/1     Running   0          2m
</code></pre>
<p>The Falco DaemonSet should show <strong>2/2 Running</strong>. Sidekick, UI, and Redis pods should each show <strong>1/1 Running</strong>. Pod name suffixes on your cluster will differ from the example.</p>
<p>Open <code>http://falco.local</code>. You'll see the Falcosidekick UI. Log in with the chart defaults:</p>
<table>
<thead>
<tr>
<th>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Login</strong></td>
<td><code>admin</code></td>
</tr>
<tr>
<td><strong>Password</strong></td>
<td><code>admin</code></td>
</tr>
</tbody></table>
<p>To read the credentials from the cluster instead of trusting the lab defaults:</p>
<pre><code class="language-bash">kubectl get secret falco-falcosidekick-ui -n falco \
  -o jsonpath='{.data.FALCOSIDEKICK_UI_USER}' | base64 -d &amp;&amp; echo
# admin:admin
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/eeac67ee-182a-403d-806d-328e1fdcd8b7.png" alt="Screenshot fo Falco UI" style="display:block;margin:0 auto" width="1293" height="1318" loading="lazy">

<h4 id="heading-falcosidekick-ui-quick-orientation">Falcosidekick UI: quick orientation</h4>
<p>After login you land on the <strong>Events</strong> tab. The table can look busy before you run any demo, which is normal.</p>
<ul>
<li><p><strong>Rule</strong>: detection name (what fired)</p>
</li>
<li><p><strong>Priority</strong>: <strong>Critical</strong> / <strong>Warning</strong> / <strong>Notice</strong> (focus on Critical and Warning for this lab)</p>
</li>
<li><p><strong>Output</strong>: pod name, file, or command details</p>
</li>
<li><p><strong>Tags</strong>: look for <code>clearledger</code> on lab alerts</p>
</li>
</ul>
<p><strong>Background noise you can ignore:</strong> Notice rows from ArgoCD. <strong>Critical</strong> <strong>Sensitive File Read</strong> rows from <code>postgres-0</code> reading <code>/etc/passwd</code> (repeats every few seconds). Your demo alert is different. See §6.2.</p>
<p><strong>Verify custom rules loaded</strong> (do this before §6.2):</p>
<pre><code class="language-bash">kubectl get pods -n falco                                    # Falco pod 2/2 Running
kubectl get configmap clearledger-falco-rules -n falco
kubectl logs -n falco -l app.kubernetes.io/name=falco -c falco --tail=200 \
  | grep 'rules.d/clearledger_rules'
</code></pre>
<p><strong>Expected:</strong> <code>clearledger_rules.yaml | schema validation: ok</code></p>
<p>An empty grep with <code>--tail=30</code> alone isn't a failure. Use <code>--tail=200</code>. If you see <code>LOAD_ERR_COMPILE_CONDITION</code>, see <code>troubleshooting.md</code>.</p>
<p>If rules didn't load, §6.2 and §6.3 will look like they passed when nothing fired.</p>
<h3 id="heading-62-guided-demo-make-demo-6">6.2: Guided Demo (<code>make demo-6</code>)</h3>
<p>Run this <strong>after</strong> §6.1 (Falco installed, rules verified, UI opens at <code>http://falco.local</code>).</p>
<pre><code class="language-bash">make demo-6
# or:
bash stages/stage-6-runtime-security/scripts/demo-falco-alerts.sh
</code></pre>
<h4 id="heading-what-the-demo-script-does">What the demo script does</h4>
<p>The demo proves Falco can detect suspicious activity inside a running container.</p>
<p>The script checks that Falco is running, opens <code>http://falco.local</code>, and waits while you log in with:</p>
<ul>
<li><p>Username: <code>admin</code></p>
</li>
<li><p>Password: <code>admin</code></p>
</li>
</ul>
<p>Then it runs this test command inside the <code>auth-service</code> container:</p>
<pre><code class="language-bash">kubectl exec -n clearledger \
  auth-service-&lt;pod-suffix&gt; \
  -c auth-service -- /bin/sh -c 'id &amp;&amp; exit'
</code></pre>
<p>The script picks the real pod name for you.</p>
<p><strong>Non-interactive</strong> (CI or no Enter prompts): <code>SKIP_PROMPT=1 make demo-6</code>.</p>
<p>This starts a shell inside the app container. That's suspicious in production because app containers should run the app, not open shells. Falco should detect it and create an alert called:</p>
<p><code>Shell Spawned in ClearLedger Container</code></p>
<p>When the script prints:</p>
<pre><code class="language-text">✓ Runtime detection confirmed
</code></pre>
<p>refresh the Falco UI.</p>
<p>Look for a <strong>Critical</strong> alert with:</p>
<ul>
<li><p>Rule: <code>Shell Spawned in ClearLedger Container</code></p>
</li>
<li><p>Pod: <code>auth-service-...</code></p>
</li>
<li><p>Command: <code>sh -c id &amp;&amp; exit</code></p>
</li>
</ul>
<p>Ignore alerts from <code>postgres-0</code>, especially <code>Sensitive File Read</code>. Those are background noise for this lab.</p>
<p>If the UI is noisy, search the page for <code>Shell Spawned</code> or check from the terminal:</p>
<pre><code class="language-bash">kubectl logs -n falco -l app.kubernetes.io/name=falco -c falco --tail=500 \
  | grep 'Shell Spawned'
</code></pre>
<p>For your screenshot, capture the <code>Shell Spawned</code> alert for <code>auth-service</code>.</p>
<h3 id="heading-63-break-it-scenarios-manual-optional">6.3: Break-it Scenarios (Manual, Optional)</h3>
<p>These are the same detections as §6.2, but you run each command yourself. Skip this section if you already completed <code>make demo-6</code>.</p>
<table>
<thead>
<tr>
<th>Rule name</th>
<th>You trigger it by…</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Shell Spawned in ClearLedger Container</strong></td>
<td>Scenario 1 — <code>kubectl exec … /bin/sh</code></td>
</tr>
<tr>
<td><strong>Sensitive File Read in ClearLedger</strong></td>
<td>Scenario 2 — <code>cat /etc/passwd</code></td>
</tr>
<tr>
<td><strong>Package Manager / Outbound connection</strong></td>
<td>Scenario 3 — <code>wget</code> or <code>curl</code></td>
</tr>
</tbody></table>
<p>After each command, refresh <code>http://falco.local</code> or use the find methods in §6.2.</p>
<h4 id="heading-scenario-1-shell-in-a-running-pod-command-injection-simulation">Scenario 1 – Shell in a running pod (command injection simulation):</h4>
<pre><code class="language-bash">kubectl exec -n clearledger \
  $(kubectl get pod -n clearledger -l app=auth-service -o name | head -1) \
  -c auth-service -- /bin/sh -c "id &amp;&amp; exit"
</code></pre>
<p><strong>Expected in Falco UI / logs</strong> (within ~10 seconds):</p>
<pre><code class="language-text">CRITICAL: Shell spawned in ClearLedger container
  user=... container=auth-service pod=auth-service-... cmd=sh -c id &amp;&amp; exit
</code></pre>
<p><strong>What this means:</strong> Stage 4 allowed the pod (it is compliant). Stage 6 detected <em>behavior inside</em> the pod: exactly what an attacker would do after command injection.</p>
<p><strong>If you see no alert:</strong> confirm the exec used <code>-c auth-service</code> (not the vault-agent sidecar), rules show <code>schema validation: ok</code>, and the pod image name contains <code>clearledger</code>.</p>
<h4 id="heading-scenario-2-read-a-sensitive-file-reconnaissance">Scenario 2 – Read a sensitive file (reconnaissance):</h4>
<pre><code class="language-bash">kubectl exec -n clearledger \
  $(kubectl get pod -n clearledger -l app=auth-service -o name | head -1) \
  -c auth-service -- cat /etc/passwd
</code></pre>
<p><strong>Expected:</strong></p>
<pre><code class="language-text">CRITICAL: Sensitive file read in ClearLedger
  file=/etc/passwd container=auth-service pod=auth-service-...
</code></pre>
<h4 id="heading-scenario-3-download-tool-at-runtime-optional">Scenario 3 – Download tool at runtime (optional):</h4>
<pre><code class="language-bash">kubectl exec -n clearledger \
  $(kubectl get pod -n clearledger -l app=auth-service -o name | head -1) \
  -c auth-service -- sh -c "wget -q ifconfig.me -O - 2&gt;/dev/null || true"
</code></pre>
<p>May fire Package manager executed and/or Unexpected outbound connection (WARNING).</p>
<p>Take screenshots of Scenarios 1 and 2: portfolio evidence for runtime detection.</p>
<h3 id="heading-64-apply-network-policies-zero-trust-segmentation">6.4: Apply Network Policies (Zero-trust Segmentation)</h3>
<p>Network policies are firewall rules between pods. Apply them after the Falco demo.</p>
<p><code>make check-6</code> checks for these policies, so run it only after this section. The <code>default-deny-all</code> policy blocks traffic by default.</p>
<p>The <code>allow-*</code> policies open only the paths ClearLedger needs to work. Falco detects suspicious behavior. Network policies limit where a pod can connect.</p>
<p><strong>Apply:</strong></p>
<pre><code class="language-bash">kubectl apply -f infra/deferred-by-stage/stage-6-runtime-security/netpol/network-policies.yaml
kubectl get networkpolicy -n clearledger
</code></pre>
<p><strong>Expected:</strong> seven policies: <code>default-deny-all</code> plus six <code>allow-*</code> (<code>auth-service</code>, <code>ledger-service</code>, <code>notification-service</code>, <code>postgres</code>, <code>redis</code>, <code>frontend</code>).</p>
<p>Verify the app still works:</p>
<pre><code class="language-bash">curl -s http://clearledger.local/auth/health | jq .
# {"status":"ok","service":"auth-service"}

curl -s http://clearledger.local/notifications/health | jq .
# {"status":"ok",...}
</code></pre>
<p><strong>Checkpoint (required)</strong>: proves netpol didn't break the real app:</p>
<pre><code class="language-bash">kubectl get networkpolicy -n clearledger
curl -s -o /dev/null -w "%{http_code}\n" http://clearledger.local/
kubectl get pods -n clearledger --field-selector=status.phase!=Running
</code></pre>
<table>
<thead>
<tr>
<th>Result</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td>Seven policies listed</td>
<td>Netpol applied</td>
</tr>
<tr>
<td><code>200</code> from curl</td>
<td>Users can still reach the app through ingress</td>
</tr>
<tr>
<td>Third command prints <strong>nothing</strong></td>
<td>No crashed pods</td>
</tr>
</tbody></table>
<p>If auth or ledger start restarting after netpol, egress rules are too strict. See <code>troubleshooting.md</code>.</p>
<h4 id="heading-scenario-4-blocked-cross-service-traffic-optional">Scenario 4 – blocked cross-service traffic (optional)</h4>
<p>Skip if the checkpoint passed and you plan to run <code>make check-6</code>. This proves ledger can't call notification directly (no allow rule for that path). Failure to connect is success.</p>
<p><strong>Don't use the old</strong> <code>wget</code> <strong>one-liner</strong>: the ledger image has no <code>wget</code>/<code>curl</code>, and <code>head -1</code> can pick a Terminating pod (exec hangs or exit <strong>137</strong>).</p>
<pre><code class="language-bash">LEDGER_POD=$(kubectl get pods -n clearledger -l app=ledger-service --no-headers \
  | awk '$2=="2/2" &amp;&amp; $3=="Running" {print $1; exit}')

echo "Using pod: $LEDGER_POD"

kubectl exec -n clearledger "$LEDGER_POD" -c ledger-service -- python3 -c "
import urllib.request
try:
    urllib.request.urlopen('http://notification-service/', timeout=5)
    print('UNEXPECTED: connection succeeded')
except Exception as e:
    print('BLOCKED (expected):', e)
"
</code></pre>
<p><strong>Expected:</strong></p>
<pre><code class="language-text">BLOCKED (expected): &lt;urlopen error timed out&gt;
</code></pre>
<p>or <code>Connection refused</code>, <strong>not</strong> <code>UNEXPECTED: connection succeeded</code>.</p>
<h3 id="heading-66-health-check">6.6: Health Check</h3>
<p>Run this <strong>after §6.4</strong> (network policies). It confirms Falco, custom rules, and netpol are installed. It does <strong>not</strong> prove an alert fired (that is §6.2).</p>
<pre><code class="language-bash">make check-6
</code></pre>
<p><strong>What you should see:</strong></p>
<pre><code class="language-text">▶ Stage 6 — Runtime Security (Falco)
  ✓ Falco DaemonSet: 1/1 nodes
  ✓ ClearLedger custom Falco rules ConfigMap exists
  ✓ NetworkPolicy default-deny-all exists
  ✓ NetworkPolicy allow-auth-service exists
  ✓ NetworkPolicy allow-ledger-service exists
  ✓ NetworkPolicy allow-notification-service exists
  ✓ auth-service reachable after network policies
  ✓ notification-service reachable after network policies

All checks passed. Ready for the next stage.
</code></pre>
<h3 id="heading-how-stage-6-fits-the-full-stack-optional-reading">How Stage 6 fits the full stack (optional reading)</h3>
<p>Each stage guards a different point in the lifecycle. Stages 1 through 5 work before or during pod startup. Stage 6 watches what happens inside a container that is already running.</p>
<ul>
<li><p><strong>In Stage 3,</strong> CI catches bad code and images on <code>git push</code>.</p>
</li>
<li><p><strong>Stage 4,</strong> Kyverno blocks bad pods at admission.</p>
</li>
<li><p><strong>Stage 5,</strong> Vault injects secrets at startup.</p>
</li>
<li><p><strong>Stage 6,</strong> Falco watches syscalls after the pod is running (shell spawns, sensitive file reads).</p>
</li>
<li><p><strong>Stage 6,</strong> Network policies filter pod-to-pod traffic.</p>
</li>
</ul>
<p>They answer three different questions: Kyverno asks whether this pod may be created. Falco asks what the pod is doing right now. Network policies ask who the pod may talk to.</p>
<p>Falco doesn't replace CI or Kyverno. If you skip Stages 3–5, Falco can still alert, but you already shipped vulnerable code and secrets in Git.</p>
<h3 id="heading-stage-6-is-complete-proceed-to-stage-65-or-7">Stage 6 is Complete. Proceed to Stage 6.5 or 7.</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>Check</th>
<th>How to verify</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Falco running</td>
<td><code>kubectl get pods -n falco</code> — DaemonSet <code>2/2</code></td>
</tr>
<tr>
<td>2</td>
<td>Custom rules loaded</td>
<td>`kubectl logs -n falco -l app.kubernetes.io/name=falco -c falco --tail=200</td>
</tr>
<tr>
<td>3</td>
<td>Shell alert fired <strong>and you read it</strong></td>
<td><code>make demo-6</code> → Critical row with <code>cmd=sh -c id &amp;&amp; exit</code>, pod <code>auth-service-…</code> — §6.2</td>
</tr>
<tr>
<td>4</td>
<td>Network policies applied</td>
<td><code>kubectl get networkpolicy -n clearledger</code> — §6.4</td>
</tr>
<tr>
<td>5</td>
<td>App still healthy</td>
<td><code>curl</code> auth + notification health return 200</td>
</tr>
<tr>
<td>6</td>
<td>Health check</td>
<td><code>make check-6</code> green — §6.6</td>
</tr>
</tbody></table>
<p><strong>Portfolio screenshots (optional):</strong> shell-in-container alert · sensitive-file read alert in Falco UI.</p>
<p>What comes next: Stage 6 gives you Falco alerts and basic network policies. You can refine the network policies later. Stage 6.5 is optional chaos testing with Litmus, and Stage 7 adds Grafana dashboards so you can see security events over time.</p>
<h3 id="heading-what-you-learned-in-stage-6">What You Learned in Stage 6</h3>
<ul>
<li><p>What runtime security catches that CI and admission control can't: threats inside running containers</p>
</li>
<li><p>What Falco is: eBPF syscall monitoring with custom YAML rules</p>
</li>
<li><p>What network policies are: Kubernetes firewall rules between pods</p>
</li>
<li><p>How to trigger and interpret alerts: incident response skills</p>
</li>
<li><p><strong>The full stack:</strong> code scanning, admission control, secrets management, runtime detection which leads to (next) observability</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Deployed Falco for runtime threat detection with custom rules, and can trigger and read an alert for a shell-in-container or sensitive-file read the way an on-call engineer would.</p>
</blockquote>
<p><code>make snapshot STAGE=6 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage6</code>. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<h2 id="heading-stage-65-chaos-engineering-optional">Stage 6.5 — Chaos Engineering (Optional)</h2>
<p><strong>Most learners skip this.</strong> If Stage 6 is done and <code>make check-6</code> passes, jump straight to <a href="#heading-stage-7-security-observability">Stage 7</a>. Nothing in Stages 7–8 requires Litmus.</p>
<p><strong>If you want chaos/resilience (~1 hour):</strong> LitmusChaos deletes one <code>auth-service</code> pod and proves <code>/auth/health</code> stays <strong>200</strong> while Kubernetes replaces it.</p>
<h3 id="heading-do-the-steps-in-this-order">Do the Steps in This Order</h3>
<table>
<thead>
<tr>
<th>Step</th>
<th>Section</th>
<th>What you do</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td><a href="#heading-650-before-you-start-auth-pods-must-be-22">§6.5.0</a></td>
<td><code>make fix-65-prereqs</code> — auth pods <strong>2/2 Ready</strong></td>
</tr>
<tr>
<td>2</td>
<td><a href="#heading-651-install-litmuschaos-operator-ui-cluster-connection">§6.5.1</a></td>
<td><code>bash ...install-litmus.sh</code> — UI shows <strong>Active 1</strong></td>
</tr>
<tr>
<td>3</td>
<td><a href="#heading-652-run-your-first-experiment-pod-delete">§6.5.2</a></td>
<td>UI: Pod-delete experiment + <code>curl</code> stays 200</td>
</tr>
<tr>
<td>4</td>
<td><a href="#heading-657-health-check">§6.5.7</a></td>
<td><code>make check-65</code>, snapshot</td>
</tr>
</tbody></table>
<p><strong>Optional:</strong> <a href="#heading-653-same-experiment-from-the-terminal-make-demo-65-optional">§6.5.3</a>: same test via <code>make demo-65</code> (terminal path) instead of the UI wizard.</p>
<h3 id="heading-650-before-you-start-auth-pods-must-be-22">6.5.0: Before You Start (Auth Pods Must be 2/2)</h3>
<p>Chaos deletes pods. If replacements fail to start, you debug CrashLoopBackOff instead of learning resilience.</p>
<pre><code class="language-bash">export GITHUB_OWNER=YOUR_GITHUB_USERNAME   # required — without this, fix-argocd breaks ArgoCD repoURL
make fix-65-prereqs
kubectl get pods -n clearledger -l app=auth-service
</code></pre>
<p><strong>Pass:</strong> two pods, both <strong>2/2 Ready</strong>. Don't install Litmus until this is true.</p>
<p><strong>If something fails:</strong></p>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td>ArgoCD <strong>ComparisonError</strong> after <code>fix-65-prereqs</code></td>
<td><code>kubectl apply -f stages/stage-2-gitops/argocd/clearledger-app.yaml</code></td>
</tr>
<tr>
<td>Auth <strong>Init:0/1</strong>, Vault <code>permission denied</code></td>
<td>Re-run Stage 5 <code>setup.sh</code> + <code>seed-vault-secrets.sh</code>, delete auth/ledger pods</td>
</tr>
<tr>
<td>Auth <strong>1/2</strong> or postgres timeout</td>
<td><code>make fix-65-prereqs</code> again (adds netpol + startup probes)</td>
</tr>
</tbody></table>
<h3 id="heading-651-install-litmuschaos-operator-ui-cluster-connection">6.5.1: Install LitmusChaos (Operator, UI, Cluster Connection)</h3>
<pre><code class="language-bash">bash stages/stage-6.5-chaos-engineering/scripts/install-litmus.sh
kubectl get pods -n litmus
open http://litmus.local    # login: admin / litmus
</code></pre>
<p><strong>Pass before §6.5.2:</strong> Overview shows Infrastructures: Active 1 (not 0, not Pending).</p>
<p><strong>Verify pods:</strong></p>
<pre><code class="language-bash">kubectl get pods -n litmus
# litmus-core, chaos frontend/server, mongodb, subscriber — all Running
</code></pre>
<h4 id="heading-if-overview-shows-0-infrastructures-or-pending">If Overview shows 0 infrastructures or PENDING</h4>
<p>The UI is empty until a subscriber agent connects your cluster:</p>
<pre><code class="language-bash">export LITMUS_PASSWORD='litmus'   # only if you changed the default
bash stages/stage-6.5-chaos-engineering/scripts/connect-litmus-infra.sh
</code></pre>
<p>Hard-refresh the browser. Start at <strong><a href="http://litmus.local">http://litmus.local</a></strong> only, not old <code>/account/.../settings</code> bookmarks.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/69212567-ae17-4f8f-b60d-7c3aac1592b8.png" alt="screenshot showing litmus ui" style="display:block;margin:0 auto" width="1255" height="627" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/66cfe3f9-99b5-4c1b-bfed-aa90fbcca2e8.png" alt="screenshot showing litmus ui" style="display:block;margin:0 auto" width="1267" height="951" loading="lazy">

<h4 id="heading-ui-navigation-click-order-for-652">UI navigation (click order for §6.5.2)</h4>
<ol>
<li><p><strong>Overview</strong>: Confirm <strong>Active 1</strong></p>
</li>
<li><p><strong>ChaosHubs</strong>, <strong>Pod Delete</strong>, <strong>Launch Experiment</strong></p>
</li>
<li><p><strong>Chaos Experiments</strong>: watch <strong>Running to Completed</strong></p>
</li>
</ol>
<p>Left nav: <strong>Overview</strong>, <strong>Environments</strong>, <strong>ChaosHub</strong>, <strong>Chaos Experiments</strong>. Skip <strong>Resilience Probes</strong> and deep <strong>Settings</strong> URLs for this lab.</p>
<h3 id="heading-652-run-your-first-experiment-pod-delete">6.5.2: Run Your First Experiment (Pod Delete)</h3>
<p><strong>Goal:</strong> Kill one <code>auth-service</code> pod and prove <code>/auth/health</code> stays <strong>200</strong>.</p>
<p><strong>Before you click Run in the UI</strong>, open two terminals:</p>
<pre><code class="language-bash"># Terminal A — watch pods
kubectl get pods -n clearledger -l app=auth-service -w

# Terminal B — watch health every 5 seconds
while true; do
  date +%H:%M:%S
  curl -s -o /dev/null -w "health=%{http_code}\n" http://clearledger.local/auth/health
  sleep 5
done
</code></pre>
<p><strong>In the UI (</strong><code>http://litmus.local</code><strong>):</strong> Left nav → ChaosHubs → Pod Delete card → Launch Experiment.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ca183e75-6680-4743-9ffe-03a144e09e13.png" alt="screenshot showing litmus ui" style="display:block;margin:0 auto" width="1267" height="951" loading="lazy">

<p><strong>Litmus UI note:</strong> ChaosCenter labels change between versions (for example, “Tune fault”, “Target selection”, “Chaos Experiment”). Match fields by <strong>concept</strong>, not exact button text. Accept wizard defaults unless the table below lists a value.</p>
<p>In the Litmus UI, open:</p>
<p><code>ChaosHubs</code> → <code>Pod Delete</code> → <code>Launch Experiment</code></p>
<p>This opens the experiment wizard. Use these values when the wizard asks for them:</p>
<ul>
<li><p>Infrastructure: <code>clearledger-cluster</code> and it must be <code>Active</code></p>
</li>
<li><p>Namespace: <code>clearledger</code></p>
</li>
<li><p>Target label: <code>app=auth-service</code></p>
</li>
<li><p>Target kind: <code>Deployment</code></p>
</li>
<li><p>Pods affected: <code>50%</code></p>
</li>
<li><p>Duration: <code>30</code> seconds</p>
</li>
<li><p>Fault/experiment name: <code>pod-delete</code></p>
</li>
</ul>
<p>Finish the wizard with Save or Create, then click Run. Don't choose <strong>Schedule</strong>.</p>
<p><strong>What success looks like:</strong></p>
<table>
<thead>
<tr>
<th>Where</th>
<th>Good sign</th>
</tr>
</thead>
<tbody><tr>
<td>Terminal A</td>
<td>One pod <strong>Terminating</strong>, then back to <strong>2/2 Ready</strong></td>
</tr>
<tr>
<td>Terminal B</td>
<td><code>health=200</code> even while one pod is down</td>
</tr>
<tr>
<td>Litmus UI</td>
<td>Experiment <strong>Running → Completed</strong></td>
</tr>
</tbody></table>
<p><strong>Prefer terminal over UI?</strong> Skip the wizard and run <a href="#heading-653-same-experiment-from-the-terminal-make-demo-65-optional">§6.5.3</a> (<code>make demo-65</code>) instead.</p>
<h3 id="heading-653-same-experiment-from-the-terminal-make-demo-65-optional">6.5.3 — Same experiment from the terminal (<code>make demo-65</code>) — optional</h3>
<p>Use this if you want to run the pod-delete test without clicking through the Litmus UI.</p>
<p>Make sure auth pods are healthy first:</p>
<pre><code class="language-bash">make fix-65-prereqs
</code></pre>
<p>Then run the demo:</p>
<pre><code class="language-bash">make demo-65
</code></pre>
<p>The script applies the <code>auth-service-pod-delete</code> ChaosEngine in the <code>litmus</code> namespace. Litmus deletes one <code>auth-service</code> pod, Kubernetes replaces it, and the script checks that <code>/auth/health</code> keeps returning <code>200</code>.</p>
<p>After it finishes, verify the result:</p>
<pre><code class="language-bash">kubectl get chaosresult -n litmus
kubectl get pods -n clearledger -l app=auth-service
</code></pre>
<p>You passed if the script ends with <code>PASS</code>, the <code>ChaosResult</code> is <code>Completed / Pass</code>, and two <code>auth-service</code> pods are running again.</p>
<p>You can also see the run in the Litmus UI: <strong>Chaos Experiments</strong> → refresh → open the latest run.</p>
<p>If new auth pods get stuck in <code>Init:0/1</code>, re-apply the Stage 6 network policies:</p>
<pre><code class="language-bash">kubectl apply -f infra/deferred-by-stage/stage-6-runtime-security/netpol/network-policies.yaml
</code></pre>
<h3 id="heading-653a-real-output-examples-verified-on-the-lab-cluster">6.5.3a: Real Output Examples (Verified on the Lab Cluster)</h3>
<p>These samples were captured from a working cluster after <code>make fix-65-prereqs</code>, <code>make connect-litmus</code>, and <code>make demo-65</code>.</p>
<h4 id="heading-make-check-65"><code>make check-65</code></h4>
<pre><code class="language-text">▶ Stage 6.5 — Chaos Engineering (LitmusChaos)
  ✓ litmus namespace exists
  ✓ litmus-admin ServiceAccount exists in litmus
  ✓ pod-delete ChaosExperiment installed in litmus
  ✓ Litmus chaos operator is running
  ✓ Litmus ChaosCenter reachable at http://litmus.local
  ✓ Litmus subscriber running (UI connected to cluster)
  ✓ auth-service healthy (baseline before chaos)
  ✓ auth-service has 2/2 Ready replicas (stable for chaos)
  ✓ allow-postgres NetworkPolicy exists (Stage 6 fix)

All checks passed. Ready for the next stage.
</code></pre>
<h4 id="heading-make-demo-65-captured-from-a-real-run-2026-06-01"><code>make demo-65</code> captured from a real run (2026-06-01)</h4>
<pre><code class="language-text">Stage 6.5 — auth-service pod-delete

Preflight: 2 auth-service pods Running

Applying ChaosEngine auth-service-pod-delete (namespace litmus)

Watching http://clearledger.local/auth/health

  10s  health=200  pods=2
  20s  health=200  pods=1
  30s  health=200  pods=1
  40s  health=200  pods=2
  50s  health=200  pods=2
  60s  health=200  pods=2

Result:
  ChaosResult: Completed / Pass
  Recovery:    2 auth-service pod(s) Running
  Health:      6/6 checks returned 200

PASS
</code></pre>
<p>If health lines show <code>000</code>, run <code>bash scripts/setup-hosts.sh</code> on your Mac and re-run. The script also tries <code>multipass exec clearledger -- curl</code> when the VM is present.</p>
<h4 id="heading-terminal-b-health-loop-expected-output">Terminal B (health loop, expected output)</h4>
<pre><code class="language-text">22:05:01
health=200
22:05:06
health=200
22:05:11
health=200
</code></pre>
<p>Pod count may show <strong>1</strong> while the replacement pod is starting, which is expected.</p>
<h4 id="heading-terminal-a-during-chaos-kubectl-get-pods-w">Terminal A during chaos (<code>kubectl get pods -w</code>)</h4>
<pre><code class="language-text">NAME                            READY   STATUS        RESTARTS   AGE
auth-service-84cc988c4d-hdb45   2/2     Running       0          67m
auth-service-84cc988c4d-b59sj   2/2     Terminating   0          15m    ← killed
auth-service-84cc988c4d-dxz9q   0/2     Pending       0          0s     ← replacement
auth-service-84cc988c4d-dxz9q   0/2     Init:0/1      0          2s
auth-service-84cc988c4d-dxz9q   2/2     Running       0          90s
</code></pre>
<h4 id="heading-after-demo-verify">After demo: verify</h4>
<pre><code class="language-bash">kubectl get chaosresult -n litmus
# auth-service-pod-delete-pod-delete   Completed   Pass

kubectl get pods -n clearledger -l app=auth-service
# auth-service-84cc988c4d-xxxxx   2/2   Running
# auth-service-84cc988c4d-yyyyy   2/2   Running

kubectl get cm subscriber-config -n litmus -o jsonpath='{.data.IS_INFRA_CONFIRMED}'
# true
</code></pre>
<h4 id="heading-subscriber-connected-infrastructure-active-in-ui">Subscriber connected (infrastructure Active in UI)</h4>
<pre><code class="language-text">kubectl logs -n litmus -l app.kubernetes.io/name=subscriber --tail=3
level=info msg="AgentID: a63c2a2c-... has been confirmed"
level=info msg="Server connection established, Listening...."
</code></pre>
<h3 id="heading-654-understand-the-yaml-files-read-before-running">6.5.4: Understand the YAML Files (Read Before Running)</h3>
<p>Each file is a <code>ChaosEngine</code>: a request to Litmus: “run experiment X against app Y for Z seconds.”</p>
<h4 id="heading-litmus-installyaml"><code>litmus-install.yaml</code></h4>
<p>This creates the <code>litmus</code> namespace only. Platform workloads live here, separate from <code>clearledger</code> app pods.</p>
<h4 id="heading-litmus-rbacyaml"><code>litmus-rbac.yaml</code></h4>
<table>
<thead>
<tr>
<th>Resource</th>
<th>What it does</th>
</tr>
</thead>
<tbody><tr>
<td><code>ServiceAccount litmus-admin</code> (namespace <code>litmus</code>)</td>
<td>Identity for Litmus runner pods</td>
</tr>
<tr>
<td><code>ClusterRoleBinding → cluster-admin</code></td>
<td>Allows deleting pods / injecting faults in <code>clearledger</code> (lab simplification. Production would use least-privilege)</td>
</tr>
</tbody></table>
<h4 id="heading-auth-service-pod-deleteyaml-experiment-1-used-by-demo"><code>auth-service-pod-delete.yaml</code> (Experiment 1: used by demo)</h4>
<pre><code class="language-yaml">metadata:
  namespace: litmus          # engine lives here (Kyverno-safe)
spec:
  appinfo:
    appns: clearledger       # target app namespace
    applabel: app=auth-service
    appkind: deployment
  experiments:
    - name: pod-delete
      spec:
        components:
          env:
            - name: PODS_AFFECTED_PERC
              value: "50"    # 50% of 2 replicas = 1 pod killed
            - name: TOTAL_CHAOS_DURATION
              value: "30"    # chaos window in seconds
</code></pre>
<p>What happens when applied:</p>
<ol>
<li><p>Operator reads <code>ChaosEngine</code> and creates <code>auth-service-pod-delete-runner</code> pod in <code>litmus</code></p>
</li>
<li><p>Runner selects one <code>auth-service</code> pod in <code>clearledger</code> and sends SIGTERM / delete</p>
</li>
<li><p>Kubernetes Deployment controller sees 1/2 replicas and schedules a replacement pod</p>
</li>
<li><p>Service routes traffic to the <strong>surviving</strong> replica during recovery</p>
</li>
<li><p><code>ChaosResult</code> CR records pass/fail from Litmus’s perspective</p>
</li>
</ol>
<h4 id="heading-ledger-service-network-latencyyaml-experiment-2-manual"><code>ledger-service-network-latency.yaml</code> (Experiment 2 — manual)</h4>
<p>Adds <strong>2000 ms</strong> network latency to <code>ledger-service</code> pods for 60 seconds. Proves timeouts return <strong>503</strong> instead of hanging the UI.</p>
<h4 id="heading-notification-service-memory-hogyaml-experiment-3-manual"><code>notification-service-memory-hog.yaml</code> (Experiment 3 — manual)</h4>
<p>Fills <strong>80%</strong> of pod memory limit for 60 seconds. Proves OOMKill + restart behavior.</p>
<p><strong>Never apply all three at once.</strong> Run one experiment, verify recovery, then the next.</p>
<h3 id="heading-655-after-the-demo-what-to-look-for-do-not-skip">6.5.5: After the Demo, What to Look For (Do Not Skip)</h3>
<p><strong>1. During chaos: availability</strong></p>
<table>
<thead>
<tr>
<th>Signal</th>
<th>Good</th>
<th>Bad</th>
</tr>
</thead>
<tbody><tr>
<td><code>curl http://clearledger.local/auth/health</code></td>
<td><strong>200</strong> while one pod is down</td>
<td>502/503/timeout</td>
</tr>
<tr>
<td><code>kubectl get pods -l app=auth-service</code></td>
<td>1 Running + 1 Init/Pending (replacement starting)</td>
<td>0 Running</td>
</tr>
</tbody></table>
<p><strong>2. After chaos: recovery</strong></p>
<table>
<thead>
<tr>
<th>Signal</th>
<th>Good</th>
<th>Bad</th>
</tr>
</thead>
<tbody><tr>
<td>Pod count</td>
<td>2/2 <strong>Ready</strong> (may take 1–2 min — Vault agent init)</td>
<td>Stuck at 1 replica</td>
</tr>
<tr>
<td>Events</td>
<td><code>Killing</code> then <code>Scheduled</code> / <code>Started</code> on new pod</td>
<td>Repeated CrashLoopBackOff</td>
</tr>
<tr>
<td>ArgoCD</td>
<td>Synced</td>
<td>—</td>
</tr>
</tbody></table>
<p><strong>3. Litmus</strong> <code>ChaosResult</code> <strong>verdict</strong></p>
<pre><code class="language-bash">kubectl get chaosresult -n litmus
</code></pre>
<p><strong>Your pass criteria:</strong></p>
<ul>
<li><p><code>/auth/health</code> returned <strong>200</strong> at least once during the chaos window</p>
</li>
<li><p>A pod was <strong>Killed</strong> (see events)</p>
</li>
<li><p>Deployment returned to <strong>2 replicas</strong></p>
</li>
</ul>
<h3 id="heading-656-manual-experiments-after-experiment-1-succeeds">6.5.6 Manual Experiments (After Experiment 1 Succeeds)</h3>
<p>Wait until both auth-service pods show <strong>2/2 Ready</strong>, then run <strong>one</strong> experiment at a time:</p>
<pre><code class="language-bash"># Experiment 2 — 2s network latency on ledger-service (60s)
kubectl delete chaosengine ledger-service-network-latency -n litmus --ignore-not-found
kubectl apply -f stages/stage-6.5-chaos-engineering/infra/chaos/ledger-service-network-latency.yaml

# Experiment 3 — memory pressure on notification-service (60s)
kubectl delete chaosengine notification-service-memory-hog -n litmus --ignore-not-found
kubectl apply -f stages/stage-6.5-chaos-engineering/infra/chaos/notification-service-memory-hog.yaml
</code></pre>
<table>
<thead>
<tr>
<th>Experiment</th>
<th>File</th>
<th>What to verify</th>
</tr>
</thead>
<tbody><tr>
<td>Pod delete</td>
<td><code>auth-service-pod-delete.yaml</code></td>
<td>Health 200 during kill, 2 replicas after</td>
</tr>
<tr>
<td>Network latency</td>
<td><code>ledger-service-network-latency.yaml</code></td>
<td>API returns 503/timeout, not infinite hang</td>
</tr>
<tr>
<td>Memory hog</td>
<td><code>notification-service-memory-hog.yaml</code></td>
<td>Pod OOMKills and restarts, Redis subscription recovers</td>
</tr>
</tbody></table>
<p>Clean up an experiment:</p>
<pre><code class="language-bash">kubectl delete chaosengine auth-service-pod-delete -n litmus
</code></pre>
<h3 id="heading-657-health-check">6.5.7: Health Check</h3>
<pre><code class="language-bash">make check-65
</code></pre>
<p><strong>Expected:</strong> see full sample in <a href="#heading-653a-real-output-examples-verified-on-the-lab-cluster">§6.5.3a</a> (<code>make check-65</code> block). Minimum:</p>
<pre><code class="language-text">▶ Stage 6.5 Chaos Engineering (LitmusChaos)
  ✓ Litmus subscriber running (UI connected to cluster)
  ✓ auth-service has 2/2 Ready replicas (stable for chaos)
  ...
All checks passed. Ready for the next stage.
</code></pre>
<h3 id="heading-stage-65-complete-checklist">Stage 6.5 Complete: Checklist</h3>
<table>
<thead>
<tr>
<th>#</th>
<th>Check</th>
<th>How to verify</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>Litmus operator running</td>
<td><code>kubectl get pods -n litmus</code> — <code>litmus-*</code> Running</td>
</tr>
<tr>
<td>2</td>
<td>Experiments installed</td>
<td><code>kubectl get chaosexperiment pod-delete -n litmus</code></td>
</tr>
<tr>
<td>3</td>
<td>Pod-delete demo run</td>
<td><code>make demo-65</code> health 200 during chaos</td>
</tr>
<tr>
<td>4</td>
<td>Recovery observed</td>
<td>2 auth-service replicas Ready. Killing/Scheduled events</td>
</tr>
<tr>
<td>5</td>
<td>Evidence saved</td>
<td>Terminal output from <code>run-chaos.sh</code> (DORA artifact)</td>
</tr>
<tr>
<td>6</td>
<td>Health check</td>
<td><code>make check-65</code> green</td>
</tr>
<tr>
<td>7</td>
<td>UI infrastructure connected</td>
<td>Overview → <strong>Active: 1</strong> (§6.5.2)</td>
</tr>
</tbody></table>
<h3 id="heading-what-you-learned-in-stage-65">What You Learned in Stage 6.5</h3>
<ul>
<li><p><strong>Detection ≠ resilience</strong>: Falco alerts don't prove HA</p>
</li>
<li><p><strong>Replicas + Services + probes</strong>: why <code>replicas: 2</code> isn't cosmetic</p>
</li>
<li><p><strong>ChaosEngine YAML</strong>: declarative failure injection as code</p>
</li>
<li><p><strong>Platform vs app namespaces</strong>: Kyverno blocks chaos runners in <code>clearledger</code>, engines run in <code>litmus</code></p>
</li>
<li><p><strong>MTTR</strong>: time from pod kill to 2/2 Ready again (Stage 7 graphs this)</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Ran chaos experiments with LitmusChaos (pod-delete, network latency, memory pressure) to prove the system recovers, and can distinguish detection from resilience.</p>
</blockquote>
<p><code>make snapshot STAGE=65 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage65</code>. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<h2 id="heading-stage-7-security-observability">Stage 7 — Security Observability</h2>
<p>Security you can't measure, you can't prove.</p>
<p>The goal here is to understand how metrics, logs, and dashboards fit together. Then prove it by running commands in the terminal, watching the same events appear in Grafana, and explaining what each panel means.</p>
<p>This stage is not “install Grafana and move on.” Stage 7 isn't complete until your dashboards show real Kyverno violations and Falco alerts that you triggered in §7.4: plus portfolio screenshots (§7.6). <code>make check-7</code> only proves the stack is up; it does not prove you can detect security events.</p>
<p><strong>Before you start:</strong> <code>make check-6</code> should pass (Stage 6.5 is optional. Skip is fine). Check the VM is not overloaded: <code>multipass exec clearledger -- uptime</code>. If you ran Stage 6.5, do <a href="#heading-70-free-node-resources-scale-down-litmus">§7.0</a> first to scale Litmus down. Plan about half a day. This is the heaviest stage on a single-node VM.</p>
<p>You'll be done when §7.6 is complete: dashboards show your Kyverno denial and Falco alert, not empty panels. Then <code>make check-7</code> (§7.7), <code>make snapshot STAGE=7</code>, and <code>make snapshots</code> (confirm <code>clearledger.stage7</code>).</p>
<p><strong>Already installed?</strong> If <code>kubectl get pods -n monitoring</code> shows Grafana <strong>3/3</strong> and Loki <strong>1/1</strong>, skip §7.1. Start at §7.2 (verify the stack), then §7.4 (hands-on lab).</p>
<h3 id="heading-what-you-need-to-know-first">What You Need to Know First</h3>
<p>Up to now, each stage had its own window into the cluster. Stage 3 gave you CI scan results in GitHub Actions. Stage 4 showed Kyverno blocking a bad deploy in the terminal. Stage 6 gave you Falco alerts in its UI, and you could always run <code>kubectl logs</code> on a pod. Those views are useful, but they are scattered.</p>
<p>Stage 7 brings them together in one place: <strong>Grafana</strong>. Instead of jumping between five different tools, you open a dashboard and see whether security events, policy violations, and app health are happening over time.</p>
<h4 id="heading-the-three-tools-youre-installing">The three tools you're installing</h4>
<p><strong>Prometheus</strong> collects numbers from the cluster: things like “how many Kyverno denials in the last hour” or “how many HTTP requests per second.” It checks those numbers every 15–30 seconds and keeps a history you can graph.</p>
<p><strong>Loki</strong> collects log lines: the same kind of text you see from <code>kubectl logs</code>, but from many pods at once. Falco alerts, failed login attempts, and application errors all land here so you can search them later.</p>
<p><strong>Grafana</strong> is the web UI where charts and tables pull data from Prometheus and Loki. This is what you would show an auditor: not a one-off terminal screenshot, but proof that you can find and measure events after they happen.</p>
<p>Prometheus doesn't magically know what to collect. ServiceMonitors and PodMonitors are small config objects that point it at the right targets.<br>If Kyverno has no monitor, the Kyverno dashboard stays empty even when Kyverno is working fine. The same applies to application request rates. Those panels stay blank until §7.5, when metrics-enabled images are deployed through GitOps.</p>
<p>Logs follow a similar path. <strong>Promtail</strong> reads container logs and sends them to Loki. If Loki isn't running, Grafana log panels show “No data” even though <code>kubectl logs</code> still works on individual pods.</p>
<h4 id="heading-how-this-connects-to-what-you-already-built">How this connects to what you already built</h4>
<p>When you blocked a bad <code>kubectl apply</code> in Stage 4, Kyverno recorded that denial. In Stage 7, that shows up on the <strong>Kyverno Policy Violations</strong> dashboard (via Prometheus).</p>
<p>When you triggered a shell inside a pod in Stage 6, Falco wrote an alert. In Stage 7, that appears on the <strong>Security Event Timeline</strong> (via Loki).</p>
<p>When ClearLedger handles HTTP traffic or a failed login, those events feed the <strong>Service Health</strong> dashboards (Loki and Prometheus together).</p>
<p>Vault (Stage 5) and network policies (Stage 6) don't always have their own panel, but they still matter: fewer secrets in Git and blocked pod traffic show up indirectly in a healthier, quieter cluster.</p>
<h4 id="heading-what-youll-do-in-this-stage">What you'll do in this stage</h4>
<p>You'll run a command in the terminal (for example, a Kyverno violation or a Falco trigger) and then wait a short time while Prometheus or Loki ingests the event. Within about 15–90 seconds, the matching Grafana panel should update.</p>
<p>That's the whole point of observability for security: the terminal proves the event happened once, while the dashboard proves you can <strong>detect and measure</strong> it later without being logged into the cluster at that exact moment.</p>
<h3 id="heading-70-free-node-resources-scale-down-litmus">7.0: Free Node Resources (Scale Down Litmus)</h3>
<p>Stage 6.5 is complete. You don't need the Litmus UI, MongoDB, or chaos operator running while Prometheus, Loki, and Grafana start. They compete for the same CPUs on a single-node lab VM (6 by default, see <code>scripts/setup-cluster.sh</code>).</p>
<p>Scaling Litmus to zero frees ~500–800MB RAM and reduces CPU churn before the observability install.</p>
<pre><code class="language-bash">kubectl scale deployment,statefulset -n litmus --replicas=0 --all
kubectl get pods -n litmus
# Expected: no Running pods (Succeeded job pods from chaos experiments are OK)
multipass exec clearledger -- uptime
# Expected: load average (1m) ideally below ~8 before continuing
</code></pre>
<p>You can scale Litmus back up later if you want to re-run chaos experiments (<code>bash stages/stage-6.5-chaos-engineering/scripts/install-litmus.sh</code>). For Stages 7–7.5, keep it scaled down.</p>
<h3 id="heading-71-install-the-observability-stack">7.1: Install the Observability Stack</h3>
<p><strong>This is safe to run more than once.</strong> The script checks what's already installed. If Grafana, Prometheus, and Loki are healthy, it skips the heavy install and only updates dashboards and scrape configs. Running it again after a partial failure won't duplicate or break a working stack.</p>
<p>Only add <code>FORCE=1</code> if something is genuinely stuck, for example you edited the Helm values files and need a full reinstall, or Loki keeps crashing in a restart loop:</p>
<pre><code class="language-bash">FORCE=1 bash stages/stage-7-observability/scripts/install-observability.sh
</code></pre>
<p>On a first-time install, use the plain command in Step 1 below. Don't use <code>FORCE=1</code> unless the troubleshooting section tells you to.</p>
<p><strong>macOS, Linux, and WSL2:</strong> <code>FORCE=1 bash ...</code> works as written.</p>
<p><strong>Native Windows PowerShell</strong> doesn't use that syntax.</p>
<p>Run the lab inside <strong>WSL2 Ubuntu</strong> (recommended), or set the variable first: <code>$env:FORCE=1; bash stages/stage-7-observability/scripts/install-observability.sh</code>.</p>
<p><strong>Step 1: install</strong> (wait until the script prints <code>✓ Stage 7 installed.</code>):</p>
<pre><code class="language-bash">bash stages/stage-7-observability/scripts/install-observability.sh
</code></pre>
<h4 id="heading-if-you-see-waiting-for-falco-during-the-stage-7-install-thats-expected">If you see “Waiting for Falco” during the Stage 7 install, that's expected.</h4>
<p>You already installed Falco in Stage 6. Stage 7 is not adding a second Falco. It is making sure the existing Falco setup can feed logs and metrics into the observability stack.</p>
<p>The flow is:</p>
<ul>
<li><p>Falco still runs in the <code>falco</code> namespace.</p>
</li>
<li><p>Promtail sends Falco logs to Loki.</p>
</li>
<li><p>Grafana reads those logs from Loki.</p>
</li>
<li><p>The Security Event Timeline dashboard shows the Falco alerts.</p>
</li>
</ul>
<p>Right after install, the Grafana panels may be empty. That's normal. You need to trigger a new alert in §7.4 before the dashboard has something fresh to show.</p>
<p><strong>Step 2. Check pods</strong> (run this after Step 1 finishes):</p>
<pre><code class="language-bash">kubectl get pods -n monitoring
</code></pre>
<p>You want something like this (pod name suffixes vary):</p>
<pre><code class="language-text">NAME                                              READY   STATUS    RESTARTS   AGE
kube-prometheus-stack-grafana-....                3/3     Running   0          5m
kube-prometheus-stack-prometheus-....             2/2     Running   0          5m
loki-0                                            1/1     Running   0          5m
loki-promtail-....                                1/1     Running   0          5m
</code></pre>
<p>Grafana must show <strong>3/3</strong> Ready (not 2/3). Loki must show <strong>1/1</strong>. If pods are still <code>Pending</code> or <code>ContainerCreating</code>, wait a few minutes and run <code>kubectl get pods -n monitoring</code> again.</p>
<p><strong>Expected – Loki healthy:</strong></p>
<pre><code class="language-bash">kubectl exec -n monitoring loki-0 -- wget -qO- http://127.0.0.1:3100/ready
</code></pre>
<pre><code class="language-text">ready
</code></pre>
<p><strong>Expected – Grafana can reach Loki (same path log panels use):</strong></p>
<pre><code class="language-bash">kubectl exec -n monitoring deploy/kube-prometheus-stack-grafana -c grafana -- \
  wget -qO- --timeout=5 http://loki:3100/ready
</code></pre>
<pre><code class="language-text">ready
</code></pre>
<p><strong>Expected – Grafana UI reachable:</strong></p>
<pre><code class="language-bash">curl -sI http://grafana.local | head -n 1
</code></pre>
<pre><code class="language-text">HTTP/1.1 302 Found
</code></pre>
<p>Log into <strong><a href="http://grafana.local">http://grafana.local</a>:</strong> <code>admin</code> / <code>admin123</code></p>
<p>Empty panels right after install are <strong>normal</strong>. You haven't generated events yet. Continue to §7.2–§7.4.</p>
<p>If Helm fails: wait 30s, then <code>FORCE=1 bash stages/stage-7-observability/scripts/install-observability.sh</code>. See <code>troubleshooting.md. Stage 7</code>.</p>
<p><strong>✋ Hands-on checkpoint: confirm Loki and dashboards are ready</strong></p>
<p>Before you open Grafana, confirm the logging stack and dashboards actually installed.</p>
<p>On a single-node VM, Grafana can look fine while Loki is crash-looping or the ClearLedger dashboards never loaded. If you skip this check, you may spend the rest of Stage 7 debugging empty panels.</p>
<p><strong>Run:</strong></p>
<pre><code class="language-bash">kubectl get pods -n monitoring
kubectl get pods -n monitoring -l app.kubernetes.io/name=loki \
  -o jsonpath='{.items[*].status.containerStatuses[*].restartCount}{"\n"}'
kubectl get configmap -n monitoring -l clearledger_dashboard=1 --no-headers | wc -l
</code></pre>
<p><strong>Expected:</strong></p>
<ul>
<li><p>All monitoring pods are <code>Running</code></p>
</li>
<li><p>Grafana shows <code>3/3</code> Ready</p>
</li>
<li><p>Loki shows <code>1/1</code> Ready</p>
</li>
<li><p>Loki restart count is <code>0</code>, or low and not climbing</p>
</li>
<li><p>The dashboard count is <code>6</code></p>
</li>
</ul>
<p>If Loki keeps restarting or the dashboard count is <code>0</code>, stop here and fix the install before continuing. Empty Grafana panels usually mean Loki or the dashboards are missing, not that the security events failed.</p>
<h3 id="heading-72-verify-prometheus-loki-and-grafana-before-opening-dashboards">7.2: Verify Prometheus, Loki, and Grafana (before opening dashboards)</h3>
<p>Run these three checks so you know which layer is broken if a panel is empty.</p>
<h4 id="heading-check-1-prometheus-has-kyverno-metrics">Check 1: Prometheus has Kyverno metrics</h4>
<pre><code class="language-bash">kubectl exec -n monitoring deploy/kube-prometheus-stack-grafana -c grafana -- \
  wget -qO- 'http://kube-prometheus-stack-prometheus.monitoring:9090/api/v1/query?query=kyverno_admission_requests_total' 2&gt;/dev/null \
  | head -c 400
</code></pre>
<p>(Prometheus runs as a StatefulSet pod, not a Deployment. This query goes through Grafana to the Prometheus Service.)</p>
<p><strong>Expected:</strong> JSON with <code>"status":"success"</code> and a <code>"metric"</code> block (values may be <code>0</code> until you trigger a violation in §7.4).</p>
<p>If you see <code>"status":"success"</code> but <code>"result":[]</code>, Prometheus is up but Kyverno hasn't recorded admissions yet. That's fine before the lab.</p>
<h4 id="heading-check-2-loki-has-falco-logs">Check 2: Loki has Falco logs</h4>
<pre><code class="language-bash">kubectl exec -n monitoring loki-0 -- wget -qO- \
  'http://127.0.0.1:3100/loki/api/v1/labels' 2&gt;/dev/null | head -c 300
</code></pre>
<p><strong>Expected:</strong> JSON listing labels such as <code>"namespace"</code> (and after Falco events, you'll see <code>"falco"</code> in label values).</p>
<p>Quick log search (may return empty lines until §7.4 Exercise B):</p>
<pre><code class="language-bash">kubectl exec -n monitoring loki-0 -- wget -qO- \
  'http://127.0.0.1:3100/loki/api/v1/query?query=%7Bnamespace%3D%22falco%22%7D&amp;limit=3' 2&gt;/dev/null \
  | head -c 500
</code></pre>
<p><strong>Expected:</strong> <code>"status":"success"</code>. <code>"result":[]</code> means no Falco lines in Loki yet, not a broken Loki.</p>
<h4 id="heading-check-3-grafana-imported-clearledger-dashboards">Check 3: Grafana imported ClearLedger dashboards</h4>
<pre><code class="language-bash">curl -s -u admin:admin123 'http://grafana.local/api/search?tag=clearledger' | jq -r '.[].title'
</code></pre>
<p><strong>Expected: six titles:</strong></p>
<pre><code class="language-text">ClearLedger - Compliance Posture
ClearLedger - DORA Metrics
ClearLedger - Kubernetes Audit Log Analysis
ClearLedger - Kyverno Policy Violations
ClearLedger - Security Event Timeline
ClearLedger - Service Health + Auth Security
</code></pre>
<p>Or in the UI: go. to<strong>Dashboards</strong> then filter tag <code>clearledger</code>. You should see exactly these six (no missing names).</p>
<h3 id="heading-73-your-first-10-minutes-in-grafana">7.3: Your First 10 Minutes in Grafana</h3>
<p>This section is only a tour. You're not proving anything yet.</p>
<p><strong>Rule for all of Stage 7:</strong> an empty panel usually means no events have happened in the selected time range, not that Grafana is broken. You create the real events in §7.4.</p>
<h4 id="heading-step-1-open-grafana">Step 1: Open Grafana</h4>
<p>Go to <code>http://grafana.local</code> and log in:</p>
<ul>
<li><p>Username: <code>admin</code></p>
</li>
<li><p>Password: <code>admin123</code></p>
</li>
</ul>
<h4 id="heading-step-2-set-the-time-range">Step 2: Set the Time Range</h4>
<p>In the top-right corner, choose <strong>Last 15 minutes</strong>.</p>
<p>Keep this setting for all of Stage 7. Wider ranges like <strong>Last 24 hours</strong> can overload Loki on a single-node lab VM.</p>
<h4 id="heading-step-3-open-dashboards-one-at-a-time">Step 3: Open Dashboards One at a Time</h4>
<p>Open one dashboard, look around, then move to the next. Don't open all six at once.</p>
<ol>
<li><p><a href="http://grafana.local/d/clearledger-kyverno-violations">Kyverno Policy Violations</a>: policy blocks from Stage 4.</p>
</li>
<li><p><a href="http://grafana.local/d/clearledger-security-events">Security Event Timeline</a>: Falco alerts from Stage 6. You may see old <code>postgres</code> noise in the log table.</p>
</li>
<li><p><a href="http://grafana.local/d/clearledger-service-health">Service Health + Auth</a>: app traffic and login attempts.</p>
</li>
<li><p><a href="http://grafana.local/d/clearledger-compliance">Compliance Posture</a>: summary view for auditors. Skim it and come back after §7.4.</p>
</li>
<li><p><a href="http://grafana.local/d/clearledger-audit-logs">Audit Log Analysis</a>: empty on MicroK8s by design (audit pipeline not enabled by default).</p>
</li>
<li><p><a href="http://grafana.local/d/clearledger-dora-metrics">DORA Metrics</a>: deploy-frequency charts. Needs multiple CI runs to accumulate data. May show blank on first look. Optional.</p>
</li>
</ol>
<p>Use the short dashboard links in this guide. Avoid old bookmarked URLs with long random slugs.</p>
<p>You can also find them in Grafana: go to <strong>Dashboards</strong> then search tag <code>clearledger</code>.</p>
<h4 id="heading-step-4-how-to-read-what-you-see">Step 4: How to Read What You See</h4>
<p>Grafana panels pull data from two places:</p>
<ul>
<li><p><strong>Prometheus</strong> shows numbers over time, like Kyverno violation counts and request rates</p>
</li>
<li><p><strong>Loki</strong> shows log lines, like Falco alerts and auth-service messages</p>
</li>
</ul>
<p>A big number panel asks: did this count go above zero?</p>
<p>A line chart asks: was there a spike after I ran something?</p>
<p>A logs panel shows the actual text, like rule names, <code>CRITICAL</code>, or <code>Failed login attempt</code>.</p>
<p>If only log panels show <code>connection refused</code>, check Loki again in §7.1.</p>
<p>If number panels work but log panels fail, the problem is likely Loki, not Grafana itself.</p>
<h4 id="heading-step-5-move-on">Step 5: Move On</h4>
<p>Open dashboards 1–3, then continue to §7.4.</p>
<p>That's where you'll run commands in the terminal and watch the panels update with real security events.</p>
<h3 id="heading-74-hands-on-lab-terminal-dashboard-proof">7.4: Hands-on Lab: Terminal → Dashboard Proof</h3>
<p>This is the core learning section. For each exercise: run the command, wait, then confirm in Grafana.</p>
<p><strong>Timing:</strong> wait <strong>30–90 seconds</strong> after each command for Prometheus scrape and Loki ingestion.</p>
<h3 id="heading-two-ways-to-do-this-lab">Two Ways to Do This Lab</h3>
<h4 id="heading-option-1-follow-the-exercises-below-recommended-for-learning">Option 1: follow the exercises below (recommended for learning)</h4>
<p>Run each command yourself, then check Grafana. That's Exercise A, B, and C.</p>
<h4 id="heading-option-2-use-the-guided-script">Option 2: use the guided script</h4>
<p>The script runs the same steps and pauses so you can check Grafana between them:</p>
<pre><code class="language-bash">bash stages/stage-7-observability/scripts/generate-dashboard-data.sh
</code></pre>
<p>Or:</p>
<pre><code class="language-bash">make demo-7
</code></pre>
<p>Both commands do the same thing. The script will say things like “Press Enter after you checked the Kyverno dashboard.” Switch to Grafana, look at the panel, then come back and press Enter.</p>
<p><strong>Want it to run without pauses?</strong> (faster, less hand-holding)</p>
<pre><code class="language-bash">SKIP_PROMPT=1 make demo-7
</code></pre>
<p>Use Option 1 if you want to understand each step. Use Option 2 if you want a walkthrough. Use <code>SKIP_PROMPT=1</code> if you just want the data generated quickly.</p>
<h4 id="heading-exercise-a-kyverno-block-prometheus-kyverno-dashboard">Exercise A: Kyverno block → Prometheus → Kyverno dashboard</h4>
<p><strong>Terminal</strong>: apply a pod that violates Stage 4 policy (runs as root):</p>
<pre><code class="language-bash">cat &lt;&lt;'YAML' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
  name: stage7-kyverno-lab
  namespace: clearledger
spec:
  containers:
    - name: test
      image: nginx:alpine
YAML
</code></pre>
<p><strong>How to know it worked:</strong></p>
<p>You're testing whether Kyverno <strong>blocks</strong> a deliberately bad pod. Success means the pod <strong>never gets created</strong>.</p>
<p><strong>Pass. You should see:</strong></p>
<ul>
<li><p>The terminal prints <code>Error from server</code> and <code>denied the request</code></p>
</li>
<li><p>The exact policy names in the error don't matter. Your output might list one rule or several (<code>disallow-root-containers</code>, <code>require-resource-limits</code>, <code>drop-all-capabilities</code>, …). More lines just means more rules failed, that's still a pass.</p>
</li>
<li><p>The pod name never shows up in the cluster:</p>
</li>
</ul>
<pre><code class="language-bash">kubectl get pods -n clearledger | grep stage7-kyverno-lab
</code></pre>
<p><strong>Expected:</strong> no output.</p>
<p><strong>If it fails: stop and fix Stage 4 first</strong></p>
<ul>
<li><p>The command ends quietly with <code>created</code> (no error)</p>
</li>
<li><p><code>kubectl get pods -n clearledger</code> shows <code>stage7-kyverno-lab</code></p>
</li>
</ul>
<p>That means Kyverno let a root pod through. Run <code>make check-4</code> before continuing Stage 7.</p>
<p><strong>Example of a passing terminal</strong> (yours may list more policies):</p>
<pre><code class="language-text">Error from server: error when creating "STDIN": admission webhook "validate.kyverno.svc" denied the request:
policy disallow-root-containers/validate-run-as-non-root fail: Running as root is not allowed
</code></pre>
<p><strong>Confirm Prometheus saw it</strong> (optional but useful if Grafana is empty):</p>
<pre><code class="language-bash">kubectl exec -n monitoring deploy/kube-prometheus-stack-grafana -c grafana -- \
  wget -qO- 'http://kube-prometheus-stack-prometheus.monitoring:9090/api/v1/query?query=kyverno_admission_requests_total{request_allowed="false"}' 2&gt;/dev/null \
  | grep -o '"value":\[[^]]*\]' | head -3
</code></pre>
<p><strong>Expected:</strong> a <code>"value"</code> entry with a recent Unix timestamp and a number <strong>greater than 0</strong> (for example <code>"value":[..., "1"]</code>). If you see this, Kyverno and Prometheus are working even when Grafana panels say <strong>No data</strong>.</p>
<p><strong>Grafana</strong>: open <a href="http://grafana.local/d/clearledger-kyverno-violations?from=now-15m&amp;to=now">Kyverno Policy Violations</a>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/7f5cf039-f2de-41d6-b71e-f5af2fc3ccab.png" alt="screenshot of Kyverno Policy Violations" style="display:block;margin:0 auto" width="1325" height="1288" loading="lazy">

<p><strong>What you're proving:</strong> the terminal denial showed up in Grafana. You don't need every panel to light up. You need <strong>one clear sign</strong> that Kyverno blocks are being counted.</p>
<p><strong>Step 1: quick sanity check (top row, left to right)</strong></p>
<ol>
<li><p><strong>Policy Violations (time range)</strong>: big number. Pass: shows 1 or more. Fail: says No data.</p>
</li>
<li><p><strong>Violations (time range)</strong>: same idea, second counter. Pass: 1 or more.</p>
</li>
<li><p><strong>Active Kyverno Rules</strong> — usually 18. If this number shows up, Grafana can talk to Prometheus. That is good even when the first two panels are still empty.</p>
</li>
</ol>
<p><strong>Step 2: if the top two numbers work, skim the charts</strong></p>
<ul>
<li><p><strong>Violation Rate by Resource Kind</strong> (middle chart): look for a bump labeled Pod around the time you ran <code>kubectl apply</code>.</p>
</li>
<li><p><strong>Top Blocked Resource Types</strong> (bottom-left table): look for a Pod row.</p>
</li>
<li><p><strong>Violations by Namespace (trend)</strong> (bottom-right chart): look for a bump for clearledger.</p>
</li>
</ul>
<p>Charts can lag. A big number &gt; 0 in Step 1 is enough to move on. The charts are bonus proof for §7.6 screenshots.</p>
<p><strong>If the top two panels say "No data" but the terminal denial worked:</strong></p>
<p>This is common. Those panels count <strong>new</strong> denials during the time range, not the total ever recorded. One denial sometimes lands in Prometheus before Grafana's counter moves.</p>
<p>Try this:</p>
<ol>
<li><p>Run the same <code>kubectl apply</code> command again (denied again, that is expected).</p>
</li>
<li><p>Wait 60 seconds.</p>
</li>
<li><p>Click Refresh (circular arrow, top-right).</p>
</li>
</ol>
<p>After a second denial you should see 2 in the top stat panels. Screenshot that for §7.6.</p>
<p><strong>Still empty? Use Explore as backup proof:</strong></p>
<ol>
<li><p>Grafana left menu → Explore</p>
</li>
<li><p>Datasource: Prometheus</p>
</li>
<li><p>Paste: <code>sum(kyverno_admission_requests_total{request_allowed="false"})</code></p>
</li>
<li><p>Click Run query</p>
</li>
</ol>
<p><strong>Pass:</strong> the result is 1 or 2.</p>
<p>A screenshot of the terminal denial plus Explore showing a number &gt; 0 counts as portfolio proof even if the dashboard stats stay slow.</p>
<h4 id="heading-exercise-b-falco-shell-loki-security-event-timeline">Exercise B: Falco shell → Loki → Security Event Timeline</h4>
<p><strong>What you're doing (same idea as Exercise A):</strong></p>
<ul>
<li><p><strong>Exercise A:</strong> you did something bad, Kyverno blocked it, and the Grafana <strong>Kyverno</strong> dashboard updated.</p>
</li>
<li><p><strong>Exercise B:</strong> you do something suspicious inside a running pod, Falco detects it, and Grafana <strong>Security Event Timeline</strong> updates.</p>
</li>
</ul>
<p>You already did this in Stage 6 (<code>make demo-6</code>). Here you do it again and prove the alert shows up in Grafana, not only in <code>http://falco.local</code>.</p>
<p><strong>The story in one line:</strong> pretend you're an attacker who got shell access inside <code>auth-service</code>: Falco should scream, and the scream should appear on the timeline dashboard.</p>
<p><strong>Step 1: trigger the alert (terminal way)</strong></p>
<p>You're pretending an attacker got into <code>auth-service</code> and ran a quick command (<code>id</code>) to see who they're logged in as. That's suspicious. Falco is supposed to catch it.</p>
<p>The block below is three commands in order. Copy-paste the whole block:</p>
<pre><code class="language-bash">AUTH_POD=$(kubectl get pod -n clearledger -l app=auth-service \
  --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}')
echo "Using pod: $AUTH_POD"
kubectl exec -n clearledger "$AUTH_POD" -c auth-service -- /bin/sh -c 'id &amp;&amp; exit'
</code></pre>
<p>What each line does:</p>
<ol>
<li><p><strong>Line 1</strong>: finds the name of a running <code>auth-service</code> pod and saves it in <code>AUTH_POD</code>.</p>
</li>
<li><p><strong>Line 2</strong>: prints that name so you can see it worked (not empty).</p>
</li>
<li><p><strong>Line 3</strong>: runs <code>/bin/sh -c 'id &amp;&amp; exit'</code> <strong>inside</strong> that pod. This is the fake “attack.” Falco watches for shells like this.</p>
</li>
</ol>
<p><strong>Pass: you only need these two lines in the output:</strong></p>
<pre><code class="language-text">Using pod: auth-service-77b7d9cd99-xxxxx
uid=1000 gid=1000 groups=1000
</code></pre>
<ul>
<li><p>First line: a real pod name (not blank).</p>
</li>
<li><p>Second line: the <code>id</code> command ran inside the container.</p>
</li>
</ul>
<p>That's Step 1 done. The pod is still running. You didn't break anything.</p>
<p><strong>Fail: stop and fix before Step 2:</strong></p>
<ul>
<li><p><code>error: Internal error</code> or <code>container not found</code></p>
</li>
<li><p><code>Using pod:</code> with nothing after it</p>
</li>
</ul>
<p>Run <code>kubectl get pods -n clearledger -l app=auth-service</code> and retry when one pod shows <strong>Running</strong>.</p>
<p><strong>Step 2: Confirm Falco saw it (terminal, right away)</strong></p>
<p>The Falco log is one long JSON line. Don't try to read the whole thing. Run:</p>
<pre><code class="language-bash">kubectl logs -n falco -l app.kubernetes.io/name=falco --tail=50 | grep -i 'Shell spawned'
</code></pre>
<p><strong>Pass. You should see one short phrase somewhere in the line:</strong></p>
<pre><code class="language-text">Shell spawned in ClearLedger container ... pod=auth-service-... cmd=sh -c id &amp;&amp; exit
</code></pre>
<p>Or the rule name:</p>
<pre><code class="language-text">"rule":"Shell Spawned in ClearLedger Container"
</code></pre>
<p><strong>That one grep hit means Exercise B worked in the terminal.</strong> Screenshot this line for your portfolio.</p>
<p><strong>Ignore:</strong></p>
<ul>
<li><p><code>Defaulted container "falco" out of: ...</code>: normal kubectl noise</p>
</li>
<li><p>Lines about <code>postgres-0</code> and <code>/etc/passwd</code>: background noise from Stage 6, not your test</p>
</li>
<li><p>The rest of the JSON (<code>output_fields</code>, <code>k8smeta</code>, and so on). You don't need to parse it</p>
</li>
</ul>
<p><strong>If grep prints nothing:</strong> run Step 1 again, wait 5 seconds, then re-run the grep.</p>
<p><strong>Step 3: Confirm Loki stored it (wait ~60 seconds first)</strong></p>
<p>The story so far:</p>
<ul>
<li><p><strong>Step 1</strong>: you triggered the alert inside <code>auth-service</code></p>
</li>
<li><p><strong>Step 2</strong>: Falco wrote the alert to its own logs ✓</p>
</li>
</ul>
<p><strong>Step 3 asks:</strong> did that log line make it into <strong>Loki</strong> which is Grafana's log database?</p>
<p>Falco doesn't talk to Grafana directly. Promtail copies Falco's logs into Loki. That copy takes 60–90 seconds. Wait after Step 1, then run this check.</p>
<p><strong>What this command does:</strong></p>
<p>"Search Loki for Falco logs that contain <code>Shell spawned</code>, then show only lines that also mention <code>auth-service</code>."</p>
<pre><code class="language-bash">kubectl exec -n monitoring loki-0 -- wget -qO- \
  'http://127.0.0.1:3100/loki/api/v1/query?query=%7Bnamespace%3D%22falco%22%2Ccontainer%3D%22falco%22%7D%20%7C%3D%20%22Shell%20spawned%22&amp;limit=3' 2&gt;/dev/null \
  | grep -i 'auth-service'
</code></pre>
<p><strong>Pass:</strong> you see a line with both <code>auth-service</code> and <code>Shell spawned</code>. That means Loki has your alert and Grafana can show it.</p>
<p><strong>Fail (misleading pass):</strong> you grep for <code>ClearLedger</code> alone and get a hit from <code>postgres-0</code> reading <code>/etc/passwd</code>. That's background noise from Stage 6, not your shell test. Always look for <code>auth-service</code>.</p>
<p><strong>Empty output?</strong> That's OK. If Step 2 passed, <strong>continue to Step 4</strong>. Promtail may still be catching up, or the JSON is too long for this quick grep. Grafana often shows the alert even when this command prints nothing.</p>
<p><strong>Step 4: open Grafana</strong></p>
<p>Open <a href="http://grafana.local/d/clearledger-security-events?from=now-1h&amp;to=now">Security Event Timeline</a>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/c8f78ce4-8857-485b-9e4a-77c83bb95bd9.png" alt="screenshot of security timeline dashboard" style="display:block;margin:0 auto" width="1114" height="1024" loading="lazy">

<p>This is the right dashboard. The title at the top should say <strong>ClearLedger - Security Event Timeline.</strong></p>
<p><strong>Before you look at panels:</strong></p>
<ol>
<li><p>Time range: <strong>Last 1 hour</strong> (top-right)</p>
</li>
<li><p>Auto-refresh: Off</p>
</li>
<li><p>Re-run Step 1 if your shell command was more than a few minutes ago</p>
</li>
<li><p>Wait 90 seconds, then click Refresh</p>
</li>
</ol>
<p><strong>What you'll probably see (and this is normal):</strong></p>
<ul>
<li><p><strong>CRITICAL Alerts (1h)</strong>: a big number like <strong>1.08 K</strong>. That is mostly <code>postgres-0</code> reading <code>/etc/passwd</code> on a loop (Stage 6 background noise). It does <strong>not</strong> mean you failed.</p>
</li>
<li><p><strong>Alerts by Rule Name</strong> (pie chart): dominated by <strong>Sensitive File Read in ClearLedger</strong>. Also normal.</p>
</li>
<li><p><strong>Recent CRITICAL / WARNING Events</strong>: lots of Postgres rows. Your shell alert is in there, but buried.</p>
</li>
</ul>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/577e14fa-205d-462a-beb2-7a6a08514295.png" alt="anothre screenshot showing security even timeline grafana dashboard" style="display:block;margin:0 auto" width="1060" height="1009" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/e3a363af-676b-47a6-a61a-fe5ee8b7c130.png" alt="e3a363af-676b-47a6-a61a-fe5ee8b7c130" style="display:block;margin:0 auto" width="1119" height="977" loading="lazy">

<p>The top timeline (<strong>Falco Alerts by Priority - Timeline</strong>) may say <strong>No data</strong>. That's a known quirk. Don't panic, just use the log panel and browser search instead.</p>
<p><strong>How to find <em>your</em> alert (on this dashboard):</strong></p>
<ol>
<li><p>Stay on <strong>ClearLedger - Security Event Timeline</strong> — not Explore, not Tempo.</p>
</li>
<li><p>Click inside <strong>Recent CRITICAL / WARNING Events</strong> (the log list on the right).</p>
</li>
<li><p>Press <strong>Cmd+F</strong> (Mac) or <strong>Ctrl+F</strong> (Windows/Linux).</p>
</li>
<li><p>Search for <code>auth-service</code> or <code>Shell spawned</code>.</p>
</li>
</ol>
<p>If the search finds a row mentioning your pod and <strong>Shell spawned</strong>, screenshot it.</p>
<p><strong>Wrong place (common mistake):</strong> Grafana <strong>Explore</strong> with datasource <strong>Tempo</strong> showing <code>ledger-service</code> traces. That's <strong>Stage 7.5</strong> (OpenTelemetry), not Exercise B. Tempo shows request traces, not Falco security alerts.</p>
<p><strong>Pass for Exercise B (pick one):</strong></p>
<ol>
<li><p><strong>Best:</strong> Step 2 terminal grep shows <code>Shell spawned</code> <strong>and</strong> the <strong>Security Event Timeline</strong> log search finds <code>auth-service</code> / <code>Shell spawned</code> — screenshot both.</p>
</li>
<li><p><strong>Also fine:</strong> Step 2 grep screenshot <strong>plus</strong> the <strong>Security Event Timeline</strong> dashboard with <strong>CRITICAL Alerts (1h)</strong> showing a number (proves that Falco → Loki → Grafana works, even if your shell row is buried in postgres noise).</p>
</li>
<li><p><strong>Fallback (only if the dashboard search fails):</strong> Step 2 grep <strong>plus</strong> Grafana <strong>Explore</strong> with datasource <strong>Loki</strong> (not Tempo):</p>
<ul>
<li><p>Left menu, go to <strong>Explore</strong></p>
</li>
<li><p>Top-left datasource dropdown: choose <strong>Loki</strong></p>
</li>
<li><p>Query: <code>{namespace="falco", container="falco"} |= "Shell spawned"</code></p>
</li>
<li><p>Click <strong>Run query</strong></p>
</li>
<li><p>Look for a line with <code>auth-service</code></p>
</li>
</ul>
</li>
</ol>
<p>Screenshot for §7.6.</p>
<h4 id="heading-exercise-c-failed-login-loki-and-service-health">Exercise C: Failed login, Loki, and Service Health</h4>
<p><strong>The story:</strong> someone is guessing passwords on your login API.<br>You send ten bad login attempts from the terminal. <code>auth-service</code> writes <code>Failed login attempt</code> to its logs. Grafana <strong>Service Health + Auth Security</strong> should show the count go up.</p>
<p>Same pattern as A and B: terminal action, then logs, then dashboard.</p>
<p><strong>Step 1: send bad login attempts (terminal)</strong></p>
<p>Copy-paste the whole block:</p>
<pre><code class="language-bash">for i in $(seq 1 10); do
  curl -s http://clearledger.local/auth/health &gt;/dev/null
  curl -s -X POST http://clearledger.local/auth/login \
    -H 'Content-Type: application/json' \
    -d '{"email":"lab-attacker@evil.com","password":"wrong"}' &gt;/dev/null
done
echo "done"
</code></pre>
<p><strong>Pass:</strong> the only output you need is:</p>
<pre><code class="language-text">done
</code></pre>
<p>No output from the <code>curl</code> lines is normal. The loop hits <code>/auth/health</code> (keeps the app warm) and <code>/auth/login</code> with a wrong password ten times.</p>
<p><strong>Fail:</strong> <code>curl: (6) Could not resolve host</code>. Run <code>bash scripts/setup-hosts.sh</code> on your Mac. <code>curl: (7) Failed to connect</code>. Check <code>kubectl get pods -n clearledger -l app=auth-service</code>.</p>
<p><strong>Step 2: Confirm auth-service logged it</strong></p>
<pre><code class="language-bash">kubectl logs -n clearledger -l app=auth-service --tail=30 | grep -i 'Failed login' | tail -3
</code></pre>
<p><strong>Pass. You should see lines like:</strong></p>
<pre><code class="language-text">Failed login attempt for email: lab-attacker@evil.com
</code></pre>
<p>You may see several lines (one per failed attempt). One line is enough. Screenshot this for your portfolio.</p>
<p><strong>If grep prints nothing:</strong> wait 10 seconds and run again. If still empty, check the auth pod is Running: <code>kubectl get pods -n clearledger -l app=auth-service</code>.</p>
<p><strong>Step 3: open Grafana (wait ~60 seconds after Step 1)</strong></p>
<p>Open <a href="http://grafana.local/d/clearledger-service-health?from=now-1h&amp;to=now">Service Health + Auth Security</a>.</p>
<p><strong>This is the right dashboard.</strong> The title should say <strong>ClearLedger - Service Health + Auth Security</strong>.</p>
<ol>
<li><p>Time range: <strong>Last 1 hour</strong></p>
</li>
<li><p>Auto-refresh: <strong>Off</strong></p>
</li>
<li><p>Click <strong>Refresh</strong> once</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/a6df00f0-3f4a-4951-ab5f-3efb498beaa1.png" alt="screenshot showing Service Health + Auth Security grafana dashboard" style="display:block;margin:0 auto" width="1118" height="1028" loading="lazy">

<p>What to check (only these matter for Exercise C):</p>
<ol>
<li><p><strong>Failed Login Attempts (1h)</strong>: big number. <strong>Pass:</strong> <strong>&gt; 0</strong>. This is your main proof.</p>
</li>
<li><p><strong>Failed Login Log Stream</strong>: log lines in the panel. <strong>Pass:</strong> lines with <code>Failed login attempt</code> or <code>lab-attacker@evil.com</code>. Use <strong>Cmd+F</strong> inside the panel if needed.</p>
</li>
</ol>
<p>Panels you can ignore if empty:</p>
<ul>
<li><p><strong>Successful Logins</strong>: fine at <strong>0</strong> (you only sent bad passwords)</p>
</li>
<li><p><strong>Request Rate by Service</strong>: may be empty until §7.5 metrics images. Not required for Exercise C.</p>
</li>
</ul>
<p>You pass Exercise C when you have <strong>two screenshots:</strong></p>
<p><strong>Screenshot 1 (required):</strong> your Step 2 terminal output showing <code>Failed login attempt for lab-attacker@evil.com</code>. This proves the app logged the bad logins.</p>
<p><strong>Screenshot 2 (pick one of these):</strong></p>
<ul>
<li><p><strong>Option A:</strong> the <strong>Failed Login Attempts (1h)</strong> panel showing a number greater than zero (for example <strong>10</strong>). This proves Grafana counted the failures.</p>
</li>
<li><p><strong>Option B:</strong> the <strong>Failed Login Log Stream</strong> panel showing a line with <code>lab-attacker@evil.com</code>. Use this if the big number panel is still empty but the log stream has your email.</p>
</li>
</ul>
<p>You need Screenshot 1 and either Option A or Option B. That's enough for §7.6.</p>
<h4 id="heading-exercise-d-compliance-dashboard-the-auditor-summary">Exercise D: Compliance dashboard (the auditor summary)</h4>
<p><strong>What you're doing:</strong> open one dashboard that rolls up Exercises A, B, and C. This is the “show the auditor” view: admission control + runtime detection + application security in one screen.</p>
<p><strong>When:</strong> only after you finished A, B, and C.</p>
<p><strong>Step 1: open the dashboard</strong></p>
<p><a href="http://grafana.local/d/clearledger-compliance?from=now-1h&amp;to=now">Compliance Posture</a></p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/060714bc-c7c3-4d53-ad0e-d6d642970550.png" alt="screenshot of grafana Compliance Posture dashboard" style="display:block;margin:0 auto" width="1115" height="1132" loading="lazy">

<p>Set <strong>Last 1 hour</strong>, auto-refresh <strong>Off</strong>, click <strong>Refresh</strong>.</p>
<p><strong>Step 2: Check the top row stats</strong></p>
<table>
<thead>
<tr>
<th>Stat on dashboard</th>
<th>Came from</th>
<th>Pass</th>
</tr>
</thead>
<tbody><tr>
<td><strong>Policy Violations</strong></td>
<td>Exercise A (Kyverno)</td>
<td><strong>&gt; 0</strong></td>
</tr>
<tr>
<td><strong>Runtime Threats</strong></td>
<td>Exercise B (Falco)</td>
<td><strong>&gt; 0</strong> (postgres noise counts — that is OK)</td>
</tr>
<tr>
<td><strong>Failed Auth Attempts</strong></td>
<td>Exercise C (bad logins)</td>
<td><strong>&gt; 0</strong></td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ba6d0d41-bc88-4d8b-a93b-a1b78f99f4da.png" alt="screenshot of grafana Compliance Posture dashboard" style="display:block;margin:0 auto" width="1068" height="1058" loading="lazy">

<p>All three don't need to be huge numbers. They just need to be <strong>above zero</strong> after your tests.</p>
<p><strong>If one stat is still 0:</strong> re-run that exercise (A, B, or C), wait 90 seconds, refresh. Policy Violations may need a second Kyverno denial like Exercise A.</p>
<p>This is screenshot #3 for §7.6: the single frame that proves defense-in-depth.</p>
<p><strong>✋ Hands-on checkpoint: are you actually done with Stage 7?</strong></p>
<p>Installing Grafana isn't the goal. Detection is: you triggered real events and can see them on dashboards.</p>
<p><strong>Optional terminal check (proves Grafana is wired up):</strong></p>
<pre><code class="language-bash">curl -s -u admin:admin123 'http://grafana.local/api/search?tag=clearledger' | jq -r '.[].title'

curl -s -u admin:admin123 'http://grafana.local/api/datasources' | jq -r '.[].name'
</code></pre>
<p>First command, you should see six dashboard names:</p>
<ul>
<li><p>ClearLedger - Kyverno Policy Violations</p>
</li>
<li><p>ClearLedger - Security Event Timeline</p>
</li>
<li><p>ClearLedger - Service Health + Auth Security</p>
</li>
<li><p>ClearLedger - Compliance Posture</p>
</li>
<li><p>ClearLedger - Kubernetes Audit Log Analysis</p>
</li>
<li><p>ClearLedger - DORA Metrics</p>
</li>
</ul>
<p>Second command, you should see at least:</p>
<ul>
<li><p>Prometheus</p>
</li>
<li><p>Loki</p>
</li>
</ul>
<p><strong>What does NOT mean you're done:</strong></p>
<p><code>make check-7</code> only checks that monitoring pods are running. Green output there does <strong>not</strong> replace §7.4.</p>
<p><strong>What DOES mean you are done:</strong></p>
<p>You ran Exercises A, B, and C in §7.4 and saved the §7.6 screenshots:</p>
<ol>
<li><p>Kyverno denial (terminal + dashboard)</p>
</li>
<li><p>Falco shell alert (terminal + Security Event Timeline)</p>
</li>
<li><p>Failed logins (terminal + Service Health)</p>
</li>
<li><p>Compliance Posture summary (all three stats above zero)</p>
</li>
</ol>
<p>If you have those four screenshots, Stage 7 is complete.</p>
<h3 id="heading-75-fill-in-the-request-rate-chart-optional">7.5: Fill in the Request Rate Chart (Optional)</h3>
<p><strong>This is not required for Stage 7.</strong> Exercises A–C and §7.6 screenshots don't need this section. Skip it if you're happy moving on.</p>
<p>Also, this is not the same as Stage 7.5 (OpenTelemetry/Tempo). This subsection is only about the <strong>Request Rate by Service</strong> chart on the Service Health dashboard.</p>
<h4 id="heading-what-this-section-is-for">What this section is for:</h4>
<p>On Service Health + Auth Security, the Failed Login panels work from logs (Loki). The Request Rate by Service chart needs something different: app pods must expose a <code>/metrics</code> endpoint so Prometheus can scrape request counts.</p>
<p>The code is already in the repo (<code>app/*/prom_metrics.py</code>). Prometheus is already configured to scrape it (<code>clearledger-podmonitor.yaml</code>). The usual problem: your cluster is still running older images from before that code was in your build.</p>
<h4 id="heading-step-1-check-if-you-already-have-metrics-30-seconds">Step 1: check if you already have metrics (30 seconds)</h4>
<p>Run this first. If it passes, skip the rest of §7.5.</p>
<pre><code class="language-bash">kubectl exec -n monitoring deploy/kube-prometheus-stack-grafana -c grafana -- \
  wget -qO- 'http://kube-prometheus-stack-prometheus.monitoring:9090/api/v1/query?query=http_requests_total' 2&gt;/dev/null \
  | grep -o '"__name__":"http_requests_total"' | head -1
</code></pre>
<p><strong>Pass:</strong> prints <code>"__name__":"http_requests_total"</code>. Open Service Health, refresh, and the Request Rate by Service chart should already have lines.</p>
<p><strong>No output:</strong> continue to Step 2.</p>
<h4 id="heading-step-2-deploy-images-that-expose-metrics">Step 2: deploy images that expose <code>/metrics</code></h4>
<p>Pick one path.</p>
<p><strong>Path A: GitOps (if you have been using CI/CD since Stage 1–2)</strong></p>
<ol>
<li><p>Push a commit to <code>main</code> on your app repo.</p>
</li>
<li><p>Wait for CI to build new images and update <code>clearledger-infra</code>.</p>
</li>
<li><p>Wait for ArgoCD to show <strong>Synced</strong> and <strong>Healthy</strong> on the clearledger app.</p>
</li>
<li><p>Go to Step 3.</p>
</li>
</ol>
<p><strong>Path B: lab shortcut (faster, local only)</strong></p>
<pre><code class="language-bash">export DOCKER_USERNAME=your-dockerhub-user
bash stages/stage-7-observability/scripts/build-metrics-images.sh
</code></pre>
<p>This builds, pushes, and rolls out metrics-enabled images for all three services.</p>
<p><strong>Heads-up:</strong> ArgoCD self-heal may revert these image tags within a few minutes if <code>clearledger-infra</code> still points at older tags. That's fine for a quick lab demo. For a lasting fix, use Path A or update the infra repo (see §2 rollback notes).</p>
<h4 id="heading-step-3-verify-metrics-landed-60-seconds-after-rollout">Step 3: verify metrics landed (~60 seconds after rollout)</h4>
<pre><code class="language-bash">kubectl exec -n clearledger deploy/auth-service -c auth-service -- \
  wget -qO- http://127.0.0.1:8000/metrics 2&gt;/dev/null | head -5
</code></pre>
<p><strong>Pass:</strong> lines starting with <code># HELP</code> or <code>http_requests_total</code>.</p>
<p>Then confirm Prometheus sees them:</p>
<pre><code class="language-bash">kubectl exec -n monitoring deploy/kube-prometheus-stack-grafana -c grafana -- \
  wget -qO- 'http://kube-prometheus-stack-prometheus.monitoring:9090/api/v1/query?query=http_requests_total' 2&gt;/dev/null \
  | grep -o '"__name__":"http_requests_total"' | head -1
</code></pre>
<p><strong>Pass:</strong> <code>"__name__":"http_requests_total"</code></p>
<p>Generate a little traffic (re-run the Exercise C curl loop or hit <code>http://clearledger.local/auth/health</code> a few times), wait 60 seconds, then open <strong>Service Health + Auth Security</strong> and refresh. <strong>Request Rate by Service</strong> should show lines for <code>auth-service</code>, <code>ledger-service</code>, or <code>notification-service</code>.</p>
<h4 id="heading-when-to-stop">When to stop:</h4>
<ul>
<li><p><strong>Request Rate still empty but Failed Login panels work?</strong> You're done with Stage 7. Request Rate is a nice-to-have.</p>
</li>
<li><p><strong>Prometheus query passes but chart empty?</strong> Widen time range to <strong>Last 1 hour</strong>, generate traffic, wait 60s, refresh.</p>
</li>
</ul>
<h3 id="heading-76-wrap-up-stage-7-screenshots-done-check">7.6: Wrap up Stage 7 (Screenshots + Done Check)</h3>
<p>You're almost done. This section is just about saving proof, then moving on.</p>
<h4 id="heading-are-you-actually-finished">Are you actually finished?</h4>
<p>Opening Grafana and seeing six dashboards isn't enough. <code>make check-7</code> passing isn't enough either. That only proves pods are running.</p>
<p>You're done when you ran §7.4, waited for the panels to update, and saved three screenshots from your cluster.</p>
<p>If the panels are empty or only show old Postgres noise, go back to §7.4 first.</p>
<p><strong>Before each screenshot:</strong> set time range to <strong>Last 15 minutes</strong> (or <strong>Last 1 hour</strong> for Exercise B). Include the time picker and panel titles in the frame.</p>
<p><strong>Screenshot 1: Falco alert (Exercise B)</strong></p>
<p>Open <a href="http://grafana.local/d/clearledger-security-events">Security Event Timeline</a>.</p>
<p>Capture <strong>Recent CRITICAL / WARNING Events</strong> with a row that mentions <code>Shell spawned</code> or <code>auth-service</code>. If postgres rows bury it, use Cmd+F inside the log panel, that still counts.</p>
<p><strong>Screenshot 2: Kyverno denial (Exercise A)</strong></p>
<p>Open <a href="http://grafana.local/d/clearledger-kyverno-violations">Kyverno Policy Violations</a>.</p>
<p>Capture <strong>Policy Violations (time range)</strong> or <strong>Violations (time range)</strong> showing a number of 1 or more.</p>
<p><strong>Screenshot 3: Compliance summary (Exercise D)</strong></p>
<p>Open <a href="http://grafana.local/d/clearledger-compliance">Compliance Posture</a>.</p>
<p>Capture the top row with all three stats above zero: <strong>Policy Violations</strong>, <strong>Runtime Threats</strong>, and <strong>Failed Auth Attempts</strong>.</p>
<p><strong>Screenshot 4 (optional): Failed logins (Exercise C)</strong></p>
<p>Open <a href="http://grafana.local/d/clearledger-service-health">Service Health + Auth Security</a>.</p>
<p>Capture <strong>Failed Login Attempts (1h)</strong> above zero, or <strong>Failed Login Log Stream</strong> showing <code>lab-attacker@evil.com</code>.</p>
<p>Save files somewhere sensible, like <code>docs/evidence/stage-7-screenshot-1-falco.png</code>. Name them so you know what each proves.</p>
<p><strong>Final check:</strong> run <code>make check-7</code> (§7.7), save your VM, and you can claim Stage 7.</p>
<h3 id="heading-77-verify">7.7: Verify</h3>
<pre><code class="language-bash">make check-7
</code></pre>
<p><strong>Expected:</strong></p>
<pre><code class="language-text">▶ Stage 7 — Observability (Grafana + Prometheus + Loki)
  ✓ Prometheus is running
  ✓ Grafana reachable (http://grafana.local or in-cluster health OK)
  ✓ Loki pod is running (0 restarts)
  ✓ Loki reachable from Grafana (http://loki:3100/ready)
  ✓ ClearLedger alerting rules exist
  ✓ ClearLedger dashboards imported (6 found)
</code></pre>
<p>Warnings about Loki restarts or missing dashboards: fix with §7.1 before claiming Stage 7 complete.</p>
<p><strong>Save your VM</strong> after §7.6 and <code>make check-7</code>. See the block at the end of Stage 7 below.</p>
<h3 id="heading-78-what-broke-lab-notes-interview-talking-points">7.8: What Broke (Lab Notes + Interview Talking Points)</h3>
<p><strong>The stack in one sentence:</strong> Prometheus stores numbers (metrics), Loki stores log lines, and Grafana displays both visually. Nothing appears until something actually happens in the cluster.</p>
<h4 id="heading-what-tripped-you-up-in-the-lab">What tripped you up in the lab</h4>
<ol>
<li><p><strong>Empty dashboards right after install:</strong> Normal. Grafana doesn't create events. You trigger them in §7.4 (Kyverno denial, Falco shell, failed logins).</p>
</li>
<li><p><strong>Loki slow or refresh stuck on “Cancel”:</strong> Falco logs are huge. <strong>Last 24 hours</strong> overloads a small cluster. Use <strong>Last 1 hour</strong>, one dashboard at a time, and wait ~10 seconds.</p>
</li>
<li><p><code>make check-7</code> passed but panels still empty <em>(lab checklist only, not an interview topic)</em>: The health check confirms Prometheus/Loki/Grafana pods are up. It does <strong>not</strong> mean events exist. You still need §7.4 + §7.6 before you snapshot and move on.</p>
</li>
</ol>
<h4 id="heading-if-someone-asks-about-this-in-an-interview">If someone asks about this in an interview</h4>
<p><strong>Empty dashboards?</strong> Grafana only shows what already happened. No event in the time range means an empty panel. That's normal until you trigger something.</p>
<p><strong>Loki slow on a small cluster?</strong> Falco logs are huge. We kept time ranges short (15 minutes, not 24 hours) and opened one dashboard at a time. Same trade-off you would make in prod on limited hardware.</p>
<p><strong>How did you prove it worked?</strong> I ran the attacks myself: denied a bad pod, spawned a shell in a running container, and sent failed logins. Then I checked Grafana and screenshot the matching panels. Terminal action first, dashboard proof second.</p>
<p><strong>Short version you can say out loud:</strong></p>
<blockquote>
<p>"I connected Kyverno and Falco into Grafana. To prove it, I triggered a policy block and a runtime alert, then showed both on security dashboards. On a single-node lab, Loki got slow with wide time ranges, so we kept queries tight."</p>
</blockquote>
<p>Pipeline problems from earlier stages (Trivy, Kyverno, image tags, and so on) are in <code>docs/troubleshooting.md</code> — not something you need to rehearse for Stage 7.</p>
<h3 id="heading-79-if-panels-look-wrong-after-a-repo-update">7.9: If Panels Look Wrong After a Repo Update</h3>
<p>Re-apply dashboards, then generate real events (§7.4, not fake data):</p>
<pre><code class="language-bash">bash stages/stage-7-observability/scripts/install-observability.sh
# Then run Exercises A–C from §7.4 (Kyverno denial, Falco shell, failed logins)
</code></pre>
<p>Open Grafana at <strong>Last 1 hour</strong>, wait ~30–60s after each exercise, and refresh once. The §7.4 exercises cover expected appearance for each dashboard.</p>
<h3 id="heading-what-you-learned-in-stage-7">What You Learned in Stage 7</h3>
<ul>
<li><p><strong>Prometheus</strong> proves countable security events (Kyverno denials, HTTP rates)</p>
</li>
<li><p><strong>Loki</strong> proves forensic detail (Falco JSON, auth log lines)</p>
</li>
<li><p><strong>Grafana</strong> is the narrative layer, not a second install step after the lab</p>
</li>
<li><p>You can trace: terminal action, backend signal, panel update</p>
</li>
<li><p>ServiceMonitors / PodMonitors are what connect Stages 4–6 to charts</p>
</li>
<li><p>Empty dashboards mean “no events yet” or “wrong time range”, not “broken security”</p>
</li>
<li><p>Compliance posture is how you answer an auditor in one screen</p>
</li>
<li><p>Network policies must explicitly allow the <code>monitoring</code> namespace to reach app pods on port 8000, otherwise PodMonitor scrapes silently fail with <code>context deadline exceeded</code></p>
</li>
<li><p>Kubernetes Audit Log dashboard is empty on MicroK8s by design: the API server audit pipeline (audit-policy → file → Promtail → Loki) isn't enabled by default</p>
</li>
<li><p>Request Rate requires the full chain: app image with <code>/metrics</code>, PodMonitor, and network policy: any one missing means the panel stays empty</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Built security observability with Prometheus, Loki, and Grafana (dashboards correlating Kyverno violations, Falco alerts, and DORA metrics) and can prove a security event end-to-end from terminal to dashboard.</p>
</blockquote>
<h4 id="heading-stage-7-done-checklist">Stage 7 done checklist:</h4>
<ul>
<li><p><code>make check-7</code> → 6/6 ✓ (Stage 6.5 Litmus failure is expected: scaled down for memory)</p>
</li>
<li><p><code>http://grafana.local/d/clearledger-kyverno-violations</code>. Violations stat &gt; 0</p>
</li>
<li><p><code>http://grafana.local/d/clearledger-security-events</code>. CRITICAL Falco alert visible</p>
</li>
<li><p><code>http://grafana.local/d/clearledger-compliance</code>. Policy Violations + Runtime Threats + Failed Auth Attempts all &gt; 0</p>
</li>
<li><p><code>http://grafana.local/d/clearledger-service-health</code>. Failed Login Attempts &gt; 0. Request Rate &gt; 0 only if you did §7.5</p>
</li>
<li><p>Portfolio screenshots 1–3 saved</p>
</li>
</ul>
<p><code>make snapshot STAGE=7 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage7</code>. <strong>Don't skip this</strong>. Stage 7 is heavy, and disk pressure is common. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<p>After a Mac reboot or sleep, auth/ledger pods may show <strong>Unknown</strong> or <strong>Init:0/1</strong> even though the cluster is up (see <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md) Mac reboot</a>).</p>
<h2 id="heading-stage-75-opentelemetry-optional">Stage 7.5 — OpenTelemetry (Optional)</h2>
<p><strong>You can skip this whole stage.</strong> Stage 7 (metrics + logs) is enough to finish the homelab and move to Stage 8.</p>
<p>Only do Stage 7.5 if you want distributed traces for your portfolio or interviews, and your VM has spare RAM (about 1.5 Gi free).</p>
<h3 id="heading-what-you-are-adding">What You Are Adding</h3>
<p>Stage 7 answers: <em>did something happen?</em> (Kyverno blocked a pod, Falco saw a shell, or login failed.)</p>
<p>Traces answer: <em>what steps ran on this one request, and how long did each take?</em></p>
<ul>
<li><p><strong>Metrics</strong>: how many requests, how many errors</p>
</li>
<li><p><strong>Logs</strong>: what the app printed in its log file, such as errors, warnings, login failures)</p>
</li>
<li><p><strong>Traces</strong>: ledger-service called auth-service (12ms), then Postgres (8ms)</p>
</li>
</ul>
<p>In this stage, you send one real transaction, then open that request in Grafana Explore (Tempo). You'll see each step listed with its timing: ledger-service, auth-service, Postgres.</p>
<h3 id="heading-before-you-start">Before You Start</h3>
<ol>
<li><p>Finish Stage 7: §7.4 exercises done, §7.6 screenshots saved, <code>SKIP_CHAOS_CHECK=1 make check-7</code> passes.</p>
</li>
<li><p>Check VM memory: <code>multipass exec clearledger -- free -h</code> , want about 1.5 Gi free.</p>
</li>
<li><p>If you ran Stage 6.5 Litmus, scale it down first (§7.0).</p>
</li>
</ol>
<p>You're done when you see the full request trace in Grafana Explore (Tempo datasource) and <code>make check-75</code> passes. Then <code>make snapshot STAGE=75</code>.</p>
<h3 id="heading-ignore-this-warning-in-app-logs">Ignore This Warning in App Logs</h3>
<p>Since Stage 7 you may see:</p>
<pre><code class="language-plaintext">WARNING: Transient error StatusCode.UNAVAILABLE encountered while exporting traces
</code></pre>
<p>That is harmless. The apps are already set up to send trace data, but the receiver isn't installed until §7.5.3.</p>
<p>Your apps still work fine, the trace data just gets thrown away. Installing the collector in §7.5.3 makes the warning go away.</p>
<h3 id="heading-how-tracing-is-wired">How Tracing is Wired</h3>
<ol>
<li><p>Your apps send trace data when a request runs</p>
</li>
<li><p>OTel Collector receives it (port 4317) and passes it along</p>
</li>
<li><p>Grafana Tempo stores it</p>
</li>
<li><p>Grafana Explore (Tempo selected) is where you look at one request step by step</p>
</li>
</ol>
<p>Apps talk to the collector only, not to Tempo directly. That way you can change where traces are stored later without rebuilding the apps.</p>
<h3 id="heading-751-check-memory-and-load">7.5.1: Check Memory and Load</h3>
<p>Tempo needs ~300MB. Confirm headroom before installing:</p>
<pre><code class="language-bash">multipass exec clearledger -- free -h    # want ~1.5Gi available
multipass exec clearledger -- uptime      # load should be reasonable for your CPU count
SKIP_CHAOS_CHECK=1 bash scripts/health-check.sh 7
</code></pre>
<p>If Litmus is still running from Stage 6.5, scale it down first (§7.0):</p>
<pre><code class="language-bash">kubectl get pods -n litmus --field-selector=status.phase=Running
# Expected: no resources found
</code></pre>
<h3 id="heading-752-install-grafana-tempo">7.5.2: Install Grafana Tempo</h3>
<p>Tempo is the trace storage backend. Install it into the <code>monitoring</code> namespace next to Prometheus and Loki:</p>
<pre><code class="language-bash">helm repo add grafana https://grafana.github.io/helm-charts
helm repo update

helm install tempo grafana/tempo \
  --namespace monitoring \
  --set tempo.storage.trace.backend=local \
  --set tempo.storage.trace.local.path=/var/tempo \
  --set persistence.enabled=true \
  --set persistence.size=5Gi \
  --wait
</code></pre>
<p><strong>Verify Tempo is running:</strong></p>
<pre><code class="language-bash">kubectl get pods -n monitoring -l app.kubernetes.io/name=tempo
# Expected: tempo-0   1/1   Running
</code></pre>
<pre><code class="language-bash">kubectl exec -n monitoring tempo-0 -- wget -qO- http://localhost:3200/ready
# Expected: ready
</code></pre>
<h3 id="heading-753-deploy-otel-collector-and-wire-grafana">7.5.3: Deploy OTel Collector and Wire Grafana</h3>
<p>This applies the OTel Collector (receives spans from app pods) and registers Tempo as a Grafana datasource automatically via the sidecar:</p>
<pre><code class="language-bash">kubectl apply -f stages/stage-7.5-opentelemetry/infra/otel/otel-collector.yaml
kubectl apply -f stages/stage-7.5-opentelemetry/infra/otel/grafana-datasource-tempo.yaml
</code></pre>
<p><strong>Verify the collector is running:</strong></p>
<pre><code class="language-bash">kubectl get pods -n monitoring -l app=otel-collector
# Expected: otel-collector-xxxxx   1/1   Running
</code></pre>
<p><strong>Verify the collector started (not trace receipt yet):</strong></p>
<p>Apps push spans to the collector over OTLP: the collector doesn't scrape pods. At this step you're only confirming that it's listening.</p>
<pre><code class="language-bash">kubectl logs -n monitoring deploy/otel-collector --tail=15
# Expected:
#   Starting GRPC server ... endpoint: 0.0.0.0:4317
#   Starting HTTP server ... endpoint: 0.0.0.0:4318
#   Everything is ready. Begin running and processing data.
# No crash loops or repeated errors.
</code></pre>
<p>Proof that traces are actually flowing comes later: after you generate traffic in §7.5.6, check collector logs for span export lines from the <code>debug</code> exporter, then confirm the trace in Grafana Tempo (§7.5.7).</p>
<h3 id="heading-754-enable-prometheus-remote-write-receiver">7.5.4: Enable Prometheus Remote Write Receiver</h3>
<p>The OTel Collector also forwards OTel metrics to Prometheus via remote write. Prometheus needs to accept them:</p>
<pre><code class="language-bash">helm upgrade kube-prometheus-stack prometheus-community/kube-prometheus-stack \
  --namespace monitoring \
  -f stages/stage-7-observability/infra/helm/kube-prometheus-stack-values.yaml \
  --wait
</code></pre>
<p>This applies the <code>enableRemoteWriteReceiver: true</code> setting added to the Helm values in Stage 7.5. Wait for Prometheus to restart (about 60 seconds).</p>
<h3 id="heading-755-verify-app-pods-connect-to-the-collector">7.5.5: Verify App Pods Connect to the Collector</h3>
<p>The deployments in <code>clearledger-infra</code> already have <code>OTEL_EXPORTER_OTLP_ENDPOINT</code> set. Once the collector is running, the pods auto-connect.</p>
<p>Confirm that the OTEL warnings are gone:</p>
<pre><code class="language-bash">kubectl logs -n clearledger deploy/ledger-service -c ledger-service --tail=20 2&gt;/dev/null \
  | grep -v "opentelemetry\|otlp\|Transient" | tail -10
# Expected: only INFO request logs, no WARNING: Transient error
</code></pre>
<p>If warnings persist, the network policy may not have port 4317 egress. Apply the latest policies:</p>
<pre><code class="language-bash">kubectl apply -f infra/deferred-by-stage/stage-6-runtime-security/netpol/network-policies.yaml
</code></pre>
<h3 id="heading-756-generate-a-trace">7.5.6: Generate a Trace</h3>
<p>Now create a transaction and watch it flow through the system:</p>
<pre><code class="language-bash"># Step 1: register (skip if already registered)
curl -s -X POST http://clearledger.local/auth/register \
  -H "Content-Type: application/json" \
  -d '{"email":"trace-demo@clearledger.io","password":"TracePass123"}' | python3 -m json.tool

# Step 2: login and grab the token
TOKEN=$(curl -s -X POST http://clearledger.local/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"trace-demo@clearledger.io","password":"TracePass123"}' \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['access_token'])")
echo "Token acquired: ${TOKEN:0:20}..."

# Step 3: create a transaction (this is the request you will trace)
curl -s -X POST http://clearledger.local/ledger/transactions \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"amount": 5000, "direction": "credit"}' | python3 -m json.tool
</code></pre>
<p><strong>Verify the collector received spans:</strong></p>
<pre><code class="language-bash">kubectl logs -n monitoring deploy/otel-collector --tail=30 \
  | grep -iE "Traces|spans|ResourceSpans" || echo "No span lines yet — see §7.5.5 (OTEL env / netpol)"
# Expected after a successful transaction: debug exporter lines mentioning exported traces/spans
</code></pre>
<h3 id="heading-757-view-the-trace-in-grafana">7.5.7: View the Trace in Grafana</h3>
<p>Open <strong><a href="http://grafana.local">http://grafana.local</a></strong> and go to the left sidebar <strong>Explore</strong> (compass icon).</p>
<h4 id="heading-step-1-select-tempo-and-open-search">Step 1: Select Tempo and open Search</h4>
<p>At the top of the query pane:</p>
<ol>
<li><p>Datasource dropdown (orange <strong>T</strong> logo) → <strong>Tempo</strong></p>
</li>
<li><p>Query row labeled A (Tempo) → three tabs: Search | TraceQL | Service Graph</p>
</li>
<li><p>Click <strong>Search</strong>. This shows dropdown filters. <strong>TraceQL</strong> is a text box only. If you land there with nothing typed you get <code>0 series returned</code>.</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/b005b7ed-ad35-4c2f-8ca4-7d685717754f.png" alt="screenshot of grafana showing tempo and ledger service" style="display:block;margin:0 auto" width="1158" height="408" loading="lazy">

<h4 id="heading-step-2-filter-by-service">Step 2: Filter by service</h4>
<p>In the <strong>Search</strong> tab:</p>
<ul>
<li><p><strong>Service Name</strong> → type or select <code>ledger-service</code></p>
</li>
<li><p>Leave Span Name, Status, Duration, and Tags empty for now</p>
</li>
<li><p>Grafana shows the query it will run: <code>{resource.service.name="ledger-service"}</code></p>
</li>
</ul>
<p>Set the time range (top-right clock icon) to <strong>Last 15 minutes</strong> so your §7.5.6 transaction is included.</p>
<h4 id="heading-step-3-run-the-query">Step 3: Run the query</h4>
<p>Grafana Explore has <strong>no “Run query” button</strong>: results appear automatically after selecting a service. If the table stays empty, use the <strong>blue refresh button</strong> top-right of the pane.</p>
<h4 id="heading-step-4-open-the-trace-waterfall">Step 4: Open the trace waterfall</h4>
<p>Below the query editor, find <strong>Table - Traces</strong>. You should see at least one row like:</p>
<table>
<thead>
<tr>
<th>Column</th>
<th>Example</th>
</tr>
</thead>
<tbody><tr>
<td>Trace ID</td>
<td><code>5730edf3…</code> (blue link)</td>
</tr>
<tr>
<td>Start time</td>
<td>when you ran the <code>curl</code></td>
</tr>
<tr>
<td>Service</td>
<td><code>ledger-service</code></td>
</tr>
<tr>
<td>Name</td>
<td><code>POST /transactions</code></td>
</tr>
<tr>
<td>Duration</td>
<td>~200ms (yours may differ)</td>
</tr>
</tbody></table>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ac4aad25-4ca4-4ffc-9fb6-25c30f0214bd.png" alt="screenshot of grafana showing tempo and ledger service and query result" style="display:block;margin:0 auto" width="1179" height="1055" loading="lazy">

<p><strong>Click the Trace ID link.</strong> The right panel opens the trace detail view.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/d3c15dc0-e4b3-49e4-93e6-28b49feab6eb.png" alt="screenshot of grafana showing tempo and ledger service and query results" style="display:block;margin:0 auto" width="1226" height="1289" loading="lazy">

<h4 id="heading-what-the-trace-detail-view-shows">What the trace detail view shows</h4>
<p>Header: <code>ledger-service: POST /transactions</code></p>
<ul>
<li><p><strong>Trace ID</strong>: unique ID for this request</p>
</li>
<li><p><strong>Duration</strong>: total end-to-end time</p>
</li>
<li><p><strong>Services</strong>: <code>2</code> (<code>ledger-service</code> and <code>auth-service</code> for a normal transaction)</p>
</li>
</ul>
<p>Expand spans in the timeline:</p>
<pre><code class="language-plaintext">ledger-service   POST /transactions          (~total duration)
  ├── auth-service   GET /verify             ← JWT check over HTTP
  ├── ledger-service INSERT / sqlalchemy    ← Postgres write
  └── (optional) redis PUBLISH              ← only if amount ≥ notification threshold
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/0b69babc-90c6-4cdc-a0bf-bad012854a36.jpg" alt="trace transaction flow" style="display:block;margin:0 auto" width="1536" height="957" loading="lazy">

<p><strong>Reading the trace detail screen:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/3a72b4a4-2243-403d-9f03-ebc3b22e5337.png" alt="Tempo trace detail: ledger-service transaction with auth-service verify step." style="display:block;margin:0 auto" width="1226" height="1289" loading="lazy">

<p>Each row is one step in the request (Grafana calls it a <em>span</em>). The colored bar on the right shows <strong>how long that step took</strong>. That's the <em>span bar</em>. A longer bar = more time spent on that step.</p>
<p>Click a row or its bar to open the details panel on the right. You'll see two kinds of metadata:</p>
<ul>
<li><p><strong>Span attributes</strong>: what happened in <em>this step</em>.<br>Examples: HTTP method (<code>POST</code>, <code>GET</code>), status code (<code>200</code>), or SQL text on a database step. In your trace you might see <code>asgi.event.type: http.request</code> on the FastAPI receive step.</p>
</li>
<li><p><strong>Resource attributes</strong>: <em>where</em> the step ran.<br>Examples: <code>service.name: ledger-service</code>, <code>k8s.cluster.name: clearledger</code>, <code>deployment.environment: production</code>.</p>
</li>
</ul>
<p>Quick mental model: span attributes = what the step did. Resource attributes = which service produced it.</p>
<p><strong>Connecting traces to logs:</strong> once you have a step selected, the Logs tab will take you straight to the matching Loki log lines for that pod at the same moment in time.</p>
<p><strong>Screenshot this trace detail view</strong>: portfolio proof for Stage 7.5.</p>
<h4 id="heading-traceql-alternative">TraceQL alternative</h4>
<p>If you prefer the text box, Switch to the <strong>TraceQL</strong> tab, and paste:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/679d687d-1a2e-474b-8f7e-672f3ab2eb2b.png" alt="screenshot showing direction for where traceql button is" style="display:block;margin:0 auto" width="1157" height="263" loading="lazy">

<pre><code class="language-traceql">{ resource.service.name = "ledger-service" }
</code></pre>
<h4 id="heading-if-the-table-is-empty">If the table is empty</h4>
<p><strong>If TraceQL says</strong> <code>0 series returned</code>: Use the <strong>Search</strong> tab instead, or paste the TraceQL query from above into the TraceQL tab.</p>
<p><strong>If search tab has no rows:</strong> Widen the time range to <strong>Last 15 minutes</strong>, re-run the transaction curl from §7.5.6, wait a few seconds, and refresh.</p>
<p><strong>If grafana can't connect to Tempo:</strong> The datasource URL needs port <strong>3200</strong>. Re-apply the datasource and restart Grafana:</p>
<pre><code class="language-bash">kubectl apply -f stages/stage-7.5-opentelemetry/infra/otel/grafana-datasource-tempo.yaml
kubectl rollout restart deployment/kube-prometheus-stack-grafana -n monitoring
</code></pre>
<p><strong>If collector logs show no trace data:</strong> Work through §7.5.5: usually the OTEL environment variables or network policy blocking port 4317.</p>
<h3 id="heading-757b-understand-when-a-trace-happens">7.5.7b: Understand When a Trace Happens</h3>
<p>You ran one curl command in §7.5.6. Grafana shows every place that single request traveled.</p>
<p>Think of it like tracking a package:</p>
<ol>
<li><p><strong>You</strong> sent <code>POST /transactions</code> to <strong>ledger-service</strong></p>
</li>
<li><p><strong>ledger-service</strong> asked <strong>auth-service</strong>: "is this user logged in?"</p>
</li>
<li><p><strong>ledger-service</strong> saved the row to the <strong>database</strong></p>
</li>
<li><p><strong>redis</strong> only runs if the amount is <strong>big</strong> (10,000 or more)</p>
</li>
</ol>
<p>Each of those is a row you see in the Tempo detail screen. You're not looking at four separate requests. It's <strong>one</strong> request with multiple stops.</p>
<p><strong>Why do I see both ledger-service and auth-service?</strong></p>
<p>Because ledger had to call auth before it could save the transaction. Grafana groups those stops into one trip so you can see the full path, not just the first hop.</p>
<p><strong>Why did my demo have no Redis row?</strong></p>
<p>You used <code>"amount": 5000</code>. The app only talks to Redis when the amount is 10,000 or higher. So seeing ledger + auth + database but no Redis is correct.</p>
<p>Want to see Redis? Run §7.5.6 again with <code>"amount": 15000</code> and search Tempo again.</p>
<p><strong>Optional: connect it to the code</strong></p>
<p>Open <code>app/ledger-service/main.py</code>, find <code>create_transaction</code>, and read top to bottom. The Tempo rows follow that function in order: check the user, save to the database, and maybe notify Redis.</p>
<p><strong>Optional: same request, three tools</strong></p>
<p>At the time you ran the curl:</p>
<ul>
<li><p><strong>Tempo</strong> (this stage): which services ran and how long each took</p>
</li>
<li><p><strong>Loki</strong> (Stage 7): what the apps wrote in their log files</p>
</li>
<li><p><strong>Prometheus</strong> (Stage 7): how many requests happened around that time</p>
</li>
</ul>
<p>Same moment, three different views. You already used Loki and Prometheus in Stage 7.</p>
<h3 id="heading-758-verify">7.5.8: Verify</h3>
<pre><code class="language-bash">make check-75
</code></pre>
<p>Expected output:</p>
<pre><code class="language-text">▶ Stage 7.5 — OpenTelemetry (Distributed Tracing)
  ✓ OTel Collector is running (1 replica(s))
  ✓ Grafana Tempo datasource ConfigMap exists
  ✓ Tempo is running
  ✓ auth-service has OTEL_EXPORTER_OTLP_ENDPOINT set
</code></pre>
<p><strong>If you see a warning instead:</strong></p>
<pre><code class="language-text">⚠ OTel env vars not found on auth-service, redeploy with updated manifests
</code></pre>
<p><code>check-75</code> looks for <code>OTEL_EXPORTER_OTLP_ENDPOINT</code> in the deployment manifest. Older Stage 5 manifests may not list it even though tracing works: the Python apps default to <code>http://otel-collector.monitoring.svc.cluster.local:4317</code> when the env var is missing.</p>
<p>You can proceed if collector logs show spans and Tempo shows your trace. To clear the warning, apply only the app deployments (not the whole kustomize tree: Kyverno may block redis/postgres patches):</p>
<pre><code class="language-bash">kubectl apply -f infra/manifests/auth-service/deployment.yaml
kubectl apply -f infra/manifests/ledger-service/deployment.yaml
kubectl rollout restart deployment/auth-service deployment/ledger-service -n clearledger
make check-75
</code></pre>
<p><strong>Save your VM</strong> after <code>make check-75</code>. See the block at the end of Stage 7.5 below.</p>
<h3 id="heading-what-you-learned">What You Learned</h3>
<p>Stage 7 gave you metrics (how busy?) and logs (what was printed?). Stage 7.5 adds traces (for one slow request, which step took the time?).</p>
<p>In the lab you proved it with one <code>POST /transactions</code> curl. In production the idea is the same: a user hits an API, the request crosses multiple services, and you need to see that full path in one place.</p>
<h4 id="heading-if-someone-asks-in-an-interview">If someone asks in an interview:</h4>
<p><strong>Why traces at all?</strong> Metrics might tell you p99 latency doubled. Logs might show an error on one pod. Traces tell you <em>which downstream call</em> in the chain caused the delay, auth, database, cache, or third-party API, without guessing.</p>
<p><strong>How did you implement it?</strong> We instrumented the services with OpenTelemetry, sent telemetry to a collector, and stored traces in Grafana Tempo. Apps talk to the collector, not directly to the backend, so we can change storage later without redeploying every service.</p>
<p><strong>What would you do in an incident?</strong> Find a slow or failing trace ID (from logs, metrics, or an alert), open it in Tempo, walk the call chain service by service, see where time stacked up, then jump to logs for that service at the same timestamp. That's faster than tailing logs on five pods and hoping they line up.</p>
<p><strong>Short version you can say out loud:</strong></p>
<blockquote>
<p>"We use the three pillars together: Prometheus for rates and errors, Loki for log detail, and Tempo for request-level debugging across microservices. When latency spikes, I start from a trace, identify the slow hop, often a database or downstream API, and correlate back to logs and metrics for that service."</p>
</blockquote>
<p><code>make check-75 &amp;&amp; make snapshot STAGE=75 &amp;&amp; make snapshots</code>. Confirm <code>clearledger.stage75</code>. See <a href="#heading-how-to-save-your-progress">How to Save Your Progress</a>.</p>
<h2 id="heading-stage-8-aws-migration">Stage 8 — AWS Migration</h2>
<p>Your goal here is to run the same ClearLedger app on AWS instead of your laptop VM.</p>
<p>You're not rewriting the application. Stages 0–7 built containers on Kubernetes with GitOps, Kyverno, secrets, and observability. Stage 8 changes where it runs. You keep the same images, the same ArgoCD workflow, and the same security policies. Only the cloud services underneath change (MicroK8s → EKS, Vault → Secrets Manager, and so on).</p>
<ul>
<li><p><strong>Homelab:</strong> MicroK8s, Postgres in a pod, dev Vault, Docker Hub, <code>clearledger.local</code></p>
</li>
<li><p><strong>AWS:</strong> EKS, RDS, Secrets Manager, ECR, ALB hostname</p>
</li>
</ul>
<p><strong>Am I ready for Stage 8?</strong></p>
<ul>
<li><p>Homelab complete through Stage 7 (Stage 7.5 optional)</p>
</li>
<li><p>make check-7 passes (and make check-75 if you did traces)</p>
</li>
<li><p>AWS account with billing alerts enabled. make aws-up creates billable resources</p>
</li>
<li><p>Skim §8.2 so you know what make aws-up does (even if you use the quick path)</p>
</li>
</ul>
<p><strong>Done when</strong> the app is reachable on the AWS ALB, ArgoCD syncing, and you run <code>make aws-down</code> when finished to stop charges.</p>
<h3 id="heading-what-make-aws-up-gives-you">What <code>make aws-up</code> Gives You</h3>
<p>This is a <strong>demo stack</strong>: production-<em>shaped</em>, but not production-<em>ready</em>. It has HTTP only (no TLS cert).</p>
<p>Stage 7 observability is installed automatically. CI still runs Gitleaks, Semgrep, Checkov, Trivy, and Cosign.</p>
<p>For real production, you would add HTTPS (see <a href="https://github.com/Osomudeya/clearledger/blob/main/stages/stage-8-aws-migration/manifests/ingress-aws-https.example.yaml"><code>ingress-aws-https.example.yaml</code></a>), staging before promote, and alert routing. Those are documented but not applied by the spinup script.</p>
<p><strong>GitOps rule:</strong> after bootstrap, don't <code>kubectl apply</code> app Deployments by hand. ArgoCD owns the cluster (Stage 2). Push manifest changes to Git and let ArgoCD sync.</p>
<h3 id="heading-secrets-on-aws">Secrets on AWS</h3>
<p>On the homelab, Vault wrote secret files into the pod. On AWS, secrets live in <strong>AWS Secrets Manager</strong> (created by Terraform). Your app still needs them as environment variables like <code>DATABASE_URL</code>.</p>
<p><strong>ESO (default in this lab)</strong>: the simple mental model:</p>
<ol>
<li><p>Terraform stores the real password in AWS Secrets Manager (for example <code>clearledger/auth-service</code>)</p>
</li>
<li><p>External Secrets Operator (ESO) watches that AWS secret</p>
</li>
<li><p>ESO copies it into a normal Kubernetes Secret inside the cluster (for example <code>auth-service-secret</code>)</p>
</li>
<li><p>Your deployment reads <code>DATABASE_URL</code> from that Kubernetes Secret, same as Stage 0, but the values come from AWS instead of a YAML file in Git</p>
</li>
</ol>
<p>You never put passwords in Git. ESO keeps the Kubernetes Secret in sync with Secrets Manager.</p>
<p><strong>CSI (optional, §8.5 exercise)</strong>: same AWS secrets but different delivery: mounted as <strong>files</strong> at <code>/mnt/secrets/*</code> instead of env vars. This is closer to how Vault worked on the homelab.</p>
<p><strong>IRSA</strong>: how ESO is allowed to read Secrets Manager without storing AWS access keys in the cluster. AWS trusts a Kubernetes service account instead.</p>
<p>IRSA lets AWS trust a Kubernetes ServiceAccount, no <code>AWS_ACCESS_KEY_ID</code> in Git or in the cluster.</p>
<p>Details here: <a href="https://github.com/Osomudeya/clearledger/tree/main/stages/stage-8-aws-migration/docs"><code>stages/stage-8-aws-migration/docs/secrets-patterns.md</code></a>.</p>
<h3 id="heading-81-two-ways-through-stage-8">8.1: Two Ways Through Stage 8</h3>
<p><strong>Quick path (~45–60 min):</strong> edit <code>terraform/secrets.tf</code> (replace <code>CHANGE_ME_BEFORE_APPLY</code>), then:</p>
<pre><code class="language-bash">make aws-up    # runs stages/stage-8-aws-migration/scripts/aws-spinup.sh
make aws-down  # destroys billable resources when you are done
</code></pre>
<p>Read §8.2 afterward so you know what ran.</p>
<p><strong>Manual path (§8.3):</strong> run Terraform, ECR push, ArgoCD, Kyverno, ESO, and deploy yourself. Use this when learning, interviewing, or debugging a failed spinup.</p>
<p>Don't skip §8.2–§8.5 if you only ran <code>make aws-up</code>. Otherwise you won't know what Terraform, ESO, or ArgoCD each did.</p>
<p>Before your first Stage 8 push, read <a href="#heading-ci-routing-stages-17-vs-stage-8">§8: CI routing and <code>CLEARLEDGER_CI_TARGET</code></a> and set <code>CLEARLEDGER_CI_TARGET=aws</code> only after Terraform succeeds, not while you are still on Stages 1–7.</p>
<h3 id="heading-82-what-make-aws-up-runs">8.2: What <code>make aws-up</code> Runs</h3>
<p>The spinup script runs 15 steps in order:</p>
<p><strong>Setup (1–6)</strong>: Check tools and AWS login; <code>terraform apply</code> (VPC, EKS, RDS, ECR, Secrets Manager, GuardDuty, CloudTrail, IAM), confirm security services, build and push images to ECR, patch <code>manifests/kustomization.yaml</code> with your registry and git SHA, and configure <code>kubectl</code> for EKS.</p>
<p><strong>Platform (7–12)</strong>: install ArgoCD; Kyverno + cluster policies, Falco, External Secrets Operator + IRSA service accounts, CSI secrets driver, and Stage 7 observability stack.</p>
<p><strong>Deploy (13–15)</strong>: ArgoCD app <code>clearledger-aws</code> syncs <code>stages/stage-8-aws-migration/manifests/</code>, wait for ALB hostname, and print URL and tear-down reminder.</p>
<p>After the script finishes, open the printed <code>http://&lt;alb-dns&gt;/</code> in your browser (ClearLedger login UI), or follow <a href="#heading-when-to-open-what-checkpoint-map">§8.3. When to open what</a> for Argo CD and Grafana port-forwards.</p>
<p>Default app deploy uses ESO for secrets. CSI is also installed so you can try file mounts in §8.5 without extra setup.</p>
<p><strong>Terraform layout</strong>: there's no <code>terraform.tf</code> file. The <code>terraform {}</code> block (version, providers, optional S3 backend) is at the top of <code>main.tf</code>. Resources are split by topic: <code>vpc.tf</code>, <code>eks.tf</code>, <code>rds.tf</code>, <code>ecr.tf</code>, <code>alb.tf</code>, <code>iam.tf</code>, <code>secrets.tf</code>, <code>security.tf</code>.</p>
<p>Run all commands from <code>stages/stage-8-aws-migration/terraform/</code>.</p>
<h3 id="heading-83-manual-walkthrough">8.3: Manual Walkthrough</h3>
<p>Go to <strong>Before you start</strong> in this section and run the manual steps from <strong>Step A</strong> yourself at least once instead of <code>make aws-up</code>. Paths are from the repo root.</p>
<p>Commands install things, while UIs prove they work. Homelab Stages 2 and 7 already taught you to open Argo CD and Grafana in a browser. Stage 8 is the same idea.</p>
<p>But on AWS there's no <code>clearledger.local</code> or <code>grafana.local</code> in <code>/etc/hosts</code>. You use port-forward for control-plane UIs and the public ALB hostname for the app.</p>
<h4 id="heading-when-to-open-what-checkpoint-map">When to open what (checkpoint map)</h4>
<p><code>make aws-up</code> runs fifteen steps. You don't need every UI open at once, just know when to look and what success looks like as the script moves along.</p>
<p>First, Terraform builds the AWS foundation. When step 2 finishes, open the <strong>AWS Console</strong> and confirm the cluster, registry, and database exist before any pods run: EKS <code>clearledger</code> is <strong>Active</strong>, ECR has four repos including <code>frontend</code> (empty is fine for now), and RDS <code>clearledger-postgres</code> is <strong>Available</strong>.</p>
<p>See <a href="#heading-aws-console-after-step-2">AWS Console (after step 2)</a> for the walkthrough.</p>
<p>Next come container images. After step 4, or after CI — AWS (ECR + OIDC) goes green in GitHub Actions, check ECR: each repo should list your git SHA tag. That's what ArgoCD will pull when the app deploys.</p>
<p>Around step 7 the script installs Argo CD. Port-forward to the UI and confirm the login page loads. You won't see the app yet. You're only checking that GitOps is reachable. Details: <a href="#heading-step-13-watch-argocd-sync-ui-cli">Argo CD UI</a>.</p>
<p>Step 12 adds observability. Port-forward to Grafana, log in, and confirm the six ClearLedger dashboards are listed. Panels can stay empty until you generate events. This is the same as Stage 7 on the homelab.</p>
<p>Step 13 applies the <code>clearledger-aws</code> app. Go back to Argo CD → <strong>Applications</strong> → <code>clearledger-aws</code>. You want Synced, Healthy, and running pods for auth, ledger, and notification.</p>
<p>Step 14 exposes the app on a public URL. Open <code>http://&lt;alb-dns&gt;/</code> in your browser: you should see the same ClearLedger login UI as homelab <code>clearledger.local</code>, served from the ALB with no <code>/etc/hosts</code> entry.<br>Use <code>/auth/health</code> and the other health URLs when you want a quick API check from the terminal.</p>
<p>See <a href="#heading-step-15-open-the-app-in-your-browser">ALB — first time the app is public</a>.</p>
<p>If you want extra confirmation, the optional check is <strong>EC2 → Load Balancers →</strong> <code>clearledger</code>: status <strong>Active</strong>, with healthy targets for frontend and the API services.</p>
<p>On AWS the app is four services behind one ALB: the frontend at <code>/</code> (login, dashboard, transactions) and the three APIs at <code>/auth</code>, <code>/ledger</code>, and <code>/notifications</code>.</p>
<p>Your portfolio screenshot for Stage 8 is the ALB URL showing the UI, like <code>http://clearledger-xxxxxxxxxx.eu-west-1.elb.amazonaws.com</code> with the ClearLedger login or dashboard visible.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/411a7ae0-8e7e-4a19-9267-e207f78ece93.png" alt="screenshot of clearledger ui with ALB Url" style="display:block;margin:0 auto" width="1348" height="364" loading="lazy">

<p>For Argo CD and Grafana, keep a dedicated terminal running <code>kubectl port-forward</code> while the browser tab is open. <code>Ctrl+C</code> closes the tunnel.</p>
<h4 id="heading-before-you-start">Before you start</h4>
<p><strong>Step A: set real passwords in</strong> <code>secrets.tf</code></p>
<p>Open <code>stages/stage-8-aws-migration/terraform/secrets.tf</code> and search for the literal text <code>CHANGE_ME_BEFORE_APPLY</code>. It appears four times in the file (Postgres password, JWT secret, and two database URLs). Replace every occurrence:</p>
<ul>
<li><p><strong>Postgres password</strong>: pick a strong password (same value in all three places that reference it)</p>
</li>
<li><p><strong>JWT secret</strong>: run <code>openssl rand -base64 64</code> and paste the output</p>
</li>
</ul>
<p><code>make aws-up</code> will <strong>refuse to run</strong> if any <code>CHANGE_ME_BEFORE_APPLY</code> text is still in that file.</p>
<p><strong>Step B: terminal checks</strong></p>
<pre><code class="language-bash">aws sts get-caller-identity
terraform --version

# REQUIRED before first terraform apply; GitHub Actions OIDC (ci-aws.yaml) reads this at apply time:
cp stages/stage-8-aws-migration/terraform/terraform.tfvars.example \
   stages/stage-8-aws-migration/terraform/terraform.tfvars
# Edit terraform.tfvars: github_owner = "YOUR_GITHUB_USERNAME"   # your GitHub user or org, not a placeholder

terraform -chdir=stages/stage-8-aws-migration/terraform validate
# Fails with "Set github_owner in terraform.tfvars" until you replace YOUR_GITHUB_USERNAME
</code></pre>
<p><strong>Don't run</strong> <code>terraform apply</code> <strong>until</strong> <code>github_owner</code> <strong>is set.</strong> If you apply with the placeholder, AWS creates IAM role <code>clearledger-github-actions-ecr</code> with trust <code>repo:YOUR_GITHUB_USERNAME/...</code>. CI then fails at <strong>Publish images → ECR</strong> with <code>Not authorized to perform sts:AssumeRoleWithWebIdentity</code>.</p>
<p>Fix: edit <code>terraform.tfvars</code> → <code>terraform apply</code> again → verify with <code>aws iam get-role</code> below then <strong>Re-run failed jobs</strong> on the failed Actions run (not the full pipeline).</p>
<h4 id="heading-steps-12-terraform">Steps 1–2: Terraform</h4>
<pre><code class="language-bash">cd stages/stage-8-aws-migration/terraform
terraform init -upgrade
terraform apply

# Save outputs:
terraform output -raw ecr_registry_url
terraform output -raw github_actions_ecr_role_arn
terraform output -raw eso_role_arn
terraform output -raw auth_service_irsa_role_arn
terraform output -raw kubeconfig_command
cd ../../..
</code></pre>
<h4 id="heading-aws-console-after-step-2">AWS Console after step 2.</h4>
<p>Confirm Terraform created resources before you touch the cluster:</p>
<ol>
<li><p><strong>EKS</strong> → Clusters → <code>clearledger</code> → <strong>Status: Active</strong>, <strong>3 nodes</strong></p>
</li>
<li><p><strong>ECR</strong> → Repositories → <code>clearledger/auth-service</code>, <code>ledger-service</code>, <code>notification-service</code>, <code>frontend</code> (0 images until step 4 or CI)</p>
</li>
<li><p><strong>RDS</strong> → Databases → <code>clearledger-postgres</code> → <strong>Available</strong></p>
</li>
</ol>
<p><strong>Verify GitHub can push to ECR (only if you plan to use AWS CI later)</strong></p>
<p>GitHub Actions needs permission to push images to your AWS account. Terraform creates an IAM role for that, but only if you set your real GitHub username in <code>terraform.tfvars</code> before <code>terraform apply</code>.</p>
<p>Check it worked:</p>
<pre><code class="language-bash">aws iam get-role --role-name clearledger-github-actions-ecr \
  --query 'Role.AssumeRolePolicyDocument.Statement[0].Condition.StringEquals."token.actions.githubusercontent.com:sub"' \
  --output text
</code></pre>
<p><strong>Good:</strong> <code>repo:your-real-username/clearledger:environment:production</code></p>
<p><strong>Bad:</strong> <code>repo:YOUR_GITHUB_USERNAME/clearledger:...</code> you forgot to edit <code>terraform.tfvars</code>.</p>
<p>Fix the file, run <code>terraform apply</code> again, then in GitHub go to Actions and then the failed CI, AWS (ECR + OIDC) run → click Re-run failed jobs. That retries only the push step. You don't need to rebuild and rescan everything.</p>
<p>Skip this whole block if you are only using <code>make aws-up</code> for now and not enabling AWS CI yet.</p>
<p><strong>When do ECR repos appear?</strong></p>
<p>During <code>terraform apply</code> <strong>(step 2)</strong>, not when you <code>docker push</code>. Terraform creates <strong>empty</strong> image repositories: <code>clearledger/auth-service</code>, <code>ledger-service</code>, <code>notification-service</code>, and <code>frontend</code>, so seeing 0 images right after apply is normal.</p>
<p>Images land later in step 4 (manual <code>docker push</code>) or when GitHub Actions CI succeeds.</p>
<p><strong>Set your AWS CLI region to</strong> <code>eu-west-1</code></p>
<p>Everything in this lab lives in eu-west-1 (Ireland). If your CLI defaults to <code>us-east-1</code>, commands will say resources are missing even though they exist:</p>
<pre><code class="language-bash">aws configure set region eu-west-1
aws configure get region   # expect: eu-west-1
</code></pre>
<h4 id="heading-steps-34-security-services-ecr-images">Steps 3–4: Security services + ECR images</h4>
<pre><code class="language-bash">AWS_REGION=eu-west-1   # or rely on aws configure set region above

# Step 3: verify security services (must pass --region eu-west-1)
aws guardduty list-detectors --region "${AWS_REGION}"
# Expect: DetectorIds: ["&lt;id&gt;"]  — empty [] means wrong region, not "not created"

aws cloudtrail get-trail-status --name clearledger-trail --region "${AWS_REGION}"
# Expect: IsLogging: true
# Error "Unknown trail ... us-east-1" → you forgot --region eu-west-1

# Step 4: build and push images to the ECR repos Terraform already created
ECR_REGISTRY=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw ecr_registry_url)
AUTH_ECR=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw auth_service_ecr_url)
LEDGER_ECR=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw ledger_service_ecr_url)
NOTIFY_ECR=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw notification_service_ecr_url)
TAG=$(git rev-parse --short HEAD)

aws ecr get-login-password --region "${AWS_REGION}" \
  | docker login --username AWS --password-stdin "${ECR_REGISTRY}"

docker build -t "${AUTH_ECR}:${TAG}" app/auth-service &amp;&amp; docker push "${AUTH_ECR}:${TAG}"
docker build -t "${LEDGER_ECR}:${TAG}" app/ledger-service &amp;&amp; docker push "${LEDGER_ECR}:${TAG}"
docker build -t "${NOTIFY_ECR}:${TAG}" app/notification-service &amp;&amp; docker push "${NOTIFY_ECR}:${TAG}"

# Confirm images landed (optional)
aws ecr describe-images --repository-name clearledger/auth-service --region "${AWS_REGION}" \
  --query 'imageDetails[*].imageTags' --output table
</code></pre>
<p><strong>ECR console (after step 4 or green CI)</strong>: open each repository and go. tothe Images tab. You should see tags matching your git commit SHA. If repos are empty, ArgoCD will show <code>ImagePullBackOff</code> later.</p>
<p><strong>GitHub Actions (if using CI instead of manual push)</strong>: repo → Actions → workflow CI. AWS (ECR + OIDC).</p>
<p>If all jobs are green, publish the images. ECR succeeded. This is the supply-chain proof before deploy.</p>
<h4 id="heading-step-5-gitops-source-of-truth">Step 5: GitOps source of truth</h4>
<p>Patch placeholders in <code>kustomization.yaml</code> (same <code>sed</code> as <code>aws-spinup.sh</code> step 5):</p>
<pre><code class="language-bash">AWS_REGION=eu-west-1
ECR_REGISTRY=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw ecr_registry_url)
TAG=$(git rev-parse --short HEAD)
KUST=stages/stage-8-aws-migration/manifests/kustomization.yaml

sed -i.bak \
  -e "s|REPLACE_ECR_REGISTRY|${ECR_REGISTRY}|g" \
  -e "s|REPLACE_IMAGE_TAG|${TAG}|g" \
  "${KUST}"
rm -f "${KUST}.bak"

# Region in ESO + CSI manifests (only if not eu-west-1)
if [[ "${AWS_REGION}" != "eu-west-1" ]]; then
  sed -i.bak "s|region: eu-west-1|region: ${AWS_REGION}|g" \
    stages/stage-8-aws-migration/manifests/external-secrets.yaml \
    stages/stage-8-aws-migration/manifests/csi/auth-service-spc.yaml \
    stages/stage-8-aws-migration/manifests/csi/ledger-service-spc.yaml
  rm -f stages/stage-8-aws-migration/manifests/external-secrets.yaml.bak \
        stages/stage-8-aws-migration/manifests/csi/*.bak 2&gt;/dev/null || true
fi

# Verify before commit
grep -E 'newName:|newTag:' "${KUST}"
# Expect: YOUR_AWS_ACCOUNT.dkr.ecr.eu-west-1.amazonaws.com/clearledger/... and your git SHA

git add stages/stage-8-aws-migration/manifests/kustomization.yaml
git commit -m "stage8: ECR images ${TAG}"
git push
</code></pre>
<p>Also fix the ArgoCD Application repo URL once (replace with your GitHub username):</p>
<pre><code class="language-bash"># Example: YOUR_GITHUB_USERNAME/clearledger — check: git remote get-url origin
sed -i.bak 's|YOUR_GITHUB_USERNAME|YOUR_ACTUAL_GITHUB_USER|g' \
  stages/stage-8-aws-migration/argocd/clearledger-aws-app.yaml
rm -f stages/stage-8-aws-migration/argocd/clearledger-aws-app.yaml.bak
</code></pre>
<h4 id="heading-step-6-cluster-access-terraform-outputs">Step 6: Cluster access + Terraform outputs</h4>
<p>Run from the repo root. Set the CLI region first (EKS and IAM outputs are regional), then kubeconfig, then export IRSA role ARNs: steps 9–10 need them.</p>
<pre><code class="language-bash">aws configure set region eu-west-1

eval "$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw kubeconfig_command)"
kubectl get nodes

export AWS_REGION=eu-west-1
export ESO_ROLE_ARN=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw eso_role_arn)
export FALCO_ROLE_ARN=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw falco_role_arn)
export REPLACE_AUTH_IRSA_ROLE_ARN=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw auth_service_irsa_role_arn)
export REPLACE_LEDGER_IRSA_ROLE_ARN=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw ledger_service_irsa_role_arn)
export REPLACE_NOTIFICATION_IRSA_ROLE_ARN=$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw notification_service_irsa_role_arn)

# Sanity check (all should print ARNs, not empty)
echo "ESO:      ${ESO_ROLE_ARN}"
echo "Falco:    ${FALCO_ROLE_ARN}"
echo "Auth IRSA: ${REPLACE_AUTH_IRSA_ROLE_ARN}"
</code></pre>
<h4 id="heading-steps-712-platform-stack-on-the-cluster">Steps 7–12: Platform stack on the cluster</h4>
<p>You finished steps 1–6 (AWS exists, images in ECR, <code>kubectl</code> works). Now for steps 7-12 you'll install the platform stack, the same components as <code>aws-spinup.sh</code>, but you run the commands from the sections below, not the script.</p>
<p>For each step, run the Install code block, then run the Verify block right under it. Don't move to the next step until you see Running pods (or a ClusterPolicy list). “Command finished with no output” isn't enough.</p>
<table>
<thead>
<tr>
<th>Step</th>
<th>Namespace</th>
<th>What you are installing</th>
<th>Rough pod count</th>
</tr>
</thead>
<tbody><tr>
<td>7</td>
<td><code>argocd</code></td>
<td>GitOps controller</td>
<td>~7 pods</td>
</tr>
<tr>
<td>8</td>
<td><code>kyverno</code></td>
<td>Admission policies</td>
<td>~4 pods + ClusterPolicies</td>
</tr>
<tr>
<td>9</td>
<td><code>falco</code></td>
<td>Runtime detection</td>
<td>1 DaemonSet pod <strong>per node</strong> (3 on this cluster)</td>
</tr>
<tr>
<td>10</td>
<td><code>external-secrets</code> + <code>clearledger</code></td>
<td>ESO + IRSA ServiceAccounts</td>
<td>~3 ESO pods + 3 ServiceAccounts</td>
</tr>
<tr>
<td>11</td>
<td><code>kube-system</code> + <code>clearledger</code></td>
<td>CSI driver + AWS provider</td>
<td>3 driver + 3 provider (one per node)</td>
</tr>
<tr>
<td>12</td>
<td><code>monitoring</code></td>
<td>Prometheus, Grafana, Loki</td>
<td>~10+ pods</td>
</tr>
</tbody></table>
<p>Steps 13–15 (deploy app, wait for ALB, verify UI) come after step 12 below.</p>
<h4 id="heading-step-7-argocd">Step 7: ArgoCD</h4>
<pre><code class="language-bash">kubectl create namespace argocd --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -n argocd --server-side --force-conflicts \
  -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
kubectl rollout status deployment/argocd-server -n argocd --timeout=180s
</code></pre>
<p><strong>Verify what got created:</strong></p>
<pre><code class="language-bash">kubectl get pods -n argocd
kubectl get svc -n argocd
kubectl get deploy -n argocd
</code></pre>
<p><strong>Expected:</strong> <code>argocd-server</code>, <code>argocd-repo-server</code>, <code>argocd-application-controller</code>, and so on: most pods <strong>Running</strong> <strong>1/1</strong> or <strong>2/2</strong>. <code>argocd-server</code> Service exposes port 443.</p>
<p><strong>UI (optional now, required after step 13):</strong> new terminal, leave running. Use any free local port (<code>8081</code> if <code>8080</code> is in use):</p>
<pre><code class="language-bash">kubectl port-forward svc/argocd-server -n argocd 8080:443
# Or if 8080 is taken:
# kubectl port-forward svc/argocd-server -n argocd 8081:443
# https://localhost:8080 (or 8081)  user: admin
kubectl get secret argocd-initial-admin-secret -n argocd -o jsonpath='{.data.password}' | base64 -d; echo
</code></pre>
<p>Applications list is empty until step 13. That's normal.</p>
<h4 id="heading-step-8-kyverno-policies">Step 8: Kyverno + policies</h4>
<p><code>cosign.pub</code> / <code>infra/cosign.pub</code> are gitignored (private key must never commit; public key is learner-specific).<br>The repo ships example keys in <code>require-signed-images.yaml</code> / <code>require-signed-images-ecr.yaml</code>.<br>If you regenerated keys in Stage 3, sync your local public key into policies before apply:</p>
<pre><code class="language-bash"># infra/cosign.pub exists locally but is gitignored — safe to copy into committed policy YAMLs
bash scripts/embed-cosign-pub-in-policies.sh
diff infra/cosign.pub &lt;(grep -A3 'BEGIN PUBLIC KEY' infra/policies/require-signed-images-ecr.yaml | grep -v publicKeys)
</code></pre>
<pre><code class="language-bash">helm repo add kyverno https://kyverno.github.io/kyverno/ --force-update
helm upgrade --install kyverno kyverno/kyverno \
  --namespace kyverno --create-namespace \
  -f stages/stage-4-admission-control/infra/kyverno/values.yaml \
  --set admissionController.replicas=1 \
  --wait --timeout=180s
kubectl apply -f infra/policies/
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pods -n kyverno
kubectl get clusterpolicy
kubectl get clusterpolicy require-signed-images-ecr -o jsonpath='{.spec.rules[0].verifyImages[0].attestors[0].entries[0].keys.publicKeys}' | head -3
</code></pre>
<p><strong>Expected:</strong> admission-controller, background-controller, cleanup-controller, reports-controller pods Running.</p>
<p><code>kubectl get clusterpolicy</code> lists 6+ policies including <code>require-signed-images-ecr</code>, <code>disallow-root-containers</code>, and so on. The <code>publicKeys</code> output must show <code>-----BEGIN PUBLIC KEY-----</code>, not <code>PASTE_YOUR_COSIGN_PUBLIC_KEY_HERE</code> (Kyverno treats a placeholder as a file path and blocks all deploys).</p>
<p><code>require-signed-images-ecr</code> defaults to Audit until CI Cosign-signs ECR images (<code>COSIGN_PRIVATE_KEY</code> + <code>COSIGN_PASSWORD</code> in GitHub). Unsigned images still deploy. Signed-image enforcement is optional later.</p>
<p>If <code>verify-slsa-provenance</code> fails to apply (Audit + <code>mutateDigest</code>), set <code>mutateDigest: false</code> in that file, or skip it. It's optional for Stage 8.</p>
<h4 id="heading-step-9-falco">Step 9: Falco</h4>
<pre><code class="language-bash">helm repo add falcosecurity https://falcosecurity.github.io/charts --force-update
helm upgrade --install falco falcosecurity/falco \
  --namespace falco --create-namespace \
  -f stages/stage-6-runtime-security/infra/falco/helm-values.yaml \
  --set driver.kind=modern_ebpf \
  --set "serviceAccount.annotations.eks\.amazonaws\.com/role-arn=${FALCO_ROLE_ARN}" \
  --wait --timeout=300s
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pods -n falco -o wide
kubectl get daemonset -n falco
kubectl get sa falco -n falco -o jsonpath='{.metadata.annotations.eks\.amazonaws\.com/role-arn}'; echo
</code></pre>
<p><strong>Expected:</strong> Falco DaemonSet with DESIRED = number of nodes (3). Each pod <strong>Running</strong>. ServiceAccount annotation shows your <code>FALCO_ROLE_ARN</code>.</p>
<h4 id="heading-step-10-external-secrets-operator-irsa-serviceaccounts">Step 10: External Secrets Operator + IRSA ServiceAccounts</h4>
<pre><code class="language-bash">helm repo add external-secrets https://charts.external-secrets.io --force-update
helm upgrade --install external-secrets external-secrets/external-secrets \
  --namespace external-secrets --create-namespace \
  --set "serviceAccount.annotations.eks\.amazonaws\.com/role-arn=${ESO_ROLE_ARN}" \
  --wait --timeout=180s
kubectl apply -f stages/stage-8-aws-migration/manifests/resources/namespace.yaml
envsubst &lt; stages/stage-8-aws-migration/manifests/clearledger-serviceaccounts.yaml | kubectl apply -f -
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pods -n external-secrets
kubectl get sa -n external-secrets external-secrets -o jsonpath='{.metadata.annotations.eks\.amazonaws\.com/role-arn}'; echo
kubectl get sa -n clearledger
</code></pre>
<p><strong>Expected:</strong> <code>external-secrets</code> deployment <strong>Running</strong> (often 3 containers / 1 pod). Three ServiceAccounts in <code>clearledger</code>: <code>auth-service</code>, <code>ledger-service</code>, <code>notification-service</code>: each with an <code>eks.amazonaws.com/role-arn</code> annotation. No app pods yet (ArgoCD deploys those in step 13).</p>
<p><strong>Step 11: CSI driver + SecretProviderClasses</strong></p>
<pre><code class="language-bash">bash stages/stage-8-aws-migration/scripts/install-csi-secrets.sh
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pods -n kube-system | grep -E 'secrets-store|provider-aws'
kubectl get secretproviderclass -n clearledger
helm list -n kube-system | grep -E 'csi-secrets|secrets-provider'
</code></pre>
<p><strong>Expected:</strong> CSI driver pods <strong>3/3 Running</strong> (one per node). AWS provider pods <strong>1/1 Running</strong> per node. Two <code>SecretProviderClass</code> objects in <code>clearledger</code>. Helm shows <code>csi-secrets-store</code> and/or <code>secrets-provider-aws</code> <strong>deployed</strong>.</p>
<p>If Helm reports <code>meta.helm.sh/release-name</code> conflicts, re-run the script. It installs the AWS provider without duplicating the driver chart.</p>
<h4 id="heading-step-12-observability">Step 12: Observability</h4>
<pre><code class="language-bash">bash stages/stage-7-observability/scripts/install-observability.sh
</code></pre>
<p><strong>Verify:</strong></p>
<pre><code class="language-bash">kubectl get pods -n monitoring
kubectl get svc -n monitoring | grep -E 'grafana|prometheus|loki'
kubectl get configmap -n monitoring -l grafana_dashboard=1 --no-headers | wc -l
</code></pre>
<p><strong>Expected:</strong> Grafana <strong>3/3 Running</strong>, Prometheus and Loki pods <strong>Running</strong>. ConfigMap count for dashboards is <strong>6</strong> (ClearLedger dashboards). Script prints <code>http://grafana.local</code>: on EKS use port-forward instead:</p>
<pre><code class="language-bash"># New terminal — keep running
kubectl port-forward -n monitoring svc/kube-prometheus-stack-grafana 3000:80
# http://localhost:3000  admin / admin123
# http://localhost:3000/dashboards?tag=clearledger
</code></pre>
<p>Panels may show <strong>No data</strong> until you trigger events (§7.4 exercises work on this cluster too).</p>
<p><strong>Platform stack summary</strong>: quick sanity check before step 13:</p>
<pre><code class="language-bash">for ns in argocd kyverno falco external-secrets monitoring clearledger; do
  echo "=== ${ns} ==="
  kubectl get pods -n "${ns}" --no-headers 2&gt;/dev/null | awk '{print $3}' | sort | uniq -c || echo "(no pods yet)"
done
kubectl get clusterpolicy --no-headers | wc -l | xargs echo "ClusterPolicies:"
kubectl get secretproviderclass -n clearledger --no-headers | wc -l | xargs echo "SecretProviderClasses:"
</code></pre>
<p><strong>Expected:</strong> every namespace shows only <code>Running</code> (or <code>Completed</code> for jobs). <code>clearledger</code> may be empty until ArgoCD syncs. ClusterPolicies ≥ 6. SecretProviderClasses = 2.</p>
<p><strong>EKS API timeout on namespace create?</strong> You may see <code>Unexpected error when reading response body</code> / <code>context deadline exceeded</code> and still get <code>namespace/argocd created</code>. That's a <strong>transient client timeout</strong> talking to the EKS API (first request, slow network, or control plane catching up), not a failed create. Confirm with <code>kubectl get namespace argocd</code> and continue. If commands keep timing out, retry once or run <code>kubectl cluster-info</code> to verify connectivity.</p>
<h4 id="heading-steps-1314-deploy-via-argocd-see-the-alb">Steps 13–14: Deploy via ArgoCD + see the ALB</h4>
<p>The app YAMLs under <code>stages/stage-8-aws-migration/manifests/</code> aren't applied by hand. Step 13 tells Argo CD to sync Git. Argo CD then creates Deployments, Services, Ingress, and the rest.</p>
<p><strong>Repo access first</strong></p>
<p>If your GitHub repo is private, add a PAT in Argo CD → Settings → Repositories. If you made the repo public, refresh the app, <code>ComparisonError: authentication required</code> should clear.</p>
<p><strong>If sync still fails</strong>, check the usual causes:</p>
<ul>
<li><p><code>external-secrets.io/v1beta1</code> <strong>not found</strong>, your cluster has a newer ESO API. Push <code>external-secrets.yaml</code> with <code>apiVersion: external-secrets.io/v1</code>.</p>
</li>
<li><p><strong>Kyverno complains about</strong> <code>PASTE_YOUR_COSIGN_PUBLIC_KEY_HERE</code> , run <code>bash scripts/embed-cosign-pub-in-policies.sh</code>, then <code>kubectl apply -f infra/policies/</code>.</p>
</li>
<li><p><code>SecretSyncedError</code> <strong>on auth,</strong> <code>database_url</code> <strong>or</strong> <code>jwt_secret</code> <strong>not found</strong> — the AWS secret <code>clearledger/auth-service</code> must contain both keys (Terraform writes them in <code>secrets.tf</code>). Re-run <code>terraform apply</code> after fixing <code>CHANGE_ME_BEFORE_APPLY</code> values, or check the secret in the AWS console.</p>
</li>
<li><p><strong>Pods stuck</strong> <code>Pending</code> <strong>or “too many pods”</strong> the lab nodes are small. Scale the node group in Terraform or lower replica counts in the manifests.</p>
</li>
</ul>
<p>Register the app:</p>
<pre><code class="language-bash">kubectl apply -f stages/stage-8-aws-migration/argocd/clearledger-aws-app.yaml
</code></pre>
<p>Watch Argo CD until <code>clearledger-aws</code> is <strong>Synced</strong> and <strong>Healthy</strong>. That's when app pods appear in <code>clearledger</code>.</p>
<h4 id="heading-step-13-watch-argocd-sync-ui-cli">Step 13: Watch ArgoCD sync (UI + CLI)</h4>
<p>Open the Argo CD browser tab you kept open (port-forward from step 7).</p>
<pre><code class="language-plaintext">https://localhost:8080        ← or 8081 if 8080 was busy
</code></pre>
<p>Click <code>clearledger-aws</code>. Wait for <strong>Healthy + Synced</strong> (2–5 minutes on first deploy). You can watch the same info from the terminal without touching the browser:</p>
<pre><code class="language-bash">kubectl get application clearledger-aws -n argocd -w
# Ctrl-C when HEALTH STATUS shows Healthy
</code></pre>
<p>While that's settling, watch pods start up in a second terminal:</p>
<pre><code class="language-bash">kubectl get pods -n clearledger -w
# All pods should reach 1/1 Running within 2 minutes
# Ctrl-C when everything is Running
</code></pre>
<h4 id="heading-step-14-get-your-public-app-url-alb">Step 14: Get your public app URL (ALB)</h4>
<p>AWS takes 2–5 minutes after ArgoCD syncs to provision the load balancer.<br>Run this and wait until the ADDRESS column fills in:</p>
<pre><code class="language-bash">kubectl get ingress clearledger-ingress -n clearledger -w
# ADDRESS is empty at first, then shows something like:
# clearledger-xxxxxxxxxx.eu-west-1.elb.amazonaws.com
# Ctrl-C once the hostname appears
</code></pre>
<p>Export the URL for the steps below:</p>
<pre><code class="language-bash">export ALB_DNS=$(kubectl get ingress clearledger-ingress -n clearledger \
  -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
echo "Your app is live at: http://${ALB_DNS}"
</code></pre>
<p><strong>Still empty after 10 minutes?</strong> See <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md</a> for ALB/ingress recovery steps.</p>
<h4 id="heading-step-15-open-the-app-in-your-browser">Step 15: Open the app in your browser</h4>
<p>Paste the ALB root URL into your browser. No DNS entry, port-forward, or VPN:</p>
<pre><code class="language-plaintext">http://clearledger-xxxxxxxxxx.eu-west-1.elb.amazonaws.com/
</code></pre>
<p>You should see the ClearLedger login screen (same SPA as homelab <code>clearledger.local</code>). Register or log in, submit a transaction, and confirm the dashboard loads. That's your Stage 8 portfolio screenshot.</p>
<p><strong>Quick API health checks</strong> (terminal or browser):</p>
<pre><code class="language-bash">curl -fsS "http://${ALB_DNS}/auth/health" &amp;&amp; echo
curl -fsS "http://${ALB_DNS}/ledger/health" &amp;&amp; echo
curl -fsS "http://${ALB_DNS}/notifications/health" &amp;&amp; echo
</code></pre>
<p>Each should return JSON like <code>{"status":"ok","service":"auth-service"}</code>.</p>
<h4 id="heading-step-16-verify-in-the-aws-console-optional-but-recommended">Step 16: Verify in the AWS Console (optional but recommended)</h4>
<p>This is what the deployed stack looks like from AWS side:</p>
<table>
<thead>
<tr>
<th>Console location</th>
<th>What to look for</th>
</tr>
</thead>
<tbody><tr>
<td><strong>EC2 → Load Balancers</strong></td>
<td>A load balancer named <code>clearledger-…</code> with state <strong>Active</strong></td>
</tr>
<tr>
<td><strong>EC2 → Target Groups</strong></td>
<td>Two or three target groups, all targets showing <strong>healthy</strong></td>
</tr>
<tr>
<td><strong>ECR → Repositories</strong></td>
<td><code>clearledger/auth-service</code>, <code>clearledger/ledger-service</code>, <code>clearledger/notification-service</code>, <code>clearledger/frontend</code> — each with a recently pushed image tag</td>
</tr>
<tr>
<td><strong>EKS → Clusters → clearledger → Workloads</strong></td>
<td>Your pods shown as Running in the <code>clearledger</code> namespace</td>
</tr>
<tr>
<td><strong>Secrets Manager</strong></td>
<td><code>clearledger/auth-service</code>, <code>clearledger/ledger-service</code>, <code>clearledger/postgres</code> — all present</td>
</tr>
</tbody></table>
<p><strong>502/503 from the ALB?</strong> The load balancer is up but the pods aren't healthy yet, or the secrets haven't synced. Check: <code>kubectl get pods -n clearledger</code> (all <code>1/1 Running</code>?) and <code>kubectl get externalsecret -n clearledger</code> (both <code>SecretSynced True</code>?).</p>
<p><strong>✋ Hands-on checkpoint: app is publicly reachable</strong></p>
<pre><code class="language-bash"># All three must print {"status":"ok",...}
curl -fsS "http://${ALB_DNS}/auth/health"         &amp;&amp; echo
curl -fsS "http://${ALB_DNS}/ledger/health"        &amp;&amp; echo
curl -fsS "http://${ALB_DNS}/notifications/health" &amp;&amp; echo

# All pods Running
kubectl get pods -n clearledger

# Nothing printed here = all pods Running (non-Running pods would show)
kubectl get pods -n clearledger --field-selector=status.phase!=Running
</code></pre>
<p><code>ImagePullBackOff</code> in the pod list means ECR images aren't there yet. Check GitHub Actions and re-run the workflow. A <code>502</code> from the health URL means the pod isn't ready yet. Wait 30 seconds and retry.</p>
<h3 id="heading-84-verify-eso-default-secret-path">8.4: Verify ESO (Default Secret Path)</h3>
<p>After Argo CD syncs, confirm External Secrets Operator copied values from AWS Secrets Manager into normal Kubernetes Secrets:</p>
<pre><code class="language-bash">kubectl get externalsecret,secret -n clearledger
kubectl describe externalsecret auth-service-secret -n clearledger | grep -A6 "Conditions:"
kubectl get pods -n clearledger -l app=auth-service
kubectl exec -n clearledger deploy/auth-service -c auth-service -- env | grep DATABASE_URL
</code></pre>
<h4 id="heading-command-1-externalsecrets-secrets">Command 1: ExternalSecrets + Secrets</h4>
<p>You should see two ExternalSecrets and two matching Secrets (auth has 2 keys, ledger has 1):</p>
<pre><code class="language-plaintext">NAME                                                     STORE                 REFRESH INTERVAL   STATUS         READY
externalsecret.external-secrets.io/auth-service-secret   aws-secrets-manager   1h                 SecretSynced   True
externalsecret.external-secrets.io/ledger-service-secret aws-secrets-manager   1h                 SecretSynced   True

NAME                         TYPE     DATA   AGE
secret/auth-service-secret   Opaque   2      3m
secret/ledger-service-secret Opaque   1      3m
</code></pre>
<p><code>STATUS</code> must be <strong>SecretSynced</strong> and <strong>READY</strong> must be <strong>True</strong>. If you see <code>SecretSyncedError</code>, stop here and fix IRSA before §8.5.</p>
<h4 id="heading-command-2-describe-auth-externalsecret">Command 2: describe auth ExternalSecret</h4>
<p>Look for <code>Reason: SecretSynced</code> and <code>Status: True</code>:</p>
<pre><code class="language-plaintext">  Conditions:
    Last Transition Time:   2026-07-10T22:15:00Z
    Message:                Secret was synced
    Reason:                 SecretSynced
    Status:                 True
    Type:                   Ready
</code></pre>
<h4 id="heading-command-3-auth-pods-running">Command 3: auth pods running</h4>
<pre><code class="language-plaintext">NAME                            READY   STATUS    RESTARTS   AGE
auth-service-xxxxxxxxxx-xxxxx   1/1     Running   0          2m
auth-service-xxxxxxxxxx-xxxxx   1/1     Running   0          2m
</code></pre>
<p>Both replicas <strong>1/1 Running</strong>. If pods are <code>CrashLoopBackOff</code> or <code>CreateContainerConfigError</code>, the K8s Secret may be missing or empty.</p>
<h4 id="heading-command-4-databaseurl-is-an-env-var-eso-path-not-a-file-path">Command 4: DATABASE_URL is an env var (ESO path), not a file path</h4>
<pre><code class="language-plaintext">DATABASE_URL=postgresql://clearledger:*****@clearledger-postgres.xxxxx.eu-west-1.rds.amazonaws.com:5432/clearledger
</code></pre>
<p>Good: a <code>postgresql://...</code> connection string (password shown as <code>*****</code> or your real password).</p>
<p>Bad for this section: <code>/mnt/secrets/database_url</code> that means CSI file mounts (§8.5), not the default ESO env-var path.</p>
<p>You can also spot-check the secret exists without printing values:</p>
<pre><code class="language-bash">kubectl get secret auth-service-secret -n clearledger -o jsonpath='{.data}' | grep -o 'database_url\|jwt_secret'
# Expect: database_url and jwt_secret (two keys)
</code></pre>
<p>If <code>SecretSynced=False</code>, check ESO logs and IRSA:</p>
<pre><code class="language-bash">kubectl logs -n external-secrets deploy/external-secrets -c external-secrets | tail -30
kubectl get sa auth-service -n clearledger -o yaml | grep role-arn
</code></pre>
<p><strong>✋ Hands-on checkpoint. External Secrets actually synced from AWS</strong></p>
<pre><code class="language-bash">kubectl get externalsecret -n clearledger
kubectl get secret -n clearledger
</code></pre>
<p>Expected: <code>auth-service-secret</code> and <code>ledger-service-secret</code> each show <code>SecretSynced</code> / Ready <code>True</code>. The matching Kubernetes Secrets exist in <code>clearledger</code>. A <code>SecretSyncedError</code> means IRSA/IAM can't reach Secrets Manager: fix the role binding before §8.5.</p>
<p>If you skip this, §8.5 (CSI driver) builds on working secret access, and a silent IAM failure here surfaces as an unrelated-looking pod error two sections later.</p>
<h3 id="heading-85-hands-on-csi-driver-file-mounts">8.5: Hands-on, CSI Driver (File Mounts)</h3>
<p>The default pods already use ESO: secrets arrive as environment variables from a Kubernetes Secret object. This exercise switches <code>auth-service</code> to the CSI path instead: secrets are mounted as plain files under <code>/mnt/secrets/</code>, and the app reads them from disk. It's the same code path the homelab uses with Vault (<code>DATABASE_URL_FILE</code> / <code>JWT_SECRET_FILE</code>).</p>
<p>CSI was already installed at spinup step 11, so there's nothing extra to install.</p>
<h4 id="heading-step-1-confirm-csi-is-running">Step 1: Confirm CSI is running</h4>
<pre><code class="language-bash">kubectl get pods -n kube-system -l app=secrets-store-csi-driver
kubectl get secretproviderclass -n clearledger
</code></pre>
<p>You should see one CSI driver pod per node, and two <code>SecretProviderClass</code> objects: one for auth-service and one for ledger-service.</p>
<h4 id="heading-step-2-swap-the-deployment-in-git">Step 2: swap the deployment in Git</h4>
<p>Open <code>stages/stage-8-aws-migration/manifests/kustomization.yaml</code> and change one line:</p>
<pre><code class="language-yaml"># Before
  - deployments/auth-service.yaml

# After
  - deployments/auth-service-csi.yaml
</code></pre>
<p>Commit and push, then sync:</p>
<pre><code class="language-bash">argocd app sync clearledger-aws
kubectl rollout status deployment/auth-service -n clearledger
</code></pre>
<p>ArgoCD will roll out a new auth-service pod with the CSI volume attached.</p>
<h4 id="heading-step-3-confirm-the-files-are-there">Step 3: Confirm the files are there</h4>
<pre><code class="language-bash"># Find the new pod
kubectl get pod -n clearledger -l secrets=csi

# List the mounted secret files
kubectl exec -n clearledger deploy/auth-service -- ls /mnt/secrets

# Check the database URL was written correctly
kubectl exec -n clearledger deploy/auth-service -- cat /mnt/secrets/database_url

# Confirm the service is still healthy
curl -s "http://$(kubectl get ingress clearledger-ingress -n clearledger \
  -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')/auth/health"
</code></pre>
<p>You should see <code>database_url</code> and <code>jwt_secret</code> listed as files, and the health check should return <code>{"status":"ok"}</code>.</p>
<p><strong>ESO vs CSI: what actually changed?</strong></p>
<p>Both paths read the same passwords from AWS Secrets Manager. Only the delivery method changes.</p>
<p><strong>ESO (default, what you verified in §8.4)</strong></p>
<p>Think of ESO as a copy clerk that runs in the cluster:</p>
<ol>
<li><p>ESO has its own AWS permission (IAM role).</p>
</li>
<li><p>It reads <code>clearledger/auth-service</code> from Secrets Manager.</p>
</li>
<li><p>It copies the values into a normal Kubernetes Secret named <code>auth-service-secret</code>.</p>
</li>
<li><p>The auth pod reads <code>DATABASE_URL</code> and <code>JWT_SECRET</code> as <strong>environment variables.</strong></p>
</li>
</ol>
<p>The password lives briefly inside the cluster as a Kubernetes Secret object.</p>
<p><strong>CSI (this exercise, file mounts)</strong></p>
<p>Think of CSI as the pod picking up secrets itself when it starts:</p>
<ol>
<li><p>The auth-service pod has its own AWS permission (IRSA on its ServiceAccount).</p>
</li>
<li><p>When the pod starts, the CSI driver asks Secrets Manager for the values.</p>
</li>
<li><p>They appear as files under <code>/mnt/secrets/</code> (<code>database_url</code>, <code>jwt_secret</code>).</p>
</li>
<li><p>The pod is told <code>DATABASE_URL_FILE=/mnt/secrets/database_url</code> , it reads from disk, not from a copied K8s Secret.</p>
</li>
</ol>
<p>No Kubernetes Secret copy is created for those values on this path.</p>
<p><strong>Why does the same app code work for both?</strong></p>
<p><code>app/auth-service/main.py</code> uses a small helper <code>_read_secret()</code>:</p>
<ul>
<li><p>If <code>DATABASE_URL_FILE</code> points to a file that exists → read the file (CSI or homelab Vault).</p>
</li>
<li><p>Otherwise → read <code>DATABASE_URL</code> directly (ESO / Stage 0–4).</p>
</li>
</ul>
<p>Same image, same code: you only change which deployment YAML Argo CD syncs.</p>
<p><strong>To switch back to ESO:</strong> in <code>kustomization.yaml</code>, change <code>auth-service-csi.yaml</code> back to <code>auth-service.yaml</code>, commit, push, and <code>argocd app sync clearledger-aws</code>.</p>
<p><strong>Terraform</strong> provisions all the AWS resources (VPC, EKS, RDS, ECR, Secrets Manager, and IAM roles) from <code>.tf</code> files in <code>stages/stage-8-aws-migration/terraform/</code>.</p>
<h3 id="heading-two-oidc-ideas-in-stage-8">Two OIDC Ideas in Stage 8</h3>
<p>Stage 8 uses OIDC in two different places. They sound similar, but they solve different problems.</p>
<p><strong>GitHub Actions OIDC</strong> lets the CI pipeline push images to ECR without storing long-lived AWS keys in GitHub. When a job runs, GitHub mints a short-lived token that proves the job's identity. AWS trusts that token and hands back temporary credentials: enough to push images and nothing else.</p>
<p><strong>IRSA</strong> does the same thing, but for pods running inside EKS. Instead of a GitHub token, the pod presents its Kubernetes ServiceAccount token. AWS trusts the EKS cluster's OIDC provider, verifies the token, and returns temporary credentials scoped to exactly what that pod needs.</p>
<p>It helps to see what each one says:</p>
<pre><code class="language-text">GitHub Actions OIDC:
  Pipeline says → "I am a job in the production environment of YOUR_USERNAME/clearledger"
  AWS replies   → "Here are credentials to push to ECR, valid for one hour"

IRSA:
  Pod says   → "I am the auth-service ServiceAccount in the clearledger namespace"
  AWS replies → "Here are credentials to read only the auth-service secret, valid for one hour"
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ac83e9bf-dbfd-42b3-bba8-0bbf327b03c5.png" alt="Image flow diagram showing difference between OIDC and IRSA" style="display:block;margin:0 auto" width="1536" height="1024" loading="lazy">

<p>The key is what is <em>not</em> stored anywhere:</p>
<pre><code class="language-text">No AWS_ACCESS_KEY_ID in GitHub Secrets
No AWS_SECRET_ACCESS_KEY in GitHub Secrets
No AWS keys inside Kubernetes Secrets
</code></pre>
<p>Terraform creates the role <code>clearledger-github-actions-ecr</code> and wires up the trust policies for both. The pipeline in <code>.github/workflows/ci-aws.yaml</code> assumes that role, pushes images to ECR, and updates <code>kustomization.yaml</code>. ArgoCD picks up the change and deploys the new images.</p>
<h3 id="heading-ci-routing-stages-17-vs-stage-8">CI Routing: Stages 1–7 vs Stage 8</h3>
<p>The repo ships two workflow files. You don't need both running at the same time.</p>
<p><code>ci.yaml</code> is the homelab pipeline from Stages 1–7. It runs on your self-hosted Multipass VM, pushes images to Docker Hub, and updates your <code>clearledger-infra</code> GitOps repo. This is the default: nothing to configure.</p>
<p><code>ci-aws.yaml</code> is the AWS pipeline for Stage 8. It runs on GitHub-hosted <code>ubuntu-latest</code> runners, pushes images to ECR, and updates <code>kustomization.yaml</code> directly in this repo. It only activates when you set the repo variable <code>CLEARLEDGER_CI_TARGET=aws</code>.</p>
<p><strong>If you're on Stages 1–7, do nothing.</strong> The <code>CLEARLEDGER_CI_TARGET</code> variable is unset by default, so every push runs <code>ci.yaml</code> on your self-hosted runner as normal. The AWS workflow file exists in the repo but its jobs are skipped.</p>
<p><strong>Do not set</strong> <code>CLEARLEDGER_CI_TARGET=aws</code> <strong>until your EKS cluster is running.</strong></p>
<p>If you set it early, <code>ci.yaml</code> stops running on push (no more Docker Hub builds), and <code>ci-aws.yaml</code> will fail immediately because there's no ECR, no OIDC role, and no AWS infrastructure yet. If you accidentally set it, delete the variable: GitHub → repo <strong>Settings</strong> → <strong>Secrets and variables</strong> → <strong>Actions</strong> → <strong>Variables</strong> → delete <code>CLEARLEDGER_CI_TARGET</code>.</p>
<p><strong>Enabling AWS CI (do this after</strong> <code>terraform apply</code> <strong>completes)</strong></p>
<p>You need three repository variables and one secret in a <code>production</code> environment.</p>
<p>First, set the variables: replace <code>YOUR_USERNAME</code> with your GitHub username:</p>
<pre><code class="language-bash">gh variable set CLEARLEDGER_CI_TARGET --body aws --repo YOUR_USERNAME/clearledger

gh variable set AWS_ACCOUNT_ID --body "$(aws sts get-caller-identity --query Account --output text)" --repo YOUR_USERNAME/clearledger

gh variable set AWS_REGION --body eu-west-1 --repo YOUR_USERNAME/clearledger
</code></pre>
<p>Then create the <code>production</code> environment and add the OIDC role ARN as a secret:</p>
<pre><code class="language-bash"># Create the environment first, gh secret set returns 404 if it does not exist
gh api --method PUT "repos/YOUR_USERNAME/clearledger/environments/production"

gh secret set AWS_ACTIONS_ROLE_ARN \
  --env production \
  --body "$(terraform -chdir=stages/stage-8-aws-migration/terraform output -raw github_actions_ecr_role_arn)" \
  --repo YOUR_USERNAME/clearledger
</code></pre>
<p><strong>Note</strong>: GitHub blocks secret names that start with <code>GITHUB_</code>. Use <code>AWS_ACTIONS_ROLE_ARN</code>, not <code>GITHUB_ACTIONS_ROLE_ARN</code>.</p>
<p>Also make sure <code>github_owner</code> is set correctly in <code>terraform.tfvars</code> (see <code>terraform.tfvars.example</code>) before running <code>terraform apply</code>. This wires up the OIDC trust policy so AWS will accept tokens from your specific GitHub account.</p>
<p>Once <code>CLEARLEDGER_CI_TARGET=aws</code> is set, every push to <code>main</code> runs the AWS pipeline: Gitleaks → Semgrep → Checkov → build → Trivy scan → ECR push → kustomization update. The homelab <code>ci.yaml</code> is skipped.</p>
<p><strong>If CI fails at the ECR push step:</strong></p>
<p>The most common failure is <code>Not authorized to perform sts:AssumeRoleWithWebIdentity</code>. This means the IAM role trust policy still has a placeholder <code>YOUR_GITHUB_USERNAME</code> in the <code>:sub</code> condition. Fix it by setting <code>github_owner</code> in <code>terraform.tfvars</code> and running <code>terraform apply</code> again, then re-run only the failed job (not the whole pipeline: the earlier scan steps already passed).</p>
<pre><code class="language-text">GitHub → Actions → failed run → Re-run failed jobs
</code></pre>
<p>If you see <code>404</code> when running <code>gh secret set</code>, the <code>production</code> environment doesn't exist yet. Run the <code>gh api --method PUT</code> command above first.</p>
<p><strong>Re-run after fixing OIDC:</strong> failed jobs only, not the full pipeline. Earlier gates (Gitleaks, build, scan) already passed, and their artifacts are still in the workflow run. Use Re-run all jobs only if you changed app code or want a clean scan from scratch.</p>
<h3 id="heading-production-hardening-checklist">Production Hardening Checklist</h3>
<p>The lab architecture is production-style, but a real production setup needs extra guardrails. Add these before you describe it as production-ready.</p>
<h4 id="heading-1-protect-the-main-branches">1. Protect the main branches</h4>
<p>Protect both GitHub repos:</p>
<pre><code class="language-text">github.com/YOUR_GITHUB_USERNAME/clearledger
github.com/YOUR_GITHUB_USERNAME/clearledger-infra
</code></pre>
<p>Go to each repo:</p>
<pre><code class="language-text">Settings
→ Rules
→ Rulesets
→ New ruleset
→ Branch targeting: main
</code></pre>
<p>Enable:</p>
<pre><code class="language-text">Require a pull request before merging
Require approvals
Require status checks to pass
Require branches to be up to date before merging
Block force pushes
Block branch deletion
</code></pre>
<p>Why this matters: nobody should push straight to the code repo or the GitOps repo in production. A bad direct push to <code>clearledger-infra</code> is a direct deployment request.</p>
<h4 id="heading-2-use-github-environments-with-approvals">2. Use GitHub Environments with approvals</h4>
<p>Create a protected environment:</p>
<pre><code class="language-text">clearledger repo
→ Settings
→ Environments
→ New environment
→ Name: production
→ Required reviewers: add yourself or the team
→ Deployment branches: main only
</code></pre>
<p>The AWS workflow uses:</p>
<pre><code class="language-yaml">environment: production
</code></pre>
<p>That means GitHub pauses the AWS deployment until an approved reviewer allows it. This creates a real promotion gate instead of "every push deploys to prod."</p>
<h4 id="heading-3-prefer-fine-grained-tokens-or-a-github-app">3. Prefer fine-grained tokens or a GitHub App</h4>
<p>For the basic lab, <code>INFRA_REPO_TOKEN</code> can be a classic PAT. For production, tighten it.</p>
<p>Better option:</p>
<pre><code class="language-text">Fine-grained personal access token
→ Repository access: only YOUR_GITHUB_USERNAME/clearledger-infra
→ Permissions:
   Contents: Read and write
   Metadata: Read
</code></pre>
<p>Best option for teams: use a GitHub App installed only on <code>clearledger-infra</code>, with permission to write contents. That gives better audit logs and easier rotation than a personal token.</p>
<p>Store <code>INFRA_REPO_TOKEN</code> as a production environment secret, not a general repository secret:</p>
<pre><code class="language-text">clearledger
→ Settings
→ Environments
→ production
→ Environment secrets
→ INFRA_REPO_TOKEN
</code></pre>
<h4 id="heading-4-lock-aws-oidc-to-the-production-environment">4. Lock AWS OIDC to the production environment</h4>
<p>This isn't a shell command. It's a trust rule Terraform writes into AWS when you run <code>terraform apply</code>.</p>
<p>In <code>iam.tf</code>, the IAM role <code>clearledger-github-actions-ecr</code> only accepts GitHub tokens whose subject claim matches:</p>
<pre><code class="language-text">repo:YOUR_GITHUB_USERNAME/clearledger:environment:production
</code></pre>
<p>Only GitHub Actions jobs running in the <code>production</code> environment of your <code>clearledger</code> repo can assume the ECR push role. A random branch, fork, or workflow without that environment can't get AWS credentials.</p>
<p><strong>What you do:</strong></p>
<ol>
<li><p>Set <code>github_owner</code> in <code>terraform.tfvars</code>, then <code>terraform apply</code> (Stage 8 step 2).</p>
</li>
<li><p>On GitHub: <strong>Settings → Environments → production.</strong> Create it if missing, and add protection rules if you want.</p>
</li>
<li><p>Add environment secret <code>AWS_ACTIONS_ROLE_ARN</code> = <code>terraform output -raw github_actions_ecr_role_arn</code>.</p>
</li>
<li><p><code>ci-aws.yaml</code> already sets <code>environment: production</code> on the ECR jobs, that is what makes GitHub mint a matching token.</p>
</li>
</ol>
<p><strong>Verify the rule exists (optional):</strong></p>
<pre><code class="language-bash">aws iam get-role --role-name clearledger-github-actions-ecr \
  --query 'Role.AssumeRolePolicyDocument.Statement[0].Condition.StringEquals."token.actions.githubusercontent.com:sub"' \
  --output text
</code></pre>
<p>Expect: <code>repo:your-username/clearledger:environment:production</code></p>
<p>On the manual Stage 8 path you can skip GitHub CI entirely, this lock only matters when you enable CI, AWS (ECR + OIDC).</p>
<h4 id="heading-5-staging-before-production-promote-dont-rebuild">5. Staging before production (promote, don't rebuild)</h4>
<p><strong>Lab flow:</strong> push to <code>main</code> → <code>ci-aws.yaml</code> builds and scans → CI updates <code>stages/stage-8-aws-migration/manifests/kustomization.yaml</code> with the new image tag → Argo CD syncs <code>clearledger-aws</code>.</p>
<p>Homelab Stages 1–7 still use <code>clearledger-infra</code> and Docker Hub. Stage 8 AWS uses the in-repo kustomize path.</p>
<p>Real production adds a staging step in the middle: build the image once, deploy that same tag or digest to staging, run smoke tests or get manual approval, then promote to production, without building again.</p>
<p>Why? Because, if you rebuild for prod, you might ship different code than what passed staging. The safe pattern is one artifact, tested once, promoted twice.</p>
<pre><code class="language-text">Build once (one image SHA)
  → deploy to staging
  → test / approve
  → deploy the same SHA to production
</code></pre>
<h4 id="heading-6-use-private-networking-where-possible">6. Use private networking where possible</h4>
<p>For production AWS:</p>
<pre><code class="language-text">- EKS nodes in private subnets
- RDS in private subnets
- Private EKS API endpoint, or restricted public endpoint
- Security groups scoped to required ports only
- ALB public only if the app is public
- No SSH-based deployment path
</code></pre>
<p>The pipeline should talk to AWS APIs through IAM/OIDC and deploy through GitOps. It shouldn't SSH into EC2 instances.</p>
<h4 id="heading-7-store-terraform-state-remotely">7. Store Terraform state remotely</h4>
<p>Local Terraform state is fine for a lab. Production should use encrypted remote state:</p>
<pre><code class="language-text">- S3 bucket for terraform.tfstate
- DynamoDB table for state locking
- SSE encryption enabled
- Bucket versioning enabled
- Public access blocked
</code></pre>
<p>The Terraform backend block is already included in <code>stages/stage-8-aws-migration/terraform/main.tf</code> as a commented template.<br>Uncomment it after you create the S3 bucket and DynamoDB lock table.</p>
<h4 id="heading-production-ready-summary">Production-ready summary:</h4>
<pre><code class="language-text">- CI builds and proves the artifact.
- GitHub Environments approve production.
- OIDC gives short-lived AWS credentials.
- ECR stores immutable images.
- kustomization.yaml (Stage 8 path) records desired state.
- ArgoCD clearledger-aws deploys from Git.
- No SSH. No static AWS keys. No direct kubectl from CI.
</code></pre>
<p>Open the URL. ClearLedger is running on AWS. Same architecture, same security layers, just new infrastructure.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/3e6a283d-add1-467b-83da-97c1915f0b92.png" alt="screenshot of clearledger UI running on EKS with ALB URL" style="display:block;margin:0 auto" width="1473" height="1269" loading="lazy">

<p><strong>Destroy when done.</strong> This stops all charges:</p>
<pre><code class="language-bash">make aws-down
</code></pre>
<p>See <code>stages/stage-8-aws-migration/README.md</code> for the full walkthrough and cost reference.</p>
<h3 id="heading-what-you-learned-in-stage-8">What You Learned in Stage 8</h3>
<ul>
<li><p>That containerized applications are portable: the same code runs on your laptop and on AWS</p>
</li>
<li><p>What Terraform does: declares infrastructure as code so environments are reproducible</p>
</li>
<li><p>What changes in a cloud migration (managed services, IAM, networking) and what does not (application code, CI logic, security policies)</p>
</li>
<li><p>Three AWS secret delivery paths: ESO (default), CSI file mounts (§8.5), vs Vault on homelab</p>
</li>
<li><p>AWS-specific security services: GuardDuty (threat detection), CloudTrail (API audit), GitHub Actions OIDC (pipeline AWS auth without long-lived keys), and IRSA (pod-level IAM without long-lived credentials)</p>
</li>
</ul>
<p><strong>What you can now put on your CV / say in an interview:</strong></p>
<blockquote>
<p>Migrated the same architecture to AWS (EKS, ECR, RDS, ALB, with secrets via External Secrets Operator and IRSA) provisioned by Terraform, without rewriting the application.</p>
</blockquote>
<p><strong>When you're done on AWS, tear down to stop charges:</strong></p>
<pre><code class="language-bash">make aws-down
</code></pre>
<p>Your homelab VM is separate. If you plan to return to it, you should already have a snapshot from Stage 7 (<code>make snapshots</code> to confirm). See <a href="#heading-how-to-save-your-progress">Saving your progress</a>.</p>
<h2 id="heading-troubleshooting-see-troubleshootingmd">Troubleshooting (See <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md</a>)</h2>
<p><strong>Pod stuck in Pending:</strong></p>
<pre><code class="language-bash">kubectl describe pod POD_NAME -n clearledger
# Insufficient memory/cpu → reduce resource requests
# Image pull error → check Docker Hub repo name and credentials
</code></pre>
<p><strong>Kyverno blocking a deployment:</strong></p>
<pre><code class="language-bash">kubectl get events -n clearledger --sort-by='.lastTimestamp' | tail -10
kubectl get policyreport -n clearledger -o yaml
</code></pre>
<p><strong>Vault agent not injecting secrets:</strong></p>
<pre><code class="language-bash">kubectl logs POD_NAME -n clearledger -c vault-agent-init
kubectl exec -n vault vault-0 -- vault read auth/kubernetes/role/auth-service
</code></pre>
<p><strong>Falco not firing alerts:</strong></p>
<pre><code class="language-bash">kubectl logs -n falco daemonset/falco | grep -i error | tail -20
</code></pre>
<p><strong>ArgoCD shows OutOfSync:</strong></p>
<pre><code class="language-bash">argocd app sync clearledger --force
argocd app get clearledger
kubectl get events -n clearledger --sort-by='.lastTimestamp'
</code></pre>
<p><strong>clearledger.local not resolving:</strong></p>
<pre><code class="language-bash">multipass info clearledger | grep IPv4
grep clearledger /etc/hosts
# If the IP changed, update /etc/hosts
</code></pre>
<p><strong>VM disk full or pods Evicted (disk pressure):</strong></p>
<pre><code class="language-bash">make doctor     # PASS / WARN / FAIL + PVC and Prometheus TSDB sizes
make reclaim    # safe reclaim — unused images + journald only (not PVCs)
</code></pre>
<p>If still FAIL after reclaim, tear down and recreate: <code>make teardown &amp;&amp; make setup</code>. Full guidance: <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">troubleshooting.md: disk health</a> and <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/troubleshooting.md">VM disk full</a>.</p>
<h2 id="heading-compliance-reference">Compliance Reference</h2>
<p>Every control maps to at least one framework. Full mapping: <a href="compliance-mapping.md"><code>docs/compliance-mapping.md</code></a>.</p>
<table>
<thead>
<tr>
<th>Control</th>
<th>Tool</th>
<th>Stage</th>
<th>PCI-DSS</th>
<th>SOC2</th>
<th>CIS K8s</th>
</tr>
</thead>
<tbody><tr>
<td>Secrets detection</td>
<td>Gitleaks</td>
<td>3</td>
<td>6.2</td>
<td>CC8.1</td>
<td>—</td>
</tr>
<tr>
<td>SAST</td>
<td>Semgrep</td>
<td>3</td>
<td>6.3.2</td>
<td>CC7.1</td>
<td>—</td>
</tr>
<tr>
<td>Dependency scan</td>
<td>Trivy SCA</td>
<td>3</td>
<td>6.3.3</td>
<td>CC7.1</td>
<td>—</td>
</tr>
<tr>
<td>IaC scan</td>
<td>Checkov</td>
<td>3</td>
<td>6.3.1</td>
<td>CC6.1</td>
<td>—</td>
</tr>
<tr>
<td>Image signing</td>
<td>Cosign</td>
<td>3</td>
<td>6.3</td>
<td>CC6.1</td>
<td>—</td>
</tr>
<tr>
<td>SBOM generation</td>
<td>Syft</td>
<td>3</td>
<td>6.3.3</td>
<td>CC6.1</td>
<td>—</td>
</tr>
<tr>
<td>Non-root containers</td>
<td>Kyverno</td>
<td>4</td>
<td>6.5</td>
<td>CC6.3</td>
<td>5.2.6</td>
</tr>
<tr>
<td>Resource limits</td>
<td>Kyverno</td>
<td>4</td>
<td>—</td>
<td>A1.1</td>
<td>5.2.4</td>
</tr>
<tr>
<td>No privilege escalation</td>
<td>Kyverno</td>
<td>4</td>
<td>6.5</td>
<td>CC6.3</td>
<td>5.2.5</td>
</tr>
<tr>
<td>Secrets management</td>
<td>Vault</td>
<td>5</td>
<td>3.5</td>
<td>CC6.1</td>
<td>—</td>
</tr>
<tr>
<td>Runtime detection</td>
<td>Falco</td>
<td>6</td>
<td>10.7</td>
<td>CC7.2</td>
<td>—</td>
</tr>
<tr>
<td>Network segmentation</td>
<td>NetworkPolicy</td>
<td>6</td>
<td>1.3</td>
<td>CC6.6</td>
<td>5.3.2</td>
</tr>
<tr>
<td>Security observability</td>
<td>Grafana</td>
<td>7</td>
<td>10.6</td>
<td>CC7.2</td>
<td>—</td>
</tr>
<tr>
<td>DORA metrics</td>
<td>ArgoCD + Grafana</td>
<td>7</td>
<td>—</td>
<td>—</td>
<td>—</td>
</tr>
<tr>
<td>Account threat detection</td>
<td>GuardDuty</td>
<td>8</td>
<td>10.6</td>
<td>CC7.2</td>
<td>—</td>
</tr>
<tr>
<td>API audit trail</td>
<td>CloudTrail</td>
<td>8</td>
<td>10.2</td>
<td>CC7.3</td>
<td>—</td>
</tr>
</tbody></table>
<p><strong>EU DORA (Digital Operational Resilience Act):</strong> applies to EU financial entities since January 2025. ClearLedger maps to all five DORA pillars. Full mapping in <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/compliance-mapping.md"><code>docs/compliance-mapping.md</code></a>.</p>
<h2 id="heading-interview-preparation">Interview Preparation</h2>
<p>Full weak/strong answers: <a href="https://github.com/Osomudeya/clearledger/blob/main/docs/interview-prep.md"><code>docs/interview-prep.md</code></a></p>
<p>Practice these as you finish each stage:</p>
<p><strong>Stage 0:</strong> How does traffic reach your services in Kubernetes? What breaks first when deployment is manual?</p>
<p><strong>Stage 1:</strong> How do you prove what image is deployed for a given commit? What stops a developer bypassing CI?</p>
<p><strong>Stage 2:</strong> What does GitOps mean mechanically? How do you prove drift is corrected automatically?</p>
<p><strong>Stage 3:</strong> Difference between SAST, IaC scanning, and image scanning? Where do you draw the line for fail-on severity?</p>
<p><strong>Stage 4:</strong> What is admission control and why is it different from CI? How would you safely introduce a policy exception?</p>
<p><strong>Stage 5:</strong> Why are Kubernetes Secrets not "secret management"? How do you rotate secrets with minimal downtime risk?</p>
<p><strong>Stage 6:</strong> What does runtime detection catch that CI and admission can't? What is your first response to a shell-spawn alert?</p>
<p><strong>Stage 7:</strong> What's the difference between a dashboard and an alert? How do you produce audit evidence, not just claims?</p>
<p><strong>Stage 8:</strong> What actually changes when you move to EKS? What shouldn't change? How does IRSA reduce risk?</p>
<h2 id="heading-aws-cost-reference">AWS Cost Reference</h2>
<p>Default Stage 8 sizes (eu-west-1, approximate):</p>
<table>
<thead>
<tr>
<th>Resource</th>
<th>Monthly (8h/day)</th>
<th>Monthly (24/7)</th>
</tr>
</thead>
<tbody><tr>
<td>EKS control plane</td>
<td>~$24</td>
<td>~$73</td>
</tr>
<tr>
<td>3× t3.medium nodes</td>
<td>~$30</td>
<td>~$92</td>
</tr>
<tr>
<td>NAT Gateway</td>
<td>~$11</td>
<td>~$33</td>
</tr>
<tr>
<td>RDS db.t3.micro</td>
<td>~$4</td>
<td>~$13</td>
</tr>
<tr>
<td>ALB</td>
<td>~$2</td>
<td>~$6</td>
</tr>
<tr>
<td>GuardDuty + CloudTrail</td>
<td>~$2</td>
<td>~$5</td>
</tr>
<tr>
<td><strong>Total estimate</strong></td>
<td><strong>~$73</strong></td>
<td><strong>~$222</strong></td>
</tr>
</tbody></table>
<p>Always destroy when not in use:</p>
<pre><code class="language-bash">make aws-down
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've now built a fintech application and layered eight security and reliability controls on top of it: all from a laptop.</p>
<p>You started with raw Kubernetes and manual deploys in Stage 0. You added a CI pipeline that builds, scans, and signs images automatically in Stage 1. You connected Git to the cluster with ArgoCD in Stage 2. You gated every push with SAST, IaC, and image scanning in Stage 3. You blocked bad workloads at the cluster boundary with Kyverno in Stage 4. You moved credentials out of Git and Kubernetes secrets into Vault in Stage 5. You added runtime threat detection with Falco and network segmentation in Stage 6. You built observability dashboards that produce audit evidence in Stage 7. And you migrated the whole thing to AWS in Stage 8.</p>
<p>None of these stages is a toy exercise. Each one represents a real problem that real teams hit in production. You felt the pain, then built the solution. That's the difference between reading about DevSecOps and being able to do it.</p>
<p>Take your screenshots, update your CV with the specific tools and outcomes, and use the interview prep section when you need to talk through the decisions you made. You built every one of them.</p>
<p><em>If you found this guide helpful, share it with someone breaking into DevOps or DevSecOps and</em> <a href="https://www.linkedin.com/in/osomudeya-zudonu-17290b124"><em>connect on LinkedIn</em></a><em>.</em></p>
<p><em>I also post DevOps walkthroughs and interview tips for getting hired; follow or</em> <a href="https://osomudeya.kit.com/23db7ca59f"><em>subscribe there</em></a> <em>if you want more.</em></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Manage Secrets Securely with Azure Key Vault in Node.js ]]>
                </title>
                <description>
                    <![CDATA[ Last year a client called me about exactly this. Someone ran git log -p on a hunch and found a .env committed two years earlier, never caught. Database password, Stripe secret, JWT signing key — all s ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-manage-secrets-securely-with-azure-key-vault-in-node-js/</link>
                <guid isPermaLink="false">6a5e27b295e748bed9510853</guid>
                
                    <category>
                        <![CDATA[ JavaScript ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Azure ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Mon, 20 Jul 2026 13:50:42 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/5491b408-9c6b-4d4d-a53e-215119fb2d97.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Last year a client called me about exactly this. Someone ran <code>git log -p</code> on a hunch and found a <code>.env</code> committed two years earlier, never caught. Database password, Stripe secret, JWT signing key — all still active. All still in production.</p>
<p>IBM's 2024 breach cost report put the average data breach at <strong>$4.88 million</strong> — and that's the average, not the worst cases.</p>
<p>Exposed credentials are consistently near the top of root causes. GitHub found over a million secrets leaked in public repos in 2023 alone, before you even count the private ones nobody ever discovered.</p>
<p>It's not a people problem. The developers I've worked with aren't careless — the architecture is just set up to fail them. A <code>.env</code> file gets committed once by accident. Credentials get copied and pasted into a Slack message to unblock a teammate. A Docker image gets published with secrets baked into a layer. A server gets shut down, and nobody rotates the credentials it was holding.</p>
<p>Azure Key Vault solves this differently. Your application fetches credentials at runtime from a centralized, encrypted service — the <code>.env</code> file stops being a liability because it stops holding anything worth stealing.</p>
<p>What you'll build is a Node.js Express API that fetches every secret from Azure Key Vault at startup. No passwords in the code. When someone quits, there's nothing in the repo to rotate. The <code>.env</code> ends up with one line — the vault name.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>An Azure account (free tier works)</p>
</li>
<li><p>Azure CLI installed and logged in (<code>az login</code>)</p>
</li>
<li><p>Basic knowledge of Express.js</p>
</li>
<li><p>Docker (optional — only needed for the local database test section)</p>
</li>
</ul>
<h2 id="heading-what-we-will-build">What We Will Build</h2>
<p>A Node.js Express API that:</p>
<ol>
<li><p>Connects to PostgreSQL using credentials fetched from Key Vault at startup</p>
</li>
<li><p>Uses Managed Identity for authentication — no client secrets or passwords anywhere</p>
</li>
<li><p>Caches secrets in memory, so Key Vault isn't called on every request</p>
</li>
<li><p>Works locally via Azure CLI auth and in production via Managed Identity — same code, zero changes</p>
</li>
</ol>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-how-the-architecture-works">How the Architecture Works</a></p>
</li>
<li><p><a href="#heading-what-is-azure-key-vault">What Is Azure Key Vault?</a></p>
</li>
<li><p><a href="#heading-set-up-the-key-vault">Set Up the Key Vault</a></p>
</li>
<li><p><a href="#heading-create-the-nodejs-project">Create the Node.js Project</a></p>
</li>
<li><p><a href="#heading-connect-to-key-vault-with-managed-identity">Connect to Key Vault with Managed Identity</a></p>
</li>
<li><p><a href="#heading-cache-secrets-at-startup">Cache Secrets at Startup</a></p>
</li>
<li><p><a href="#heading-use-secrets-in-your-express-api">Use Secrets in Your Express API</a></p>
</li>
<li><p><a href="#heading-test-locally">Test Locally</a></p>
</li>
<li><p><a href="#heading-deploy-to-azure-app-service">Deploy to Azure App Service</a></p>
</li>
<li><p><a href="#heading-grant-key-vault-access-to-the-app">Grant Key Vault Access to the App</a></p>
</li>
<li><p><a href="#heading-rotate-secrets-without-redeploying">Rotate Secrets Without Redeploying</a></p>
</li>
<li><p><a href="#heading-troubleshooting">Troubleshooting</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-how-the-architecture-works">How the Architecture Works</h2>
<p>Before writing any code, it helps to see the full picture:</p>
<pre><code class="language-plaintext"> LOCAL DEVELOPMENT
.-------------------------------------------------------.
|                                                        |
|   [Node.js App]                                        |
|        |                                               |
|        v                                               |
|   [DefaultAzureCredential] ---&gt; az login session       |
|        |                                               |
|        v                                               |
|   [Azure Key Vault]  ---&gt; Returns secrets              |
|        |                                               |
|        v                                               |
|   [In-memory cache]  ---&gt; App uses secrets at runtime  |
'-------------------------------------------------------'

 PRODUCTION (Azure)
.-------------------------------------------------------.
|                                                        |
|   [Azure App Service]                                  |
|        |                                               |
|        v                                               |
|   [DefaultAzureCredential] ---&gt; Managed Identity       |
|        |                                               |
|        v                                               |
|   [Azure Key Vault]  ---&gt; Returns secrets              |
|        |                                               |
|        v                                               |
|   [In-memory cache]  ---&gt; App uses secrets at runtime  |
'-------------------------------------------------------'
</code></pre>
<p>Both environments run the exact same code. <code>DefaultAzureCredential</code> figures out where it is — locally it picks up your <code>az login</code> session, on Azure it uses Managed Identity. You don't switch config files and you don't manage credentials. It just works.</p>
<h2 id="heading-what-is-azure-key-vault">What Is Azure Key Vault?</h2>
<p>Azure Key Vault is Microsoft's managed secret store — it handles secrets, keys, and certificates. For this tutorial, we're only using the secrets part: database passwords, API keys, JWT signing keys, anything your app needs to run but has no business being in your Git history.</p>
<p>Compared to <code>.env</code> files, the practical differences are worth understanding before you write any code.</p>
<p>Rotation is the one I notice most on real projects. Update a secret in Key Vault and every app picks it up on the next restart — no hunting down five different environment configs across staging and production.</p>
<p>Access control is the other big one. Each application only gets permission to read the secrets it actually needs. If one service gets compromised, it can't read credentials belonging to other services.</p>
<p>And every read gets logged. When something goes wrong — and eventually something will — you can see exactly which app accessed which secret, and when. That log is what auditors actually want to see.</p>
<p>I've sat in enough security reviews to know that "we use <code>.env</code> files and tell people not to commit them" doesn't satisfy an auditor. SOC 2, HIPAA, GDPR — they all want demonstrable controls. A vault with an access log is demonstrable.</p>
<h2 id="heading-set-up-the-key-vault">Set Up the Key Vault</h2>
<p>Run these commands. The vault name has to be globally unique across all of Azure — not just your own subscription — so pick something specific. Letters, numbers, and hyphens, 3 to 24 characters.</p>
<pre><code class="language-bash"># Create a resource group (skip if you already have one)
az group create \
  --name keyvault-demo-rg \
  --location eastus

# Create the Key Vault (RBAC enabled by default — required for the role assignment later)
az keyvault create \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --location eastus

# Grant yourself permission to manage secrets (required with RBAC — creators are not auto-assigned)
az role assignment create \
  --role "Key Vault Secrets Officer" \
  --assignee-object-id $(az ad signed-in-user show --query id -o tsv) \
  --scope $(az keyvault show \
    --name your-vault-name \
    --resource-group keyvault-demo-rg \
    --query id -o tsv)

# Add your secrets
az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-HOST" \
  --value "your-db-host.postgres.database.azure.com"

az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-PASSWORD" \
  --value "your-super-secret-password"

az keyvault secret set \
  --vault-name your-vault-name \
  --name "JWT-SECRET" \
  --value "your-jwt-signing-secret"
</code></pre>
<p>Verify the secrets were stored:</p>
<pre><code class="language-bash">az keyvault secret list --vault-name your-vault-name --query "[].name" -o tsv
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">DB-HOST
DB-PASSWORD
JWT-SECRET
</code></pre>
<h2 id="heading-create-the-nodejs-project">Create the Node.js Project</h2>
<p>Set up the project structure:</p>
<pre><code class="language-bash">mkdir nodejs-azure-keyvault
cd nodejs-azure-keyvault
npm init -y
npm install express pg jsonwebtoken @azure/keyvault-secrets @azure/identity dotenv
</code></pre>
<p>The two Azure packages do all the work:</p>
<ul>
<li><p><code>@azure/keyvault-secrets</code> — connects to your vault and pulls secrets out</p>
</li>
<li><p><code>@azure/identity</code> — handles auth. Locally, it uses your <code>az login</code> session, in production, it switches to Managed Identity automatically</p>
</li>
</ul>
<p>Add a start script to <code>package.json</code>:</p>
<pre><code class="language-bash">npm pkg set scripts.start="node server.js"
</code></pre>
<p>Create the following file structure:</p>
<pre><code class="language-plaintext">nodejs-azure-keyvault/
|-- src/
|   |-- config/
|   |   `-- secrets.js   # Key Vault client and secret loader
|   |-- db/
|   |   `-- index.js     # PostgreSQL pool using secrets
|   `-- routes/
|       `-- users.js     # Example route
|-- app.js               # Express app
`-- server.js            # Entry point -- loads secrets first
</code></pre>
<h2 id="heading-connect-to-key-vault-with-managed-identity">Connect to Key Vault with Managed Identity</h2>
<p>Create the secrets config file:</p>
<pre><code class="language-javascript">// src/config/secrets.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential } = require('@azure/identity');

const VAULT_URL = `https://${process.env.KEY_VAULT_NAME}.vault.azure.net`;

const credential = new DefaultAzureCredential();
const client = new SecretClient(VAULT_URL, credential);

async function getSecret(name) {
  const secret = await client.getSecret(name);
  return secret.value;
}

module.exports = { getSecret };
</code></pre>
<p><code>DefaultAzureCredential</code> is the most important part of this setup. It tries a chain of authentication methods in order:</p>
<ol>
<li><p>Environment variables (for CI/CD pipelines)</p>
</li>
<li><p>Azure CLI credentials (for local development — <code>az login</code>)</p>
</li>
<li><p>Managed Identity (for deployed apps on Azure)</p>
</li>
</ol>
<p>This means the exact same code works locally and in production with zero changes. Locally, it uses your <code>az login</code> session. In production, it uses the app's Managed Identity. You never touch credentials.</p>
<h2 id="heading-cache-secrets-at-startup">Cache Secrets at Startup</h2>
<p>Calling Key Vault on every request adds latency and costs money. Load all secrets once at startup and cache them in memory. Replace <code>src/config/secrets.js</code> with this complete version:</p>
<pre><code class="language-javascript">// src/config/secrets.js
const { SecretClient } = require('@azure/keyvault-secrets');
const { DefaultAzureCredential } = require('@azure/identity');

const VAULT_URL = `https://${process.env.KEY_VAULT_NAME}.vault.azure.net`;

const credential = new DefaultAzureCredential();
const client = new SecretClient(VAULT_URL, credential);

// In-memory cache
const cache = {};

async function getSecret(name) {
  if (cache[name]) return cache[name];
  const secret = await client.getSecret(name);
  cache[name] = secret.value;
  return secret.value;
}

async function loadAllSecrets() {
  console.log('Loading secrets from Azure Key Vault...');
  const secretNames = ['DB-HOST', 'DB-PASSWORD', 'JWT-SECRET'];

  await Promise.all(
    secretNames.map(async (name) =&gt; {
      cache[name] = await getSecret(name);
      console.log(`  ✓ ${name} loaded`);
    })
  );

  console.log('All secrets loaded successfully.');
}

function getFromCache(name) {
  if (!cache[name]) throw new Error(`Secret "${name}" not loaded. Did loadAllSecrets() run?`);
  return cache[name];
}

module.exports = { loadAllSecrets, getFromCache };
</code></pre>
<p>The <code>loadAllSecrets</code> function runs once when the application starts. After that, all secrets are served from the in-memory cache with zero latency and zero Key Vault calls.</p>
<h2 id="heading-use-secrets-in-your-express-api">Use Secrets in Your Express API</h2>
<p>Set up the database connection using the cached secrets:</p>
<pre><code class="language-javascript">// src/db/index.js
const { Pool } = require('pg');
const { getFromCache } = require('../config/secrets');

let pool;

function getPool() {
  if (!pool) {
    pool = new Pool({
      host:     getFromCache('DB-HOST'),
      database: process.env.DB_NAME || 'myapp',
      user:     process.env.DB_USER || 'dbadmin',
      password: getFromCache('DB-PASSWORD'),
      port:     parseInt(process.env.DB_PORT || '5432'),
      ssl:      process.env.NODE_ENV === 'production'
                  ? { rejectUnauthorized: false }
                  : false,
    });

    pool.on('error', (err) =&gt; {
      console.error('Unexpected database pool error:', err.message);
    });
  }

  return pool;
}

module.exports = { getPool };
</code></pre>
<p>Notice the distinction: <code>DB-HOST</code> and <code>DB-PASSWORD</code> come from Key Vault because they're sensitive. The database name, username, and port are not — they don't need to be protected, so they use environment variables with sensible defaults. Key Vault is for credentials, not all configuration.</p>
<p>The SSL flag is environment-aware: forced on in production, off locally so Docker connections work without a certificate. The <code>rejectUnauthorized: false</code> setting accepts Azure Database for PostgreSQL's certificate without verifying the CA chain — this is standard for Azure-managed databases. For stricter environments, you can download the Azure root CA and pass it via the <code>ca</code> option in the pool config instead.</p>
<p>Create a sample route that uses JWT verification with the secret from Key Vault:</p>
<pre><code class="language-javascript">// src/routes/users.js
const express = require('express');
const jwt     = require('jsonwebtoken');
const { getFromCache } = require('../config/secrets');
const { getPool }      = require('../db');

const router = express.Router();

// Auth middleware — JWT secret comes from Key Vault, not process.env
function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }

  const token = authHeader.split(' ')[1];

  try {
    req.user = jwt.verify(token, getFromCache('JWT-SECRET'));
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// GET /api/users — list users (authenticated)
router.get('/', authMiddleware, async (req, res) =&gt; {
  try {
    const result = await getPool().query(
      'SELECT id, email, created_at FROM users ORDER BY created_at DESC LIMIT 20'
    );
    res.json(result.rows);
  } catch (err) {
    console.error('Database error:', err.message);
    res.status(500).json({ error: 'Internal server error' });
  }
});

// GET /api/users/:id — single user (authenticated)
router.get('/:id', authMiddleware, async (req, res) =&gt; {
  try {
    const result = await getPool().query(
      'SELECT id, email, created_at FROM users WHERE id = $1',
      [req.params.id]
    );
    if (!result.rows[0]) return res.status(404).json({ error: 'User not found' });
    res.json(result.rows[0]);
  } catch (err) {
    console.error('Database error:', err.message);
    res.status(500).json({ error: 'Internal server error' });
  }
});

module.exports = router;
</code></pre>
<p>Notice the error handler returns <code>'Internal server error'</code> instead of <code>err.message</code>. Database errors are surprisingly chatty — they'll hand an attacker your table names, column names, and query structure if you let them through.</p>
<p>Set up the Express application. Both files define <code>authMiddleware</code> locally — yes, it's duplicated. In production, I'd pull this into a shared middleware file. For this tutorial, keeping it local means you can read either file without bouncing between three others:</p>
<pre><code class="language-javascript">// app.js
const express = require('express');
const jwt = require('jsonwebtoken');
const { getFromCache } = require('./src/config/secrets');
const usersRouter = require('./src/routes/users');

const app = express();
app.use(express.json());

// Auth middleware — JWT secret comes from Key Vault, not process.env
function authMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or malformed Authorization header' });
  }
  const token = authHeader.split(' ')[1];
  try {
    req.user = jwt.verify(token, getFromCache('JWT-SECRET'));
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// Health check — no auth required
app.get('/health', (req, res) =&gt; {
  res.json({ status: 'healthy', timestamp: new Date().toISOString() });
});

// Status endpoint — proves Key Vault integration without needing a database
app.get('/api/status', authMiddleware, (req, res) =&gt; {
  res.json({
    message: 'All secrets loaded from Azure Key Vault',
    vault: process.env.KEY_VAULT_NAME,
    secrets_loaded: ['DB-HOST', 'DB-PASSWORD', 'JWT-SECRET'],
    authenticated_as: req.user.email,
    timestamp: new Date().toISOString()
  });
});

app.use('/api/users', usersRouter);

app.use((req, res) =&gt; res.status(404).json({ error: 'Route not found' }));
app.use((err, req, res, next) =&gt; {
  console.error('Unhandled error:', err.message);
  res.status(500).json({ error: 'Internal server error' });
});

module.exports = app;
</code></pre>
<p>The entry point loads secrets before starting the server. The server doesn't start unless all secrets load successfully:</p>
<pre><code class="language-javascript">// server.js
require('dotenv').config();
const app = require('./app');
const { loadAllSecrets } = require('./src/config/secrets');

const PORT = process.env.PORT || 3000;

async function start() {
  try {
    await loadAllSecrets();
    app.listen(PORT, () =&gt; {
      console.log(`Server running on port ${PORT}`);
    });
  } catch (err) {
    console.error('Failed to start server:', err.message);
    console.error('Hint: Run "az login" for local development, or check Managed Identity for Azure deployments.');
    process.exit(1);
  }
}

start();
</code></pre>
<p>That <code>process.exit(1)</code> is deliberate. I'd rather the app crash loudly at startup than limp along with missing credentials and fail on the first real request two hours later.</p>
<h2 id="heading-test-locally">Test Locally</h2>
<p>Create a <code>.env</code> file for local development. This only contains the Key Vault name, nothing sensitive:</p>
<pre><code class="language-bash"># .env
KEY_VAULT_NAME=your-vault-name
PORT=3000
</code></pre>
<p>Add <code>.env</code> and the deployment zip to <code>.gitignore</code>:</p>
<pre><code class="language-bash">echo ".env" &gt;&gt; .gitignore
echo "app.zip" &gt;&gt; .gitignore
</code></pre>
<p>Make sure you're logged into Azure CLI:</p>
<pre><code class="language-bash">az login
</code></pre>
<p>Start the application:</p>
<pre><code class="language-bash">npm start
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">Loading secrets from Azure Key Vault...
  ✓ JWT-SECRET loaded
  ✓ DB-PASSWORD loaded
  ✓ DB-HOST loaded
All secrets loaded successfully.
Server running on port 3000
</code></pre>
<p>The order secrets load may vary — <code>Promise.all</code> fetches them in parallel and resolves as each one completes. What matters is that all three are confirmed before the server starts.</p>
<p>Test the health endpoint:</p>
<pre><code class="language-bash">curl http://localhost:3000/health
# {"status":"healthy","timestamp":"2026-07-14T19:38:11.659Z"}
</code></pre>
<p>Now prove the integration end-to-end. Grab the value you stored as <code>JWT-SECRET</code> and use it to sign a test token — paste it in for <code>YOUR-JWT-SECRET-VALUE</code>. Then hit <code>/api/status</code> with it:</p>
<pre><code class="language-bash">node -e "const jwt = require('jsonwebtoken'); console.log(jwt.sign({id:1, email:'test@test.com'}, 'YOUR-JWT-SECRET-VALUE', {expiresIn:'1h'}));"
</code></pre>
<p>On Linux/macOS:</p>
<pre><code class="language-bash">curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/api/status
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">Invoke-RestMethod -Uri "http://localhost:3000/api/status" -Headers @{Authorization = "Bearer YOUR_TOKEN"}
</code></pre>
<p>You should see:</p>
<pre><code class="language-json">{
  "message": "All secrets loaded from Azure Key Vault",
  "vault": "your-vault-name",
  "secrets_loaded": ["DB-HOST", "DB-PASSWORD", "JWT-SECRET"],
  "authenticated_as": "test@test.com",
  "timestamp": "2026-07-14T19:50:08.687Z"
}
</code></pre>
<p>If you got that response, the whole chain worked. The JWT was signed and verified using a secret that lived only in Key Vault — not in your code, not in your<code>.env</code>, not anywhere in the repo. Your <code>az login</code> session handled the auth locally. In production, Managed Identity takes over. Same code, nothing changes.</p>
<h3 id="heading-test-the-full-database-flow-with-docker">Test the Full Database Flow with Docker</h3>
<p>The app reads <code>DB-HOST</code> and <code>DB-PASSWORD</code> from Key Vault, so those secrets need to match your local Docker container. Update them now:</p>
<pre><code class="language-bash">az keyvault secret set --vault-name your-vault-name --name "DB-HOST" --value "localhost"
az keyvault secret set --vault-name your-vault-name --name "DB-PASSWORD" --value "demopassword123"
</code></pre>
<p>Docker up a Postgres container. The password has to match <code>demopassword123</code> — that's what you just put in Key Vault:</p>
<pre><code class="language-bash">docker run --name pg-demo \
  -e POSTGRES_USER=dbadmin \
  -e POSTGRES_PASSWORD=demopassword123 \
  -e POSTGRES_DB=myapp \
  -p 5432:5432 \
  -d postgres:15
</code></pre>
<p>Get the table created and throw in some test rows:</p>
<pre><code class="language-bash">docker exec -it pg-demo psql -U dbadmin -d myapp -c \
  "CREATE TABLE IF NOT EXISTS users (id SERIAL PRIMARY KEY, email VARCHAR(255) UNIQUE NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW());"

docker exec -it pg-demo psql -U dbadmin -d myapp -c \
  "INSERT INTO users (email) VALUES ('alice@example.com'), ('bob@example.com'), ('carol@example.com');"
</code></pre>
<p>Kill the server and bring it back up — secrets load at startup, so it needs a fresh run to pick up what you just changed in Key Vault:</p>
<pre><code class="language-bash">npm start
</code></pre>
<p>Call the users endpoint with a valid JWT:</p>
<pre><code class="language-bash"># Generate a token (use the same value you stored as JWT-SECRET in Key Vault)
node -e "const jwt = require('jsonwebtoken'); console.log(jwt.sign({id:1, email:'test@test.com'}, 'YOUR-JWT-SECRET-VALUE', {expiresIn:'1h'}));"
</code></pre>
<p>On Linux/macOS:</p>
<pre><code class="language-bash">curl -H "Authorization: Bearer YOUR_TOKEN" http://localhost:3000/api/users
</code></pre>
<p>On Windows PowerShell:</p>
<pre><code class="language-powershell">Invoke-RestMethod -Uri "http://localhost:3000/api/users" -Headers @{Authorization = "Bearer YOUR_TOKEN"}
</code></pre>
<p>You should see:</p>
<pre><code class="language-json">[
  { "id": 1, "email": "alice@example.com", "created_at": "2026-07-14T19:59:21.064Z" },
  { "id": 2, "email": "bob@example.com",   "created_at": "2026-07-14T19:59:21.064Z" },
  { "id": 3, "email": "carol@example.com", "created_at": "2026-07-14T19:59:21.064Z" }
]
</code></pre>
<p>That query ran using a password that came straight from Key Vault. It's not in your <code>.env</code>, not hardcoded anywhere, and not in a local variable. The repo has nothing worth stealing.</p>
<p>Before you deploy, put the real production values back in Key Vault:</p>
<pre><code class="language-bash">az keyvault secret set --vault-name your-vault-name --name "DB-HOST" --value "your-db-host.postgres.database.azure.com"
az keyvault secret set --vault-name your-vault-name --name "DB-PASSWORD" --value "your-super-secret-password"
</code></pre>
<p>If you skip this, the deployed app will try to connect to <code>localhost</code> and fail immediately — <code>localhost</code> doesn't exist on App Service.</p>
<h2 id="heading-deploy-to-azure-app-service">Deploy to Azure App Service</h2>
<p><strong>Note:</strong> This section creates the App Service infrastructure. The actual code deployment (zip upload) happens at the end of the next section — the app must have Key Vault access configured before its first startup, or it will fail immediately and exit.</p>
<p>Create the App Service:</p>
<pre><code class="language-bash"># Create an App Service Plan (B1 is the cheapest paid tier)
az appservice plan create \
  --name keyvault-demo-plan \
  --resource-group keyvault-demo-rg \
  --sku B1 \
  --is-linux

# Create the Web App
az webapp create \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --plan keyvault-demo-plan \
  --runtime "NODE:18-lts"

# Set app settings — KEY_VAULT_NAME tells the app which vault to use
# NODE_ENV=production enables SSL for the database connection
az webapp config appsettings set \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --settings KEY_VAULT_NAME=your-vault-name NODE_ENV=production
</code></pre>
<h2 id="heading-grant-key-vault-access-to-the-app">Grant Key Vault Access to the App</h2>
<p>Enable Managed Identity on the app. This gives it an identity in Microsoft Entra ID that Key Vault can trust:</p>
<pre><code class="language-bash"># Enable system-assigned managed identity
az webapp identity assign \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg
</code></pre>
<p>The following commands capture the <code>principalId</code> automatically and use it to grant the role:</p>
<pre><code class="language-bash"># Get the principal ID
PRINCIPAL_ID=$(az webapp identity show \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --query principalId \
  --output tsv)

# Get the Key Vault resource ID
KV_ID=$(az keyvault show \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --query id \
  --output tsv)

# Grant the app the "Key Vault Secrets User" role
az role assignment create \
  --role "Key Vault Secrets User" \
  --assignee-object-id $PRINCIPAL_ID \
  --scope $KV_ID
</code></pre>
<p>The <code>Key Vault Secrets User</code> role allows the app to read secrets. It can't create, update, or delete them. This is the principle of least privilege — the application can only do what it needs to do.</p>
<p>Time to ship it. Linux/macOS can run this directly — Windows users, open Git Bash (it ships with Git for Windows):</p>
<pre><code class="language-bash">zip -r app.zip . -x "node_modules/*" ".git/*" ".env" "app.zip"
</code></pre>
<p>Then deploy:</p>
<pre><code class="language-bash">az webapp deployment source config-zip \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg \
  --src app.zip
</code></pre>
<p>The deployed application authenticates to Key Vault using its Managed Identity automatically. No passwords, no client secrets, no credentials of any kind in the deployment.</p>
<p>Check the health endpoint to confirm it's running:</p>
<pre><code class="language-bash">curl https://my-keyvault-node-app.azurewebsites.net/health
# {"status":"healthy","timestamp":"..."}
</code></pre>
<p>If it won't start, pull the logs:</p>
<pre><code class="language-bash">az webapp log tail --name my-keyvault-node-app --resource-group keyvault-demo-rg
</code></pre>
<p>Nine times out of ten, it's that the Key Vault role assignment has not been propagated yet. Give it 2–3 minutes, then restart:</p>
<pre><code class="language-bash">az webapp restart --name my-keyvault-node-app --resource-group keyvault-demo-rg
</code></pre>
<h2 id="heading-rotate-secrets-without-redeploying">Rotate Secrets Without Redeploying</h2>
<p>One of the biggest practical benefits of Key Vault is secret rotation. When a database password needs to change, you update it in Key Vault — not in your app:</p>
<pre><code class="language-bash">az keyvault secret set \
  --vault-name your-vault-name \
  --name "DB-PASSWORD" \
  --value "new-rotated-password"
</code></pre>
<p>The cache builds at startup, so you don't need a redeploy — a restart is enough:</p>
<pre><code class="language-bash">az webapp restart \
  --name my-keyvault-node-app \
  --resource-group keyvault-demo-rg
</code></pre>
<p>No code change. No new deployment. The secret is rotated, and the app is using the new value in seconds.</p>
<p>If you need zero-downtime rotation, add a <code>/refresh-secrets</code> endpoint behind admin auth that clears the cache and then calls <code>loadAllSecrets()</code>. The order matters — <code>loadAllSecrets()</code> uses <code>getSecret()</code> which returns cached values if they exist, so you must clear the cache first, or it will reload nothing. This is optional but useful for long-running processes that can't afford a restart.</p>
<h2 id="heading-troubleshooting">Troubleshooting</h2>
<p><code>CredentialUnavailableError: DefaultAzureCredential failed to retrieve a token</code></p>
<p>You're not logged into Azure CLI. Run <code>az login</code> and try again. On Azure App Service, check that Managed Identity is enabled and the role assignment was created correctly.</p>
<p><code>RestError: Forbidden — The user does not have secrets get permission</code></p>
<p>The Managed Identity isn't wired up to Key Vault yet. Go back and run the <code>az role assignment create</code> command. If you already did, it might just need time. Azure can take 2–3 minutes to propagate role assignments, so give it a moment before you dig further.</p>
<p><code>Error: Secret "DB-PASSWORD" not loaded. Did loadAllSecrets() run?</code></p>
<p><code>getFromCache()</code> ran before <code>loadAllSecrets()</code> finished, meaning the startup sequence is out of order. Open <code>server.js</code> and confirm <code>await loadAllSecrets()</code> comes before <code>app.listen()</code>. If the order's fine, the secret might just not be in the vault yet. Run <code>az keyvault secret list --vault-name YOUR_VAULT</code> to double-check. (A name mismatch — wrong case, typo — throws <code>SecretNotFound</code> instead, which is the entry below.)</p>
<p><strong>App starts locally but fails on Azure App Service</strong></p>
<p>Almost always, the app setting. Either <code>KEY_VAULT_NAME</code> isn't in App Service configuration at all, or the vault name has a typo. Run <code>az webapp log tail</code> to see the actual startup error — that'll tell you which one.</p>
<p><code>AuthorizationFailed</code> <strong>when running</strong> <code>az role assignment create</code></p>
<p>You are a guest user in your Azure tenant and lack the Owner role needed to assign roles. Switch the existing vault to the access policy model — no need to recreate it or lose your secrets:</p>
<pre><code class="language-bash">az keyvault update \
  --name your-vault-name \
  --resource-group keyvault-demo-rg \
  --enable-rbac-authorization false
</code></pre>
<p>If this happened during <strong>Set Up the Key Vault</strong> (granting yourself access), run:</p>
<pre><code class="language-bash">az keyvault set-policy \
  --name your-vault-name \
  --object-id $(az ad signed-in-user show --query id -o tsv) \
  --secret-permissions get set list delete
</code></pre>
<p>If this happened during <strong>Grant Key Vault Access to the App</strong> (granting the Managed Identity access), run:</p>
<pre><code class="language-bash">az keyvault set-policy \
  --name your-vault-name \
  --object-id $PRINCIPAL_ID \
  --secret-permissions get list
</code></pre>
<p><strong>Key Vault returns</strong> <code>SecretNotFound</code></p>
<p>The secret was never added, was deleted, or its name doesn't match exactly what your code requests — Key Vault secret names are case-sensitive. A secret named <code>db-password</code> and a request for <code>DB-PASSWORD</code> are different names. Run <code>az keyvault secret list --vault-name YOUR_VAULT</code> and compare what's actually in the vault against what <code>loadAllSecrets()</code> is asking for in <code>src/config/secrets.js</code>. Usually, it's a casing issue or a stray hyphen.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The <code>.env</code> file in this project contains exactly one value: the Key Vault name. That's not sensitive. Every actual secret — database passwords, API keys, signing secrets — lives in Key Vault and never touches your codebase or your deployment pipeline.</p>
<p>This is the pattern I use on Azure projects now. The startup check is the part I find most useful in practice: if Key Vault is unreachable or a secret is missing, the server exits immediately with a clear error instead of starting up broken and failing on the first real request. You find out right away, rather than getting an obscure database connection error two hours later.</p>
<p>To add another secret, put it in Key Vault and drop its name into the <code>secretNames</code> array — that's it. Everything else scales with it.</p>
<p>The full working code is on GitHub: <a href="https://github.com/ziaongit/nodejs-azure-keyvault">nodejs-azure-keyvault</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[ How to Containerize a Node.js Application with Docker and Deploy with GitHub Actions ]]>
                </title>
                <description>
                    <![CDATA[ If you've been building Node.js projects, you've probably had an experience like this. The project runs fine on your machine, but when you push it to a server, something breaks. Maybe it's a different ]]>
                </description>
                <link>https://www.freecodecamp.org/news/containerize-a-node-js-app-with-docker-and-deploy-with-github-actions/</link>
                <guid isPermaLink="false">6a569b9cbd138d774dee2042</guid>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Node.js ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub Actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker compose ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker-compose.yml ]]>
                    </category>
                
                    <category>
                        <![CDATA[ containerization ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Backend Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Tutorial ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Zia Ullah ]]>
                </dc:creator>
                <pubDate>Tue, 14 Jul 2026 20:27:08 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/343864e6-5319-4378-a2b1-4955e38ad6d8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've been building <a href="https://www.freecodecamp.org/news/role-based-access-control-nodejs-rest-api-jwt/">Node.js projects</a>, you've probably had an experience like this. The project runs fine on your machine, but when you push it to a server, something breaks.</p>
<p>Maybe it's a different Node version, maybe an environment variable is missing, or maybe a system dependency doesn't match. You spend an hour debugging something that was never actually a code problem.</p>
<p>Docker fixes this at the root. With Docker, you stop shipping just code. The Node version, dependencies, and config all travel inside the container. Your laptop, a CI server, a production VM — it behaves the same on all of them. No more environment surprises.</p>
<p>In this tutorial, we'll go through all this step by step: a multi-stage Dockerfile, using Docker Compose with PostgreSQL for local development, and a GitHub Actions workflow that pushes a fresh image to Docker Hub on every merge to <code>main</code>.</p>
<p>The complete code for this tutorial is available on <a href="https://github.com/ziaongit/nodejs-docker-cicd">GitHub</a>.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-sample-application">The Sample Application</a></p>
</li>
<li><p><a href="#heading-writing-the-dockerfile">Writing the Dockerfile</a></p>
</li>
<li><p><a href="#heading-the-dockerignore-file">The .dockerignore File</a></p>
</li>
<li><p><a href="#heading-the-gitignore-file">The .gitignore File</a></p>
</li>
<li><p><a href="#heading-build-and-test-the-image-locally">Build and Test the Image Locally</a></p>
</li>
<li><p><a href="#heading-docker-compose-for-local-development">Docker Compose for Local Development</a></p>
</li>
<li><p><a href="#heading-automate-the-build-with-github-actions">Automate the Build with GitHub Actions</a></p>
</li>
<li><p><a href="#heading-deploying-the-image">Deploying the Image</a></p>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<ul>
<li><p>Node.js 18+</p>
</li>
<li><p>Docker Desktop, which you can download at <a href="https://docs.docker.com/get-docker/">docs.docker.com/get-docker</a>. Windows users need WSL 2 before Docker starts. Open PowerShell as Administrator and run <code>wsl --install</code>. After the restart, Docker Desktop will install without issues.</p>
</li>
<li><p>A GitHub account</p>
</li>
<li><p>A Docker Hub account (free at <a href="https://hub.docker.com">hub.docker.com</a>)</p>
</li>
<li><p>Some Express.js experience helps, but isn't required</p>
</li>
</ul>
<h2 id="heading-the-sample-application">The Sample Application</h2>
<p>We're building a task management API with Express and PostgreSQL. Keep in mind the app is just a vehicle to teach you how this works. The Dockerfile and pipeline we set up here work the same way for any Node.js project.</p>
<p>Create the project:</p>
<pre><code class="language-bash">mkdir nodejs-docker-cicd &amp;&amp; cd nodejs-docker-cicd
npm init -y
npm install express pg dotenv
npm install --save-dev nodemon
</code></pre>
<p>Create <code>src/index.js</code>:</p>
<pre><code class="language-javascript">const express = require('express');
const { Pool } = require('pg');
require('dotenv').config();

const app = express();
app.use(express.json());

const pool = new Pool({
  host: process.env.DB_HOST,
  port: process.env.DB_PORT,
  database: process.env.DB_NAME,
  user: process.env.DB_USER,
  password: process.env.DB_PASSWORD,
});

// Create table on startup
pool.query(`
  CREATE TABLE IF NOT EXISTS tasks (
    id SERIAL PRIMARY KEY,
    title VARCHAR(255) NOT NULL,
    completed BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT NOW()
  )
`).catch(console.error);

// Health check — required for Docker HEALTHCHECK and load balancers
app.get('/health', (req, res) =&gt; {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.get('/tasks', async (req, res) =&gt; {
  try {
    const result = await pool.query('SELECT * FROM tasks ORDER BY created_at DESC');
    res.json(result.rows);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.post('/tasks', async (req, res) =&gt; {
  const { title } = req.body;
  if (!title) return res.status(400).json({ error: 'Title is required' });
  try {
    const result = await pool.query(
      'INSERT INTO tasks (title) VALUES ($1) RETURNING *',
      [title]
    );
    res.status(201).json(result.rows[0]);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

app.patch('/tasks/:id', async (req, res) =&gt; {
  const { id } = req.params;
  const { completed } = req.body;
  try {
    const result = await pool.query(
      'UPDATE tasks SET completed = $1 WHERE id = $2 RETURNING *',
      [completed, id]
    );
    if (result.rows.length === 0) return res.status(404).json({ error: 'Task not found' });
    res.json(result.rows[0]);
  } catch (err) {
    res.status(500).json({ error: err.message });
  }
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () =&gt; console.log(`Server running on port ${PORT}`));
</code></pre>
<p>Open <code>package.json</code> and update the <code>"scripts"</code> section:</p>
<pre><code class="language-json">"scripts": {
  "start": "node src/index.js",
  "dev": "nodemon src/index.js"
}
</code></pre>
<p><code>npm start</code> runs the app directly with Node. <code>npm run dev</code> uses nodemon so the server restarts automatically when you edit a file.</p>
<p>For running without Docker, create a <code>.env</code> file:</p>
<pre><code class="language-plaintext">DB_HOST=localhost
DB_PORT=5432
DB_NAME=tasksdb
DB_USER=postgres
DB_PASSWORD=yourpassword
PORT=3000
</code></pre>
<p>Notice that all database credentials come from environment variables rather than being hardcoded. Swap the variables, and the same image runs against your local database or a production one — no code changes needed. The <code>/health</code> endpoint is what Docker pings to know the app is actually handling requests.</p>
<h2 id="heading-writing-the-dockerfile">Writing the Dockerfile</h2>
<p>Before touching the Dockerfile, there are two terms you'll keep seeing. An <strong>image</strong> is a packaged, immutable version of your app — Node runtime, code, dependencies, everything together in one artifact. A <strong>container</strong> is a running instance of that image. One image, many containers, any machine.</p>
<p>Here's the Dockerfile we'll use:</p>
<pre><code class="language-dockerfile"># ── Stage 1: Install dependencies ──────────────────────────────────────────
FROM node:18-alpine AS builder

WORKDIR /app

# Copy package files first — Docker caches this layer separately.
# If you only change src code (not package.json), Docker skips npm ci on rebuild.
COPY package*.json ./
RUN npm ci

COPY . .


# ── Stage 2: Production image ───────────────────────────────────────────────
FROM node:18-alpine AS production

# Create a non-root user — running as root inside a container is a security risk
RUN addgroup -g 1001 -S nodejs &amp;&amp; \
    adduser -S nodeuser -u 1001

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

# Copy only the source code from the builder stage (not node_modules or dev files)
COPY --from=builder /app/src ./src

RUN chown -R nodeuser:nodejs /app
USER nodeuser

EXPOSE 3000

# Docker will ping /health every 30s. If it fails 3 times, the container is marked unhealthy.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "src/index.js"]
</code></pre>
<p>This is a multi-stage build. The first stage (<code>builder</code>) installs everything, including dev dependencies. The second stage (<code>production</code>) starts fresh and only copies what the app needs to run. Nodemon, test frameworks, and anything else dev-only never make it into the final image.</p>
<p>The size difference is real. A <code>node:18</code> Debian image is over 950MB. Switch to <code>node:18-alpine</code> and cut out the dev dependencies, and the final image lands around 150–200MB instead. A smaller image means faster pushes and faster deploys.</p>
<p><code>npm ci</code> instead of <code>npm install</code> is a deliberate choice for CI/CD. It reads exact versions from <code>package-lock.json</code> and fails hard if the lockfile doesn't match <code>package.json</code>. Every build on every machine installs the exact same versions — no surprises from a dependency that quietly updated overnight.</p>
<p>The <code>nodeuser</code> account exists because containers run as root by default. That's fine until something goes wrong. A non-root user means that an attacker who gets inside the container can't just do whatever they want.</p>
<h2 id="heading-the-dockerignore-file">The <code>.dockerignore</code> File</h2>
<p>Create <code>.dockerignore</code> before building:</p>
<pre><code class="language-plaintext">node_modules
npm-debug.log
.env
.git
.gitignore
README.md
Dockerfile
.dockerignore
</code></pre>
<p>The <code>node_modules</code> exclusion is the critical one. Your local modules were compiled for your operating system — macOS or Windows binaries won't work inside a Linux container. Excluding them means Docker installs fresh modules during the build, compiled for the correct platform. Without this exclusion, you'd either copy broken binaries into the image or waste time uploading hundreds of megabytes to the build context.</p>
<p>Never put <code>.env</code> in an image. Passwords, API keys, anything sensitive — those go in at runtime as environment variables, never inside the image itself.</p>
<h2 id="heading-the-gitignore-file">The <code>.gitignore</code> File</h2>
<p>One more thing before the first commit: a <code>.gitignore</code>. You don't want <code>node_modules</code> or <code>.env</code> tracked:</p>
<pre><code class="language-plaintext">node_modules/
.env
.env.local
npm-debug.log*
logs/
.DS_Store
Thumbs.db
.vscode/
.idea/
dist/
build/
</code></pre>
<h2 id="heading-build-and-test-the-image-locally">Build and Test the Image Locally</h2>
<p>Open Docker Desktop first and give it a moment. On Windows, you'll see a whale icon in the taskbar that animates while the engine is starting up. Once it goes still, you're good to run Docker commands. If you try to run Docker before the engine is up, you'll hit this:</p>
<pre><code class="language-plaintext">ERROR: Error response from daemon: Docker Desktop is unable to start
</code></pre>
<p>If that happens, quit Docker Desktop. Open PowerShell as Administrator, run <code>wsl --update</code>, and restart. Then go to Control Panel → Programs → Turn Windows features on or off. Both Hyper-V and Virtual Machine Platform need to be checked. After the restart, Docker Desktop should come up fine.</p>
<p>It's worth knowing about this error too:</p>
<pre><code class="language-plaintext">docker : The term 'docker' is not recognized as the name of a cmdlet, function,
script file, or operable program.
</code></pre>
<p>This means that Docker Desktop isn't running or isn't installed. Open it from the Start menu and wait.</p>
<p>Run the build:</p>
<pre><code class="language-bash">docker build -t nodejs-docker-cicd:latest .
</code></pre>
<p>The first time takes roughly 30 seconds since Docker has to pull <code>node:18-alpine</code> from the internet. Once that's cached, subsequent builds are much quicker. Both stages will scroll by:</p>
<pre><code class="language-plaintext">[+] Building 33.1s (17/17) FINISHED
 =&gt; [builder 1/5] FROM docker.io/library/node:18-alpine       20.9s
 =&gt; [builder 4/5] RUN npm ci                                   3.5s
 =&gt; [production 5/7] RUN npm ci --only=production              3.2s
 =&gt; [production 7/7] RUN chown -R nodeuser:nodejs /app         3.2s
 =&gt; exporting to image                                         1.5s
 =&gt; =&gt; naming to docker.io/library/nodejs-docker-cicd:latest     0.0s
</code></pre>
<p>When you see <code>(17/17) FINISHED</code> the image is built. Check the size:</p>
<pre><code class="language-bash">docker images nodejs-docker-cicd
</code></pre>
<pre><code class="language-plaintext">IMAGE                     ID             DISK USAGE   CONTENT SIZE
nodejs-docker-cicd:latest   c9eed311d999        198MB         47.5MB
</code></pre>
<p><strong>CONTENT SIZE</strong> (47.5MB) is the compressed size that gets pushed to Docker Hub. <strong>DISK USAGE</strong> (198MB) is what it takes up on disk locally. Compare that to a <code>node:18</code> Debian image at 950MB+, and you can see why the Alpine base and multi-stage approach matter.</p>
<p>On subsequent builds, Docker reuses cached layers. Edit only your source files without touching <code>package.json</code> and the <code>npm ci</code> step gets skipped completely. That 33-second first build becomes 3 seconds.</p>
<h2 id="heading-docker-compose-for-local-development">Docker Compose for Local Development</h2>
<p>The app needs a database. Setting up PostgreSQL locally means every developer who clones the repo has to do it, too. Docker Compose handles this: one file defines both services, and one command starts them.</p>
<p>Create <code>docker-compose.yml</code>:</p>
<pre><code class="language-yaml">services:
  app:
    build:
      context: .
      target: production
    ports:
      - '3000:3000'
    environment:
      DB_HOST: postgres
      DB_PORT: 5432
      DB_NAME: tasksdb
      DB_USER: postgres
      DB_PASSWORD: postgres
      PORT: 3000
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_DB: tasksdb
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
    ports:
      - '5432:5432'
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ['CMD-SHELL', 'pg_isready -U postgres']
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
</code></pre>
<p>A few things worth pointing out. <code>DB_HOST</code> is set to <code>postgres</code>. That's the service name, not <code>localhost</code>. Containers on the same Docker network reach each other by service name. Put <code>localhost</code> there and the app tries to connect to itself.</p>
<p><code>depends_on</code> with <code>condition: service_healthy</code> holds the app back until Postgres actually passes its health check. Skip this and the app starts, tries to connect to a database that isn't ready yet, and crashes. The health check pings <code>pg_isready</code> every 5 seconds. Once it gets a green response, the app container starts.</p>
<p>The named volume <code>postgres_data</code> keeps your data alive between restarts. Run <code>docker compose down</code> and the data is still there next time. Add <code>--volumes</code> to wipe it clean.</p>
<p>Start both services:</p>
<pre><code class="language-bash">docker compose up --build
</code></pre>
<p>You'll see PostgreSQL initialize and then the app start. Once you see <code>Server running on port 3000</code> in the logs, the stack is up.</p>
<p>Open a second terminal to test — leave the compose logs running in the first one.</p>
<p><strong>Linux/macOS:</strong></p>
<pre><code class="language-bash">curl -X POST http://localhost:3000/tasks \
  -H "Content-Type: application/json" \
  -d '{"title": "Learn Docker"}'

curl http://localhost:3000/tasks

curl http://localhost:3000/health
</code></pre>
<p><strong>Windows PowerShell:</strong> Typing <code>curl</code> in PowerShell runs <code>Invoke-WebRequest</code>, not actual curl. Run <code>curl.exe</code> instead. For JSON bodies, write to a file first:</p>
<pre><code class="language-powershell">'{"title": "Learn Docker"}' | Set-Content body.json
curl.exe -X POST http://localhost:3000/tasks -H "Content-Type: application/json" --data `@body.json

curl.exe http://localhost:3000/tasks

curl.exe http://localhost:3000/health
</code></pre>
<p>The backtick before <code>@body.json</code> is necessary. PowerShell would otherwise try to interpret <code>@</code> as a splatting operator rather than passing it to curl as a filename prefix.</p>
<p>You should see responses like these:</p>
<pre><code class="language-json"># POST /tasks
{"id":1,"title":"Learn Docker","completed":false,"created_at":"2026-07-09T22:21:17.073Z"}

# GET /tasks
[{"id":1,"title":"Learn Docker","completed":false,"created_at":"2026-07-09T22:21:17.073Z"}]

# GET /health
{"status":"ok","timestamp":"2026-07-09T22:11:44.700Z"}
</code></pre>
<p>The task hit PostgreSQL in one container and came back through the app. <code>Ctrl+C</code> in the compose terminal stops both.</p>
<h2 id="heading-automate-the-build-with-github-actions">Automate the Build with GitHub Actions</h2>
<p>The image works locally, so it's time to stop doing this by hand.</p>
<h3 id="heading-step-1-create-a-docker-hub-access-token">Step 1: Create a Docker Hub Access Token</h3>
<p>Go to <a href="https://hub.docker.com">hub.docker.com</a> and then Account Settings → Security → New Access Token. Set permission to Read &amp; Write, as read-only breaks the push. The token appears once, so copy it before closing the page.</p>
<p><strong>Security warning:</strong> Don't paste this token into a chat, email, or commit. If you expose it by accident, delete it immediately, then make a new one.</p>
<h3 id="heading-step-2-add-secrets-to-your-github-repository">Step 2: Add Secrets to Your GitHub Repository</h3>
<p>Head to Settings → Secrets and variables → Actions in your repo and add:</p>
<ul>
<li><p><code>DOCKERHUB_USERNAME</code> — your Docker Hub username</p>
</li>
<li><p><code>DOCKERHUB_TOKEN</code> — paste the token here, nowhere else</p>
</li>
</ul>
<p>If you ran into <code>Error: Username and password required</code>, the secrets either aren't saved yet or the names are typed wrong. Both are case-sensitive.</p>
<p>A Node 20 deprecation warning in the logs is normal. It comes from the Docker actions internally, not your code.</p>
<h3 id="heading-step-3-create-the-workflow-file">Step 3: Create the Workflow File</h3>
<p>Create <code>.github/workflows/docker-publish.yml</code>:</p>
<pre><code class="language-yaml">name: Build and Push Docker Image

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

env:
  IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/nodejs-docker-cicd

jobs:
  build-and-push:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3

      - name: Log in to Docker Hub
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.IMAGE_NAME }}
          tags: |
            type=sha,prefix=sha-
            type=raw,value=latest,enable={{is_default_branch}}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          target: production
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
</code></pre>
<p>The login step has <code>if: github.event_name != 'pull_request'</code>. This skips authentication on pull requests. PRs from forks don't have access to your secrets, so trying to log in would just fail. The build still runs on PRs to validate your Dockerfile, but the image isn't pushed.</p>
<p>The metadata action generates two tags on every merge to <code>main</code>: <code>latest</code> and a short commit SHA like <code>sha-a1b2c3d</code>. The SHA tag is what makes rollbacks practical. If <code>latest</code> breaks in production, you can pull any previous <code>sha-</code> tag and you're back to a known-good state in seconds.</p>
<p>The <code>cache-from/cache-to: type=gha</code> lines store Docker's layer cache in GitHub Actions' built-in cache. The first run builds everything from scratch. After that, unchanged layers are pulled from cache rather than rebuilt. On a typical Node.js app this brings build time from 2–3 minutes down to under 30 seconds.</p>
<h3 id="heading-push-and-watch-it-run">Push and Watch it Run</h3>
<pre><code class="language-bash">git add .
git commit -m "Add Docker configuration and GitHub Actions workflow"
git push origin main
</code></pre>
<p>Go to your repo's <strong>Actions</strong> tab. You'll see the workflow running in real time. Each step turns green as it completes:</p>
<pre><code class="language-plaintext">✅ Checkout code
✅ Set up Docker Buildx
✅ Log in to Docker Hub
✅ Extract metadata
✅ Build and push
</code></pre>
<p>Green across the board means your image is live on Docker Hub — two tags, <code>latest</code> and a commit SHA like <code>sha-a1b2c3d</code>. Every push to <code>main</code> from here builds and ships automatically.</p>
<h2 id="heading-deploying-the-image">Deploying the Image</h2>
<p>With your image on Docker Hub, you can deploy it to any infrastructure:</p>
<p><strong>Any VPS or server:</strong></p>
<pre><code class="language-bash">docker pull yourusername/nodejs-docker-cicd:latest
docker run -d -p 3000:3000 \
  -e DB_HOST=your-db-host \
  -e DB_NAME=tasksdb \
  -e DB_USER=postgres \
  -e DB_PASSWORD=yourpassword \
  yourusername/nodejs-docker-cicd:latest
</code></pre>
<p><strong>Railway</strong> — Connect your Docker Hub image in the Railway dashboard and it deploys on the next push.</p>
<p><strong>Fly.io</strong> — Run <code>fly launch</code> pointing at your Dockerfile and Fly handles the rest.</p>
<p><strong>Render</strong> — Paste your Docker Hub image URL into the Render service settings.</p>
<p>Each push to <code>main</code> runs the workflow. New image goes to Docker Hub, platform picks it up — that's your deployment handled.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>What started as a local Node.js app now runs in a container. You get the same behavior on any machine, real PostgreSQL in development, and a pipeline that builds and ships to Docker Hub without you doing anything after the push.</p>
<p>The multi-stage build keeps the image lean — dev tools stay out, non-root user, health check baked in. Compose gets the full stack up with one command for anyone who clones the repo. The SHA tag on every GitHub Actions build means rolling back is just a matter of pulling an older tag.</p>
<p>These same patterns (multi-stage builds, Compose for local development, automated image publishing) are used across the industry for production Node.js deployments. Pick up these patterns once and they follow you to every project.</p>
<p>From here, you can extend the pipeline: drop a test step in before the build, or add multi-platform support if you're targeting ARM. Once Docker Compose starts feeling limiting in production, that's usually when Kubernetes enters the picture.</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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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[ How to Organize and Maintain Azure Repositories at Scale: An Azure DevOps Engineer's Guide  ]]>
                </title>
                <description>
                    <![CDATA[ Managing a few of repositories is easy. And managing dozens can be challenging. But managing hundreds across multiple teams, products, and deployment environments is where things start to break down.  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-organize-and-maintain-azure-repositories-at-scale/</link>
                <guid isPermaLink="false">6a39609b4c4daad6a42f931b</guid>
                
                    <category>
                        <![CDATA[ Azure ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ scaling ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Casmir Onyekani ]]>
                </dc:creator>
                <pubDate>Mon, 22 Jun 2026 16:19:39 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e4c2ef53-8e1f-4b05-99b8-6461c966335d.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Managing a few of repositories is easy. And managing dozens can be challenging.</p>
<p>But managing hundreds across multiple teams, products, and deployment environments is where things start to break down.</p>
<p>At first, repository management feels simple. A team creates a repository, pushes code, and starts building features.</p>
<p>But as the organization grows, repositories multiply, and new services appear. Teams expand. Deployment pipelines become more complex. Different security requirements emerge. Suddenly, nobody knows who owns what, branch policies differ from one repository to another, and onboarding new developers becomes increasingly difficult.</p>
<p>I've seen this happen repeatedly in growing engineering teams.</p>
<p>What starts as a clean Azure DevOps environment eventually becomes a collection of inconsistent repositories, duplicated configurations, bloated Git histories, and fragmented governance.</p>
<p>The good news is that Azure Repos provides everything needed to prevent this from happening.</p>
<p>The challenge isn't creating repositories. The challenge is creating a repository strategy that continues working as your engineering organization grows.</p>
<p>In this guide, you'll learn how to organize and maintain Azure Repositories at scale using ownership-driven structures, cross-repository governance, automation, and repository maintenance practices that support long-term growth.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-repository-organization-becomes-a-scaling-problem">Why Repository Organization Becomes a Scaling Problem</a></p>
</li>
<li><p><a href="#heading-build-an-ownership-driven-repository-structure">Build an Ownership-Driven Repository Structure</a></p>
</li>
<li><p><a href="#heading-choosing-between-a-mono-repo-and-multi-repo-strategy">Choosing Between a Mono-Repo and Multi-Repo Strategy</a></p>
<ul>
<li><p><a href="#heading-when-a-mono-repo-makes-sense">When a Mono-Repo Makes Sense</a></p>
</li>
<li><p><a href="#heading-when-a-multi-repo-strategy-works-better">When a Multi-Repo Strategy Works Better</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-separate-azure-devops-projects-by-business-boundaries">Separate Azure DevOps Projects by Business Boundaries</a></p>
</li>
<li><p><a href="#heading-create-naming-standards-before-repository-growth-happens">Create Naming Standards Before Repository Growth Happens</a></p>
</li>
<li><p><a href="#heading-implement-cross-repository-policies-instead-of-managing-repositories-individually">Implement Cross-Repository Policies Instead of Managing Repositories Individually</a></p>
</li>
<li><p><a href="#heading-enforce-branch-policies-across-multiple-repositories">Enforce Branch Policies Across Multiple Repositories</a></p>
<ul>
<li><p><a href="#heading-which-branches-should-be-protected">Which Branches Should Be Protected?</a></p>
</li>
<li><p><a href="#heading-protecting-the-main-branch">Protecting the Main Branch</a></p>
</li>
<li><p><a href="#heading-protecting-release-branches">Protecting Release Branches</a></p>
</li>
<li><p><a href="#heading-protecting-hotfix-branches">Protecting Hotfix Branches</a></p>
</li>
<li><p><a href="#heading-applying-consistent-policies-across-repositories">Applying Consistent Policies Across Repositories</a></p>
</li>
<li><p><a href="#heading-require-build-validation-before-code-reaches-production">Require Build Validation Before Code Reaches Production</a></p>
</li>
<li><p><a href="#heading-use-role-based-access-control-instead-of-individual-permissions">Use Role-Based Access Control Instead of Individual Permissions</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-automate-repository-provisioning-from-day-one">Automate Repository Provisioning from Day One</a></p>
<ul>
<li><p><a href="#heading-why-repository-templates-matter">Why Repository Templates Matter</a></p>
</li>
<li><p><a href="#heading-automating-repository-creation-with-terraform">Automating Repository Creation with Terraform</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-creating-repositories-through-the-azure-devops-rest-api">Creating Repositories Through the Azure DevOps REST API</a></p>
<ul>
<li><p><a href="#heading-automatically-creating-cicd-pipelines">Automatically Creating CI/CD Pipelines</a></p>
</li>
<li><p><a href="#heading-automatically-applying-branch-policies">Automatically Applying Branch Policies</a></p>
</li>
<li><p><a href="#heading-example-automating-the-provisioning-of-a-new-typescript-service">Example: Automating the Provisioning of a New TypeScript Service</a></p>
</li>
<li><p><a href="#heading-think-of-repository-creation-as-product-manufacturing">Think of Repository Creation as Product Manufacturing</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-monitor-repository-health-before-performance-degrades">Monitor Repository Health Before Performance Degrades</a></p>
</li>
<li><p><a href="#heading-keep-repository-sizes-under-control">Keep Repository Sizes Under Control</a></p>
</li>
<li><p><a href="#heading-use-git-lfs-for-large-assets">Use Git LFS for Large Assets</a></p>
<ul>
<li><p><a href="#heading-automate-git-lfs-setup-for-new-repositories">Automate Git LFS Setup for New Repositories</a></p>
</li>
<li><p><a href="#heading-scriptssetup-git-lfssh">scripts/setup-git-lfs.sh</a></p>
</li>
<li><p><a href="#heading-automate-git-lfs-check-in-azure-pipelines">Automate Git LFS Check in Azure Pipelines</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-clean-repositories-regularly">Clean Repositories Regularly</a></p>
</li>
<li><p><a href="#heading-improve-developer-experience-with-shallow-cloning">Improve Developer Experience with Shallow Cloning</a></p>
</li>
<li><p><a href="#heading-example-architecture-for-a-modern-typescript-platform">Example Architecture for a Modern TypeScript Platform</a></p>
</li>
<li><p><a href="#heading-final-thoughts">Final Thoughts</a></p>
</li>
</ul>
<h2 id="heading-why-repository-organization-becomes-a-scaling-problem">Why Repository Organization Becomes a Scaling Problem</h2>
<p>Many teams underestimate repository management because they only see today's requirements.</p>
<p>A startup with one application may only have:</p>
<pre><code class="language-text">frontend
backend
database
</code></pre>
<p>Everything seems manageable.</p>
<p>Fast forward two years and the same company might have:</p>
<pre><code class="language-text">customer-portal-web
customer-portal-api
billing-service
notification-service
auth-service
analytics-service
mobile-api
shared-components
design-system
internal-tools
</code></pre>
<p>The problem is no longer writing code. It becomes managing code.</p>
<p>Without clear standards, organizations typically experience:</p>
<ul>
<li><p>Repositories with unclear ownership</p>
</li>
<li><p>Different branching strategies</p>
</li>
<li><p>Security permission sprawl</p>
</li>
<li><p>Duplicate CI/CD configurations</p>
</li>
<li><p>Slower developer onboarding</p>
</li>
<li><p>Bloated Git repositories</p>
</li>
<li><p>Inconsistent documentation</p>
</li>
<li><p>Difficult compliance audits</p>
</li>
</ul>
<p>Repository management is ultimately about reducing operational friction. Every repository should be easy to understand, easy to secure, easy to maintain, and easy to scale.</p>
<h2 id="heading-build-an-ownership-driven-repository-structure">Build an Ownership-Driven Repository Structure</h2>
<p>One of the biggest mistakes teams make is organizing repositories like folders.</p>
<p>Repositories shouldn't exist because a folder was needed. They should exist because ownership, deployment, security, or lifecycle boundaries require them.</p>
<p>When deciding whether a new repository should be created, ask:</p>
<ul>
<li><p>Who owns this code?</p>
</li>
<li><p>How is it deployed?</p>
</li>
<li><p>Who can access it?</p>
</li>
<li><p>Does it version independently?</p>
</li>
<li><p>Does it require different security controls?</p>
</li>
</ul>
<p>If the answers differ significantly from another codebase, it likely deserves its own repository.</p>
<p>Think about repositories as business assets rather than technical containers.</p>
<h2 id="heading-choosing-between-a-mono-repo-and-multi-repo-strategy">Choosing Between a Mono-Repo and Multi-Repo Strategy</h2>
<p>One of the first decisions you'll make is whether to store everything in one repository or split projects across multiple repositories.</p>
<p>There's no universal answer.</p>
<p>The correct choice depends on ownership and deployment requirements.</p>
<h3 id="heading-when-a-mono-repo-makes-sense">When a Mono-Repo Makes Sense</h3>
<p>A mono-repository works well when the same team owns everything and components are tightly coupled.</p>
<p>Example:</p>
<pre><code class="language-text">company-platform/
│
├── frontend/
├── backend/
├── shared-ui/
├── docs/
└── infrastructure/
</code></pre>
<p>This structure simplifies:</p>
<ul>
<li><p>Dependency management</p>
</li>
<li><p>Refactoring</p>
</li>
<li><p>Shared tooling</p>
</li>
<li><p>Coordinated releases</p>
</li>
</ul>
<p>But as teams grow, mono-repositories often become difficult to govern because everyone shares the same repository boundaries.</p>
<h3 id="heading-when-a-multi-repo-strategy-works-better">When a Multi-Repo Strategy Works Better</h3>
<p>Large organizations generally benefit from multiple repositories.</p>
<p>Consider a SaaS platform built with Node.js, TypeScript, and React. Instead of one massive repository, separate repositories may look like:</p>
<pre><code class="language-text">customer-portal-web
customer-portal-api
billing-service
notification-service
shared-ui-library
authentication-service
</code></pre>
<p>Each repository can then:</p>
<ul>
<li><p>Have its own release cycle</p>
</li>
<li><p>Maintain separate permissions</p>
</li>
<li><p>Deploy independently</p>
</li>
<li><p>Scale independently</p>
</li>
</ul>
<p>This approach aligns naturally with modern microservice architectures.</p>
<h2 id="heading-separate-azure-devops-projects-by-business-boundaries">Separate Azure DevOps Projects by Business Boundaries</h2>
<p>Many teams create repositories correctly but place everything inside a single Azure DevOps Project.</p>
<p>That works initially, but becomes problematic later.</p>
<p>Azure DevOps Projects should represent organizational boundaries.</p>
<p>For example:</p>
<pre><code class="language-text">Customer Platform
├── customer-web
├── customer-api
├── mobile-api

Internal Systems
├── hr-system
├── payroll-api

Developer Platform
├── shared-components
├── infrastructure-tools
</code></pre>
<p>This structure improves security management, reporting, compliance, repository governance, and team autonomy.</p>
<p>A project should represent a logical business domain rather than a random collection of repositories.</p>
<h2 id="heading-create-naming-standards-before-repository-growth-happens">Create Naming Standards Before Repository Growth Happens</h2>
<p>Naming conventions often feel unimportant...until you have 500 repositories.</p>
<p>Without naming standards, developers waste time searching for repositories and understanding ownership.</p>
<p>Bad examples:</p>
<pre><code class="language-text">backend
backend-v2
new-api
test-project
final-final-api
</code></pre>
<p>Good examples:</p>
<pre><code class="language-text">sales-order-service
sales-payment-api
customer-auth-service
platform-notification-service
marketing-website
</code></pre>
<p>A simple convention works well:</p>
<pre><code class="language-text">[domain]-[service]
</code></pre>
<p>For example:</p>
<pre><code class="language-text">billing-payment-service
billing-invoice-service
customer-auth-service
</code></pre>
<p>Immediately, everyone understands the business domain, the service purpose, and the repository ownership.</p>
<p>Good naming reduces confusion before confusion appears.</p>
<h2 id="heading-implement-cross-repository-policies-instead-of-managing-repositories-individually">Implement Cross-Repository Policies Instead of Managing Repositories Individually</h2>
<p>This is where many Azure DevOps environments begin to fail.</p>
<p>Imagine managing 100 repositories, 300 developers, and 20 teams. Would you manually configure branch policies for every repository?</p>
<p>Of course not.</p>
<p>Yet many organizations still do exactly that. And the result is inconsistency.</p>
<p>Some repositories require pull requests, while others allow direct commits. Some require successful builds, while others don't.</p>
<p>Over time, repository quality becomes impossible to enforce consistently.</p>
<p>The solution is cross-repository governance. Instead of treating repositories individually, treat policies as organizational standards.</p>
<h2 id="heading-enforce-branch-policies-across-multiple-repositories">Enforce Branch Policies Across Multiple Repositories</h2>
<p>As engineering teams grow, maintaining consistent code quality becomes increasingly difficult.</p>
<p>A repository with five developers may survive without strict governance. But a repository ecosystem with hundreds of developers and dozens of services can't.</p>
<p>Without branch protection, developers can:</p>
<ul>
<li><p>Push directly to production branches</p>
</li>
<li><p>Bypass code reviews</p>
</li>
<li><p>Merge untested code</p>
</li>
<li><p>Accidentally introduce breaking changes</p>
</li>
<li><p>Deploy features without proper traceability</p>
</li>
</ul>
<p>Azure DevOps branch policies help prevent these problems by enforcing organizational standards before code can be merged.</p>
<p>Rather than configuring policies individually for every repository, organizations should establish a standard branch strategy and apply protections consistently across repositories.</p>
<h3 id="heading-which-branches-should-be-protected">Which Branches Should Be Protected?</h3>
<p>Not every branch requires the same level of protection.</p>
<p>Most teams focus on protecting branches that directly affect production releases or customer-facing environments.</p>
<p>A common strategy is protecting:</p>
<pre><code class="language-plaintext">main
release/*
hotfix/*
</code></pre>
<p>Let's look at why each branch matters.</p>
<h3 id="heading-protecting-the-main-branch">Protecting the Main Branch</h3>
<p>The <code>main</code> branch typically represents the most stable version of your application.</p>
<p>For a Node.js and TypeScript application, code in <code>main</code> is often what gets deployed to production.</p>
<p>Example:</p>
<pre><code class="language-plaintext">main
│
├── Latest production-ready code
├── Passed automated testing
└── Approved through code review
</code></pre>
<p>Because this branch directly impacts customers, developers should never push changes directly into it. Instead, all changes should flow through pull requests.</p>
<p>Recommended protections include:</p>
<ul>
<li><p>Require pull requests</p>
</li>
<li><p>Require reviewer approval</p>
</li>
<li><p>Require successful build validation</p>
</li>
<li><p>Require linked work items</p>
</li>
<li><p>Block force pushes</p>
</li>
</ul>
<p>This ensures every change entering production has been reviewed and validated.</p>
<h3 id="heading-protecting-release-branches">Protecting Release Branches</h3>
<p>Release branches are often used to prepare production deployments.</p>
<p>Example:</p>
<pre><code class="language-plaintext">release/v1.0
release/v1.1
release/v2.0
</code></pre>
<p>These branches usually contain code that's undergoing final testing before deployment.</p>
<p>Without protection, developers may accidentally introduce new features or untested changes while the release is being stabilized.</p>
<p>Recommended protections include:</p>
<pre><code class="language-plaintext">release/*
</code></pre>
<ul>
<li><p>Require pull requests</p>
</li>
<li><p>Require QA approval</p>
</li>
<li><p>Require successful test execution</p>
</li>
<li><p>Restrict direct commits</p>
</li>
</ul>
<p>This keeps release branches stable and predictable.</p>
<h3 id="heading-protecting-hotfix-branches">Protecting Hotfix Branches</h3>
<p>Hotfix branches are used when urgent production issues need immediate attention.</p>
<p>Examples include:</p>
<ul>
<li><p>Payment failures</p>
</li>
<li><p>Authentication outages</p>
</li>
<li><p>Security vulnerabilities</p>
</li>
<li><p>Critical application bugs</p>
</li>
</ul>
<p>Example:</p>
<pre><code class="language-plaintext">hotfix/payment-timeout
hotfix/authentication-error
</code></pre>
<p>Because hotfixes are usually created under pressure, they're more likely to introduce mistakes.</p>
<p>Teams often want to deploy quickly and skip review processes. But that's precisely why protection matters.</p>
<p>Recommended protections include:</p>
<pre><code class="language-plaintext">hotfix/*
</code></pre>
<ul>
<li><p>Require at least one reviewer</p>
</li>
<li><p>Require automated testing</p>
</li>
<li><p>Track changes through work items</p>
</li>
<li><p>Restrict direct pushes</p>
</li>
</ul>
<p>Even during emergencies, code quality standards should remain intact.</p>
<h3 id="heading-applying-consistent-policies-across-repositories">Applying Consistent Policies Across Repositories</h3>
<p>Imagine an organization managing the following:</p>
<pre><code class="language-plaintext">customer-portal-api
billing-service
notification-service
authentication-service
reporting-service
</code></pre>
<p>If every repository has different branch rules, developers become confused and governance becomes difficult.</p>
<p>Instead, the team should establish repository-wide standards:</p>
<pre><code class="language-plaintext">main       → 2 reviewers + successful build
release/*  → QA approval + successful build
hotfix/*   → 1 reviewer + successful build
</code></pre>
<p>This creates predictable workflows regardless of which repository a developer is working in.</p>
<p>A developer moving from the billing service to the notification service already understands the merge process because the same rules apply everywhere.</p>
<p>By enforcing branch protections consistently across Azure Repositories, organizations reduce production incidents, improve code quality, strengthen security, and create a development workflow that scales alongside engineering growth.</p>
<h3 id="heading-require-build-validation-before-code-reaches-production">Require Build Validation Before Code Reaches Production</h3>
<p>Many bugs reach production because code is reviewed but never tested automatically. Build validation closes that gap.</p>
<p>For a TypeScript Node.js project, an Azure Pipeline might run:</p>
<pre><code class="language-yaml">trigger:
  - main

pool:
  vmImage: ubuntu-latest

steps:
  - task: NodeTool@0
    inputs:
      versionSpec: '20.x'

  - script: npm install

  - script: npm run lint

  - script: npm run test

  - script: npm run build
</code></pre>
<p>This example uses an Ubuntu build agent. For most TypeScript, Node.js, React, and Tailwind CSS projects, Ubuntu is usually enough because the app isn't tied to a specific operating system.</p>
<p>But teams can also test across multiple operating systems. If so, replace <code>imageName: ubuntu-latest</code> with its respective operating system.</p>
<p>Example:</p>
<pre><code class="language-yaml">
strategy:
  matrix:
    linux:
      imageName: ubuntu-latest
    windows:
      imageName: windows-latest
    mac:
      imageName: macOS-latest

pool:
  vmImage: $(imageName)

steps:
  - task: NodeTool@0
    inputs:
      versionSpec: '20.x'

  - script: npm install
  - script: npm run lint
  - script: npm run test
  - script: npm run build
</code></pre>
<p>Use this when the project needs to confirm that builds and tests pass on Linux, Windows, and macOS.</p>
<p>For a normal web app, Ubuntu is fine. For desktop apps, CLI tools, cross-platform packages, or mobile-related builds, multi-OS testing is better.</p>
<p>This pipeline ensures that dependencies install correctly, linting passes, tests pass, and production builds succeed. All before the pull request is merged.</p>
<p>Developers stop arguing about code quality because the pipeline enforces it automatically.</p>
<h3 id="heading-use-role-based-access-control-instead-of-individual-permissions">Use Role-Based Access Control Instead of Individual Permissions</h3>
<p>Managing individual user permissions becomes impossible at scale. Imagine manually configuring 500 developers.</p>
<p>Instead, create groups.</p>
<p>Example:</p>
<pre><code class="language-text">Frontend Developers
Backend Developers
DevOps Engineers
QA Team
Project Administrators
</code></pre>
<p>Then map those groups to Azure DevOps roles:</p>
<pre><code class="language-text">Frontend Developers → Contributor
QA Team → Reader
DevOps Engineers → Administrator
</code></pre>
<p>This creates consistency and significantly reduces administrative overhead.</p>
<p>When a developer joins or leaves a team, administrators only update group membership. Repository permissions remain unchanged.</p>
<h2 id="heading-automate-repository-provisioning-from-day-one">Automate Repository Provisioning from Day One</h2>
<p>As organizations grow, repository creation often becomes an overlooked source of technical debt.</p>
<p>In a small team, manually creating repositories may seem harmless. A developer creates a new repository through the Azure DevOps portal, adds a README file, configures a pipeline, and starts building features.</p>
<p>The problem emerges when this process is repeated hundreds of times across multiple teams.</p>
<p>One repository contains a README. Another does not.</p>
<p>One repository has branch protection rules. Another allows direct commits to production.</p>
<p>One repository includes a CI/CD pipeline. Another requires manual deployment.</p>
<p>Over time, every repository begins to look different. And this inconsistency creates operational overhead, security risks, and onboarding challenges.</p>
<p>The solution is to treat repository creation as an automated process rather than a manual task.</p>
<p>Instead of allowing developers to create repositories from scratch, organizations should establish a repository blueprint that automatically provisions repositories with predefined standards and configurations.</p>
<p>A newly created repository should automatically include:</p>
<pre><code class="language-plaintext">README.md
CONTRIBUTING.md
CODEOWNERS
.gitignore
azure-pipelines.yml
docs/
src/
tests/
</code></pre>
<p>This ensures every project starts with the same foundation.</p>
<p>The goal is simple:</p>
<blockquote>
<p>Every repository should be production-ready the moment it's created.</p>
</blockquote>
<h3 id="heading-why-repository-templates-matter">Why Repository Templates Matter</h3>
<p>Imagine your organization has 150 repositories.</p>
<p>Without automation, every repository owner must remember to:</p>
<ul>
<li><p>Create documentation</p>
</li>
<li><p>Configure branch policies</p>
</li>
<li><p>Set up build pipelines</p>
</li>
<li><p>Configure permissions</p>
</li>
<li><p>Add security checks</p>
</li>
<li><p>Establish folder structures</p>
</li>
</ul>
<p>The likelihood of inconsistency becomes extremely high.</p>
<p>With repository templates, every new repository automatically inherits organizational standards.</p>
<p>For example, a Node.js and TypeScript repository template might look like:</p>
<pre><code class="language-plaintext">customer-auth-service/
│
├── src/
│
├── tests/
│
├── docs/
│
├── README.md
│
├── CONTRIBUTING.md
│
├── .gitignore
│
├── package.json
│
├── tsconfig.json
│
└── azure-pipelines.yml
</code></pre>
<p>Developers can immediately begin working without spending time configuring project infrastructure.</p>
<h3 id="heading-automating-repository-creation-with-terraform">Automating Repository Creation with Terraform</h3>
<p>One of the most common approaches is using Terraform to provision Azure DevOps resources.</p>
<p>Instead of manually creating repositories through the Azure DevOps dashboard, teams define repository creation using Infrastructure as Code.</p>
<p>Example:</p>
<pre><code class="language-javascript">resource "azuredevops_project" "platform" {
    name = "Customer Platform" 
} 

resource "azuredevops_git_repository" "auth_service" {         project_id = azuredevops_project.platform.id 

    name = "customer-auth-service" 
    
    initialization { 
        init_type = "Clean" 
} 
    }
</code></pre>
<p>Let's break this down.</p>
<p>The first block creates an Azure DevOps Project named: Customer Platform</p>
<p>The second block automatically creates a Git repository called <code>customer-auth-service</code>. Running:</p>
<pre><code class="language-shell">terraform apply
</code></pre>
<p>creates the repository without requiring anyone to use the Azure DevOps interface.</p>
<p>This approach becomes extremely valuable when managing dozens or hundreds of repositories.</p>
<h2 id="heading-creating-repositories-through-the-azure-devops-rest-api">Creating Repositories Through the Azure DevOps REST API</h2>
<p>Terraform is excellent for infrastructure teams. But some organizations prefer using internal automation platforms.</p>
<p>Azure DevOps provides a REST API that allows repositories to be created programmatically.</p>
<p>Example:</p>
<pre><code class="language-typescript">curl -X POST \
https://dev.azure.com/{organization}/{project}/_apis/git/repositories?api-version=7.1 \
-H "Content-Type: application/json" \
-H "Authorization: Bearer &lt;PAT&gt;" \
-d '{
  "name": "customer-auth-service"
}'
</code></pre>
<p>This request automatically creates a repository inside Azure DevOps.</p>
<p>Many organizations build internal portals where developers fill out a form:</p>
<pre><code class="language-plaintext">Repository Name:
customer-auth-service

Project:
Customer Platform

Language:
TypeScript

Template:
Node.js API
</code></pre>
<p>The platform then calls Azure DevOps APIs behind the scenes and provisions everything automatically.</p>
<h3 id="heading-automatically-creating-cicd-pipelines">Automatically Creating CI/CD Pipelines</h3>
<p>Repository creation shouldn't stop at source control. A repository without automation is incomplete.</p>
<p>For a TypeScript Node.js service, an Azure Pipeline template could automatically be included:</p>
<p>Trigger:</p>
<ul>
<li>main</li>
</ul>
<p><code>pool: vmImage: ubuntu-latest</code></p>
<p>Steps:</p>
<ul>
<li><p>task: NodeTool@0 inputs: versionSpec: '20.x'</p>
</li>
<li><p>script: npm install</p>
</li>
<li><p>script: npm run lint</p>
</li>
<li><p>script: npm run test</p>
</li>
<li><p>script: npm run build</p>
</li>
</ul>
<p>This pipeline automatically installs Node.js and dependencies, runs linting, executes tests, and builds the application.</p>
<p>Every new repository receives the same CI/CD standards with no manual setup required.</p>
<h3 id="heading-automatically-applying-branch-policies">Automatically Applying Branch Policies</h3>
<p>Repository automation should also include governance.</p>
<p>After a repository is created, automation can immediately configure:</p>
<ul>
<li><p>Pull request requirements</p>
</li>
<li><p>Reviewer policies</p>
</li>
<li><p>Build validation</p>
</li>
<li><p>Merge restrictions</p>
</li>
</ul>
<p>For example:</p>
<pre><code class="language-yaml">main branch 
│ 
├── Require 2 reviewers 
├── Require successful build 
├── Require linked work item 
└── Block direct commits
</code></pre>
<p>Instead of relying on developers to remember these settings, automation guarantees every repository follows organizational policies from day one.</p>
<h3 id="heading-example-automating-the-provisioning-of-a-new-typescript-service">Example: <strong>Automating the Provisioning of a New TypeScript Service</strong></h3>
<p>Imagine that a developer requests a new service called:</p>
<pre><code class="language-plaintext">customer-notification-service
</code></pre>
<p>A provisioning workflow could automatically:</p>
<ol>
<li><p>Create the repository.</p>
</li>
<li><p>Add README.md</p>
</li>
<li><p>Add TypeScript project structure.</p>
</li>
<li><p>Configure Azure Pipeline.</p>
</li>
<li><p>Apply branch protection rules.</p>
</li>
<li><p>Assign ownership groups.</p>
</li>
<li><p>Configure security permissions.</p>
</li>
<li><p>Register monitoring and deployment pipelines.</p>
</li>
</ol>
<p>Within minutes, the repository is ready for development. There's no manual setup, forgotten configurations, or inconsistent standards.</p>
<h3 id="heading-think-of-repository-creation-as-product-manufacturing">Think of Repository Creation as Product Manufacturing</h3>
<p>A useful way to think about repository provisioning is through manufacturing. A factory doesn't build every car from scratch. It follows a repeatable process.</p>
<p>Repository creation should work the same way.</p>
<p>Every repository should come off the production line with:</p>
<ul>
<li><p>Standardized structure</p>
</li>
<li><p>Security controls</p>
</li>
<li><p>CI/CD pipelines</p>
</li>
<li><p>Documentation</p>
</li>
<li><p>Governance policies</p>
</li>
</ul>
<p>Automation ensures that the hundredth repository is just as well-configured as the first.</p>
<p>As organizations scale, this consistency becomes one of the most important factors in maintaining repository quality, reducing operational overhead, and enabling engineering teams to move faster without sacrificing governance.</p>
<h2 id="heading-monitor-repository-health-before-performance-degrades">Monitor Repository Health Before Performance Degrades</h2>
<p>Repository health is often ignored until developers complain. By then, the repository is already bloated.</p>
<p>Azure Repos provides repository insights that help identify:</p>
<ul>
<li><p>Large repositories</p>
</li>
<li><p>Large files</p>
</li>
<li><p>Excessive commit activity</p>
</li>
<li><p>Storage growth</p>
</li>
</ul>
<p>Regular monitoring prevents performance issues before they impact developers.</p>
<h2 id="heading-keep-repository-sizes-under-control">Keep Repository Sizes Under Control</h2>
<p>Azure Repos supports repositories up to 250 GB. That doesn't mean repositories should approach that size. Performance usually begins degrading long before then. So watch out for huge binaries, large media assets, generated files, and build artifacts.</p>
<p>Never store:</p>
<pre><code class="language-text">.zip
.rar
.iso
.exe
.mp4
.psd
</code></pre>
<p>inside source repositories.</p>
<p>Instead, use Azure Blob Storage, package registries, Git LFS, and Azure Artifacts.</p>
<p>Source control should store source code. Nothing more!</p>
<h2 id="heading-use-git-lfs-for-large-assets">Use Git LFS for Large Assets</h2>
<p>Sometimes large files are unavoidable.</p>
<p>A normal web app shouldn't store heavy files in Git. But some projects need them. For example, a design system may include Photoshop files. A media platform may include sample videos. A game project may include textures, audio, and 3D assets.</p>
<p>The problem is that Git was built for source code, not large binary files. When you commit a large file directly into Git, it becomes part of the repository history. Even if you delete the file later, the old version still stays in history unless you rewrite it.</p>
<p>That is how repositories become slow and heavy over time.</p>
<p>Git LFS, which means Git Large File Storage, solves this by storing large files outside the normal Git history. Your repository keeps a small pointer file, while the real large file is stored separately.</p>
<p>Example – track large design files:</p>
<pre><code class="language-shell">git lfs install

git lfs track "*.psd"
git lfs track "*.fig"
git lfs track "*.mp4"
git lfs track "*.zip"

git add .gitattributes
git commit -m "Configure Git LFS"
</code></pre>
<p>The <code>.gitattributes</code> file will look like this:</p>
<pre><code class="language-plaintext">*.psd filter=lfs diff=lfs merge=lfs -text
*.fig filter=lfs diff=lfs merge=lfs -text
*.mp4 filter=lfs diff=lfs merge=lfs -text
*.zip filter=lfs diff=lfs merge=lfs -text
</code></pre>
<p>This tells Git: “Whenever these file types are added, store them with Git LFS instead of normal Git history.”</p>
<p>Example&nbsp;– add a large file after configuring Git LFS:</p>
<pre><code class="language-shell">git add assets/design/homepage.psd
git commit -m "Add homepage design source file"
git push
</code></pre>
<p>Now the large <code>.psd</code> file is handled by Git LFS.</p>
<h3 id="heading-automate-git-lfs-setup-for-new-repositories">Automate Git LFS Setup for New Repositories</h3>
<p>At scale, you shouldn't rely on every developer to remember these commands manually. Create a setup script that runs when a new repository is created.</p>
<h3 id="heading-scriptssetup-git-lfssh"><code>scripts/</code><a href="http://setup-git-lfs.sh"><code>setup-git-lfs.sh</code></a></h3>
<pre><code class="language-shell">#!/bin/bash

git lfs install

git lfs track "*.psd"
git lfs track "*.fig"
git lfs track "*.mp4"
git lfs track "*.mov"
git lfs track "*.zip"
git lfs track "*.ai"

git add .gitattributes
git commit -m "Configure Git LFS for large assets"
</code></pre>
<p>Run it with:</p>
<pre><code class="language-shell">bash scripts/setup-git-lfs.sh
</code></pre>
<h3 id="heading-automate-git-lfs-check-in-azure-pipelines">Automate Git LFS Check in Azure Pipelines</h3>
<p>You can also stop large files from entering the repository without Git LFS.</p>
<p><code>azure-pipelines.yml</code></p>
<pre><code class="language-yaml">trigger:
  - main

pool:
  vmImage: ubuntu-latest

steps:
  - checkout: self
    lfs: true

  - script: |
      echo "Checking for large files not tracked by Git LFS..."

      MAX_SIZE=10485760

      files=$(git ls-files)

      for file in $files; do
        if [ -f "$file" ]; then
          size=\((stat -c%s "\)file")

          if [ "\(size" -gt "\)MAX_SIZE" ]; then
            if ! git check-attr filter -- "$file" | grep -q "filter: lfs"; then
              echo "Large file not tracked by Git LFS: $file"
              exit 1
            fi
          fi
        fi
      done

      echo "Large file check passed."
    displayName: "Check large files use Git LFS"
</code></pre>
<p>This pipeline checks files larger than 10MB. If a large file isn't tracked by Git LFS, the build fails.</p>
<p>That's the automation you want at scale. It prevents repository bloat before it enters the codebase.</p>
<h2 id="heading-clean-repositories-regularly">Clean Repositories Regularly</h2>
<p>Repository maintenance isn't a one-time task. You should treat repositories like production systems and schedule periodic reviews.</p>
<p>Remove:</p>
<ul>
<li><p>Stale branches</p>
</li>
<li><p>Unused pipelines</p>
</li>
<li><p>Obsolete repositories</p>
</li>
<li><p>Outdated documentation</p>
</li>
</ul>
<p>If secrets or large files are accidentally committed, use:</p>
<pre><code class="language-bash">git-filter-repo
</code></pre>
<p>to permanently remove them from history.</p>
<p>This modern approach is significantly faster and safer than older Git history rewriting tools.</p>
<h2 id="heading-improve-developer-experience-with-shallow-cloning">Improve Developer Experience with Shallow Cloning</h2>
<p>Large repositories often contain years of history. And most developers don't need all of it.</p>
<p>A shallow clone downloads only recent history.</p>
<p>Example:</p>
<pre><code class="language-bash">git clone --depth 1 https://dev.azure.com/company/project/repository
</code></pre>
<p>Benefits include:</p>
<ul>
<li><p>Faster onboarding</p>
</li>
<li><p>Faster cloning</p>
</li>
<li><p>Reduced storage consumption</p>
</li>
<li><p>Lower network usage</p>
</li>
</ul>
<p>Small optimizations become significant when hundreds of developers interact with repositories daily.</p>
<h2 id="heading-example-architecture-for-a-modern-typescript-platform">Example Architecture for a Modern TypeScript Platform</h2>
<p>Imagine a SaaS platform built using:</p>
<ul>
<li><p>TypeScript</p>
</li>
<li><p>Node.js</p>
</li>
<li><p>React</p>
</li>
<li><p>Tailwind CSS</p>
</li>
<li><p>Azure DevOps</p>
</li>
</ul>
<p>A scalable repository structure could look like:</p>
<pre><code class="language-text">customer-portal-web
customer-portal-api
billing-service
notification-service
shared-ui-library
infrastructure-templates
developer-documentation
</code></pre>
<p>Each repository serves a distinct purpose, can deploy independently, and can scale independently.</p>
<p>Most importantly, ownership remains clear. That clarity is what makes large engineering organizations successful.</p>
<h2 id="heading-final-thoughts">Final Thoughts</h2>
<p>The biggest mistake organizations make with Azure Repositories is assuming repository management is simply a storage problem.</p>
<p>It isn't.</p>
<p>Repository management is an organizational problem. The repositories you create today determine how efficiently teams collaborate tomorrow.</p>
<p>A scalable Azure Repos strategy starts with ownership-driven structures, consistent naming conventions, centralized governance, automated repository provisioning, repository health monitoring, and disciplined maintenance practices.</p>
<p>The goal isn't to manage more repositories. The goal is to create a repository ecosystem that remains secure, maintainable, and performant regardless of how large your engineering organization becomes.</p>
<p>The earlier you establish these standards, the easier it becomes to scale Azure DevOps without accumulating the repository debt that slows so many growing development teams.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The LLM Gateway Pattern: Why Every Kubernetes-Based AI App Needs One ]]>
                </title>
                <description>
                    <![CDATA[ You ship your first LLM-powered feature. It works and the users love it. A second team adds another feature calling a different model, and a third integrates a completely different provider. Six month ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-llm-gateway-pattern-why-every-kubernetes-based-ai-app-needs-one/</link>
                <guid isPermaLink="false">6a20607178a43e3153ae3cc4</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ llm ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Temitope Oyedele ]]>
                </dc:creator>
                <pubDate>Wed, 03 Jun 2026 17:12:17 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/35be7043-56b7-4df6-b56b-a48620be2dd8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You ship your first LLM-powered feature. It works and the users love it. A second team adds another feature calling a different model, and a third integrates a completely different provider.</p>
<p>Six months later, you have fourteen microservices, each holding their own API keys, writing their own retry logic, and failing in their own unique ways.</p>
<p>Nobody knows how much you're spending on tokens or which service is hammering the rate limit. And when OpenAI goes down, everything goes down with it.</p>
<p>That scenario plays out across engineering teams every single day, and the root cause is almost always the same: moving fast with LLMs while skipping the infrastructure thinking that holds everything together at scale.</p>
<p>Fortunately, a well-established architectural pattern solves exactly these problems. If you already run Kubernetes, you're more than halfway to implementing it. That pattern is called the LLM Gateway Pattern, and this article walks you through what it is, why it matters, and how to put it into practice.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-the-llm-gateway-pattern">What Is the LLM Gateway Pattern?</a></p>
<ul>
<li><a href="#heading-how-it-works">How It Works</a></li>
</ul>
</li>
<li><p><a href="#heading-the-problem-without-a-gateway">The Problem Without a Gateway</a></p>
</li>
<li><p><a href="#heading-deploying-an-llm-gateway-on-kubernetes">Deploying an LLM Gateway on Kubernetes</a></p>
<ul>
<li><p><a href="#heading-storing-api-keys-securely">Storing API Keys Securely</a></p>
</li>
<li><p><a href="#heading-defining-routing-rules-in-a-configmap">Defining Routing Rules in a ConfigMap</a></p>
</li>
<li><p><a href="#heading-scaling-the-gateway">Scaling the Gateway</a></p>
</li>
<li><p><a href="#heading-wiring-up-observability">Wiring Up Observability</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-features-of-an-llm-gateway">Features of an LLM Gateway</a></p>
<ul>
<li><p><a href="#heading-multi-provider-routing">Multi-Provider Routing</a></p>
</li>
<li><p><a href="#heading-semantic-caching">Semantic Caching</a></p>
</li>
<li><p><a href="#heading-rate-limiting-per-consumer">Rate Limiting Per Consumer</a></p>
</li>
<li><p><a href="#heading-fallback-and-failover">Fallback and Failover</a></p>
</li>
<li><p><a href="#heading-token-usage-tracking">Token Usage Tracking</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-wrapping-up">Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-what-is-the-llm-gateway-pattern">What Is the LLM Gateway Pattern?</h2>
<p>The LLM Gateway Pattern is an architectural approach where all LLM API traffic from your applications flows through a single, centralized proxy service before reaching any external provider. Think of it as the AI equivalent of an API gateway, except it's purpose-built for the unique challenges that come with language models: token budgets, streaming responses, model routing, semantic caching, and multi-provider fallback.</p>
<p>Instead of every service in your cluster talking directly to OpenAI or Anthropic, they all talk to one internal gateway. That gateway handles authentication, routing, rate limiting, logging, and failover. Your application services stay clean and focused on business logic, while the gateway takes on all the messy operational concerns of working with LLMs at scale.</p>
<p>The pattern itself is not new in concept. Engineers have used API gateways for years to manage REST traffic. What makes LLM gateways distinct is that they understand the specific shape of LLM requests, including token counts, model parameters, prompt structure, and streaming semantics.</p>
<h3 id="heading-how-it-works">How It Works</h3>
<p>The core components of an LLM Gateway on Kubernetes are straightforward. Here is the high-level flow:</p>
<img src="https://cdn.hashnode.com/uploads/covers/627d043a4903bec29b5871be/2aaa42ed-d6b4-4a9e-9d4c-2faa42e76783.png" alt="Diagram showing how LLM Gateway works on Kubernetes" style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p><strong>App Pods</strong> send requests to the gateway using a standard OpenAI-compatible API format. Because of this, most existing LLM client libraries work without modification — you just change the base URL to point at your internal gateway service.</p>
<p><strong>The Gateway Service</strong> receives each incoming request, authenticates the caller, applies any configured rate limits, checks the cache, selects the appropriate upstream provider based on routing rules, and forwards the request. On the way back, it logs token usage and latency before returning the response to the caller.</p>
<p><strong>ConfigMap</strong> holds the routing rules. Which model should handle requests tagged as fast? Which provider should the system fall back to if the primary one is unavailable? All of this lives in configuration, not code, so you can update routing behaviour without redeploying anything.</p>
<p><strong>Secrets</strong> hold the actual API keys for each provider. The gateway is the only service in the cluster that needs access to them. Application pods never touch provider credentials directly.</p>
<p><strong>Provider endpoints</strong> are the actual LLM APIs: OpenAI, Anthropic, a self-hosted vLLM instance running in your cluster, or any other provider that exposes an OpenAI-compatible interface.</p>
<h2 id="heading-the-problem-without-a-gateway">The Problem Without a Gateway</h2>
<p>To appreciate why this pattern matters, it helps to look at what happens when you skip it.</p>
<h3 id="heading-1-scattered-secrets-and-no-central-control">1. Scattered Secrets and No Central Control</h3>
<p>Every service that calls an LLM needs an API key. In Kubernetes, this usually means creating a <a href="https://kubernetes.io/docs/concepts/configuration/secret/">Secret</a> per namespace or per deployment.</p>
<p>When that key rotates or gets compromised, you're hunting through dozens of manifests to update it. There's no single place to revoke access or audit who is calling what.</p>
<h3 id="heading-2-no-visibility-into-cost-or-usage">2. No Visibility into Cost or Usage</h3>
<p>LLM APIs charge per token. Without a centralized layer collecting usage data, you have no reliable way to know which service is responsible for that spike in your monthly bill.</p>
<h3 id="heading-3-provider-lock-in-at-the-application-level">3. Provider Lock-in at the Application Level</h3>
<p>When you hardcode <a href="https://api.openai.com">https://api.openai.com</a> into your service, switching to a different provider or routing certain requests to a cheaper model becomes a code change. You need to redeploy your application just to change which model handles a request type.</p>
<h3 id="heading-4-no-caching">4. No Caching</h3>
<p>Many LLM applications send semantically similar or identical prompts repeatedly. Without a shared caching layer, each one incurs full token costs and full latency. The savings from even basic caching can be significant.</p>
<p>All of these problems compound as your team grows and more services start calling LLMs. The gateway pattern cuts through all of them in one architectural decision.</p>
<h2 id="heading-deploying-an-llm-gateway-on-kubernetes">Deploying an LLM Gateway on Kubernetes</h2>
<p>There are several tools that can serve as an LLM gateway in a Kubernetes environment, including <a href="https://docs.litellm.ai/docs/simple_proxy">LiteLLM Proxy</a>, <a href="https://portkey.ai/">Portkey</a>, <a href="https://openrouter.ai/">OpenRouter</a>, and Envoy with custom filters.</p>
<p>For the rest of this walkthrough, we'll use LiteLLM Proxy. It ships with a Helm chart, supports over a hundred models across all major providers, and comes with a management UI that makes initial configuration straightforward.</p>
<h3 id="heading-storing-api-keys-securely">Storing API Keys Securely</h3>
<p>Start by creating a Kubernetes Secret that holds your provider API keys. Your gateway pods will consume these credentials as environment variables, which means no provider key ever needs to live inside your application containers:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: Secret
metadata:
  name: llm-gateway-secrets
  namespace: ai-platform
type: Opaque
stringData:
  OPENAI_API_KEY: "sk-..."
  ANTHROPIC_API_KEY: "sk-ant-..."
</code></pre>
<h3 id="heading-defining-routing-rules-in-a-configmap">Defining Routing Rules in a <code>ConfigMap</code></h3>
<p>The routing configuration tells the gateway which models are available and how to reach each one. Keeping this in a <code>ConfigMap</code> means you can update your routing rules without touching a single line of application code:</p>
<pre><code class="language-yaml">apiVersion: v1
kind: ConfigMap
metadata:
  name: llm-gateway-config
  namespace: ai-platform
data:
  config.yaml: |
    model_list:
      - model_name: gpt-4o
        litellm_params:
          model: openai/gpt-4o
          api_key: os.environ/OPENAI_API_KEY
      - model_name: claude-sonnet
        litellm_params:
          model: anthropic/claude-sonnet-4-20250514
          api_key: os.environ/ANTHROPIC_API_KEY
      - model_name: fast
        litellm_params:
          model: openai/gpt-4o-mini
          api_key: os.environ/OPENAI_API_KEY
</code></pre>
<p>With this configuration in place, any application in your cluster can reach the gateway at <a href="http://llm-gateway.ai-platform.svc.cluster.local">http://llm-gateway.ai-platform.svc.cluster.local</a> using the standard OpenAI client format, regardless of which actual provider sits behind it.</p>
<h3 id="heading-scaling-the-gateway">Scaling the Gateway</h3>
<p>Because the gateway is stateless, horizontal scaling is straightforward. You can attach a <code>HorizontalPodAutoscaler</code> to scale based on CPU utilization or request rate:</p>
<pre><code class="language-yaml">apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: llm-gateway-hpa
  namespace: ai-platform
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: llm-gateway
  minReplicas: 2
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 60
</code></pre>
<h3 id="heading-wiring-up-observability">Wiring Up Observability</h3>
<p>A gateway you can't observe is a gateway you can't trust, so wiring up monitoring before you go to production is worth the extra hour it takes.</p>
<p>LiteLLM exposes a <code>/metrics</code> endpoint in Prometheus format. You can scrape it with a standard <code>ServiceMonitor</code> if you run the Prometheus Operator, or configure Prometheus directly to target the gateway service.</p>
<p>The metrics that matter most in day-to-day operations are token throughput per model, request latency percentiles, error rates per provider, and cache hit ratio.</p>
<p>Once Prometheus is collecting that data, you can build Grafana dashboards that show token spend broken down by caller, model, and time period. This gives engineering managers and finance teams the cost visibility they've been asking for, and it takes surprisingly little effort to set up once the metrics pipeline is in place.</p>
<p>If you run an OpenTelemetry collector in your cluster, you can also configure the gateway to emit trace spans for every LLM request. This lets you see the full latency breakdown from the moment a user action triggers a call in your application all the way through to the provider response. So when something is slow, you can tell immediately whether the bottleneck sits in your service, the gateway, or upstream with the provider.</p>
<h2 id="heading-features-of-an-llm-gateway">Features of an LLM Gateway</h2>
<p>Not all gateway implementations are equal, so as your needs grow, these are the core capabilities worth evaluating.</p>
<h3 id="heading-multi-provider-routing">Multi-Provider Routing</h3>
<p>A well-built gateway routes requests to different providers based on declarative, configurable rules that live entirely outside your application code. This means that changing a model never requires a redeployment.</p>
<h3 id="heading-semantic-caching">Semantic Caching</h3>
<p>Rather than only caching byte-for-byte identical prompts, a semantic cache uses embedding similarity to recognise when two different prompts are asking essentially the same thing. This can cut redundant API calls dramatically.</p>
<h3 id="heading-rate-limiting-per-consumer">Rate Limiting Per Consumer</h3>
<p>The gateway should let you set token budgets and request limits per team, per namespace, or per application, so no single runaway service can starve the rest of your cluster or drive up costs unchecked.</p>
<h3 id="heading-fallback-and-failover">Fallback and Failover</h3>
<p>When a primary provider fails or exceeds acceptable latency thresholds, the gateway should automatically retry against a configured fallback. This centralizes logic that is notoriously hard to get right inside individual services.</p>
<h3 id="heading-token-usage-tracking">Token Usage Tracking</h3>
<p>Every request should produce a detailed usage record capturing input tokens, output tokens, model, caller identity, and latency. This gives engineering managers the clear, actionable picture of AI spending they need.</p>
<h2 id="heading-wrapping-up">Wrapping Up</h2>
<p>The LLM Gateway Pattern solves a set of operational problems that every team building on language models at scale will eventually run into. Scattered secrets, invisible costs, inconsistent failure handling, and provider lock-in are all symptoms of the same underlying issue: infrastructure concerns leaking into services that shouldn't have to deal with them.</p>
<p>A centralized gateway on Kubernetes gives your application teams a stable, provider-agnostic interface while giving your platform team the visibility and controls they need to manage cost and reliability effectively. When a provider goes down in the middle of the night, your configured fallback kicks in automatically instead of someone waking up to a page.</p>
<p>Start with LiteLLM Proxy, wire up the Prometheus metrics, build a simple Grafana dashboard, and watch how quickly the pattern pays for itself. Once you have seen what centralized LLM traffic management looks like in practice, it becomes very hard to go back to doing it any other way.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Self‑Host an S3‑Compatible Object Store with MinIO on Your Staging Server (and Save Hundreds of Dollars a Month) ]]>
                </title>
                <description>
                    <![CDATA[ This article is a complete copy‑paste guide to running MinIO behind Traefik with HTTPS, custom domains, and pre-signed upload/download URLs — using only Docker Compose. Your production will keep using ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-self-host-an-s3-compatible-object-store-with-minio-on-your-staging-server/</link>
                <guid isPermaLink="false">6a1d99eb2f5663bb4c520a8f</guid>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloud-storage ]]>
                    </category>
                
                    <category>
                        <![CDATA[ S3 ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Md Tarikul Islam ]]>
                </dc:creator>
                <pubDate>Mon, 01 Jun 2026 14:40:43 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/a7e1dd1d-2e31-4d80-ae9b-10242588a5e1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>This article is a complete copy‑paste guide to running MinIO behind Traefik with HTTPS, custom domains, and pre-signed upload/download URLs — using only Docker Compose.</p>
<p>Your production will keep using a managed S3 / Cloudflare R2 / Hetzner Object Storage, while every staging upload, download, and pre-signed URL goes to your <strong>own</strong> server for free.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-1-why-selfhost-object-storage-on-staging">1. Why Self‑Host Object Storage on Staging?</a></p>
</li>
<li><p><a href="#heading-2-the-architecture-production-vs-staging">2. The Architecture: Production vs. Staging</a></p>
</li>
<li><p><a href="#heading-3-prerequisites">3. Prerequisites</a></p>
</li>
<li><p><a href="#heading-4-step-1-dns-point-your-domains-to-the-staging-server">4. Step 1 — DNS: Point Your Domains to the Staging Server</a></p>
</li>
<li><p><a href="#heading-5-step-2-run-minio-with-docker-compose">5. Step 2 — Run MinIO with Docker Compose</a></p>
</li>
<li><p><a href="#heading-6-step-3-expose-minio-over-https-with-traefik">6. Step 3 — Expose MinIO over HTTPS with Traefik</a></p>
</li>
<li><p><a href="#heading-7-step-4-create-the-bucket-and-access-keys">7. Step 4 — Create the Bucket and Access Keys</a></p>
</li>
<li><p><a href="#heading-8-step-5-configure-your-app-to-use-minio-on-staging-only">8. Step 5 — Configure Your App to Use MinIO on Staging Only</a></p>
</li>
<li><p><a href="#heading-9-step-6-upload-files-3-ways">9. Step 6 — Upload Files (3 Ways)</a></p>
</li>
<li><p><a href="#heading-10-step-7-generate-presigned-urls-put-and-get">10. Step 7 — Generate Presigned URLs (PUT and GET)</a></p>
</li>
<li><p><a href="#heading-11-step-8-get-public-urls-for-documents">11. Step 8 — Get Public URLs for Documents</a></p>
</li>
<li><p><a href="#heading-12-step-9-lock-down-cors-lifecycle-and-security">12. Step 9 — Lock Down CORS, Lifecycle, and Security</a></p>
</li>
<li><p><a href="#heading-13-step-10-backups-and-monitoring">13. Step 10 — Backups and Monitoring</a></p>
</li>
<li><p><a href="#heading-14-troubleshooting-cheat-sheet">14. Troubleshooting Cheat Sheet</a></p>
</li>
<li><p><a href="#heading-15-wrapping-up">15. Wrapping Up</a></p>
</li>
</ul>
<h2 id="heading-1-why-selfhost-object-storage-on-staging">1. Why Self‑Host Object Storage on Staging?</h2>
<p>If your app handles documents — PDFs, profile pictures, application transcripts, recordings — every test upload your QA team makes costs real money on AWS S3, Cloudflare R2, or Hetzner Object Storage. The price isn't huge per file, but staging is where you:</p>
<ul>
<li><p>run automated end‑to‑end tests that upload thousands of dummy files,</p>
</li>
<li><p>reset databases nightly (which leaves orphan objects behind),</p>
</li>
<li><p>let developers experiment with broken code that re‑uploads the same files,</p>
</li>
<li><p>and hold months of test data nobody ever deletes.</p>
</li>
</ul>
<p>In production those costs are justified. Managed storage gives you replication, availability, and someone else's pager. In staging, those costs are pure waste.</p>
<p><a href="https://min.io/"><strong>MinIO</strong></a> is a free, open‑source, S3‑compatible object server. Same API, same SDKs, same presigned URLs, same <code>mc</code>/<code>aws s3</code> CLIs — but running on your own VPS, billed at $0 per gigabyte. Point your staging app at MinIO, point your production app at S3/R2, and the only thing that changes is an environment variable.</p>
<p><strong>The result:</strong> identical code paths in both environments, zero storage bill on staging, and a nice fallback if your cloud provider ever has an outage.</p>
<h2 id="heading-2-the-architecture-production-vs-staging">2. The Architecture: Production vs. Staging</h2>
<p>In real-world applications, you usually don’t want your development or staging environment writing directly to production storage.</p>
<p>A common and cost-effective setup is:</p>
<ul>
<li><p><strong>Production</strong>: managed cloud object storage</p>
</li>
<li><p><strong>Staging / Development</strong>: self-hosted S3-compatible storage</p>
</li>
</ul>
<p>The good part is that your application code doesn't need to change.</p>
<p>As long as both services are S3-compatible, the same SDK and upload logic work everywhere. Only the environment variables differ.</p>
<h3 id="heading-high-level-architecture">High-Level Architecture</h3>
<img src="https://cdn.hashnode.com/uploads/covers/66cb39fcaa2a09f9a8d691c1/01ddeefd-8a67-42e3-a3af-9b1d3664bdb2.png" alt="High-level architecture showing a Next.js application uploading files to Cloudflare R2 in production and MinIO in staging through the same S3-compatible API." style="display:block;margin:0 auto" width="600" height="400" loading="lazy">

<p>The above diagram illustrates how the same application can communicate with different storage providers depending on the deployment environment.</p>
<p>In the <strong>production environment</strong>, uploads are stored in a managed object storage service such as AWS S3, Cloudflare R2, or Hetzner Object Storage. These services handle durability, scalability, backups, and infrastructure management.</p>
<p>In the <strong>staging environment</strong>, uploads are directed to a self-hosted MinIO instance running inside Docker on a VPS. MinIO implements the S3 API, making it behave similarly to production storage while keeping costs low.</p>
<p>Because both storage systems are S3-compatible, the application uses the same upload logic in every environment. The only difference is the configuration provided through environment variables.</p>
<h3 id="heading-why-this-architecture-is-useful">Why This Architecture Is Useful</h3>
<p>This setup gives you:</p>
<ul>
<li><p>A cheap staging environment</p>
</li>
<li><p>Production-like testing</p>
</li>
<li><p>Zero storage vendor lock-in</p>
</li>
<li><p>The ability to switch providers without rewriting application code</p>
</li>
</ul>
<p>Because both environments speak the S3 protocol, your upload logic remains identical.</p>
<h3 id="heading-example-environment-variables">Example Environment Variables</h3>
<p>Your application only reads environment variables like these:</p>
<pre><code class="language-xml">S3_ENDPOINT=
S3_REGION=
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_BUCKET=
</code></pre>
<p>Switch the values, and the exact same application now uploads files to a different backend.</p>
<h3 id="heading-production-storage-example">Production Storage Example</h3>
<p>In production, you typically use managed object storage providers such as:</p>
<ul>
<li><p>AWS S3</p>
</li>
<li><p>Cloudflare R2</p>
</li>
<li><p>Hetzner Object Storage</p>
</li>
</ul>
<p>Example:</p>
<pre><code class="language-plaintext">S3_ENDPOINT=https://&lt;region&gt;.r2.cloudflarestorage.com
</code></pre>
<p>The benefits are that it's highly scalable, globally available, durable, has managed backups, and doesn't have infrastructure maintenance.</p>
<h3 id="heading-staging-environment-example">Staging Environment Example</h3>
<p>For staging, a lightweight self-hosted MinIO container is often enough.</p>
<pre><code class="language-plaintext">Next.js App
     ↓
MinIO Container (inside Docker on VPS)
</code></pre>
<p>Example domains:</p>
<table>
<thead>
<tr>
<th>Service</th>
<th>Domain</th>
<th>Internal Port</th>
</tr>
</thead>
<tbody><tr>
<td>MinIO S3 API</td>
<td><a href="http://minio-staging.domain.com"><code>minio-staging.domain.com</code></a></td>
<td><code>9000</code></td>
</tr>
<tr>
<td>MinIO Web Console</td>
<td><a href="http://minio-console-staging.domain.com"><code>minio-console-staging.domain.com</code></a></td>
<td><code>9001</code></td>
</tr>
</tbody></table>
<p>This allows you to:</p>
<ul>
<li><p>Test uploads safely</p>
</li>
<li><p>Avoid production storage costs</p>
</li>
<li><p>Reproduce production-like behavior locally</p>
</li>
</ul>
<h2 id="heading-3-prerequisites">3. Prerequisites</h2>
<p>You'll need:</p>
<ul>
<li><p>A Linux VPS (Hetzner, DigitalOcean, Contabo, OVH — anything with a public IP).</p>
</li>
<li><p>Two A records pointing at that IP (we'll register them next).</p>
</li>
<li><p>Docker + Docker Compose v2.</p>
</li>
<li><p><a href="https://traefik.io/">Traefik</a> v2 in front, with Let's Encrypt configured (any reverse proxy works&nbsp;– the labels below are Traefik's flavor).</p>
</li>
<li><p>Open ports <code>80</code> and <code>443</code> on the firewall for Let's Encrypt + HTTPS.</p>
</li>
<li><p>~10 GB free disk for the MinIO data volume to start.</p>
</li>
</ul>
<p>If Docker isn't installed:</p>
<pre><code class="language-bash">curl -fsSL https://get.docker.com | sh
sudo apt-get install -y docker-compose-plugin
docker --version &amp;&amp; docker compose version
</code></pre>
<h2 id="heading-4-step-1-dns-point-your-domains-to-the-staging-server">4. Step 1 — DNS: Point Your Domains to the Staging Server</h2>
<p>In your DNS provider (Cloudflare, Route 53, Namecheap, and so on), create two <strong>A records</strong> pointing at your staging server's public IP:</p>
<pre><code class="language-plaintext">minio-staging.domain.com           A    203.0.113.45
minio-console-staging.domain.com   A    203.0.113.45
</code></pre>
<p>If you use Cloudflare, set the proxy status to <strong>DNS only</strong> (gray cloud) for <code>minio-staging.*</code>. Cloudflare's free plan caps uploads at 100 MB, and you don't want it stripping S3 signing headers. The console subdomain can stay proxied if you want a WAF in front of it.</p>
<p>Wait a minute and verify:</p>
<pre><code class="language-bash">dig +short minio-staging.domain.com
# 203.0.113.45
</code></pre>
<h2 id="heading-5-step-2-run-minio-with-docker-compose">5. Step 2 — Run MinIO with Docker Compose</h2>
<p>Add this service to your staging compose file (<code>docker-compose.staging.yml</code>). MinIO is just one container — the disk is mounted as a Docker volume so data survives upgrades.</p>
<pre><code class="language-yaml"># docker-compose.staging.yml
networks:
  proxy:
    external: true
    name: proxy
  internal:
    name: internal

volumes:
  minio-data:

services:
  minio:
    image: minio/minio:latest
    container_name: minio-staging
    restart: unless-stopped
    environment:
      - MINIO_ROOT_USER=${MINIO_ROOT_USER:-admin}
      - MINIO_ROOT_PASSWORD=${MINIO_ROOT_PASSWORD:-change-me-please}
      # Tell MinIO which public domain to sign URLs with
      - MINIO_SERVER_URL=https://minio-staging.domain.com
      - MINIO_BROWSER_REDIRECT_URL=https://minio-console-staging.domain.com
    command: server /data --console-address ":9001"
    volumes:
      - minio-data:/data
    networks:
      - proxy
      - internal
    ports:
      - "9000:9000"  # S3 API
      - "9001:9001"  # Web console
    healthcheck:
      test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
      interval: 10s
      timeout: 5s
      retries: 3
      start_period: 30s
</code></pre>
<p>Two things deserve attention:</p>
<ul>
<li><p><code>MINIO_SERVER_URL</code> is the secret sauce. Without it, MinIO signs presigned URLs using its internal hostname (<code>http://minio:9000</code>), which then fails verification when the browser hits the public domain. Set it to the exact HTTPS URL clients will use.</p>
</li>
<li><p><code>MINIO_BROWSER_REDIRECT_URL</code> does the same for the web console (login redirects, OIDC callbacks, and so on).</p>
</li>
</ul>
<p>Bring it up:</p>
<pre><code class="language-bash">docker compose -f docker-compose.staging.yml up -d minio
docker compose -f docker-compose.staging.yml logs -f minio
</code></pre>
<p>You should see <code>API: http://...</code> and <code>Console: http://...</code> lines.</p>
<h2 id="heading-6-step-3-expose-minio-over-https-with-traefik">6. Step 3 — Expose MinIO over HTTPS with Traefik</h2>
<p>We don't expose ports <code>9000</code>/<code>9001</code> to the world directly — Traefik does that for us, terminating TLS with a free Let's Encrypt certificate.</p>
<p>Add these labels to the <code>minio</code> service:</p>
<pre><code class="language-yaml">    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"

      # ---- S3 API (port 9000) ----
      - "traefik.http.routers.minio-staging.rule=Host(`minio-staging.domain.com`)"
      - "traefik.http.routers.minio-staging.entrypoints=websecure"
      - "traefik.http.routers.minio-staging.tls.certresolver=letsencrypt"
      - "traefik.http.routers.minio-staging.service=minio-staging"
      - "traefik.http.services.minio-staging.loadbalancer.server.port=9000"

      # ---- Web Console (port 9001) ----
      - "traefik.http.routers.minio-console-staging.rule=Host(`minio-console-staging.domain.com`)"
      - "traefik.http.routers.minio-console-staging.entrypoints=websecure"
      - "traefik.http.routers.minio-console-staging.tls.certresolver=letsencrypt"
      - "traefik.http.routers.minio-console-staging.service=minio-console-staging"
      - "traefik.http.services.minio-console-staging.loadbalancer.server.port=9001"
</code></pre>
<p>You also need an <code>entrypoint</code> for <code>:443</code> and a <code>certificatesresolver</code> named <code>letsencrypt</code>. Here's the minimum Traefik config (<code>traefik.staging.yml</code>):</p>
<pre><code class="language-yaml">api:
  dashboard: true

entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"

certificatesResolvers:
  letsencrypt:
    acme:
      httpChallenge:
        entryPoint: web
      email: admin@domain.com
      storage: /etc/traefik/acme.json

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false
    network: proxy
</code></pre>
<p>Restart and watch the cert get issued:</p>
<pre><code class="language-bash">docker compose -f docker-compose.staging.yml up -d
docker compose -f docker-compose.staging.yml logs -f traefik | grep -i acme
</code></pre>
<p>Sanity check from your laptop:</p>
<pre><code class="language-bash">curl -I https://minio-staging.domain.com/minio/health/live
# HTTP/2 200
</code></pre>
<p>You can now log in to the <strong>web console</strong> at <code>https://minio-console-staging.domain.com</code> with <code>admin</code> / <code>change-me-please</code>.</p>
<p><strong>Important upload size tweak:</strong> if you're behind Cloudflare or NGINX in front of Traefik, raise the request body limit. Traefik itself has no default limit, but Cloudflare's free plan refuses anything over 100 MB. For self‑hosted edge proxies, set <code>client_max_body_size 0;</code> (NGINX) or the equivalent.</p>
<h2 id="heading-7-step-4-create-the-bucket-and-access-keys">7. Step 4 — Create the Bucket and Access Keys</h2>
<p>Anything that speaks S3 can talk to MinIO. The easiest tool is <code>mc</code> (the official MinIO client), shipped inside the same image.</p>
<h3 id="heading-71-connect-mc-to-your-server">7.1 Connect mc to your server</h3>
<pre><code class="language-bash">docker exec -it minio-staging \
  mc alias set local http://localhost:9000 admin change-me-please
</code></pre>
<h3 id="heading-72-create-a-bucket">7.2 Create a bucket</h3>
<pre><code class="language-bash">docker exec -it minio-staging mc mb local/domain-files-staging
</code></pre>
<h3 id="heading-73-choose-a-bucket-policy">7.3 Choose a bucket policy</h3>
<p>You have three choices, so just pick based on what you store:</p>
<table>
<thead>
<tr>
<th>Policy</th>
<th>When to use</th>
</tr>
</thead>
<tbody><tr>
<td><code>private</code> (default)</td>
<td>Anything sensitive — student transcripts, contracts, internal docs. Reads only via presigned URL.</td>
</tr>
<tr>
<td><code>download</code></td>
<td>Public read, no listing. Good for CDN‑style assets like avatars.</td>
</tr>
<tr>
<td><code>public</code></td>
<td>Anyone can read AND list. Use only for truly public content.</td>
</tr>
</tbody></table>
<p>Set one:</p>
<pre><code class="language-bash"># Private (recommended for documents)
docker exec -it minio-staging \
  mc anonymous set none local/domain-files-staging

# OR public read for static assets only:
docker exec -it minio-staging \
  mc anonymous set download local/domain-files-staging
</code></pre>
<h3 id="heading-74-create-a-dedicated-app-user-dont-use-root-keys">7.4 Create a dedicated app user (don't use root keys!)</h3>
<p>The <code>admin</code> account can wipe everything. Make a least‑privilege user for your app:</p>
<pre><code class="language-bash">docker exec -it minio-staging mc admin user add local \
  domain-app a-long-random-secret-key

# Attach the built-in read/write policy, scoped to one bucket via JSON:
cat &gt; /tmp/policy.json &lt;&lt;'EOF'
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:*"],
      "Resource": [
        "arn:aws:s3:::domain-files-staging",
        "arn:aws:s3:::domain-files-staging/*"
      ]
    }
  ]
}
EOF

docker cp /tmp/policy.json minio-staging:/tmp/policy.json
docker exec -it minio-staging \
  mc admin policy create local domain-rw /tmp/policy.json
docker exec -it minio-staging \
  mc admin policy attach local domain-rw --user domain-app
</code></pre>
<p>Save those two values — they are your <code>S3_ACCESS_KEY</code> and <code>S3_SECRET_KEY</code>.</p>
<h2 id="heading-8-step-5-configure-your-app-to-use-minio-on-staging-only">8. Step 5 — Configure Your App to Use MinIO on Staging Only</h2>
<p>The trick to "MinIO in staging, real S3 in prod" is to use the <strong>same S3 client</strong> in your code and only swap the env vars.</p>
<p>Your <code>staging.env</code> (loaded by your staging compose stack):</p>
<pre><code class="language-env"># ---- Staging: self-hosted MinIO ----
STORAGE_ENABLED=true
S3_ENDPOINT=https://minio-staging.domain.com
S3_PUBLIC_ENDPOINT=https://minio-staging.domain.com
S3_BUCKET=domain-files-staging
S3_ACCESS_KEY=domain-app
S3_SECRET_KEY=a-long-random-secret-key
S3_REGION=us-east-1
S3_FORCE_PATH_STYLE=true
</code></pre>
<p>Your <code>production.env</code>:</p>
<pre><code class="language-env"># ---- Production: Cloudflare R2 ----
STORAGE_ENABLED=true
S3_ENDPOINT=https://&lt;account-id&gt;.r2.cloudflarestorage.com
S3_PUBLIC_ENDPOINT=https://files.domain.com
S3_BUCKET=domain-files
S3_ACCESS_KEY=&lt;r2-access-key&gt;
S3_SECRET_KEY=&lt;r2-secret-key&gt;
S3_REGION=auto
S3_FORCE_PATH_STYLE=true
</code></pre>
<p><code>S3_FORCE_PATH_STYLE=true</code> is critical for both MinIO <strong>and</strong> R2/Hetzner. Without it, the SDK tries <code>https://bucket.minio-staging.domain.com</code> (virtual‑host style), which won't resolve.</p>
<p>Now in your application code (Node.js example using AWS SDK v3):</p>
<pre><code class="language-javascript">// src/lib/s3.js
import { S3Client } from "@aws-sdk/client-s3";

export const s3 = new S3Client({
  endpoint: process.env.S3_ENDPOINT,
  region: process.env.S3_REGION,
  credentials: {
    accessKeyId: process.env.S3_ACCESS_KEY,
    secretAccessKey: process.env.S3_SECRET_KEY,
  },
  forcePathStyle: process.env.S3_FORCE_PATH_STYLE === "true",
});

export const BUCKET = process.env.S3_BUCKET;
export const PUBLIC_ENDPOINT = process.env.S3_PUBLIC_ENDPOINT;
</code></pre>
<p>The same <code>s3</code> instance now talks to MinIO on staging and to R2 in production with no code change.</p>
<h2 id="heading-9-step-6-upload-files-3-ways">9. Step 6 — Upload Files (3 Ways)</h2>
<h3 id="heading-91-from-a-server-best-for-trusted-backends">9.1 From a server (best for trusted backends)</h3>
<pre><code class="language-javascript">import { PutObjectCommand } from "@aws-sdk/client-s3";
import { s3, BUCKET } from "./lib/s3.js";
import { readFile } from "node:fs/promises";

export async function uploadDocument(localPath, key, contentType) {
  const Body = await readFile(localPath);
  await s3.send(new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    Body,
    ContentType: contentType,
    // Optional: per-object metadata, useful for audits
    Metadata: { uploadedBy: "system", env: process.env.NODE_ENV },
  }));
  return key;
}
</code></pre>
<h3 id="heading-92-with-the-mc-cli-good-for-oneoff-uploads-migrations">9.2 With the mc CLI (good for one‑off uploads / migrations)</h3>
<pre><code class="language-bash">mc alias set staging https://minio-staging.domain.com domain-app a-long-random-secret-key
mc cp ./report.pdf staging/domain-files-staging/reports/2026/report.pdf
mc ls staging/domain-files-staging --recursive
</code></pre>
<h3 id="heading-93-directly-from-the-browser-via-a-presigned-put-url">9.3 Directly from the browser via a presigned PUT URL</h3>
<p>The recommended pattern for user uploads is: the file goes from the browser to MinIO with <strong>zero</strong> bytes touching your API server.</p>
<p>We'll cover this in detail next.</p>
<h2 id="heading-10-step-7-generate-presigned-urls-put-and-get">10. Step 7 — Generate Presigned URLs (PUT and GET)</h2>
<p>A <strong>presigned URL</strong> is a regular HTTPS URL with a time‑limited signature in the query string. Anyone with the URL can do exactly the action it was signed for (PUT this object, or GET that object) for the next N minutes — and nothing else.</p>
<p>This is what makes "users upload directly to storage" safe.</p>
<h3 id="heading-101-presigned-put-for-uploads">10.1 Presigned PUT (for uploads)</h3>
<pre><code class="language-javascript">// src/lib/presign.js
import { PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { s3, BUCKET } from "./s3.js";
import { randomUUID } from "node:crypto";

export async function presignUpload({ filename, contentType, userId }) {
  const key = `users/\({userId}/\){randomUUID()}-${filename}`;
  const cmd = new PutObjectCommand({
    Bucket: BUCKET,
    Key: key,
    ContentType: contentType,
  });
  const uploadUrl = await getSignedUrl(s3, cmd, { expiresIn: 60 * 5 }); // 5 min
  return { uploadUrl, key };
}
</code></pre>
<p>Wire it to your API:</p>
<pre><code class="language-javascript">// POST /api/uploads/presign
app.post("/api/uploads/presign", requireAuth, async (req, res) =&gt; {
  const { filename, contentType } = req.body;
  const result = await presignUpload({
    filename,
    contentType,
    userId: req.user.id,
  });
  res.json(result); // { uploadUrl, key }
});
</code></pre>
<p>The browser uploads straight to MinIO:</p>
<pre><code class="language-javascript">// In your frontend
async function uploadFile(file) {
  const { uploadUrl, key } = await fetch("/api/uploads/presign", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ filename: file.name, contentType: file.type }),
  }).then(r =&gt; r.json());

  await fetch(uploadUrl, {
    method: "PUT",
    headers: { "Content-Type": file.type },
    body: file,
  });

  // Persist `key` in your DB so you can retrieve it later
  await fetch("/api/documents", {
    method: "POST",
    body: JSON.stringify({ key, originalName: file.name }),
  });
}
</code></pre>
<p>The <code>Content-Type</code> you send during PUT <strong>must match</strong> the one you signed with, or MinIO will reject the request with <code>SignatureDoesNotMatch</code>. This catches everyone the first time.</p>
<h3 id="heading-102-presigned-get-for-downloads">10.2 Presigned GET (for downloads)</h3>
<p>Same idea, but with <code>GetObjectCommand</code>:</p>
<pre><code class="language-javascript">export async function presignDownload(key, expiresIn = 60 * 10) {
  const cmd = new GetObjectCommand({ Bucket: BUCKET, Key: key });
  return getSignedUrl(s3, cmd, { expiresIn });
}
</code></pre>
<p>A typical "view document" endpoint:</p>
<pre><code class="language-javascript">app.get("/api/documents/:id/url", requireAuth, async (req, res) =&gt; {
  const doc = await db.documents.findById(req.params.id);
  if (!doc || !canUserSee(req.user, doc)) return res.sendStatus(403);
  const url = await presignDownload(doc.key, 600);
  res.json({ url });
});
</code></pre>
<p>The frontend just opens that URL — the file streams from MinIO directly to the user.</p>
<h3 id="heading-103-why-presigned-urls-beat-proxy-through-the-api">10.3 Why presigned URLs beat "proxy through the API"</h3>
<table>
<thead>
<tr>
<th></th>
<th>Proxy through API</th>
<th>Presigned URL</th>
</tr>
</thead>
<tbody><tr>
<td>Bytes through your app</td>
<td>All of them</td>
<td>Zero</td>
</tr>
<tr>
<td>API CPU/RAM cost</td>
<td>High</td>
<td>None</td>
</tr>
<tr>
<td>Throughput limit</td>
<td>Your API</td>
<td>MinIO's NIC</td>
</tr>
<tr>
<td>Auth check</td>
<td>Your code</td>
<td>Your code (still — check before signing)</td>
</tr>
</tbody></table>
<h2 id="heading-11-step-8-get-public-urls-for-documents">11. Step 8 — Get Public URLs for Documents</h2>
<p>Sometimes you want a permanent, unauthenticated URL — for example public profile pictures.</p>
<p>If the bucket policy allows anonymous reads (<code>mc anonymous set download …</code>), the public URL pattern is:</p>
<pre><code class="language-plaintext">https://minio-staging.domain.com/&lt;bucket&gt;/&lt;key&gt;
</code></pre>
<p>So <code>users/42/avatar.png</code> becomes:</p>
<pre><code class="language-plaintext">https://minio-staging.domain.com/domain-files-staging/users/42/avatar.png
</code></pre>
<p>In code:</p>
<pre><code class="language-javascript">export function publicUrl(key) {
  return `\({process.env.S3_PUBLIC_ENDPOINT}/\){BUCKET}/${key}`;
}
</code></pre>
<p>For <strong>private</strong> buckets (most documents), don't use public URLs at all — always go through <code>presignDownload(key)</code> so you can re‑check authorization on every request and expire links.</p>
<h2 id="heading-12-step-9-lock-down-cors-lifecycle-and-security">12. Step 9 — Lock Down CORS, Lifecycle, and Security</h2>
<h3 id="heading-121-allow-your-frontend-origins-cors">12.1 Allow your frontend origins (CORS)</h3>
<p>Browser uploads need CORS rules on the bucket. Drop this JSON via <code>mc</code>:</p>
<pre><code class="language-bash">cat &gt; /tmp/cors.json &lt;&lt;'EOF'
{
  "CORSRules": [
    {
      "AllowedOrigins": [
        "https://crm-staging.domain.com",
        "http://localhost:3000"
      ],
      "AllowedMethods": ["GET", "PUT", "POST", "HEAD"],
      "AllowedHeaders": ["*"],
      "ExposeHeaders": ["ETag"],
      "MaxAgeSeconds": 3000
    }
  ]
}
EOF

docker cp /tmp/cors.json minio-staging:/tmp/cors.json
docker exec -it minio-staging \
  mc cors set local/domain-files-staging /tmp/cors.json
</code></pre>
<h3 id="heading-122-autodelete-old-test-files-lifecycle">12.2 Auto‑delete old test files (lifecycle)</h3>
<p>Staging accumulates junk. Tell MinIO to expire anything older than 30 days:</p>
<pre><code class="language-bash">docker exec -it minio-staging \
  mc ilm rule add --expire-days 30 local/domain-files-staging
</code></pre>
<h3 id="heading-123-encrypt-at-rest">12.3 Encrypt at rest</h3>
<pre><code class="language-bash">docker exec -it minio-staging \
  mc encrypt set sse-s3 local/domain-files-staging
</code></pre>
<h3 id="heading-124-hard-rules">12.4 Hard rules</h3>
<ul>
<li><p><strong>Never</strong> ship <code>MINIO_ROOT_USER=admin</code> / <code>MINIO_ROOT_PASSWORD=admin123</code> to a server reachable from the internet. Generate strong values and store them in your secret manager.</p>
</li>
<li><p>The root account should be used only by <code>mc admin</code>, never by your app. The app uses a scoped IAM user (Step 7.4).</p>
</li>
<li><p>Keep the <strong>console</strong> subdomain behind an IP allow‑list or basic auth via Traefik middleware if it's truly public.</p>
</li>
<li><p>Rotate the app access keys at least every 90 days.</p>
</li>
</ul>
<h2 id="heading-13-step-10-backups-and-monitoring">13. Step 10 — Backups and Monitoring</h2>
<h3 id="heading-131-backups-mirror-to-a-cheap-cold-bucket-weekly">13.1 Backups: mirror to a cheap cold bucket weekly</h3>
<p>Set up a tiny cron job that uses <code>mc mirror</code> to push to Backblaze B2, R2, or another cheap S3 endpoint:</p>
<pre><code class="language-bash">mc alias set b2 https://s3.us-east-005.backblazeb2.com \(B2_KEY \)B2_SECRET
mc mirror --overwrite --remove \
  staging/domain-files-staging \
  b2/domain-staging-backup
</code></pre>
<p>Even at $6/TB/month this is essentially free for staging volumes.</p>
<h3 id="heading-132-monitoring-with-prometheus">13.2 Monitoring with Prometheus</h3>
<p>MinIO exposes Prometheus metrics out of the box at <code>/minio/v2/metrics/cluster</code>. Scrape with:</p>
<pre><code class="language-yaml">scrape_configs:
  - job_name: minio
    metrics_path: /minio/v2/metrics/cluster
    scheme: https
    static_configs:
      - targets: ["minio-staging.domain.com"]
</code></pre>
<p>If you have Grafana, import dashboard ID <strong>13502</strong> for an instant overview (capacity, request rates, latency, error counts).</p>
<h2 id="heading-14-troubleshooting-cheat-sheet">14. Troubleshooting Cheat Sheet</h2>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Likely cause</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td><code>SignatureDoesNotMatch</code> on presigned PUT</td>
<td>Browser sent a different <code>Content-Type</code> than what was signed</td>
<td>Send the exact same <code>Content-Type</code> header during PUT</td>
</tr>
<tr>
<td>Presigned URL works locally but not in browser</td>
<td><code>MINIO_SERVER_URL</code> not set, so URLs are signed for <code>minio:9000</code></td>
<td>Set <code>MINIO_SERVER_URL=https://minio-staging.domain.com</code> and restart</td>
</tr>
<tr>
<td><code>403 SignatureDoesNotMatch</code> after going through Cloudflare</td>
<td>Cloudflare strips/modifies headers</td>
<td>Set the DNS record to <strong>DNS‑only</strong> (gray cloud)</td>
</tr>
<tr>
<td><code>NoSuchBucket</code></td>
<td>App pointing at the wrong endpoint or bucket</td>
<td>Re‑check <code>S3_ENDPOINT</code> and <code>S3_BUCKET</code> in env</td>
</tr>
<tr>
<td>Browser CORS preflight fails</td>
<td>No CORS rule on the bucket</td>
<td>Apply the CORS JSON from §12.1</td>
</tr>
<tr>
<td>Upload works for small files, fails at 100 MB</td>
<td>Cloudflare free plan body limit</td>
<td>Use Cloudflare paid plan, or skip CF proxy</td>
</tr>
<tr>
<td><code>x509: certificate signed by unknown authority</code> from your app</td>
<td>App container doesn't trust Let's Encrypt</td>
<td>Update CA bundle (<code>apt install ca-certificates</code>) or use HTTP inside the Docker network</td>
</tr>
<tr>
<td>Web console redirects to <code>http://minio:9001/login</code></td>
<td><code>MINIO_BROWSER_REDIRECT_URL</code> missing</td>
<td>Set it to <code>https://minio-console-staging.domain.com</code></td>
</tr>
</tbody></table>
<p>Useful diagnostics:</p>
<pre><code class="language-bash"># Check MinIO health
curl -I https://minio-staging.domain.com/minio/health/live

# List all objects in a bucket
docker exec -it minio-staging mc ls --recursive local/domain-files-staging

# Tail MinIO logs
docker compose -f docker-compose.staging.yml logs -f minio

# Decode a presigned URL to see what it was signed for
echo "&lt;paste url&gt;" | tr '&amp;' '\n'
</code></pre>
<h2 id="heading-15-wrapping-up">15. Wrapping Up</h2>
<p>Here's what you have now:</p>
<ul>
<li><p>A free, S3‑compatible object store running on your own staging server.</p>
</li>
<li><p>Real HTTPS on a real domain (<code>https://minio-staging.domain.com</code>), thanks to Traefik + Let's Encrypt.</p>
</li>
<li><p>A scoped, least‑privilege application user — root keys stay locked away.</p>
</li>
<li><p>The same exact code paths in staging and production. Switching between MinIO / R2 / Hetzner / AWS S3 is a four‑variable change in the env file.</p>
</li>
<li><p>Presigned PUT URLs so users upload straight to storage, bypassing your API.</p>
</li>
<li><p>Presigned GET URLs so private documents are short‑lived and authorization‑gated.</p>
</li>
<li><p>Lifecycle rules that nuke old test files automatically.</p>
</li>
<li><p>Optional weekly mirror to a cold backup bucket.</p>
</li>
</ul>
<p>Production keeps running on managed storage where the SLA matters. Staging now costs you exactly <strong>$0 per month per gigabyte uploaded</strong> — and you can finally stop telling QA to "delete the test files when you're done."</p>
<h3 id="heading-further-reading">Further Reading</h3>
<ul>
<li><p><a href="https://min.io/docs/minio/container/index.html">MinIO Documentation</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/AWSJavaScriptSDK/v3/latest/Package/-aws-sdk-s3-request-presigner/">AWS SDK v3 — <code>getSignedUrl</code></a></p>
</li>
<li><p><a href="https://doc.traefik.io/traefik/providers/docker/">Traefik v2 Docker provider</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/AmazonS3/latest/userguide/bucket-policies.html">S3 bucket policy reference</a></p>
</li>
</ul>
<p>If this guide saved your team a few dollars, share it with another team that's still uploading test PDFs to a $90/month S3 bucket. Happy shipping.</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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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;margin:0 auto" 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[ The Complete SOC 2 Type II Implementation Handbook for Engineers: A Month-by-Month Roadmap with Real Commands ]]>
                </title>
                <description>
                    <![CDATA[ If your team is preparing for a SOC 2 Type II review, this handbook is for you. It's a self-contained guide to the exact 90-day timeline, 14 critical controls, and evidence collection infrastructure t ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-complete-soc-2-type-ii-implementation-guide-for-engineers/</link>
                <guid isPermaLink="false">69fa364da386d7f121c468af</guid>
                
                    <category>
                        <![CDATA[ SOC ]]>
                    </category>
                
                    <category>
                        <![CDATA[ compliance  ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cloud security ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Ayobami Adejumo ]]>
                </dc:creator>
                <pubDate>Tue, 05 May 2026 18:26:21 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/83d83215-5d73-49f6-a745-d9c6cd0c33f8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If your team is preparing for a SOC 2 Type II review, this handbook is for you. It's a self-contained guide to the exact 90-day timeline, 14 critical controls, and evidence collection infrastructure that auditors actually check.</p>
<p>Everyone publishes the controls list. But nobody publishes the week-by-week engineering calendar you'll need to follow to make sure your ducks are in a row.</p>
<p>Here is the exact 90-day timeline — including the mistakes that add 60 days (and how to avoid them).</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<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-weeks-1-2-the-scope-decision">Weeks 1–2: The Scope Decision</a></p>
</li>
<li><p><a href="#heading-weeks-3-6-the-14-controls-that-must-be-active-on-day-1">Weeks 3–6: The 14 Controls That Must Be Active on Day 1</a></p>
</li>
<li><p><a href="#heading-weeks-7-10-the-evidence-collection-infrastructure">Weeks 7–10: The Evidence Collection Infrastructure</a></p>
</li>
<li><p><a href="#heading-weeks-11-14-auditor-selection-and-readiness-assessment">Weeks 11–14: Auditor Selection and Readiness Assessment</a></p>
</li>
<li><p><a href="#heading-weeks-15-18-the-observation-period">Weeks 15–18: The Observation Period</a></p>
</li>
<li><p><a href="#heading-the-90-day-soc2-timeline-at-a-glance">The 90-Day SOC2 Timeline at a Glance</a></p>
</li>
<li><p><a href="#heading-whats-next">What's Next</a></p>
</li>
<li><p><a href="#heading-resources">Resources</a></p>
</li>
</ol>
<h2 id="heading-what-youll-learn">What You'll Learn</h2>
<p>By the end of this guide, you'll know:</p>
<ul>
<li><p>How to scope your SOC2 boundary correctly — the decision that determines everything else</p>
</li>
<li><p>The 14 controls that must be active on day 1 of your observation period</p>
</li>
<li><p>How to build evidence collection infrastructure that runs automatically</p>
</li>
<li><p>How to choose an auditor and run a readiness assessment</p>
</li>
<li><p>What happens during the observation period and how to close gaps without restarting the clock</p>
</li>
</ul>
<p>Let's dive in.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before following along, you should have:</p>
<p><strong>Knowledge:</strong></p>
<ul>
<li><p>Basic understanding of AWS services (EC2, RDS, S3, IAM, VPC)</p>
</li>
<li><p>Familiarity with Terraform or another infrastructure as code tool</p>
</li>
<li><p>Comfort reading GitHub Actions YAML workflows</p>
</li>
<li><p>A general understanding of what SOC2 is — if you are starting from scratch, read the <a href="https://www.aicpa-cima.com/resources/landing/system-and-organization-controls-soc-suite-of-services">AICPA's SOC2 overview</a> first</p>
</li>
</ul>
<p><strong>Tools and access:</strong></p>
<ul>
<li><p>An AWS account with administrator access</p>
</li>
<li><p>A GitHub organisation with admin rights</p>
</li>
<li><p>Terraform installed (v1.0 or later)</p>
</li>
<li><p>Python 3.8 or later (for the evidence collector Lambda)</p>
</li>
<li><p>A compliance automation platform — <a href="https://www.vanta.com/">Vanta</a> or <a href="https://drata.com/">Drata</a> — connected to your AWS account and GitHub organisation</p>
</li>
</ul>
<p><strong>Estimated time:</strong> 90 days end-to-end, with active engineering work of approximately 8–12 hours per week in the first six weeks, tapering to 2–4 hours per week during the observation period.</p>
<h2 id="heading-weeks-12-the-scope-decision-what-is-in-and-out-of-your-soc2-boundary">Weeks 1–2: The Scope Decision — What Is In and Out of Your SOC2 Boundary</h2>
<h3 id="heading-what-most-teams-get-wrong">What Most Teams Get Wrong</h3>
<p>Most teams scope their SOC2 boundary too broadly. They include every AWS account, every service, every environment. This is a mistake — and here is exactly why.</p>
<p>A broader scope means more controls to implement, more evidence to collect, and more systems the auditor will examine.</p>
<p>Every system inside your boundary must satisfy all 14 controls. Including your development sandbox means your engineers' experimental environments must have GuardDuty enabled, CloudTrail logging, and branch-protected deployments. That adds weeks of work and months of evidence collection for systems that pose no risk to your customers.</p>
<p>A correctly bounded scope means you include only the systems that store, process, or transmit customer data — and you prove that everything else cannot reach those systems.</p>
<p><strong>Bad scope (over-inclusive):</strong></p>
<pre><code class="language-plaintext">Entire AWS Organization
├── Production (in scope)
├── Staging (in scope)
├── Development (in scope)
├── Sandbox (in scope)
└── CI/CD (in scope)
</code></pre>
<p><strong>Good scope (correctly bounded):</strong></p>
<pre><code class="language-plaintext">SOC2 Boundary
├── Production AWS Account (in scope)
├── Production EKS Cluster (in scope)
├── Production RDS (in scope)
└── Everything else (OUT of scope — proven by network segmentation)
</code></pre>
<p>The correctly bounded scope works because it draws the tightest defensible line around the systems that actually handle customer data. Everything outside that line is excluded — not by assumption, but by technical controls that prevent those systems from reaching anything inside the boundary.</p>
<h3 id="heading-the-scope-decision-framework">The Scope Decision Framework</h3>
<p>For every system in your infrastructure, ask these four questions:</p>
<table>
<thead>
<tr>
<th>Question</th>
<th>If YES</th>
<th>If NO</th>
</tr>
</thead>
<tbody><tr>
<td>Does this system store, process, or transmit customer data?</td>
<td>✅ In scope</td>
<td>❌ Out of scope</td>
</tr>
<tr>
<td>Does this system affect the availability of customer-facing services?</td>
<td>✅ In scope</td>
<td>❌ Out of scope</td>
</tr>
<tr>
<td>Does this system have access to production credentials?</td>
<td>✅ In scope</td>
<td>❌ Out of scope</td>
</tr>
<tr>
<td>Can a compromise of this system lead to a customer data breach?</td>
<td>✅ In scope</td>
<td>❌ Out of scope</td>
</tr>
</tbody></table>
<p>Any system where the answer to even one question is yes belongs inside your boundary.</p>
<h3 id="heading-network-segmentation-the-technical-proof-that-your-boundary-holds">Network Segmentation — The Technical Proof That Your Boundary Holds</h3>
<p>Network segmentation is the practice of dividing your infrastructure into isolated zones so that systems in one zone can't communicate with systems in another unless you explicitly allow it.</p>
<p>In the context of SOC2, it's the technical control that proves your out-of-scope systems genuinely can't reach your in-scope systems — not just by policy, but by infrastructure enforcement.</p>
<p>Without network segmentation, the SOC2 auditor can't trust that your boundary is real. A developer in your sandbox environment who can query your production database means the sandbox is effectively in scope, regardless of what your diagram says.</p>
<p>Here's the Terraform that implements network segmentation between your production and non-production environments. The network access control list (NACL) blocks all inbound traffic from the broader private IP range (10.0.0.0/8) into your in-scope production VPC, while the explicit <code>aws_vpc_peering_connection</code> comment documents the deliberate decision not to peer environments:</p>
<pre><code class="language-hcl"># This account has NO VPC peering to non-production environments.
# The absence of peering is itself the segmentation control.
# Do NOT add peering connections to this account without SOC2 scope review.

resource "aws_network_acl" "deny_non_production" {
  vpc_id = aws_vpc.production.id

  # Block all inbound traffic from non-production IP ranges
  ingress {
    rule_no    = 100
    action     = "deny"
    from_port  = 0
    to_port    = 0
    protocol   = "-1"
    cidr_block = "10.0.0.0/8"
  }

  # Allow legitimate inbound traffic (HTTPS from internet)
  ingress {
    rule_no    = 200
    action     = "allow"
    from_port  = 443
    to_port    = 443
    protocol   = "tcp"
    cidr_block = "0.0.0.0/0"
  }

  # Allow all outbound (tighten this per your architecture)
  egress {
    rule_no    = 100
    action     = "allow"
    from_port  = 0
    to_port    = 0
    protocol   = "-1"
    cidr_block = "0.0.0.0/0"
  }

  tags = {
    Name        = "production-nacl"
    Environment = "production"
    Purpose     = "SOC2 network segmentation"
  }
}
</code></pre>
<p>Verify the segmentation with this command after applying the Terraform:</p>
<pre><code class="language-bash"># Confirm no VPC peering connections exist from production to non-production
aws ec2 describe-vpc-peering-connections \
  --filters Name=status-code,Values=active \
  --query 'VpcPeeringConnections[*].{ID:VpcPeeringConnectionId,Requester:RequesterVpcInfo.VpcId,Accepter:AccepterVpcInfo.VpcId}' \
  --output table
</code></pre>
<h3 id="heading-the-deliverable-your-soc2-boundary-diagram">The Deliverable: Your SOC2 Boundary Diagram</h3>
<p>At the end of weeks 1–2, you need a boundary diagram — a visual document that shows every in-scope system, every out-of-scope system, and the segmentation controls between them.</p>
<p>Here is what the diagram should contain:</p>
<img src="https://cdn.hashnode.com/uploads/covers/69d00d5be466e2b76263a583/29dfe0c8-f455-44af-8562-8d088f8a111a.png" alt="29dfe0c8-f455-44af-8562-8d088f8a111a" style="display:block;margin:0 auto" width="611" height="686" loading="lazy">

<p>Include every AWS service, every data flow arrow, and a label on the segmentation control. This diagram becomes your primary scope evidence and is typically the first thing an auditor asks for.</p>
<h2 id="heading-weeks-36-the-14-controls-that-must-be-active-on-day-1">Weeks 3–6: The 14 Controls That Must Be Active on Day 1</h2>
<p>These 14 controls must be implemented and actively collecting evidence from day 1 of your observation period. If you add any of them late, the observation period clock for that control restarts from the implementation date — not from day 1 of the audit period.</p>
<p>Think of the observation period as a surveillance camera recording your infrastructure. The auditor watches the footage later. If the camera was not on when a specific event occurred, that event has no record — and the SOC2 control for it has a gap.</p>
<h3 id="heading-control-1-mfa-enforcement-cc66">Control 1: MFA Enforcement (CC6.6)</h3>
<p>Multi-Factor Authentication (MFA) requires a user to verify their identity using two independent factors — something they know (a password) and something they have (a phone or hardware key). Without MFA, a stolen password is sufficient to access your production systems.</p>
<p>SOC2 CC6.6 requires that access to systems is restricted to authorized users. MFA is the technical control that makes "authorized" meaningful. Without it, any password compromise is a production access event.</p>
<p>To implement MFA, you can use AWS IAM Identity Center (formerly SSO) connected to your identity provider (Okta, Google Workspace, or Azure AD). MFA is then enforced at the identity provider level — any user without MFA enrolled can't authenticate, regardless of which AWS service they're trying to reach.</p>
<pre><code class="language-hcl"># IAM Identity Center configuration — MFA is enforced at the IdP level.
# No IAM user has direct console or CLI access.
# All access goes through SSO sessions (8-hour expiry by default).

resource "aws_ssoadmin_instance_access_control_attributes" "mfa" {
  instance_arn = tolist(data.aws_ssoadmin_instances.this.arns)[0]

  attribute {
    key = "email"
    value {
      source = ["$${path:email}"]
    }
  }
}
</code></pre>
<p>You can verify that no IAM users retain direct console access (which would bypass MFA):</p>
<pre><code class="language-bash"># Any user listed here has direct console access bypassing SSO — investigate immediately
aws iam list-users \
  --query 'Users[?PasswordLastUsed!=`null`].[UserName,PasswordLastUsed]' \
  --output table
</code></pre>
<h3 id="heading-control-2-infrastructure-as-code-cc81">Control 2: Infrastructure as Code (CC8.1)</h3>
<p>Infrastructure as Code (IaC) means defining your cloud infrastructure in version-controlled code files (Terraform, Pulumi, or AWS CDK) rather than creating resources manually through the AWS console. Every infrastructure change is proposed in a pull request, reviewed by a colleague, and applied through an automated pipeline.</p>
<p>SOC2 CC8.1 covers change management — the requirement that every change to your production environment is documented, reviewed, and approved. Manual console changes produce no audit trail. If an engineer opens the AWS console and creates a security group without going through Terraform, that change is invisible to your SOC2 auditor. IaC makes every change reviewable and traceable.</p>
<p>Now let's see how to implement IaC here. This GitHub Actions workflow applies Terraform only from the main branch, after a pull request has been reviewed and approved. The workflow creates an immutable record of every infrastructure change:</p>
<pre><code class="language-yaml"># .github/workflows/terraform-apply.yml
name: Terraform Apply (Production)
on:
  push:
    branches: [main]
    paths: ['terraform/**']

permissions:
  id-token: write   # Required for AWS OIDC authentication
  contents: read

jobs:
  apply:
    name: Apply Infrastructure Changes
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval for production

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Configure AWS credentials (OIDC — no long-lived keys)
        uses: aws-actions/configure-aws-credentials@v2
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/terraform-apply
          aws-region: us-east-1

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v2
        with:
          terraform_version: "1.6.0"

      - name: Terraform Plan
        run: |
          terraform init
          terraform plan -out=tfplan -input=false

      - name: Terraform Apply
        run: terraform apply -input=false tfplan
</code></pre>
<p>SOC2 evidence this produces: A GitHub Actions run log for every infrastructure change, showing who triggered it (the pull request author), when it was applied, and what changed.</p>
<h3 id="heading-control-3-cloudtrail-enabled-cc71">Control 3: CloudTrail Enabled (CC7.1)</h3>
<p>AWS CloudTrail is a service that records every API call made in your AWS account — who called it, when, from which IP address, and whether it succeeded. Think of it as the complete audit log of everything that has ever happened in your AWS environment.</p>
<p>SOC2 CC7.1 requires monitoring for security events. CloudTrail is the foundational logging layer — without it, you can't detect unauthorized access, investigate incidents, or prove to an auditor that your controls were operating as intended. An auditor who can't see historical AWS API activity can't verify that your access controls were enforced during the observation period.</p>
<p>To implement it, you'll want to enable multi-region CloudTrail so that activity in every AWS region is captured, including global services like IAM. You can ship logs to an S3 bucket with Object Lock enabled (Control 3 in the evidence collection section covers this) so logs can't be modified or deleted:</p>
<pre><code class="language-bash"># Enable CloudTrail with log file validation and multi-region coverage
aws cloudtrail create-trail \
  --name production-audit-trail \
  --s3-bucket-name your-cloudtrail-logs-bucket \
  --is-multi-region-trail \
  --enable-log-file-validation \
  --include-global-service-events

# Start the trail (creation alone does not start logging)
aws cloudtrail start-logging --name production-audit-trail

# Verify the trail is active and logging
aws cloudtrail get-trail-status --name production-audit-trail \
  --query '{IsLogging:IsLogging,LatestDeliveryTime:LatestDeliveryTime}'
</code></pre>
<h3 id="heading-control-4-guardduty-enabled-cc72">Control 4: GuardDuty Enabled (CC7.2)</h3>
<p>AWS GuardDuty is a threat detection service that analyses your CloudTrail logs, VPC Flow Logs, and DNS logs. It uses machine learning to identify suspicious behaviour — things like an EC2 instance communicating with a known malware server, an IAM user logging in from an unusual country, or unusual API call patterns that indicate credential theft.</p>
<p>SOC2 CC7.2 requires the use of detection tools to identify potential security events. GuardDuty is the monitoring layer that tells you when something anomalous is happening, not just what happened after the fact. Without it, you would only discover a compromise when the damage is done.</p>
<p>Here's the implementation:</p>
<pre><code class="language-bash"># Enable GuardDuty — findings published every 15 minutes for active threats
aws guardduty create-detector \
  --enable \
  --finding-publishing-frequency FIFTEEN_MINUTES

# Verify GuardDuty is active
aws guardduty list-detectors --query 'DetectorIds' --output table
</code></pre>
<p>You can set up an EventBridge rule to route CRITICAL and HIGH severity GuardDuty findings to your incident response channel immediately. A finding sitting unreviewed for 90 days is a qualified SOC2 finding.</p>
<h3 id="heading-control-5-vpc-flow-logs-cc61">Control 5: VPC Flow Logs (CC6.1)</h3>
<p>VPC Flow Logs capture information about the IP traffic flowing through your Virtual Private Cloud — every accepted and rejected connection, including source IP, destination IP, port, protocol, and whether the traffic was allowed or denied. They are the network-level audit trail that CloudTrail doesn't provide.</p>
<p>SOC2 CC6.1 requires logical access controls and monitoring. VPC Flow Logs let you verify that your network segmentation is actually working (traffic you denied is showing as rejected in the logs), detect unexpected communication between services, and investigate security events at the network layer.</p>
<pre><code class="language-bash"># Create an IAM role for VPC Flow Logs to deliver to CloudWatch
aws iam create-role \
  --role-name vpc-flow-logs-role \
  --assume-role-policy-document '{
    "Version":"2012-10-17",
    "Statement":[{
      "Effect":"Allow",
      "Principal":{"Service":"vpc-flow-logs.amazonaws.com"},
      "Action":"sts:AssumeRole"
    }]
  }'

# Enable VPC Flow Logs for all traffic (ACCEPT and REJECT)
aws ec2 create-flow-logs \
  --resource-ids vpc-YOUR_PRODUCTION_VPC_ID \
  --resource-type VPC \
  --traffic-type ALL \
  --log-group-name /aws/vpc/flow-logs/production \
  --deliver-log-permission-arn arn:aws:iam::YOUR_ACCOUNT_ID:role/vpc-flow-logs-role

# Verify flow logs are active
aws ec2 describe-flow-logs \
  --filter Name=resource-id,Values=vpc-YOUR_PRODUCTION_VPC_ID \
  --query 'FlowLogs[*].{Status:FlowLogStatus,LogGroup:LogGroupName}'
</code></pre>
<h3 id="heading-control-6-secrets-manager-cc67">Control 6: Secrets Manager (CC6.7)</h3>
<p>Secrets management means storing credentials (database passwords, API keys, certificates, and other sensitive configuration values) in a dedicated, access-controlled service (like AWS Secrets Manager or HashiCorp Vault) rather than in <code>.env</code> files, GitHub repository secrets, or hardcoded in application code.</p>
<p>SOC2 CC6.7 requires protecting sensitive system components from unauthorized access. A secret stored in an <code>.env</code> file committed to a repository is accessible to every developer with repo access, every CI/CD runner, and every engineer who has ever cloned the repo — including those who have since left the company.</p>
<p>A Secrets Manager provides centralised storage, access logging, automatic rotation, and fine-grained IAM permissions so only specific services can retrieve specific secrets.</p>
<p>Let's look at the implementation — storing and rotating a secret:</p>
<pre><code class="language-bash"># Store a database credential with automatic 90-day rotation
aws secretsmanager create-secret \
  --name production/postgresql/credentials \
  --description "Production PostgreSQL credentials — rotated every 90 days" \
  --secret-string '{
    "username": "app_user",
    "password": "REPLACE_WITH_STRONG_PASSWORD",
    "host": "your-rds-endpoint.us-east-1.rds.amazonaws.com",
    "port": 5432,
    "dbname": "production"
  }'

# Enable automatic rotation every 90 days
aws secretsmanager rotate-secret \
  --secret-id production/postgresql/credentials \
  --rotation-rules AutomaticallyAfterDays=90
</code></pre>
<p>How your application retrieves the secret at runtime (no hardcoded credentials):</p>
<pre><code class="language-python"># Good: secret retrieved at runtime from Secrets Manager
import boto3
import json

def get_db_credentials():
    client = boto3.client('secretsmanager', region_name='us-east-1')
    response = client.get_secret_value(SecretId='production/postgresql/credentials')
    return json.loads(response['SecretString'])

# Bad: secret hardcoded in application code or .env file
DB_PASSWORD = "my_database_password_123"  # Never do this
</code></pre>
<p>The access log in CloudTrail records every time a secret is retrieved, by which IAM role, at what time. That log is your SOC2 evidence that secrets access is controlled and auditable.</p>
<h3 id="heading-control-7-ebs-encryption-cc61">Control 7: EBS Encryption (CC6.1)</h3>
<p>EBS (Elastic Block Store) encryption ensures that the persistent disks attached to your EC2 instances and used by your RDS databases are encrypted at rest using AES-256. If an AWS employee or an attacker gained physical access to the storage hardware, the data would be unreadable without the encryption key.</p>
<p>SOC2 CC6.1 requires protecting information assets from unauthorised access. Encryption at rest is the control that protects data in the event of physical storage compromise or an improperly decommissioned disk. Enabling it account-wide means every new EBS volume is encrypted automatically, including RDS storage, EKS node volumes, and EC2 instance root volumes.</p>
<pre><code class="language-bash"># Enable EBS encryption by default for all new volumes in this region
aws ec2 enable-ebs-encryption-by-default

# Verify it is enabled
aws ec2 get-ebs-encryption-by-default \
  --query 'EbsEncryptionByDefault'
# Expected output: true

# Check existing volumes — any showing false need to be migrated
aws ec2 describe-volumes \
  --query 'Volumes[?Encrypted==`false`].[VolumeId,Size,VolumeType]' \
  --output table
</code></pre>
<p>Any existing unencrypted volumes must be snapshot-and-replaced. The process: create a snapshot of the unencrypted volume, create a new encrypted volume from the snapshot, and swap it into the instance.</p>
<h3 id="heading-control-8-s3-block-public-access-cc61">Control 8: S3 Block Public Access (CC6.1)</h3>
<p>Amazon S3 buckets can be configured to allow public access — meaning anyone on the internet can read their contents without authentication. Block Public Access is an account-level and bucket-level setting that prevents any bucket from being made public, regardless of the bucket's own policy.</p>
<p>A misconfigured S3 bucket is one of the most common causes of data breaches in cloud environments. Block Public Access at the account level means a developer can't accidentally expose a bucket containing customer data, even if they set the wrong bucket policy. It's a guardrail, not just a policy.</p>
<pre><code class="language-bash"># Block public access at the AWS account level — applies to all buckets
aws s3control put-public-access-block \
  --account-id YOUR_ACCOUNT_ID \
  --public-access-block-configuration \
    BlockPublicAcls=true,\
    IgnorePublicAcls=true,\
    BlockPublicPolicy=true,\
    RestrictPublicBuckets=true

# Verify account-level setting is active
aws s3control get-public-access-block \
  --account-id YOUR_ACCOUNT_ID

# Scan for any buckets that have public access enabled (should be zero)
aws s3api list-buckets --query 'Buckets[*].Name' --output text | \
  tr '\t' '\n' | while read bucket; do
    result=\((aws s3api get-public-access-block --bucket "\)bucket" 2&gt;/dev/null)
    if echo "$result" | grep -q '"BlockPublicAcls": false'; then
      echo "WARNING: $bucket has public access not fully blocked"
    fi
  done
</code></pre>
<h3 id="heading-control-9-branch-protection-cc81">Control 9: Branch Protection (CC8.1)</h3>
<p>Branch protection is a GitHub setting that prevents engineers from pushing code directly to your main branch without going through a pull request that has been reviewed and approved by at least one other team member. It also requires your CI pipeline to pass before any code can be merged.</p>
<p>SOC2 CC8.1 requires change management — the requirement that every change to production systems is documented, reviewed, and approved. Without branch protection, an engineer can push directly to main, which deploys directly to production through your CI/CD pipeline, with no review and no audit trail. Branch protection is the technical enforcement of your change management policy.</p>
<p>The critical setting that most teams miss: the "Do not allow bypassing the above settings" option must be enabled. Without it, administrators can bypass branch protection — and a SOC2 auditor will flag this as a gap because it means your change management control can be circumvented.</p>
<pre><code class="language-yaml"># .github/settings.yml — enforces branch protection via code
# Requires the settings GitHub App: https://github.com/apps/settings

branches:
  - name: main
    protection:
      required_pull_request_reviews:
        required_approving_review_count: 1
        dismiss_stale_reviews: true
        require_code_owner_reviews: false
      required_status_checks:
        strict: true
        contexts:
          - "CI / test"
          - "Security / trivy-scan"
      enforce_admins: true         # Admins cannot bypass — this is critical
      restrictions: null           # No push restriction beyond the above
      allow_force_pushes: false
      allow_deletions: false
</code></pre>
<p>Here's how you can verify that branch protection is enforced and admins can't bypass it:</p>
<pre><code class="language-bash"># Returns the branch protection rules including enforce_admins status
curl -H "Authorization: token YOUR_GITHUB_TOKEN" \
  https://api.github.com/repos/YOUR_ORG/YOUR_REPO/branches/main/protection \
  | jq '{enforce_admins: .enforce_admins.enabled, required_reviews: .required_pull_request_reviews.required_approving_review_count}'
</code></pre>
<h3 id="heading-control-10-container-image-scanning-cc74">Control 10: Container Image Scanning (CC7.4)</h3>
<p>Container image scanning analyses your Docker images before deployment to identify known security vulnerabilities (CVEs) in the operating system packages and application dependencies they contain.</p>
<p>Trivy is an open-source scanner that checks the base image (Ubuntu, Alpine, and so on), all installed OS packages, and language-specific dependencies (npm, pip, Go modules) against the National Vulnerability Database.</p>
<p>SOC2 CC7.4 requires monitoring and identifying vulnerabilities. Every container you deploy contains a base image with OS packages — and those packages regularly receive CVE disclosures. A critical CVE left unpatched for 90 days in a production container is a SOC2 finding. Automated scanning in CI means every image is checked before it can deploy.</p>
<pre><code class="language-yaml"># .github/workflows/security-scan.yml
name: Security Scan
on: [push, pull_request]

jobs:
  trivy-scan:
    name: Container Vulnerability Scan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Build container image
        run: docker build -t app:${{ github.sha }} .

      - name: Scan image for vulnerabilities
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: app:${{ github.sha }}
          format: sarif
          output: trivy-results.sarif
          severity: CRITICAL,HIGH
          exit-code: 1          # Fail the pipeline on CRITICAL or HIGH findings

      - name: Upload results to GitHub Security tab
        uses: github/codeql-action/upload-sarif@v2
        if: always()            # Upload even if scan found issues
        with:
          sarif_file: trivy-results.sarif
</code></pre>
<p>The scanner looks for:</p>
<ul>
<li><p>CVEs in base image OS packages (for example, a critical OpenSSL vulnerability in your Ubuntu base)</p>
</li>
<li><p>Vulnerable versions of application dependencies (a known RCE in an npm package your app uses)</p>
</li>
<li><p>Misconfigurations in the Dockerfile itself (running as root, using <code>latest</code> tags)</p>
</li>
</ul>
<p>Results appear in the GitHub Security tab for your repository, giving you a historical record of every scan — which is your SOC2 evidence.</p>
<h3 id="heading-control-11-incident-response-plan-cc92">Control 11: Incident Response Plan (CC9.2)</h3>
<p>An incident response plan is a written, tested procedure that defines exactly what your team does when a security event occurs — from the moment an alert fires through to customer notification and post-incident review.</p>
<p>SOC2 CC9.2 requires that you have a documented process for responding to security events and that you've tested it. The auditor will ask for the written runbook and evidence that a tabletop exercise (a simulated incident walkthrough) has been conducted within the observation period.</p>
<p>Your incident response runbook must include:</p>
<ol>
<li><p><strong>Severity classification:</strong> Definitions of P1 (production down, customer data at risk), P2 (degraded service, potential risk), and P3 (minor issue, no customer impact) — and the response SLA for each.</p>
</li>
<li><p><strong>Escalation path:</strong> Exactly who gets paged at each severity level, with contact details. Not "the on-call engineer" — specific names and a backup if the first person doesn't respond within 10 minutes.</p>
</li>
<li><p><strong>First 15 minutes:</strong> The specific steps to take immediately — isolate the affected system, assess the scope, notify the incident channel, begin the timeline log.</p>
</li>
<li><p><strong>Communication templates:</strong> Pre-written Slack messages, customer email templates, and regulatory notification templates (GDPR requires notification within 72 hours, HIPAA within 60 days).</p>
</li>
<li><p><strong>Post-incident review:</strong> The blameless postmortem process, the <a href="https://www.freecodecamp.org/news/from-symptoms-to-root-cause-how-to-use-the-5-whys-technique/">5-why</a> root cause analysis template, and the action item tracking process.</p>
</li>
</ol>
<p>Conduct a tabletop exercise at least once during your observation period: gather your engineering team for 45 minutes, simulate a realistic scenario (for example, "an AWS access key was committed to a public GitHub repo"), and walk through the runbook together. Document the meeting date, attendees, scenario, gaps found, and remediation actions. This document is your evidence.</p>
<h3 id="heading-control-12-access-reviews-cc63">Control 12: Access Reviews (CC6.3)</h3>
<p>An access review is a quarterly audit of who has access to what in your production systems — AWS accounts, GitHub repositories, production databases, and every SaaS tool that touches customer data. You verify that every person on the list still works at the company and still needs the access their role grants them.</p>
<p>SOC2 CC6.3 requires that access is revoked when it's no longer needed. Former employees who retain access to production AWS accounts represent a genuine security risk and a definitive SOC2 finding.</p>
<p>In every access review I've conducted, at least 3–5 former employees or contractors still had active access they should not.</p>
<p>The quarterly access review checklist:</p>
<pre><code class="language-bash"># 1. IAM users — list all with their last login date
aws iam generate-credential-report
aws iam get-credential-report --output text --query Content \
  | base64 --decode | cut -d',' -f1,5 | column -t -s ','

# 2. IAM roles — find roles that have not been used in 90+ days
aws iam get-account-authorization-details \
  --query 'RoleDetailList[*].{Role:RoleName,LastUsed:RoleLastUsed.LastUsedDate}' \
  --output table

# 3. Verify AWS SSO user list matches your current employee list
aws identitystore list-users \
  --identity-store-id YOUR_IDENTITY_STORE_ID \
  --query 'Users[*].{Name:DisplayName,Email:Emails[0].Value}' \
  --output table
</code></pre>
<p>Cross-reference the output against your current employee list in your HR system. Document every change made — access removed, permissions reduced, accounts disabled. The documented changes are the evidence that the review was conducted meaningfully, not just as a checkbox exercise.</p>
<h3 id="heading-control-13-backup-verification-cc95">Control 13: Backup Verification (CC9.5)</h3>
<p>Backup verification is the process of actually restoring your backups to confirm they work — not just confirming that backups are being created. A backup that has never been tested doesn't exist from a recovery perspective.</p>
<p>SOC2 CC9.5 requires that recovery procedures are tested. If your production database is corrupted and you discover for the first time during the incident that your automated RDS snapshots can't be restored, you have both a disaster recovery failure and a SOC2 finding.</p>
<p>How to test your RDS backup:</p>
<pre><code class="language-bash"># Step 1: Find your most recent production snapshot
aws rds describe-db-snapshots \
  --db-instance-identifier your-production-db \
  --query 'sort_by(DBSnapshots, &amp;SnapshotCreateTime)[-1].DBSnapshotIdentifier' \
  --output text

# Step 2: Restore the snapshot to a test instance
aws rds restore-db-instance-from-db-snapshot \
  --db-instance-identifier backup-verification-test \
  --db-snapshot-identifier YOUR_SNAPSHOT_ID \
  --db-instance-class db.t3.medium \
  --no-publicly-accessible \
  --tags Key=Purpose,Value=backup-verification Key=Environment,Value=test

# Step 3: Wait for the restore to complete (typically 5–15 minutes)
aws rds wait db-instance-available \
  --db-instance-identifier backup-verification-test

# Step 4: Connect and verify data integrity (spot check key tables)
# Run this against the restored instance
psql -h RESTORED_INSTANCE_ENDPOINT -U your_user -d your_database \
  -c "SELECT COUNT(*) FROM users; SELECT MAX(created_at) FROM orders;"

# Step 5: Document the test result and delete the test instance
aws rds delete-db-instance \
  --db-instance-identifier backup-verification-test \
  --skip-final-snapshot
</code></pre>
<p>Document the test date, the snapshot used, the restore time, the data verification query results, and who conducted the test. Run this quarterly at minimum. This documentation is your SOC2 evidence for CC9.5.</p>
<h3 id="heading-control-14-change-management-log-cc81">Control 14: Change Management Log (CC8.1)</h3>
<p>A change management log is the auditable record of every change made to your production environment — what changed, who approved it, and when it was applied.</p>
<p>SOC2 CC8.1 requires that changes to your production environment are authorized and documented. With IaC and GitOps in place, you already have two separate sources of immutable change history that together satisfy this control.</p>
<p><strong>GitHub Pull Request history</strong> provides the record of every code and infrastructure change: who opened the PR, who reviewed and approved it, what the CI status was, and when it was merged. This is your change management log for application and infrastructure changes.</p>
<p><strong>ArgoCD sync history</strong> provides the record of every deployment to your Kubernetes cluster: which application was synced, from which Git commit, at what time, and whether the sync succeeded.</p>
<p>To export the ArgoCD sync history as evidence:</p>
<pre><code class="language-bash"># Export ArgoCD application sync history as JSON evidence
argocd app history YOUR_APP_NAME --output json &gt; argocd-sync-history-$(date +%Y%m).json

# Upload to your SOC2 evidence bucket
aws s3 cp argocd-sync-history-$(date +%Y%m).json \
  s3://your-soc2-evidence-bucket/change-management/$(date +%Y/%m)/

# For each deployment, the evidence contains:
# - App name, deployed revision (Git commit SHA)
# - Deployment timestamp
# - Initiating user or automated sync
# - Success/failure status
</code></pre>
<p>Together, the GitHub PR history and the ArgoCD sync history give the auditor a complete, tamper-evident record of every change to your production environment during the observation period.</p>
<h2 id="heading-weeks-710-the-evidence-collection-infrastructure">Weeks 7–10: The Evidence Collection Infrastructure</h2>
<p>Evidence is the difference between passing and failing SOC2.</p>
<p>You might be wondering: what exactly is evidence? In SOC2 terms, evidence is the documentation that proves a specific control was operating correctly during a specific point in time within the observation period. A policy document says you will do something. Evidence proves you did it — and that you did it continuously, not just the week before the audit.</p>
<p>For example:</p>
<ul>
<li><p>For MFA enforcement (Control 1), evidence is a screenshot of your IAM Identity Center MFA settings taken at a specific date during the observation period, combined with an IAM credential report showing zero IAM users with console access.</p>
</li>
<li><p>For GuardDuty (Control 4), evidence is the GuardDuty console screenshot showing active detectors, plus your documented response to any findings during the period.</p>
</li>
<li><p>For access reviews (Control 12), evidence is the completed access review document with dates, names, and specific access changes made.</p>
</li>
</ul>
<p>The challenge is collecting this evidence continuously across 3–12 months without spending hundreds of hours on manual work. The solution is automated evidence collection infrastructure.</p>
<h3 id="heading-the-evidence-bucket-tamper-proof-storage-for-your-audit-evidence">The Evidence Bucket — Tamper-Proof Storage for Your Audit Evidence</h3>
<p>The evidence bucket is an S3 bucket with Object Lock enabled in GOVERNANCE mode. Object Lock prevents any object from being deleted or modified for the retention period you specify — in this case, 365 days. This means once a piece of evidence is uploaded, it can't be altered, even by a user with administrator access (without explicitly overriding the lock, which itself creates an audit trail).</p>
<p>This tamper-evident property is what gives the auditor confidence that the evidence was not created or modified after the fact.</p>
<pre><code class="language-hcl"># terraform/soc2-evidence-bucket.tf

resource "aws_s3_bucket" "soc2_evidence" {
  bucket = "\({var.company_name}-soc2-evidence-\){var.environment}"
}

# Block all public access to the evidence bucket
resource "aws_s3_bucket_public_access_block" "soc2_evidence" {
  bucket = aws_s3_bucket.soc2_evidence.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

# Enable versioning so overwrites create new versions, not replacements
resource "aws_s3_bucket_versioning" "soc2_evidence" {
  bucket = aws_s3_bucket.soc2_evidence.id
  versioning_configuration {
    status = "Enabled"
  }
}

# Object Lock in GOVERNANCE mode — objects cannot be deleted for 365 days
resource "aws_s3_bucket_object_lock_configuration" "soc2_evidence" {
  bucket = aws_s3_bucket.soc2_evidence.id

  rule {
    default_retention {
      mode = "GOVERNANCE"
      days = 365
    }
  }
}

# Encrypt all evidence at rest
resource "aws_s3_bucket_server_side_encryption_configuration" "soc2_evidence" {
  bucket = aws_s3_bucket.soc2_evidence.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}
</code></pre>
<h3 id="heading-the-daily-evidence-collector-lambda">The Daily Evidence Collector Lambda</h3>
<p>This Lambda function runs automatically every day and exports the status of each critical control to a time-stamped JSON file in the evidence bucket. Over your 3–12 month observation period, it creates a daily record proving that your controls were active and operating.</p>
<p>The function checks seven controls automatically: CloudTrail status, GuardDuty status, VPC Flow Logs, S3 public access block, EBS encryption, MFA compliance, and GuardDuty finding count. Each daily snapshot is uploaded with Object Lock enabled so it can't be modified.</p>
<pre><code class="language-python"># lambda/evidence-collector/handler.py

import boto3
import json
from datetime import datetime, timedelta, timezone

def lambda_handler(event, context):
    """
    Daily SOC2 evidence collector.
    Runs at 00:00 UTC every day via EventBridge scheduler.
    Exports control status to S3 evidence bucket with Object Lock.
    """
    evidence = {
        'collection_timestamp': datetime.now(timezone.utc).isoformat(),
        'collection_date': datetime.now(timezone.utc).strftime('%Y-%m-%d'),
        'account_id': boto3.client('sts').get_caller_identity()['Account'],
        'controls': {}
    }

    # Control 3: CloudTrail status
    cloudtrail = boto3.client('cloudtrail')
    trails = cloudtrail.describe_trails(includeShadowTrails=False)['trailList']
    multi_region_trails = [t for t in trails if t.get('IsMultiRegionTrail')]
    evidence['controls']['cloudtrail'] = {
        'status': 'PASS' if multi_region_trails else 'FAIL',
        'detail': f"{len(multi_region_trails)} multi-region trail(s) active",
        'trails': [t['Name'] for t in multi_region_trails]
    }

    # Control 4: GuardDuty status
    guardduty = boto3.client('guardduty')
    detectors = guardduty.list_detectors()['DetectorIds']
    unresolved_critical = 0
    for detector_id in detectors:
        findings = guardduty.list_findings(
            DetectorId=detector_id,
            FindingCriteria={
                'Criterion': {
                    'severity': {'Gte': 7},  # HIGH and CRITICAL only
                    'service.archived': {'Eq': ['false']}
                }
            }
        )
        unresolved_critical += len(findings['FindingIds'])

    evidence['controls']['guardduty'] = {
        'status': 'PASS' if detectors else 'FAIL',
        'detail': f"{len(detectors)} detector(s) active, {unresolved_critical} unresolved HIGH/CRITICAL findings",
        'unresolved_high_critical': unresolved_critical
    }

    # Control 5: VPC Flow Logs
    ec2 = boto3.client('ec2')
    flow_logs = ec2.describe_flow_logs(
        Filters=[{'Name': 'resource-type', 'Values': ['VPC']},
                 {'Name': 'flow-log-status', 'Values': ['ACTIVE']}]
    )['FlowLogs']
    evidence['controls']['vpc_flow_logs'] = {
        'status': 'PASS' if flow_logs else 'FAIL',
        'detail': f"{len(flow_logs)} active VPC flow log(s)",
        'active_flow_logs': len(flow_logs)
    }

    # Control 7: EBS encryption by default
    ebs_encryption = ec2.get_ebs_encryption_by_default()['EbsEncryptionByDefault']
    evidence['controls']['ebs_encryption_by_default'] = {
        'status': 'PASS' if ebs_encryption else 'FAIL',
        'detail': 'EBS encryption by default is enabled' if ebs_encryption else 'EBS encryption by default is NOT enabled'
    }

    # Control 8: S3 Block Public Access (account level)
    s3control = boto3.client('s3control')
    account_id = boto3.client('sts').get_caller_identity()['Account']
    try:
        pab = s3control.get_public_access_block(AccountId=account_id)['PublicAccessBlockConfiguration']
        all_blocked = all([pab['BlockPublicAcls'], pab['IgnorePublicAcls'],
                           pab['BlockPublicPolicy'], pab['RestrictPublicBuckets']])
        evidence['controls']['s3_block_public_access'] = {
            'status': 'PASS' if all_blocked else 'FAIL',
            'detail': 'All four S3 Block Public Access settings enabled' if all_blocked else 'One or more S3 Block Public Access settings not enabled',
            'configuration': pab
        }
    except Exception as e:
        evidence['controls']['s3_block_public_access'] = {'status': 'FAIL', 'detail': str(e)}

    # Upload evidence to S3 with Object Lock
    s3 = boto3.client('s3')
    evidence_key = f"daily/{evidence['collection_date']}/control-status.json"
    lock_until = datetime.now(timezone.utc) + timedelta(days=365)

    s3.put_object(
        Bucket='YOUR_EVIDENCE_BUCKET_NAME',
        Key=evidence_key,
        Body=json.dumps(evidence, indent=2),
        ContentType='application/json',
        ObjectLockMode='GOVERNANCE',
        ObjectLockRetainUntilDate=lock_until
    )

    # Alert if any control fails
    failed_controls = [k for k, v in evidence['controls'].items() if v['status'] == 'FAIL']
    if failed_controls:
        sns = boto3.client('sns')
        sns.publish(
            TopicArn='YOUR_ALERT_TOPIC_ARN',
            Subject=f'SOC2 Control Failure Detected — {evidence["collection_date"]}',
            Message=f'The following controls failed their daily check:\n\n{json.dumps(failed_controls, indent=2)}'
        )

    return {
        'statusCode': 200,
        'controls_checked': len(evidence['controls']),
        'controls_failed': len(failed_controls),
        'evidence_location': f"s3://YOUR_EVIDENCE_BUCKET_NAME/{evidence_key}"
    }
</code></pre>
<h3 id="heading-the-github-actions-evidence-workflow">The GitHub Actions Evidence Workflow</h3>
<p>This workflow runs daily and captures evidence that can't be automated through AWS APIs — GitHub-level controls like branch protection status, recent pull request activity, and CI pipeline results. It exports these as JSON files to the same evidence bucket.</p>
<pre><code class="language-yaml"># .github/workflows/soc2-evidence.yml
name: SOC2 Evidence Collection
on:
  schedule:
    - cron: '0 1 * * *'   # 01:00 UTC daily (after the Lambda runs at 00:00)
  workflow_dispatch:        # Allow manual trigger when needed

permissions:
  contents: read

jobs:
  collect-github-evidence:
    name: Collect GitHub Control Evidence
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v2
        with:
          role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/evidence-collector
          aws-region: us-east-1

      - name: Collect branch protection status
        run: |
          DATE=$(date +%Y-%m-%d)
          mkdir -p evidence/github

          # Export branch protection rules for main
          curl -s -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
            "https://api.github.com/repos/${{ github.repository }}/branches/main/protection" \
            | jq '{
                date: "'$DATE'",
                enforce_admins: .enforce_admins.enabled,
                required_reviews: .required_pull_request_reviews.required_approving_review_count,
                required_status_checks: .required_status_checks.contexts,
                allow_force_pushes: .allow_force_pushes.enabled
              }' &gt; evidence/github/branch-protection-$DATE.json

          echo "Branch protection evidence collected"
          cat evidence/github/branch-protection-$DATE.json

      - name: Upload evidence to S3
        run: |
          DATE=$(date +%Y-%m-%d)
          aws s3 sync evidence/ \
            s3://\({{ secrets.SOC2_EVIDENCE_BUCKET }}/daily/\)DATE/github/ \
            --no-progress
          echo "Evidence uploaded: s3://\({{ secrets.SOC2_EVIDENCE_BUCKET }}/daily/\)DATE/github/"
</code></pre>
<h2 id="heading-weeks-1114-auditor-selection-and-readiness-assessment">Weeks 11–14: Auditor Selection and Readiness Assessment</h2>
<h3 id="heading-how-to-choose-a-soc2-auditor">How to Choose a SOC2 Auditor</h3>
<p>Selecting the right auditor is more consequential than most teams realize. SOC2 audits are conducted by CPA firms — specifically, firms licensed to issue SOC reports. The right firm has experience with cloud-native, SaaS companies your size. The wrong firm could apply enterprise audit frameworks to a seed-stage startup and generate findings based on controls that aren't appropriate to your context.</p>
<p>Here is what to look for and what to watch out for:</p>
<h4 id="heading-experience-matters-more-than-brand">Experience matters more than brand</h4>
<p>A large Big Four firm isn't necessarily better than a specialist boutique auditor for a 20-person SaaS company.</p>
<p>Ask specifically: "How many SOC2 audits have you completed in the last 12 months for SaaS companies between 10 and 50 employees?" You want a firm where this is common, not exceptional.</p>
<h4 id="heading-verify-familiarity-with-your-compliance-tool">Verify familiarity with your compliance tool</h4>
<p>If you're using Vanta or Drata, confirm that the auditor has experience with evidence produced by those platforms. Some auditors prefer to collect evidence directly and are unfamiliar with automated evidence exports. An auditor who doesn't trust your Vanta evidence will ask you to re-collect everything manually.</p>
<h4 id="heading-understand-what-type-ii-actually-costs">Understand what Type II actually costs</h4>
<p>For a Series A SaaS company, expect \(15,000–\)30,000 for a SOC2 Type II audit with a 3-month observation period. A quote below \(10,000 often means the auditor is cutting corners on the review depth. A quote above \)50,000 for a small company typically means the firm is applying enterprise pricing to a startup engagement.</p>
<h4 id="heading-get-references-from-similar-companies">Get references from similar companies</h4>
<p>Ask the auditor for two or three references from SaaS companies they've audited in the last year. Call those references and ask: did the auditor understand cloud infrastructure? Were the findings reasonable? How was the communication during the review?</p>
<p>Here's a summary table of some things to watch out for:</p>
<table>
<thead>
<tr>
<th>Criteria</th>
<th>What to Look For</th>
<th>Red Flag</th>
</tr>
</thead>
<tbody><tr>
<td>Experience</td>
<td>5+ years, 20+ SaaS audits annually</td>
<td>"We have completed several SOC2 audits" (vague)</td>
</tr>
<tr>
<td>Tool familiarity</td>
<td>Has reviewed Vanta/Drata evidence before</td>
<td>Requires manual re-collection of automated evidence</td>
</tr>
<tr>
<td>Company size fit</td>
<td>Has audited companies your size</td>
<td>Only lists enterprise clients as references</td>
</tr>
<tr>
<td>Cost (Type II)</td>
<td>\(15K–\)30K for a 20-person company</td>
<td>Under \(10K or over \)50K without clear justification</td>
</tr>
<tr>
<td>References</td>
<td>Can provide SaaS company contacts to call</td>
<td>Cannot provide references</td>
</tr>
</tbody></table>
<h3 id="heading-how-to-run-a-readiness-assessment-mock-audit">How to Run a Readiness Assessment (Mock Audit)</h3>
<p>A readiness assessment is a self-conducted simulation of the real audit, run 2–4 weeks before you engage the auditor. Its purpose is to find and close gaps before the auditor finds them, because gaps found in a mock audit cost you a week of remediation time, while gaps found in the real audit cost you a conditional report and a re-review.</p>
<p>You can run the readiness assessment yourself or hire a consultant to run it. The consultant approach is more valuable because an independent reviewer will find gaps you have rationalised away.</p>
<p>The process:</p>
<ol>
<li><p><strong>Step 1:</strong> Work through every control in the checklist below and attempt to produce the evidence that an auditor would request.</p>
</li>
<li><p><strong>Step 2:</strong> For every control where you can't produce clear, timestamped evidence: that's a gap. Document it.</p>
</li>
<li><p><strong>Step 3:</strong> Prioritise gaps by type. Evidence gaps (missing evidence for an active control) require evidence collection infrastructure fixes. Control gaps (a control that isn't implemented) require engineering work.</p>
</li>
<li><p><strong>Step 4:</strong> Close all gaps before engaging the real auditor.</p>
</li>
</ol>
<table>
<thead>
<tr>
<th>Control</th>
<th>Evidence Required</th>
<th>How to Verify</th>
<th>Ready?</th>
</tr>
</thead>
<tbody><tr>
<td>MFA enforced</td>
<td>IAM credential report + SSO MFA policy screenshot</td>
<td><code>aws iam get-credential-report</code></td>
<td>⬜</td>
</tr>
<tr>
<td>CloudTrail active</td>
<td>Trail status + S3 delivery confirmation</td>
<td><code>aws cloudtrail get-trail-status</code></td>
<td>⬜</td>
</tr>
<tr>
<td>GuardDuty active</td>
<td>Detector list + finding review log</td>
<td><code>aws guardduty list-detectors</code></td>
<td>⬜</td>
</tr>
<tr>
<td>VPC Flow Logs</td>
<td>Active flow log list + sample log entries</td>
<td><code>aws ec2 describe-flow-logs</code></td>
<td>⬜</td>
</tr>
<tr>
<td>Secrets in Secrets Manager</td>
<td>Secret list + rotation policy confirmation</td>
<td><code>aws secretsmanager list-secrets</code></td>
<td>⬜</td>
</tr>
<tr>
<td>EBS encryption by default</td>
<td>Account-level encryption setting</td>
<td><code>aws ec2 get-ebs-encryption-by-default</code></td>
<td>⬜</td>
</tr>
<tr>
<td>S3 Block Public Access</td>
<td>Account-level PAB configuration</td>
<td><code>aws s3control get-public-access-block</code></td>
<td>⬜</td>
</tr>
<tr>
<td>Branch protection (no admin bypass)</td>
<td>GitHub branch protection API response</td>
<td>GitHub API or Settings UI</td>
<td>⬜</td>
</tr>
<tr>
<td>Trivy scanning in CI</td>
<td>GitHub Actions run history showing scans</td>
<td>GitHub Actions logs</td>
<td>⬜</td>
</tr>
<tr>
<td>Incident response runbook</td>
<td>Written runbook + tabletop exercise notes with date</td>
<td>Document review</td>
<td>⬜</td>
</tr>
<tr>
<td>Access review</td>
<td>Quarterly review document with specific changes made</td>
<td>Document review</td>
<td>⬜</td>
</tr>
<tr>
<td>Backup test</td>
<td>RDS restore log + data verification results</td>
<td>Document review</td>
<td>⬜</td>
</tr>
<tr>
<td>Change management log</td>
<td>GitHub PR history + ArgoCD sync history</td>
<td>GitHub and ArgoCD</td>
<td>⬜</td>
</tr>
</tbody></table>
<p><strong>The one thing most teams skip:</strong> Running the readiness assessment against their own evidence bucket. Pull a random day's evidence from the daily Lambda export and verify that it's complete, timestamped, and accurately reflects the control status on that day.</p>
<p>If the evidence file for December 14th shows GuardDuty as PASS but GuardDuty was actually disabled that day, the auditor will find the discrepancy in the AWS account history — and that's a qualified finding.</p>
<h2 id="heading-weeks-1518-the-observation-period">Weeks 15–18: The Observation Period</h2>
<h3 id="heading-how-the-auditor-observes-your-controls">How the Auditor Observes Your Controls</h3>
<p>The SOC2 auditor doesn't physically visit your office or sit inside your AWS console watching your infrastructure in real time. The audit is a remote, documentation-based process conducted entirely through evidence review.</p>
<p>Here is how it actually works:</p>
<p>First, the auditor provides a list of evidence requests — typically 80–150 items for a Type II audit. You upload the evidence to a shared portal (the auditor provides this — it is usually a secure document sharing platform). The auditor reviews the evidence, asks follow-up questions, and identifies gaps where evidence is missing or a control wasn't operating as described.</p>
<p>For automated controls like CloudTrail and GuardDuty, the evidence is your daily Lambda exports — the auditor spot-checks a sample of daily snapshots across the observation period to verify the controls were consistently active.</p>
<p>For manual controls like access reviews and backup tests, the evidence is the documents you produced when you ran those processes.</p>
<p>The practical implication: the auditor is trusting your evidence. This is why the Object Lock on your evidence bucket matters. It proves to the auditor that the evidence was generated at the time it claims to have been generated and hasn't been modified since.</p>
<h3 id="heading-what-the-auditor-reviews-over-the-observation-period">What the Auditor Reviews Over the Observation Period</h3>
<table>
<thead>
<tr>
<th>What They Check</th>
<th>How Often</th>
<th>What They Are Looking For</th>
</tr>
</thead>
<tbody><tr>
<td>CloudTrail logs</td>
<td>Spot check monthly</td>
<td>Manual console changes that bypassed IaC, gaps in log delivery</td>
</tr>
<tr>
<td>GuardDuty findings</td>
<td>Review quarterly summary</td>
<td>HIGH or CRITICAL findings not remediated within your documented SLA</td>
</tr>
<tr>
<td>Access review completion</td>
<td>Verify each quarterly cycle</td>
<td>Reviews skipped, reviews with no access changes despite employee turnover</td>
</tr>
<tr>
<td>Incident response tests</td>
<td>Verify annually</td>
<td>No tabletop exercise conducted during the observation period</td>
</tr>
<tr>
<td>Evidence collection</td>
<td>Verify continuous coverage</td>
<td>Gaps in daily evidence exports, missing evidence for specific dates</td>
</tr>
<tr>
<td>Change management log</td>
<td>Sample PR/sync history</td>
<td>Deployments with no associated pull request or review</td>
</tr>
</tbody></table>
<h3 id="heading-what-triggers-a-finding">What Triggers a Finding</h3>
<p>A SOC2 finding is the auditor's documented conclusion that a control wasn't operating effectively during the observation period. Findings range from observations (minor issues that don't affect the audit opinion) to qualified opinions (material failures that result in a qualified rather than unqualified report).</p>
<p>Understanding what triggers findings — and which ones restart the observation period — is critical for managing your audit timeline.</p>
<p><strong>Control gaps</strong> occur when a required control isn't implemented or was disabled during the observation period. If you discover in month 2 that MFA wasn't enforced on one IAM user for the first three weeks, you must document the remediation and demonstrate the gap was closed.</p>
<p>Whether this restarts your observation period depends on how long the gap lasted and how the auditor assesses the risk — but a gap of less than 30 days that's immediately remediated and documented typically doesn't restart the clock.</p>
<p><strong>Evidence gaps</strong> are more serious. If your daily Lambda evidence collector failed for two weeks and produced no evidence exports, you have a two-week window with no documented proof that your controls were operating. The auditor can't verify controls they can't see evidence for.</p>
<p>Evidence gaps almost always require extending the observation period because there's no way to retroactively produce evidence for a period that wasn't recorded.</p>
<p><strong>Process failures</strong> occur when a manual control wasn't executed as documented. The most common is an access review that was skipped. Like control gaps, these can typically be remediated without restarting the clock if they're documented promptly and the remediation is clear.</p>
<p><strong>Unpatched critical CVEs</strong> are a special case. If Trivy identifies a CRITICAL vulnerability in a production container and it remains unpatched for more than your documented remediation SLA (typically 30 days for critical, 90 days for high), this is a qualified finding that the auditor will note in the report.</p>
<h3 id="heading-how-to-close-gaps-without-restarting-the-clock">How to Close Gaps Without Restarting the Clock</h3>
<p>When you discover a gap during the observation period:</p>
<p><strong>For control gaps:</strong></p>
<pre><code class="language-plaintext">1. Fix the control immediately — don't wait
2. Document the fix: screenshot, PR link, or CLI command output with timestamp
3. Note the gap date range in your audit log: "Control gap: 2024-03-10 to 2024-03-14 (4 days). Root cause: [X]. Remediated: [Y]. No customer data accessed during gap period."
4. Notify your auditor proactively — they will find it anyway; proactive disclosure is better than defensive explanation
5. The observation period doesn't restart if the gap was short-lived and promptly remediated
</code></pre>
<p><strong>For evidence gaps:</strong></p>
<pre><code class="language-plaintext">1. Fix the evidence collection infrastructure immediately
2. Understand that you can't retroactively generate evidence for the gap period
3. The observation period for affected controls effectively restarts from the date evidence collection resumed
4. If the gap is early in your observation period, you may be able to extend the period rather than restart — discuss with your auditor
</code></pre>
<p><strong>The pro tip:</strong> Set up a CloudWatch alarm that triggers if the evidence Lambda fails to deliver to S3 on schedule. A missing daily evidence file is caught within 24 hours, not discovered during the audit review.</p>
<h2 id="heading-the-90-day-soc2-timeline-at-a-glance">The 90-Day SOC2 Timeline at a Glance</h2>
<table>
<thead>
<tr>
<th>Weeks</th>
<th>Focus</th>
<th>Key Deliverables</th>
<th>Common Mistake</th>
</tr>
</thead>
<tbody><tr>
<td>1–2</td>
<td>Scope</td>
<td>Boundary diagram, network segmentation Terraform</td>
<td>Over-scoping to include dev and staging</td>
</tr>
<tr>
<td>3–6</td>
<td>Controls</td>
<td>14 controls implemented and collecting evidence</td>
<td>Starting controls after the observation period begins</td>
</tr>
<tr>
<td>7–10</td>
<td>Evidence</td>
<td>S3 evidence bucket, Lambda daily collector, GitHub Actions workflow</td>
<td>Manual evidence collection with inevitable gaps</td>
</tr>
<tr>
<td>11–14</td>
<td>Readiness</td>
<td>Mock audit, gap remediation, auditor selected</td>
<td>Skipping the mock audit</td>
</tr>
<tr>
<td>15–18</td>
<td>Observation</td>
<td>Daily evidence, quarterly reviews, incident response test</td>
<td>Discovering evidence gaps during the audit rather than before</td>
</tr>
</tbody></table>
<h2 id="heading-whats-next">What's Next?</h2>
<p>Start with Week 1. Define your SOC2 boundary. Apply the four-question framework to every system in your infrastructure. Draw the diagram in Excalidraw. Document the network segmentation controls.</p>
<p>Then implement the 14 controls in order, starting with MFA and CloudTrail — the two that most commonly fail audits when they're missing.</p>
<p>Then build your evidence collection infrastructure before the observation period starts. The automated Lambda and GitHub Actions workflow are the difference between a smooth audit and a 60-day extension.</p>
<p>One thing to remember: SOC2 is 20% controls, 30% evidence, and 50% continuous operation. Start early. Automate everything. Run a mock audit before you call the real one.</p>
<h2 id="heading-resources">Resources</h2>
<p>The following resources are referenced throughout this guide:</p>
<ul>
<li><p><a href="https://www.aicpa-cima.com/resources/landing/system-and-organization-controls-soc-suite-of-services"><strong>AICPA SOC2 Overview</strong></a> — The official SOC2 documentation from the American Institute of CPAs, including the Trust Service Criteria</p>
</li>
<li><p><a href="https://www.vanta.com/"><strong>Vanta</strong></a> — Compliance automation platform that connects to AWS and GitHub to automate evidence collection and track control status</p>
</li>
<li><p><a href="https://drata.com/"><strong>Drata</strong></a> — Alternative compliance automation platform with similar capabilities to Vanta</p>
</li>
<li><p><a href="https://github.com/aquasecurity/trivy"><strong>Trivy by Aqua Security</strong></a> — Open-source container and filesystem vulnerability scanner used in Control 10</p>
</li>
<li><p><a href="https://excalidraw.com/"><strong>Excalidraw</strong></a> — Free, open-source diagram tool for creating the SOC2 boundary diagram</p>
</li>
<li><p><a href="https://docs.aws.amazon.com/singlesignon/latest/userguide/what-is.html"><strong>AWS IAM Identity Center documentation</strong></a> — Official AWS documentation for setting up SSO and MFA enforcement</p>
</li>
<li><p><a href="https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches"><strong>GitHub branch protection documentation</strong></a> — Official GitHub documentation for configuring branch protection rules</p>
</li>
<li><p><a href="https://argo-cd.readthedocs.io/"><strong>ArgoCD documentation</strong></a> — Official ArgoCD documentation for GitOps deployment and sync history</p>
</li>
</ul>
<p><a href="https://github.com/aayostem">Ayobami Adejumo</a> <em>is a senior platform engineer and FinOps specialist. He writes about SOC2 compliance engineering, Kubernetes cost optimization, and platform engineering.</em></p>
 ]]>
                </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;margin:0 auto" 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;margin:0 auto" 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 Set Up OpenID Connect (OIDC) in GitHub Actions for AWS
 ]]>
                </title>
                <description>
                    <![CDATA[ If you've been storing AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY as GitHub Secrets to deploy to AWS, you're not alone. It's the most common approach and it's also one of the biggest security risks i ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-set-up-openid-connect-oidc-in-github-actions-for-aws/</link>
                <guid isPermaLink="false">69ef7bbf330a1ad7f7f2d579</guid>
                
                    <category>
                        <![CDATA[ OpenID Connect ]]>
                    </category>
                
                    <category>
                        <![CDATA[ OIDC ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AWS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GitHub Actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Tolani Akintayo ]]>
                </dc:creator>
                <pubDate>Mon, 27 Apr 2026 15:07:43 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/83b71e24-b63b-42a4-ac1c-d59e226da6c3.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've been storing <code>AWS_ACCESS_KEY_ID</code> and <code>AWS_SECRET_ACCESS_KEY</code> as GitHub Secrets to deploy to AWS, you're not alone. It's the most common approach and it's also one of the biggest security risks in a CI/CD pipeline.</p>
<p>Here's why: static credentials don't expire on their own. If they get leaked through a misconfigured workflow, a public fork, or a compromised repository, an attacker has persistent access to your AWS environment until you manually rotate them. And most teams don't rotate them often enough.</p>
<p>OpenID Connect (OIDC) solves this entirely. Instead of storing long-lived credentials, GitHub Actions requests a <strong>short-lived token</strong> directly from AWS every time your workflow runs. No secrets to rotate. No credentials to leak. No manual key management.</p>
<p>In this tutorial, you'll learn how to set up OIDC authentication between GitHub Actions and AWS from scratch. By the end, your workflows will authenticate to AWS securely without storing a single access key.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-openid-connect-oidc">What Is OpenID Connect (OIDC)?</a></p>
</li>
<li><p><a href="#heading-how-oidc-works-between-github-actions-and-aws">How OIDC Works Between GitHub Actions and AWS</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-step-1-create-an-iam-oidc-identity-provider-in-aws">Step 1: Create an IAM OIDC Identity Provider in AWS</a></p>
<p><a href="#heading-step-2-create-an-iam-role-with-a-trust-policy">Step 2: Create an IAM Role with a Trust Policy</a></p>
<p><a href="#heading-step-3-attach-permissions-to-the-iam-role">Step 3: Attach Permissions to the IAM Role</a></p>
<p><a href="#heading-step-4-store-the-role-arn-as-a-github-actions-variable">Step 4: Store the Role ARN as a GitHub Actions Variable</a></p>
<p><a href="#heading-step-5-configure-your-github-actions-workflow">Step 5: Configure Your GitHub Actions Workflow</a></p>
<p><a href="#heading-step-6-run-and-verify-your-workflow">Step 6: Run and Verify Your Workflow</a></p>
</li>
<li><p><a href="#heading-security-best-practices">Security Best Practices</a></p>
</li>
<li><p><a href="#heading-troubleshooting-common-errors">Troubleshooting Common Errors</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-openid-connect-oidc">What Is OpenID Connect (OIDC)?</h2>
<p>OpenID Connect is an identity protocol built on top of OAuth 2.0. It allows systems to verify identity through tokens rather than shared secrets.</p>
<p>In the context of GitHub Actions and AWS:</p>
<ul>
<li><p><strong>GitHub</strong> acts as the <strong>identity provider (IdP)</strong>. It issues a signed JWT (JSON Web Token) for each workflow run.</p>
</li>
<li><p><strong>AWS</strong> acts as the <strong>service provider</strong>. It validates that token against GitHub's public keys and exchanges it for temporary AWS credentials. The credentials AWS returns are short-lived (valid for up to 1 hour by default) and scoped to exactly the IAM role you define. When the workflow ends, those credentials are gone.</p>
</li>
</ul>
<p>This model is called <strong>federated identity</strong>. It's the same concept used when you "Sign in with Google" on a third-party website. The difference is that instead of a user signing in, your workflow is the one authenticating.</p>
<h2 id="heading-how-oidc-works-between-github-actions-and-aws">How OIDC Works Between GitHub Actions and AWS</h2>
<p>Before writing a single line of YAML, it beneficial to understand the flow. This is my personal approach when implementing new technologies or concepts. Here's what happens every time your workflow runs:</p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/8b5b39de-f671-4ffe-a2db-96d10ade69b3.jpg" alt="Diagram showing the OIDC authentication flow between GitHub Actions and AWS" style="display:block;margin:0 auto" width="449" height="544" loading="lazy">

<p>The diagram illustrates a secure authentication flow between GitHub Actions and AWS using OpenID Connect (OIDC), eliminating the need to store long-lived AWS credentials in GitHub. Here's what happens step-by-step:</p>
<p><strong>1. Initial Authentication Request</strong></p>
<p>When your GitHub Actions workflow starts, the runner (the virtual machine executing your workflow) requests a JSON Web Token (JWT) from GitHub's OIDC provider located at <code>https://token.actions.githubusercontent.com</code>.</p>
<p><strong>2. Token Issuance</strong></p>
<p>GitHub's OIDC provider generates and signs a JWT containing important claims (metadata) about your workflow. These claims include details like which repository the workflow is running from, which branch triggered it, what environment it's running in, and other contextual information that proves the workflow's identity.</p>
<p><strong>3. Token Validation</strong></p>
<p>The GitHub Actions runner presents this signed JWT to AWS Security Token Service (STS). AWS STS validates the JWT's signature by checking it against GitHub's publicly available cryptographic keys, ensuring the token is authentic and hasn't been tampered with.</p>
<p><strong>4. Trust Policy Verification</strong></p>
<p>AWS STS checks the trust policy configured on your IAM Role. This trust policy specifies which GitHub repositories, branches, or environments are allowed to assume this role. If the claims in the JWT match your trust policy conditions, authentication succeeds.</p>
<p><strong>5. Temporary Credentials Issued</strong></p>
<p>Once validated, AWS STS returns temporary security credentials to the GitHub Actions runner. These credentials include an Access Key ID, Secret Access Key, and Session Token that are valid for a limited time (typically 1 hour by default, configurable up to 12 hours).</p>
<p><strong>6. AWS API Access</strong></p>
<p>The GitHub Actions runner uses these temporary credentials to authenticate API calls to your AWS resources such as pushing Docker images to ECR, updating ECS services, writing to S3 buckets, or invoking Lambda functions.</p>
<p>The key point: <strong>AWS never sees your GitHub credentials, and GitHub never sees your AWS credentials.</strong> The JWT is the only thing exchanged and it's signed, scoped, and short-lived.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have the following in place:</p>
<ul>
<li><p>An <strong>AWS account</strong> with IAM permissions to create identity providers and roles</p>
</li>
<li><p>A <strong>GitHub repository</strong> (public or private) where your workflows will run</p>
</li>
<li><p>Basic familiarity with <strong>GitHub Actions</strong>, knowing how to write a <code>.yml</code> workflow file</p>
</li>
<li><p>Basic familiarity with <strong>AWS IAM</strong> roles, policies, and permissions</p>
</li>
<li><p>The <strong>AWS CLI</strong> installed and configured (optional, but useful for verification). You don't need to be an AWS expert. Each step includes the exact console path and the configuration values you need.</p>
</li>
</ul>
<h2 id="heading-step-1-create-an-iam-oidc-identity-provider-in-aws">Step 1: Create an IAM OIDC Identity Provider in AWS</h2>
<p>The first thing you need to do is tell AWS to trust GitHub as an identity provider. This is a one-time setup per AWS account.</p>
<h3 id="heading-how-to-do-it-in-the-aws-console">How to Do It in the AWS Console</h3>
<p>1. Open the <a href="https://console.aws.amazon.com/iam/">AWS IAM Console</a></p>
<p>2. In the left sidebar, click Identity providers</p>
<p>3. Click Add provider</p>
<p>4. For Provider type, select OpenID Connect</p>
<p>5. For Provider URL, enter:</p>
<pre><code class="language-plaintext">https://token.actions.githubusercontent.com
</code></pre>
<p>6. For Audience, enter:</p>
<pre><code class="language-plaintext">sts.amazonaws.com
</code></pre>
<p>7. Click Add provider</p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/66f1de9d-36f9-462e-ad0c-090b152be6e5.png" alt="AWS IAM console showing the Add Identity Provider form configured for GitHub Actions OIDC" style="display:block;margin:0 auto" width="1349" height="609" loading="lazy">

<h3 id="heading-how-to-do-it-with-the-aws-cli">How to Do It with the AWS CLI</h3>
<p>If you prefer the terminal, run this command:</p>
<pre><code class="language-shell">aws iam create-open-id-connect-provider \
  --url https://token.actions.githubusercontent.com \
  --client-id-list sts.amazonaws.com \
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/4b779fa0-0df2-4bc3-bbf4-9839ef8ce5e6.png" alt="terminal-oidc-connect-created" style="display:block;margin:0 auto" width="966" height="114" loading="lazy">

<p>Once created, you'll see <code>token.actions.githubusercontent.com</code> listed under <strong>Identity providers</strong> in your IAM console. This provider will be referenced in your IAM role's trust policy in the next step.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/eb820487-6553-43d2-b6b7-4e7b08d039ef.png" alt="verify oidc connect in AWS" style="display:block;margin:0 auto" width="1132" height="284" loading="lazy">

<h2 id="heading-step-2-create-an-iam-role-with-a-trust-policy">Step 2: Create an IAM Role with a Trust Policy</h2>
<p>Now you need an IAM role that your GitHub Actions workflow will assume. The trust policy on this role controls which repositories and branches are allowed to request credentials.</p>
<h3 id="heading-how-to-create-the-iam-role-in-the-aws-console">How to Create the IAM Role in the AWS Console</h3>
<p>1. Open the <a href="https://console.aws.amazon.com/iam/">AWS IAM Console</a></p>
<p>2. In the left sidebar, click <strong>Roles</strong></p>
<p>3. Click <strong>Create role</strong></p>
<p>4. For <strong>Trusted entity type</strong>, select <strong>Web identity</strong></p>
<p>5. For <strong>Identity Provider</strong>, choose: <code>token.actions.githubusercontent.com</code> which you created earlier.</p>
<p>6. For Audience, choose <code>sts.amazonaws.com</code> as well</p>
<p>7. For GitHub organisation, enter your GitHub username or organization name</p>
<p>8. For GitHub repository, enter your GitHub repository</p>
<p>9. For GitHub branch, enter your branch name (for example, main)</p>
<p>10. Click Next, then Next, give a name to the role and click create role</p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/dca12969-db8a-4ec4-885e-e953f4808f6c.png" alt="create-iam-role-for-github-action-via-the-console" style="display:block;margin:0 auto" width="1351" height="620" loading="lazy">

<p>Note: Creating the IAM role using this approach already establishes the <strong>Trusted Entities</strong> using a trusted policy based on the step 4-9 above. You can verify this by clicking on the created role and navigating to Trust relationships.</p>
<h3 id="heading-how-to-create-the-iam-role-with-the-aws-cli">How to Create the IAM Role with the AWS CLI</h3>
<p>First, you'll need to create a trust policy document on your local machine: You can call it <code>trust-policy.json</code>:</p>
<pre><code class="language-json">{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Principal": {
        "Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
      },
      "Action": "sts:AssumeRoleWithWebIdentity",
      "Condition": {
        "StringEquals": {
          "token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
        },
        "StringLike": {
          "token.actions.githubusercontent.com:sub": "repo:YOUR_GITHUB_ORG/YOUR_REPO_NAME:*"
        }
      }
    }
  ]
}
</code></pre>
<p>Replace the following placeholders before saving:</p>
<table>
<thead>
<tr>
<th>Placeholder</th>
<th>Replace With</th>
</tr>
</thead>
<tbody><tr>
<td><code>YOUR_ACCOUNT_ID</code></td>
<td>Your 12-digit AWS account ID</td>
</tr>
<tr>
<td><code>YOUR_GITHUB_ORG</code></td>
<td>Your GitHub username or organization name</td>
</tr>
<tr>
<td><code>YOUR_REPO_NAME</code></td>
<td>The name of your GitHub repository</td>
</tr>
</tbody></table>
<h3 id="heading-how-to-understand-the-sub-condition">How to Understand the <code>sub</code> Condition</h3>
<p>The <code>sub (subject)</code> claim in the JWT tells AWS exactly where the request is coming from. The value <code>repo:your-org/your-repo:*</code> means any branch in that repository can assume this role.</p>
<p>You can tighten this further depending on your needs:</p>
<pre><code class="language-shell"># Only the main branch
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
 
# Only a specific GitHub Environment
"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:environment:production"
</code></pre>
<p>Scoping this correctly is one of the most important security decisions in this setup. Here's how to decide:</p>
<ul>
<li><p>Use <code>ref:refs/heads/main</code> if only your main/production branch should deploy to AWS. This is the most restrictive and secure option: feature branches can't accidentally (or maliciously) trigger deployments or modify production resources.</p>
</li>
<li><p>Use <code>environment:production</code> if you're using GitHub Environments with protection rules (required reviewers, deployment gates). This lets you control deployments through GitHub's approval workflow while still restricting which workflows can access AWS.</p>
</li>
<li><p>Use <code>repo:your-org/your-repo:*</code> (wildcard) only if you need any branch to deploy. for example, in development environments where every feature branch deploys to its own isolated stack. Never use this for production roles.</p>
</li>
</ul>
<p>Run this command to create the role using your trust policy:</p>
<pre><code class="language-shell">aws iam create-role \
  --role-name GitHubActionsOIDCRole \
  --assume-role-policy-document file://trust-policy.json \
  --description "Role assumed by GitHub Actions via OIDC"
</code></pre>
<p>Take note of the <strong>Role ARN</strong> in the output. It will look like this:</p>
<pre><code class="language-plaintext">arn:aws:iam::YOUR_ACCOUNT_ID:role/GitHubActionsOIDCRole
</code></pre>
<p>You'll need this ARN in your workflow YAML in Step 4.</p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/6bb154e7-0fb3-4c58-94e1-90116eaea95a.png" alt="terminal output of the AWS CLI create-role command showing the returned Role ARN" style="display:block;margin:0 auto" width="1123" height="615" loading="lazy">

<h2 id="heading-step-3-attach-permissions-to-the-iam-role">Step 3: Attach Permissions to the IAM Role</h2>
<p>The IAM role can now authenticate, but it has no permissions yet. You need to attach a policy that defines what your workflow is actually allowed to do in AWS.</p>
<h3 id="heading-how-to-apply-the-principle-of-least-privilege">How to Apply the Principle of Least Privilege</h3>
<p>Only grant the permissions your workflow genuinely needs. If your workflow deploys to S3, give it S3 permissions. If it pushes images to ECR, give it ECR permissions. Never attach <code>AdministratorAccess</code> to a CI/CD role.</p>
<h4 id="heading-option-1-attach-an-aws-managed-policy-quick-start">Option 1: Attach an AWS managed policy (quick start):</h4>
<pre><code class="language-shell">aws iam attach-role-policy \
  --role-name GitHubActionsOIDCRole \
  --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess
</code></pre>
<h4 id="heading-option-2-create-a-custom-policy-scoped-to-a-specific-s3-bucket-recommended-for-production">Option 2: Create a custom policy scoped to a specific S3 bucket (recommended for production):</h4>
<p>This approach is recommended for production because it limits the blast radius of a security incident. If your workflow credentials are ever compromised, a custom policy scoped to a specific bucket means an attacker can only affect that single bucket not every S3 bucket in your AWS account. It also prevents accidental misconfigurations in your workflow from impacting unrelated resources.</p>
<p>Create a file called <code>s3-deploy-policy.json</code>:</p>
<pre><code class="language-json">{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:DeleteObject",
        "s3:ListBucket"
      ],
      "Resource": [
        "arn:aws:s3:::your-bucket-name",
        "arn:aws:s3:::your-bucket-name/*"
      ]
    }
  ]
}
</code></pre>
<p>Then create and attach it:</p>
<pre><code class="language-shell">aws iam create-policy \
  --policy-name GitHubActionsS3DeployPolicy \
  --policy-document file://s3-deploy-policy.json
 
aws iam attach-role-policy \
  --role-name GitHubActionsOIDCRole \
  --policy-arn arn:aws:iam::YOUR_ACCOUNT_ID:policy/GitHubActionsS3DeployPolicy
</code></pre>
<p>Note: You can as well implement <strong>Step 3</strong> via the console.</p>
<p><strong>Reference:</strong> For a full list of available AWS IAM actions, see the <a href="https://docs.aws.amazon.com/service-authorization/latest/reference/reference_policies_actions-resources-contextkeys.html">AWS IAM actions reference</a>.</p>
<h2 id="heading-step-4-store-the-role-arn-as-a-github-actions-variable">Step 4: Store the Role ARN as a GitHub Actions Variable</h2>
<p>Before you configure your workflow, you need to make the Role ARN available to it. You'll store it as a repository variable in GitHub, not a secret, because the ARN itself isn't sensitive data.</p>
<h3 id="heading-how-to-add-the-variable-in-your-repository">How to Add the Variable in Your Repository</h3>
<p>First, open your GitHub repository and click <strong>Settings:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/b2dd526a-00ca-44eb-8d22-b78dfd220a14.png" alt="GitHub repository top navigation bar with the Settings tab highlighted" style="display:block;margin:0 auto" width="1310" height="307" loading="lazy">

<p>In the left sidebar, scroll down to <strong>Secrets and variables</strong>, then click <strong>Actions:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/61d67c83-7bbc-4570-93ec-f2ee4207ad6e.png" alt="GitHub repository settings sidebar showing Secrets and variables expanded with Actions selected" style="display:block;margin:0 auto" width="1266" height="325" loading="lazy">

<p>Then click the <strong>Variables</strong> tab (not Secrets). Click New repository variable – you can set the <strong>Name</strong> to:</p>
<pre><code class="language-plaintext">AWS_ROLE_ARN
</code></pre>
<p>Set the <strong>Value</strong> to your Role ARN from Step 2, for example:</p>
<pre><code class="language-plaintext">arn:aws:iam::YOUR_ACCOUNT_ID::role/GitHubActionsOIDCRole
</code></pre>
<p>Click <strong>Add variable:</strong></p>
<img src="https://cdn.hashnode.com/uploads/covers/65a5bfab4c73b29396c0b895/71f5468d-d4ab-45c1-aecd-8509f575237a.png" alt="GitHub repository Actions variables tab showing AWS_ROLE_ARN variable added successfully" style="display:block;margin:0 auto" width="1083" height="377" loading="lazy">

<p>You'll reference this variable in your workflow in the next step using <code>${{</code> <code>vars.AWS_ROLE_ARN }}</code>.</p>
<h2 id="heading-step-5-configure-your-github-actions-workflow">Step 5: Configure Your GitHub Actions Workflow</h2>
<p>With AWS and GitHub fully configured, you now need to update your workflow to request an OIDC token and use it to authenticate.</p>
<h3 id="heading-how-to-set-the-required-workflow-permissions">How to Set the Required Workflow Permissions</h3>
<p>Your workflow <strong>must</strong> declare <code>id-token: write</code>. Without this, GitHub won't issue an OIDC token to the runner.</p>
<pre><code class="language-yaml">permissions:
  id-token: write   # Required to request the OIDC JWT
  contents: read    # Required to checkout the repository
</code></pre>
<p><strong>Important:</strong> If you set permissions at the job level, they override any top-level permissions. Make sure <code>id-token: write</code> is present at whichever level your AWS authentication step runs.</p>
<h3 id="heading-full-workflow-example">Full Workflow Example</h3>
<p>Here's a complete workflow that authenticates to AWS using OIDC and deploys a static site to S3:</p>
<pre><code class="language-yaml">name: Deploy to AWS S3
 
on:
  push:
    branches:
      - main
 
permissions:
  id-token: write
  contents: read
 
jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
 
    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: ${{ vars.AWS_ROLE_ARN }}
          aws-region: us-east-2
 
      - name: Verify AWS identity
        run: aws sts get-caller-identity
 
      - name: Deploy to S3
        run: |
          aws s3 sync ./code s3://your-bucket-name
</code></pre>
<p>Replace the following before committing:</p>
<table>
<thead>
<tr>
<th>Placeholder</th>
<th>Replace With</th>
</tr>
</thead>
<tbody><tr>
<td><code>AWS_ROLE_ARN</code></td>
<td>The variable name for your IAM role ARN in GitHub</td>
</tr>
<tr>
<td><code>us-east-2</code></td>
<td>Your target AWS region</td>
</tr>
<tr>
<td><code>your-bucket-name</code></td>
<td>Your S3 bucket name</td>
</tr>
<tr>
<td><code>./code</code></td>
<td>The local directory where the file you want to sync to S3 is located</td>
</tr>
</tbody></table>
<p>You can see the code sample in my GitHub Repo <a href="https://github.com/tolani-akintayo/OpenID-Connect-in-GitHub-Actions-for-AWS">here</a>.</p>
<p><strong>Note:</strong> The <code>aws-actions/configure-aws-credentials</code> action handles the entire OIDC token exchange automatically. It requests the JWT from GitHub, calls <code>sts:AssumeRoleWithWebIdentity</code>, and exports the temporary credentials as environment variables for the rest of the job.</p>
<p>See the <a href="https://github.com/aws-actions/configure-aws-credentials">action's official documentation</a> for all available options.</p>
<h2 id="heading-step-6-run-and-verify-your-workflow">Step 6: Run and Verify Your Workflow</h2>
<p>Push your workflow to the <code>main</code> branch and open the <strong>Actions</strong> tab in your repository to watch it run.</p>
<h3 id="heading-what-a-successful-run-looks-like">What a Successful Run Looks Like</h3>
<p>The Configure AWS credentials via OIDC step should show:</p>
<pre><code class="language-plaintext">Assuming role with OIDC: arn:aws:iam::YOUR_ACCOUNT_ID:role/GitHubActionsOIDCRole
</code></pre>
<p>The Verify AWS identity step (<code>aws sts get-caller-identity</code>) should return:</p>
<pre><code class="language-json">{
    "UserId": "AROA...:GitHubActions",
    "Account": "YOUR_ACCOUNT_ID",
    "Arn": "arn:aws:sts::YOUR_ACCOUNT_ID:assumed-role/GitHubActionsOIDCRole/GitHubActions"
}
</code></pre>
<p>If you see an <code>assumed-role</code> ARN in the output, OIDC is working correctly. Your workflow is now authenticating to AWS without a single stored credential.</p>
<h2 id="heading-security-best-practices">Security Best Practices</h2>
<p>Getting OIDC working is step one. Locking it down properly is step two.</p>
<h3 id="heading-scope-the-sub-condition-as-tightly-as-possible">Scope the <code>sub</code> Condition as Tightly as Possible</h3>
<p>Don't use a wildcard like <code>repo:your-org/*:*</code> that allows any repository in your organization to assume the role. Scope it to the exact repository and branch that needs access.</p>
<pre><code class="language-json">"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:ref:refs/heads/main"
</code></pre>
<h3 id="heading-use-github-environments-for-production-deployments">Use GitHub Environments for Production Deployments</h3>
<p>GitHub Environments let you add manual approval gates and restrict which branches can deploy. When combined with OIDC, you can scope your trust policy to only allow the <code>production</code> environment:</p>
<pre><code class="language-json">"token.actions.githubusercontent.com:sub": "repo:your-org/your-repo:environment:production"
</code></pre>
<h3 id="heading-apply-least-privilege-permissions-to-every-iam-role">Apply Least-Privilege Permissions to Every IAM Role</h3>
<p>Never attach <code>AdministratorAccess</code> or <code>PowerUserAccess</code> to a role used by CI/CD. Define a custom policy with only the actions your workflow actually needs.</p>
<h3 id="heading-create-separate-iam-roles-per-environment">Create Separate IAM Roles Per Environment</h3>
<p>A staging role and a production role should have different permission scopes. Your staging deployment role should never have write access to production resources.</p>
<h3 id="heading-enable-aws-cloudtrail">Enable AWS CloudTrail</h3>
<p>Every call made using the temporary credentials is logged in CloudTrail under the assumed role ARN. This gives you a full audit trail of exactly what your workflow did in AWS.</p>
<p><strong>Reference:</strong> GitHub's official security hardening guide for OIDC: <a href="https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect">About security hardening with OpenID Connect</a></p>
<h2 id="heading-troubleshooting-common-errors">Troubleshooting Common Errors</h2>
<h3 id="heading-error-not-authorized-to-perform-stsassumerolewithwebidentity">Error: <code>Not authorized to perform sts:AssumeRoleWithWebIdentity</code></h3>
<p>This usually means the trust policy on your IAM role doesn't match the <code>sub</code> claim in the JWT.</p>
<p>Check the following:</p>
<ul>
<li><p>The <code>sub</code> condition exactly matches your repository path (it is case-sensitive)</p>
</li>
<li><p>The <code>aud</code> condition is set to <code>sts.amazonaws.com</code></p>
</li>
<li><p>The <code>Federated</code> principal uses the correct AWS account ID</p>
</li>
</ul>
<p>To inspect the actual token claims your workflow is receiving, add this debug step temporarily:</p>
<pre><code class="language-yaml">- name: Print OIDC token claims
  run: |
    TOKEN=\((curl -s -H "Authorization: Bearer \)ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
      "$ACTIONS_ID_TOKEN_REQUEST_URL&amp;audience=sts.amazonaws.com" | jq -r '.value')
    echo $TOKEN | cut -d '.' -f2 | base64 -d 2&gt;/dev/null | jq .
</code></pre>
<h3 id="heading-error-could-not-load-credentials-from-any-providers">Error: <code>Could not load credentials from any providers</code></h3>
<p>This almost always means <code>id-token: write</code> is missing from your workflow permissions. Double-check that you have:</p>
<pre><code class="language-yaml">permissions:
  id-token: write
  contents: read
</code></pre>
<h3 id="heading-error-accessdenied-when-calling-an-aws-service">Error: <code>AccessDenied</code> When Calling an AWS Service</h3>
<p>Authentication succeeded but the IAM role doesn't have permission to perform the action your workflow is attempting. Check the permissions policy attached to your role and compare it against the specific action in the error message.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've gone from storing static, long-lived AWS credentials in GitHub Secrets to a fully keyless authentication setup using OIDC. Here's what you accomplished:</p>
<ul>
<li><p>Registered GitHub as a trusted OIDC identity provider in AWS.</p>
</li>
<li><p>Created an IAM role with a scoped trust policy tied to a specific repository.</p>
</li>
<li><p>Attached least-privilege permissions to that role.</p>
</li>
<li><p>Configured your GitHub Actions workflow to request and use short-lived AWS credentials.</p>
</li>
<li><p>Verified the authentication flow end-to-end.</p>
</li>
</ul>
<p>This pattern works across every AWS service from S3, ECS, Lambda, ECR, Secrets Manager, and more. The workflow example here uses S3, but you only need to swap out the permissions policy and the deployment commands to adapt it for any service.</p>
<p>If you want to go further, explore:</p>
<ul>
<li><p><a href="https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#supported-cloud-providers">Configuring OIDC for multiple cloud providers</a>: Azure, GCP, and HashiCorp Vault.</p>
</li>
<li><p><a href="https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment">GitHub Environments and deployment protection rules</a>: for multi-stage pipelines with approval gates.</p>
</li>
<li><p><a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/what-is-access-analyzer.html">AWS IAM Access Analyzer</a>: to validate and tighten your role policies automatically.</p>
</li>
</ul>
<p><em>If you're building out your DevOps practice and want a complete, production-ready reference for infrastructure automation, CI/CD, and platform engineering, check out</em> <a href="https://coachli.co/tolani-akintayo/PR-H4oQS"><em><strong>The Startup DevOps Field Guide</strong></em></a><em>. It covers the patterns, templates, and runbooks I've used across real AWS environments.</em></p>
<p><em>You can also connect with me on</em> <a href="https://www.linkedin.com/in/tolani-akintayo"><em>LinkedIn</em></a></p>
<h2 id="heading-references">References</h2>
<ul>
<li><p><a href="https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect">GitHub Docs: About security hardening with OpenID Connect</a></p>
</li>
<li><p><a href="https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services">GitHub Docs: Configuring OpenID Connect in Amazon Web Services</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html">AWS Docs: Creating OpenID Connect (OIDC) identity providers</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html">AWS Docs: AssumeRoleWithWebIdentity API Reference</a></p>
</li>
<li><p><a href="https://github.com/aws-actions/configure-aws-credentials">aws-actions/configure-aws-credentials - GitHub</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/service-authorization/latest/reference/reference_policies_actions-resources-contextkeys.html">AWS IAM Actions Reference</a></p>
</li>
<li><p><a href="https://docs.aws.amazon.com/awscloudtrail/latest/userguide/cloudtrail-user-guide.html">AWS CloudTrail User Guide</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How I Built a Production-Ready CI/CD Pipeline for a Monorepo-Based Microservices System with Jenkins, Docker Compose, and Traefik ]]>
                </title>
                <description>
                    <![CDATA[ This tutorial is a complete, real-world guide to building a production-ready CI/CD pipeline using Jenkins, Docker Compose, and Traefik on a single Linux server. You’ll learn how to expose services on  ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-production-ready-ci-cd-pipeline-for-monorepo-based-microservices-system/</link>
                <guid isPermaLink="false">69ea60c8904b915438a58ca2</guid>
                
                    <category>
                        <![CDATA[ Jenkins ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Docker ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Traefik ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Md Tarikul Islam ]]>
                </dc:creator>
                <pubDate>Thu, 23 Apr 2026 18:11:20 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/66cb39fcaa2a09f9a8d691c1/d59c62f5-e376-4f09-851f-83e437f9960a.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>This tutorial is a complete, real-world guide to building a production-ready CI/CD pipeline using Jenkins, Docker Compose, and Traefik on a single Linux server.</p>
<p>You’ll learn how to expose services on a custom domain with auto-renewing HTTPS, and implement a smart deployment strategy that detects changes and redeploys only the affected microservices. This helps avoid unnecessary full-stack redeploys. We'll also cover real production issues and the exact fixes for each one.</p>
<h2 id="heading-table-of-contents"><strong>Table of Contents</strong></h2>
<ul>
<li><p><a href="#heading-1-what-youll-build">1. What you'll build</a></p>
</li>
<li><p><a href="#heading-2-architecture">2. Architecture</a></p>
</li>
<li><p><a href="#heading-3-server-prerequisites">3. Server prerequisites</a></p>
</li>
<li><p><a href="#heading-4-traefik-the-reverse-proxy">4. Traefik — the reverse proxy</a></p>
</li>
<li><p><a href="#heading-5-run-jenkins-in-docker">5. Run Jenkins in Docker</a></p>
</li>
<li><p><a href="#heading-6-expose-jenkins-on-a-domain-via-traefik">6. Expose Jenkins on a domain via Traefik</a></p>
</li>
<li><p><a href="#heading-7-first-time-jenkins-setup">7. First-time Jenkins setup</a></p>
</li>
<li><p><a href="#heading-8-add-the-github-credential">8. Add the GitHub credential</a></p>
</li>
<li><p><a href="#heading-9-create-the-pipeline-job">9. Create the pipeline job</a></p>
</li>
<li><p><a href="#heading-10-the-jenkinsfile-deploy-only-what-changed">10. The Jenkinsfile (deploy only what changed)</a></p>
</li>
<li><p><a href="#heading-11-end-to-end-test">11. End-to-end test</a></p>
</li>
<li><p><a href="#heading-12-troubleshooting-every-error-we-hit">12. Troubleshooting — every error we hit</a></p>
</li>
<li><p><a href="#heading-13-mental-model-host-vs-container">13. Mental model: host vs. container</a></p>
</li>
<li><p><a href="#heading-14-daily-operations-cheat-sheet">14. Daily operations cheat sheet</a></p>
</li>
<li><p><a href="#heading-15-what-id-do-differently-next-time">15. What I'd do differently next time</a></p>
</li>
<li><p><a href="#heading-closing-thoughts">Closing thoughts</a></p>
</li>
</ul>
<h2 id="heading-1-what-youll-build">1. What You'll Build</h2>
<p>In this tutorial, you'll build a Jenkins instance running inside Docker on the same Linux server as your application stack.</p>
<p>Traefik will act as a reverse proxy in front of Jenkins, exposing it via a clean URL (<a href="https://jenkins.example.com"><code>https://jenkins.example.com</code></a>) with <strong>auto-renewing Let's Encrypt certificates</strong>.</p>
<p>You'll also create a Jenkinsfile in your application repository that:</p>
<ul>
<li><p>Automatically triggers on every push to the <code>staging</code> branch,</p>
</li>
<li><p>Detects which microservices changed in each commit,</p>
</li>
<li><p>Pulls the latest code on the host machine,</p>
</li>
<li><p>Rebuilds and restarts <strong>only the affected services</strong>.</p>
</li>
</ul>
<p>On every push, only the relevant services are redeployed.</p>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>Before jumping in, this guide assumes you’re already comfortable with a few core concepts and tools.</p>
<p>This isn't a beginner-level tutorial — we’ll be working directly with infrastructure, containers, and CI/CD pipelines.</p>
<p>You should be familiar with:</p>
<ul>
<li><p>Basic Linux commands (SSH, file system navigation, permissions)</p>
</li>
<li><p>Docker fundamentals (images, containers, volumes, networks)</p>
</li>
<li><p>Git workflows (clone, pull, branches)</p>
</li>
<li><p>General idea of CI/CD pipelines</p>
</li>
</ul>
<p>Tools and environment required:</p>
<ul>
<li><p>A Linux server (Ubuntu recommended)</p>
</li>
<li><p>Docker Engine + Docker Compose (v2)</p>
</li>
<li><p>A domain name (for Traefik + HTTPS)</p>
</li>
<li><p>GitHub repository (for your backend project)</p>
</li>
<li><p>Basic understanding of microservices architecture</p>
</li>
</ul>
<p>If you’re comfortable with the above, you’re ready to follow along.</p>
<h2 id="heading-2-architecture">2. Architecture</h2>
<p>Here's an overview of the architecture:</p>
<pre><code class="language-plaintext">┌──────────────────────────── Linux server (Ubuntu) ────────────────────────────┐
│                                                                               │
│   /home/developer/projects/                                                  │
│       └── project-prod-configs/             ← infra repo (compose, Traefik) │
│              ├── docker-compose.staging.yml                                   │
│              ├── traefik.staging.yml                                          │
│              └── project-backend/          ← app repo (services, gateways) │
│                     ├── Jenkinsfile                                           │
│                     ├── docker-compose.staging.yml                            │
│                     └── apps/                                                 │
│                            ├── services/&lt;name&gt;/                               │
│                            ├── gateways/&lt;name&gt;/                               │
│                            └── core/&lt;name&gt;/                                   │
│                                                                               │
│   ┌─────────────────────── Docker network: proxy ──────────────────────┐      │
│   │  traefik (80, 443)                                                 │      │
│   │     │                                                              │      │
│   │     ├──► jenkins  (projects-jenkins-staging)                     │      │
│   │     │      ↳ /projects  ← bind-mount of the host project tree     │      │
│   │     │      ↳ /var/run/docker.sock ← controls host Docker           │      │
│   │     │                                                              │      │
│   │     └──► your services &amp; gateways (built by the pipeline)          │      │
│   └────────────────────────────────────────────────────────────────────┘      │
│                                                                               │
└───────────────────────────────────────────────────────────────────────────────┘
            ▲
            │  webhook on push
            │
   GitHub: &lt;org&gt;/project-backend (branch: staging)
</code></pre>
<p>There are two key ideas here:</p>
<ol>
<li><p><strong>Jenkins runs in a container</strong>, but it controls the <strong>host's</strong> Docker by mounting <code>/var/run/docker.sock</code>. It also bind-mounts the project folder as <code>/projects/...</code>, so it can <code>cd</code> into the real code on the host and run <code>docker compose</code> there.</p>
</li>
<li><p>The <strong>Jenkinsfile lives inside the app repo</strong>, so the pipeline definition is versioned with the code. Jenkins simply points at it.</p>
</li>
</ol>
<h3 id="heading-3-server-prerequisites">3. Server Prerequisites</h3>
<p>Before we start configuring Jenkins or Traefik, we need to prepare the server properly.</p>
<p>In this step, we’ll:</p>
<ul>
<li><p>Create a dedicated Linux user for managing the project</p>
</li>
<li><p>Install Docker and Docker Compose</p>
</li>
<li><p>Set up the folder structure for our repositories</p>
</li>
</ul>
<p>This ensures our CI/CD pipeline runs in a clean and predictable environment.</p>
<pre><code class="language-bash"># Linux user that owns the project tree
sudo adduser developer

# Docker engine + Compose plugin
curl -fsSL https://get.docker.com | sh
sudo usermod -aG docker developer

# Sanity check Compose v2
docker compose version
# -&gt; Docker Compose version v2.x.y

# Find where the Compose plugin binary lives — write it down, you'll need it
ls /usr/libexec/docker/cli-plugins/docker-compose
# (some distros use /usr/lib/docker/cli-plugins/docker-compose)

# Project layout
sudo mkdir -p /home/developer/project
sudo chown -R developer:developer /home/developer/project

# Clone both repos in the right place
cd /home/developer/projects
git clone https://github.com/&lt;org&gt;/projects-prod-configs.git
cd projects-prod-configs
git clone -b staging https://github.com/&lt;org&gt;/projects-backend.git
</code></pre>
<p>You should now have:</p>
<pre><code class="language-plaintext">/home/developer/projects/projects-prod-configs/projects-backend
</code></pre>
<p>Memorize this path — your Jenkinsfile references it.</p>
<h3 id="heading-dns">DNS</h3>
<p>Point an A-record for your Jenkins subdomain to the server's public IP <strong>before</strong> the next steps so Let's Encrypt can validate via HTTP challenge:</p>
<pre><code class="language-plaintext">jenkins.example.com   A   &lt;server-public-ip&gt;
</code></pre>
<h2 id="heading-4-traefik-the-reverse-proxy">4. Traefik — the Reverse Proxy</h2>
<p>Traefik acts as the entry point to your entire system. Instead of exposing each service manually with ports, Traefik automatically:</p>
<ul>
<li><p>Routes traffic based on domain names</p>
</li>
<li><p>Generates and renews HTTPS certificates using Let’s Encrypt</p>
</li>
<li><p>Connects to Docker and detects services dynamically</p>
</li>
</ul>
<p>In simple terms, Traefik lets you access services like:</p>
<p><a href="https://jenkins.example.com">https://jenkins.example.com</a><br><a href="https://api.example.com">https://api.example.com</a></p>
<p>…without manually configuring NGINX or managing SSL certificates.</p>
<p>In this setup, Traefik watches Docker containers and routes traffic using labels we'll define later.</p>
<p>Traefik gives every container a real domain and a real cert with <strong>zero per-service config</strong> — you just add a few labels.</p>
<h3 id="heading-traefikstagingyml-static-config"><code>traefik.staging.yml</code> (static config)</h3>
<p>Put this at the root of your infra repo:</p>
<pre><code class="language-yaml">api:
  dashboard: true

entryPoints:
  web:
    address: ":80"
  websecure:
    address: ":443"

certificatesResolvers:
  letsencrypt:
    acme:
      httpChallenge:
        entryPoint: web
      email: admin@example.com           # ← change me
      storage: /etc/traefik/acme.json

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false              # only containers with traefik.enable=true
    network: proxy
  file:
    directory: /etc/traefik/dynamic
    watch: true

log:
  level: INFO

accessLog: {}
</code></pre>
<h3 id="heading-the-traefik-service-in-docker-composestagingyml">The Traefik service in <code>docker-compose.staging.yml</code></h3>
<pre><code class="language-yaml">networks:
  proxy:
    name: proxy
    driver: bridge
  internal:
    name: internal
    driver: bridge

volumes:
  acme-data:
  traefik-logs:
  jenkins-data:

services:
  traefik:
    image: traefik:v2.11
    container_name: projects-traefik-staging
    restart: unless-stopped
    ports:
      - "80:80"        # HTTP (auto-redirects to HTTPS)
      - "443:443"      # HTTPS
      - "8080:8080"    # Traefik dashboard (internal only — protect via firewall)
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./traefik.staging.yml:/etc/traefik/traefik.yml:ro
      - ./dynamic:/etc/traefik/dynamic:ro
      - acme-data:/etc/traefik           # persists Let's Encrypt certs
      - traefik-logs:/var/log/traefik
    networks:
      - proxy
    command:
      - '--api.insecure=false'
      - '--api.dashboard=true'
      - '--providers.docker=true'
      - '--providers.docker.exposedbydefault=false'
      - '--providers.docker.network=proxy'
      - '--entrypoints.web.address=:80'
      - '--entrypoints.websecure.address=:443'
      - '--entrypoints.web.http.redirections.entryPoint.to=websecure'
      - '--entrypoints.web.http.redirections.entryPoint.scheme=https'
      - '--certificatesresolvers.letsencrypt.acme.httpchallenge=true'
      - '--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web'
      - '--certificatesresolvers.letsencrypt.acme.email=${ACME_EMAIL:-admin@example.com}'
      - '--certificatesresolvers.letsencrypt.acme.storage=/etc/traefik/acme.json'
      - '--log.level=INFO'
      - '--accesslog=true'
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"
      # Traefik's own dashboard
      - "traefik.http.routers.traefik-dash.rule=Host(`traefik.example.com`)"
      - "traefik.http.routers.traefik-dash.entrypoints=websecure"
      - "traefik.http.routers.traefik-dash.tls.certresolver=letsencrypt"
      - "traefik.http.routers.traefik-dash.service=api@internal"
</code></pre>
<p>Bring it up:</p>
<pre><code class="language-bash">cd /home/developer/projects/projects-prod-configs
docker compose -f docker-compose.staging.yml up -d traefik
</code></pre>
<p>Watch the logs the first time — Traefik will request a cert for the dashboard host as soon as DNS resolves.</p>
<pre><code class="language-bash">docker logs -f projects-traefik-staging
</code></pre>
<p><strong>Tip.</strong> While testing, switch ACME to staging endpoint (<code>acme.caServer=https://acme-staging-v02.api.letsencrypt.org/directory</code>) so you don't burn through Let's Encrypt's rate limits if you misconfigure DNS. Remove that flag before going live.</p>
<h2 id="heading-5-run-jenkins-in-docker">5. Run Jenkins in Docker</h2>
<p>Add this Jenkins service to the same <code>docker-compose.staging.yml</code>. Every line matters (and the comments explain why).</p>
<pre><code class="language-yaml">  jenkins:
    image: jenkins/jenkins:lts
    container_name: projects-jenkins-staging
    restart: unless-stopped
    user: root                           # to use host docker.sock without UID juggling
    environment:
      - JAVA_OPTS=-Xmx1g -Xms512m -Duser.timezone=Asia/Dhaka
      - TZ=Asia/Dhaka                    # OS-level timezone inside container
      - JENKINS_OPTS=--prefix=/
    ports:
      - "3095:8080"                      # web UI (also reachable directly if needed)
      - "50000:50000"                    # inbound agent port
    volumes:
      - jenkins-data:/var/jenkins_home   # Jenkins config/jobs/secrets persistence
      - /var/run/docker.sock:/var/run/docker.sock                          # control host Docker
      - /usr/bin/docker:/usr/bin/docker                                     # docker CLI from host
      - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro  # docker compose plugin
      - /home/developer/projects:/projects                                # project tree
      - /etc/localtime:/etc/localtime:ro                                    # match host clock
      - /etc/timezone:/etc/timezone:ro
    networks:
      - proxy
      - internal
    healthcheck:
      test: ['CMD', 'curl', '-f', 'http://localhost:8080/login']
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 120s
    deploy:
      resources:
        limits:
          memory: 1024M
</code></pre>
<p><strong>Why</strong> <code>user: root</code><strong>?</strong> It's the simplest way to share <code>docker.sock</code> and the project bind-mount without UID/GID gymnastics. If you prefer an unprivileged user, you'll need to set <code>group: docker</code> and align UIDs/perms on host folders — possible but out of scope here.</p>
<h2 id="heading-6-expose-jenkins-on-a-domain-via-traefik">6. Expose Jenkins on a Domain via Traefik</h2>
<p>This is the section many guides skip. We'll add <strong>labels</strong> to the Jenkins service so Traefik picks it up automatically. No editing of Traefik config required.</p>
<pre><code class="language-yaml">  jenkins:
    # ... everything above ...
    labels:
      - "traefik.enable=true"
      - "traefik.docker.network=proxy"

      # 1) Router — match incoming Host
      - "traefik.http.routers.jenkins.rule=Host(`jenkins.example.com`)"
      - "traefik.http.routers.jenkins.entrypoints=websecure"
      - "traefik.http.routers.jenkins.tls.certresolver=letsencrypt"
      - "traefik.http.routers.jenkins.service=jenkins"

      # 2) Service — tell Traefik which container port is the app
      - "traefik.http.services.jenkins.loadbalancer.server.port=8080"

      # 3) Middleware — Jenkins needs X-Forwarded-Proto so it knows it's behind HTTPS
      - "traefik.http.middlewares.jenkins-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
      - "traefik.http.routers.jenkins.middlewares=jenkins-headers"
</code></pre>
<p>What each line does:</p>
<table>
<thead>
<tr>
<th>Label</th>
<th>Purpose</th>
</tr>
</thead>
<tbody><tr>
<td><code>traefik.enable=true</code></td>
<td>Opts this container in (we set <code>exposedByDefault=false</code>).</td>
</tr>
<tr>
<td><code>traefik.docker.network=proxy</code></td>
<td>Tells Traefik which network to talk to Jenkins on (Jenkins is on both <code>proxy</code> and <code>internal</code>).</td>
</tr>
<tr>
<td><code>routers.jenkins.rule=Host(...)</code></td>
<td>Forwards only this hostname to Jenkins.</td>
</tr>
<tr>
<td><code>routers.jenkins.entrypoints=websecure</code></td>
<td>Listens only on 443. (HTTP redirect was set up in section 4.)</td>
</tr>
<tr>
<td><code>routers.jenkins.tls.certresolver=letsencrypt</code></td>
<td>Auto-issues + renews the cert.</td>
</tr>
<tr>
<td><code>services.jenkins.loadbalancer.server.port=8080</code></td>
<td>Jenkins listens on 8080 inside the container.</td>
</tr>
<tr>
<td><code>customrequestheaders.X-Forwarded-Proto=https</code></td>
<td>Without this, Jenkins generates <code>http://</code> URLs in webhooks/links and breaks.</td>
</tr>
</tbody></table>
<p>Bring Jenkins up:</p>
<pre><code class="language-bash">cd /home/developer/projects/projects-prod-configs
docker compose -f docker-compose.staging.yml up -d jenkins

# Watch Traefik issue the certificate
docker logs -f projects-traefik-staging | grep -i acme
</code></pre>
<p>After 10–60 seconds you should be able to open <code>https://jenkins.example.com</code> and see Jenkins's setup wizard with a valid lock icon.</p>
<p>Inside Jenkins (after first login):</p>
<p>Manage Jenkins → System → Jenkins URL → set this to: <a href="https://jenkins.example.com/">https://jenkins.example.com/</a></p>
<p>This is important because Jenkins uses this base URL to generate:</p>
<ul>
<li><p>Webhook endpoints (for GitHub triggers)</p>
</li>
<li><p>Links inside emails and build logs</p>
</li>
</ul>
<p>If this isn't set correctly, GitHub webhooks may fail, and any links Jenkins generates will point to the wrong address (often localhost or internal IPs).</p>
<h2 id="heading-7-first-time-jenkins-setup">7. First-Time Jenkins Setup</h2>
<p>If you're running Jenkins for the first time on this server, follow this section to complete the initial setup.</p>
<p>If you already have Jenkins configured, you can skip this section — but make sure the required plugins and settings match what we use later in this guide.</p>
<ol>
<li><p>Open <code>https://jenkins.example.com</code>. Get the initial admin password:</p>
<pre><code class="language-bash">docker exec projects-jenkins-staging cat /var/jenkins_home/secrets/initialAdminPassword
</code></pre>
</li>
<li><p>Paste it, choose Install suggested plugins.</p>
</li>
<li><p>Create your admin user.</p>
</li>
<li><p>Manage Jenkins → Plugins → Available and install:</p>
<ul>
<li><p>GitHub (and GitHub Branch Source)</p>
</li>
<li><p>Pipeline: GitHub</p>
</li>
<li><p>Credentials Binding (usually preinstalled)</p>
</li>
</ul>
</li>
</ol>
<p>That's all the plugins you need for the rest of this guide.</p>
<h2 id="heading-8-add-the-github-credential">8. Add the GitHub Credential</h2>
<p>Jenkins needs permission to access your GitHub repository.</p>
<p>This is done using a GitHub Personal Access Token (PAT), which acts like a password for secure API and Git operations.</p>
<p>We’ll store this token inside Jenkins as a credential so it can pull code during pipeline execution and authenticate securely without exposing secrets in code.</p>
<p>This single credential is used both for the SCM checkout and for the deploy-time <code>git pull</code>.</p>
<ol>
<li><p>Create a Personal Access Token (classic) on GitHub with <code>repo</code> scope.</p>
</li>
<li><p>In Jenkins: Manage Jenkins → Credentials → System → Global → Add Credentials.</p>
</li>
<li><p>Fill in:</p>
<ul>
<li><p>Kind: Username with password</p>
</li>
<li><p>Username: your GitHub username</p>
</li>
<li><p>Password: the token</p>
</li>
<li><p><strong>ID:</strong> <code>github_classic_token</code> <em>(the Jenkinsfile references this exact ID)</em></p>
</li>
</ul>
</li>
</ol>
<h2 id="heading-9-create-the-pipeline-job">9. Create the Pipeline Job</h2>
<p>Now that Jenkins has access to your repository, the next step is to define how deployments should run.</p>
<p>A pipeline job tells Jenkins:</p>
<ul>
<li><p>where your code lives,</p>
</li>
<li><p>which branch to monitor,</p>
</li>
<li><p>and how to execute your deployment process.</p>
</li>
</ul>
<p>In Jenkins, create a new Pipeline job and connect it to your GitHub repository. Once this is set up, Jenkins will automatically trigger deployments whenever you push to the <code>staging</code> branch.</p>
<p>Start by creating a new job:</p>
<p>New Item → Pipeline → name it <code>projects-staging</code> → OK</p>
<p>Then configure the job:</p>
<ul>
<li><p>Under <strong>Build Triggers</strong>, enable:<br><strong>GitHub hook trigger for GITScm polling</strong></p>
</li>
<li><p>Under <strong>Pipeline</strong>:</p>
<ul>
<li><p>Definition: Pipeline script from SCM</p>
</li>
<li><p>SCM: Git</p>
</li>
<li><p>Repository URL: <code>https://github.com/&lt;org&gt;/projects-backend.git</code></p>
</li>
<li><p>Credentials: <code>github_classic_token</code></p>
</li>
<li><p>Branch: <code>*/staging</code></p>
</li>
<li><p>Script Path: <code>Jenkinsfile</code></p>
</li>
</ul>
</li>
</ul>
<p>Save the configuration.</p>
<p>At this point, Jenkins is fully connected to your repository and ready to run your deployment pipeline automatically.</p>
<h2 id="heading-10-the-jenkinsfile-deploy-only-what-changed">10. The Jenkinsfile (Deploy Only What Changed)</h2>
<p>Place this at the root of the <strong>app</strong> repo (<code>projects-backend/Jenkinsfile</code>), branch <code>staging</code>.</p>
<pre><code class="language-groovy">pipeline {
  agent any

  environment {
    PROJECT_PATH = "/projects/projects-prod-configs/projects-backend"
    COMPOSE_FILE = "docker-compose.staging.yml"
  }

  stages {

    stage('Checkout') {
      steps {
        checkout scm
        echo "Checkout completed for branch: ${env.BRANCH_NAME ?: 'staging'}"
      }
    }

    stage('Detect Changes') {
      steps {
        script {
          def changedFiles = sh(
            script: "git diff --name-only HEAD~1 HEAD",
            returnStdout: true
          ).trim()

          echo "Changed files:\n${changedFiles}"

          def services = [] as Set
          changedFiles.split('\n').each { file -&gt;
            def svc  = file =~ /^apps\/services\/([a-z0-9-]+)\//
            def gw   = file =~ /^apps\/gateways\/([a-z0-9-]+)\//
            def core = file =~ /^apps\/core\/([a-z0-9-]+)\//
            if (svc)  { services &lt;&lt; svc[0][1]  }
            if (gw)   { services &lt;&lt; gw[0][1]   }
            if (core) { services &lt;&lt; core[0][1] }
          }
          services = services.findAll { !it.endsWith('-e2e') }
          env.CHANGED_SERVICES = services.join(' ')

          echo "Services to deploy: ${env.CHANGED_SERVICES ?: '(none)'}"
        }
      }
    }

    stage('Deploy') {
      when { expression { return env.CHANGED_SERVICES?.trim() } }
      steps {
        withCredentials([usernamePassword(
          credentialsId: 'github_classic_token',
          usernameVariable: 'GIT_USER',
          passwordVariable: 'GIT_TOKEN'
        )]) {
          sh '''
            set -eu
            git config --global --add safe.directory "${PROJECT_PATH}"
            cd "${PROJECT_PATH}"
            git remote set-url origin "https://github.com/&lt;org&gt;/projects-backend.git"
            git -c credential.helper= \
                -c "credential.helper=!f() { echo username=\({GIT_USER}; echo password=\){GIT_TOKEN}; }; f" \
                pull origin staging
            docker compose -f "\({COMPOSE_FILE}" up -d --build \){CHANGED_SERVICES}
          '''
        }
        echo "Deployed: ${env.CHANGED_SERVICES}"
      }
    }

    stage('Skip Deployment') {
      when { expression { return !env.CHANGED_SERVICES?.trim() } }
      steps { echo "No service changes detected — nothing to deploy." }
    }
  }
}
</code></pre>
<p>Why each tricky line is there:</p>
<ul>
<li><p><code>git config --global --add safe.directory ...</code> — git refuses to operate on a repo whose owner UID differs from the current user's. The repo on disk is owned by <code>developer</code>, but Git inside the container runs as <code>root</code>. This whitelists the path.</p>
</li>
<li><p><code>git remote set-url origin "https://..."</code> — flips the on-disk remote to HTTPS so the <strong>token can be used</strong>. (A PAT can't authenticate <code>git@github.com:</code> URLs — those use SSH.) Idempotent — safe to re-run.</p>
</li>
<li><p><code>git -c credential.helper="!f() { echo username=...; echo password=...; }; f"</code> — feeds the username/token to git for that one command without writing the token to disk and without exposing it on the process command line.</p>
</li>
<li><p><code>${CHANGED_SERVICES}</code> is unquoted on purpose so multiple service names expand as separate args.</p>
</li>
</ul>
<h2 id="heading-11-end-to-end-test">11. End-to-End Test</h2>
<p>Before considering the setup complete, we need to verify that the entire pipeline works as expected.</p>
<p>This end-to-end test ensures that:</p>
<ul>
<li><p>GitHub webhooks are triggering Jenkins correctly,</p>
</li>
<li><p>Jenkins can detect which services changed,</p>
</li>
<li><p>and only the affected services are rebuilt and deployed.</p>
</li>
</ul>
<p>In other words, this simulates a real production deployment.</p>
<p>Start by making a small change in your repository. For example, modify a file inside:</p>
<p>apps/gateways/student-apigw/</p>
<p>Then push the change to the <code>staging</code> branch.</p>
<p>Once pushed, Jenkins should automatically trigger via the webhook. If not, you can manually click <strong>Build Now</strong>.</p>
<p>Now open the build’s <strong>Console Output</strong> and verify the flow. You should see something like:</p>
<ul>
<li><p>Checkout completed for branch: staging</p>
</li>
<li><p>Services to deploy: student-apigw</p>
</li>
<li><p>git pull origin staging (successful)</p>
</li>
<li><p>docker compose ... up -d --build student-apigw</p>
</li>
<li><p>Deployed: student-apigw</p>
</li>
</ul>
<p>If you see this sequence, your pipeline is working correctly.</p>
<p>If anything fails, don’t worry — jump to Section 12 where every common issue and its fix is documented.</p>
<h2 id="heading-12-troubleshooting-every-error-we-hit">12. Troubleshooting — Every Error We Hit</h2>
<p>This section covers real issues we faced while setting up this pipeline — and more importantly, <em>why each fix works</em>. Understanding the “why” will help you debug similar problems in your own setup.</p>
<h3 id="heading-cd-cant-cd-to-projectsprojects-prod-configsprojects-backend">cd: can't cd to /projects/projects-prod-configs/projects-backend</h3>
<p><strong>Cause:</strong><br>The Jenkinsfile runs <code>cd $PROJECT_PATH</code>, but inside the container that path doesn’t exist. This usually happens when:</p>
<ul>
<li><p>the project wasn’t cloned on the host, or</p>
</li>
<li><p>the bind mount isn’t configured correctly.</p>
</li>
</ul>
<p><strong>Fix:</strong></p>
<pre><code class="language-bash">ls /home/developer/projects/projects-prod-configs/projects-backend
# If missing: git clone -b staging &lt;url&gt; there.
</code></pre>
<p>Confirm the bind mount:</p>
<pre><code class="language-plaintext">docker inspect projects-jenkins-staging --format '{{range .Mounts}}{{.Source}} -&gt; {{.Destination}}{{println}}{{end}}'
</code></pre>
<p>If missing, recreate the container:</p>
<pre><code class="language-plaintext">docker compose -f docker-compose.staging.yml up -d --force-recreate jenkins
</code></pre>
<p><strong>Why this works:</strong></p>
<p>Jenkins runs inside a container, but your code lives on the host. The bind mount connects them. Without it, Jenkins cannot access your project directory.</p>
<h3 id="heading-fatal-detected-dubious-ownership-in-repository">fatal: detected dubious ownership in repository</h3>
<p><strong>Cause:</strong><br>Git blocks access when the repository owner differs from the current user.</p>
<ul>
<li><p>Repo owner: <code>developer</code> (host)</p>
</li>
<li><p>Git runs as: <code>root</code> (inside container)</p>
</li>
</ul>
<p><strong>Fix:</strong></p>
<pre><code class="language-plaintext">git config --global --add safe.directory "${PROJECT_PATH}"
</code></pre>
<p><strong>Why this works:</strong></p>
<p>This explicitly tells Git that the directory is trusted, bypassing ownership mismatch security restrictions.</p>
<h3 id="heading-host-key-verification-failed-could-not-read-from-remote-repository"><code>Host key verification failed</code> / <code>Could not read from remote repository</code></h3>
<h4 id="heading-cause">Cause:</h4>
<p>The repository uses SSH (<code>git@github.com:...</code>), but:</p>
<ul>
<li><p>the container has no SSH keys</p>
</li>
<li><p>no known_hosts file exists</p>
</li>
</ul>
<p>Also, GitHub tokens cannot authenticate over SSH.</p>
<p><strong>Fix (recommended):</strong></p>
<pre><code class="language-plaintext">git remote set-url origin "https://github.com/&lt;org&gt;/projects-backend.git"
</code></pre>
<p><strong>Why this works:</strong></p>
<p>HTTPS uses token-based authentication (PAT), which works inside containers without SSH configuration.</p>
<h3 id="heading-unknown-shorthand-flag-f-in-f-docker-compose"><code>unknown shorthand flag: 'f' in -f</code> ( <code>docker compose</code>)</h3>
<p><strong>Cause:</strong><br>The Docker CLI exists, but the Docker Compose plugin is missing inside the container.</p>
<p><strong>Fix:</strong></p>
<pre><code class="language-plaintext">volumes:
  - /usr/libexec/docker/cli-plugins:/usr/libexec/docker/cli-plugins:ro
</code></pre>
<p>Find your path if needed:</p>
<pre><code class="language-plaintext">find /usr -name docker-compose -type f 2&gt;/dev/null
</code></pre>
<p>Verify:</p>
<pre><code class="language-plaintext">docker exec projects-jenkins-staging docker compose version
</code></pre>
<p><strong>Why this works:</strong></p>
<p>Docker Compose v2 is a CLI plugin. Mounting this directory makes the <code>docker compose</code> command available inside the container.</p>
<h3 id="heading-wrong-timezone-in-build-timestamps-and-jenkins-ui">Wrong timezone in build timestamps and Jenkins UI</h3>
<p><strong>Fix:</strong> Set both env var and JVM flag, and bind-mount the host's clock files:</p>
<pre><code class="language-yaml">environment:
  - TZ=Asia/Dhaka
  - JAVA_OPTS=... -Duser.timezone=Asia/Dhaka
volumes:
  - /etc/localtime:/etc/localtime:ro
  - /etc/timezone:/etc/timezone:ro
</code></pre>
<p>You <strong>must</strong> recreate the container for env-var changes to take effect:</p>
<pre><code class="language-bash">docker compose -f docker-compose.staging.yml up -d --force-recreate jenkins
</code></pre>
<p><strong>Why this works:</strong><br>Jenkins runs on Java, which uses its own timezone separate from the OS.<br>By aligning OS timezone, JVM timezone, and host clock, you ensure consistent timestamps everywhere.</p>
<h3 id="heading-errsockettimeout-pnpm-install-fails">ERR_SOCKET_TIMEOUT (pnpm install fails)</h3>
<h4 id="heading-cause">Cause:</h4>
<p>If you have multiple services building in parallel and each runs pnpm install with ~1500 packages, the network gets saturated and a timeout occurs.</p>
<h4 id="heading-fixes">Fixes:</h4>
<p>a) Increase timeout + control concurrency</p>
<pre><code class="language-xml">RUN pnpm install --frozen-lockfile --ignore-scripts 
--network-timeout 600000 
--network-concurrency 8
</code></pre>
<p>Why: Gives pnpm more time and reduces network overload.</p>
<p>b) Enable pnpm cache (BuildKit)</p>
<pre><code class="language-xml">RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store 
pnpm install --frozen-lockfile --ignore-scripts
</code></pre>
<p>Why: Dependencies are cached and reused instead of downloading every time.</p>
<p>c) Avoid unnecessary rebuilds</p>
<pre><code class="language-xml">docker compose -f \(COMPOSE_FILE build \)CHANGED_SERVICES docker compose -f \(COMPOSE_FILE up -d --no-build \)CHANGED_SERVICES
</code></pre>
<p>Why: Only changed services are rebuilt → less network load → fewer failures.</p>
<h3 id="heading-container-changes-dont-apply-after-editing-docker-composeyml">Container changes don’t apply after editing docker-compose.yml</h3>
<h4 id="heading-cause">Cause:</h4>
<p>Docker compose up -d does not update running containers.</p>
<h4 id="heading-fix">Fix:</h4>
<pre><code class="language-xml">docker compose -f docker-compose.staging.yml up -d --force-recreate jenkins
</code></pre>
<p><strong>Why this works:</strong></p>
<p>This forces Docker to recreate the container with updated configuration (env, volumes, labels).</p>
<h3 id="heading-traefik-shows-default-certificate-no-https">Traefik shows default certificate (no HTTPS)</h3>
<h4 id="heading-common-causes">Common causes:</h4>
<p>DNS not pointing to server Port 80 blocked Wrong Docker network</p>
<h4 id="heading-check">Check:</h4>
<pre><code class="language-xml">dig +short jenkins.example.com docker logs projects-traefik-staging 2&gt;&amp;1 | grep -i acme
</code></pre>
<p><strong>Why this works:</strong></p>
<p>Let’s Encrypt uses HTTP-01 challenge, so it must reach your server via port 80. If DNS or networking is wrong, certificate issuance fails.</p>
<h3 id="heading-jenkins-reverse-proxy-setup-is-broken">Jenkins: "Reverse proxy setup is broken"</h3>
<h4 id="heading-fix">Fix:</h4>
<p>Set the Jenkins URL to <a href="https://jenkins.example.com/">https://jenkins.example.com/</a><br>Ensure header:</p>
<pre><code class="language-xml">X-Forwarded-Proto: https
</code></pre>
<p><strong>Why this works:</strong></p>
<p>Jenkins needs to know it's behind HTTPS. Without this, it generates incorrect URLs (http instead of https), breaking redirects and webhooks.</p>
<h2 id="heading-13-mental-model-host-vs-container">13. Mental Model: Host vs. Container</h2>
<p>Many setup mistakes come from confusing the <strong>host</strong> filesystem with the <strong>container</strong> filesystem. This table makes it explicit:</p>
<table>
<thead>
<tr>
<th>Inside the Jenkins container</th>
<th>Comes from on the host</th>
</tr>
</thead>
<tbody><tr>
<td><code>/var/jenkins_home</code></td>
<td>docker volume <code>jenkins-data</code> (Jenkins config, jobs, secrets)</td>
</tr>
<tr>
<td><code>/projects/...</code></td>
<td><code>/home/developer/projects/...</code> (your project tree)</td>
</tr>
<tr>
<td><code>/usr/bin/docker</code></td>
<td>host's <code>/usr/bin/docker</code></td>
</tr>
<tr>
<td><code>/usr/libexec/docker/cli-plugins/docker-compose</code></td>
<td>host plugin (lets <code>docker compose</code> work)</td>
</tr>
<tr>
<td><code>/var/run/docker.sock</code></td>
<td>host Docker daemon (so builds happen on the host's engine)</td>
</tr>
<tr>
<td><code>/etc/localtime</code>, <code>/etc/timezone</code></td>
<td>host clock</td>
</tr>
<tr>
<td><code>~/.ssh</code></td>
<td><strong>nothing</strong> — that's why SSH-to-GitHub doesn't work without extra setup</td>
</tr>
</tbody></table>
<p>When debugging, always ask: <em>"Inside which filesystem is this command running, and does the file/folder it's looking for exist there?"</em></p>
<h2 id="heading-14-daily-operations-cheat-sheet">14. Daily Operations Cheat Sheet</h2>
<pre><code class="language-bash"># Recreate Jenkins after changing compose
cd /home/developer/Projects/projects-prod-configs
docker compose -f docker-compose.staging.yml up -d --force-recreate jenkins

# Tail Jenkins logs
docker logs -f projects-jenkins-staging

# Open a shell inside the Jenkins container
docker exec -it projects-jenkins-staging bash

# From inside the container — sanity checks
docker compose version
ls /projects/projects-prod-configs/projects-backend
git -C /projects/projects-prod-configs/projects-backend remote -v

# Manually trigger the same deploy the pipeline does
cd /projects/projects-configs/projects-backend
git pull origin staging
docker compose -f docker-compose.staging.yml up -d --build student-apigw

# Inspect Traefik routing decisions
docker logs projects-traefik-staging 2&gt;&amp;1 | grep -i jenkins

# Check renewed certs
docker exec projects-traefik-staging cat /etc/traefik/acme.json | head -50
</code></pre>
<h2 id="heading-15-what-id-do-differently-next-time">15. What I'd Do Differently Next Time</h2>
<ul>
<li><p><strong>Pre-build a base image</strong> with all node_modules baked in. With ~1500 packages × 15 services, every clean build re-downloads ~22k tarballs. A shared base cuts that 90%.</p>
</li>
<li><p><strong>Run a private npm proxy</strong> (Verdaccio / Nexus / GitHub Packages) on the same Docker network — eliminates flaky <code>npmjs.org</code> timeouts entirely.</p>
</li>
<li><p><strong>Per-service Jenkinsfile</strong> if your services drift apart in tooling. With one Jenkinsfile, every team contends for the same pipeline definition.</p>
</li>
<li><p><strong>Replace</strong> <code>git diff HEAD~1 HEAD</code> with <code>git diff $(git merge-base HEAD origin/staging~1) HEAD</code> so squash-merges and force-pushes don't accidentally skip services.</p>
</li>
<li><p><strong>Move secrets to a vault</strong> (HashiCorp Vault / AWS Secrets Manager / Doppler). PATs in Jenkins work, but rotation across many jobs is painful.</p>
</li>
<li><p><strong>Use Jenkins' Configuration-as-Code (JCasC)</strong> so the entire Jenkins setup (jobs, credentials definitions, plugins) is in git. Then a server rebuild is a one-command operation.</p>
</li>
</ul>
<h2 id="heading-closing-thoughts">Closing Thoughts</h2>
<p>The pipeline itself is just three stages: <strong>Checkout → Detect Changes → Deploy</strong> — but a real production setup is mostly about <strong>plumbing</strong>: reverse proxy, certificates, bind-mounts, credentials, timezones, build caches. None of these are exotic. Together they decide whether your Friday-afternoon deploy goes silently green or eats your weekend.</p>
<p>Follow sections 1–11 to get a working pipeline. Bookmark section 12 to keep it working.</p>
<p>Happy shipping.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Create a GPU-Optimized Machine Image with HashiCorp Packer on GCP ]]>
                </title>
                <description>
                    <![CDATA[ Every time you spin up GPU infrastructure, you do the same thing: install CUDA drivers, DCGM, apply OS‑level GPU tuning, and fight dependency issues. Same old ritual every single time, wasting expensi ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-create-a-gpu-optimized-machine-image-with-hashicorp-packer-on-gcp/</link>
                <guid isPermaLink="false">69e93606d5f8830e7d9fbad6</guid>
                
                    <category>
                        <![CDATA[ GPU ]]>
                    </category>
                
                    <category>
                        <![CDATA[ VM Image ]]>
                    </category>
                
                    <category>
                        <![CDATA[ GCP ]]>
                    </category>
                
                    <category>
                        <![CDATA[ hashicorp packer ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Machine Learning ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mlops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Cloud Computing ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Rasheedat Atinuke Jamiu ]]>
                </dc:creator>
                <pubDate>Wed, 22 Apr 2026 20:30:00 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/fd393878-fe7c-458a-addf-7cd22d8280ac.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every time you spin up GPU infrastructure, you do the same thing: install CUDA drivers, DCGM, apply OS‑level GPU tuning, and fight dependency issues. Same old ritual every single time, wasting expensive cloud credits and getting frustrated before actual work begins.</p>
<p>In this article, you'll build a reusable GPU-optimized machine image using Packer, pre-loaded with NVIDIA drivers, CUDA Toolkit, NVIDIA Container Toolkit, DCGM, and system-level GPU tuning like persistence mode.</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-project-setup">Project Setup</a></p>
</li>
<li><p><a href="#heading-step-1-install-packer">Step 1: Install Packer</a></p>
</li>
<li><p><a href="#heading-step-2-set-up-project-directory">Step 2: Set Up Project Directory</a></p>
</li>
<li><p><a href="#heading-step-3-install-packers-plugins">Step 3: Install Packer's Plugins</a></p>
</li>
<li><p><a href="#heading-step-4-define-your-source">Step 4: Define Your Source</a></p>
</li>
<li><p><a href="#heading-step-5-writing-the-build-template">Step 5: Writing the Build Template</a></p>
</li>
<li><p><a href="#heading-step-6-writing-the-gpu-provisioning-script">Step 6: Writing the GPU Provisioning Script</a></p>
<ul>
<li><p><a href="#heading-section-1-pre-installation-kernel-headers">section 1: Pre-Installation (Kernel Headers)</a></p>
</li>
<li><p><a href="#heading-section-2-installing-nvidias-apt-repository">Section 2: Installing NVIDIA's Apt Repository</a></p>
</li>
<li><p><a href="#heading-section-3-pinning-nvidia-drivers-version">Section 3: Pinning NVIDIA Drivers Version</a></p>
</li>
<li><p><a href="#heading-section-4-installing-the-driver">Section 4: Installing the Driver</a></p>
</li>
<li><p><a href="#heading-section-5-cuda-toolkit-installation">Section 5: CUDA Toolkit Installation</a></p>
</li>
<li><p><a href="#heading-section-6-nvidia-container-toolkit">Section 6: Nvidia Container Toolkit</a></p>
</li>
<li><p><a href="#heading-section-7-installing-dcgm-data-center-gpu-manager">Section 7: Installing DCGM — Data Center GPU Manager</a></p>
</li>
<li><p><a href="#heading-section-8-enabling-persistence-mode">Section 8: Enabling Persistence Mode</a></p>
</li>
<li><p><a href="#heading-section-9-system-tuning-for-gpu-compute-workloads">Section 9: System Tuning for GPU Compute Workloads</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-step-7assembling-and-running-the-build">Step 7:Assembling and Running the Build</a></p>
</li>
<li><p><a href="#heading-step-8-test-the-image-and-verify-the-gpu-stack">Step 8: Test the Image and Verify the GPU Stack</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-prerequisites">Prerequisites</h2>
<ul>
<li><p><a href="https://www.packer.io/">HashiCorp Packer</a> &gt;= 1.9</p>
</li>
<li><p><a href="https://github.com/hashicorp/packer-plugin-googlecompute">Google Compute Packer plugin</a> (installed via <code>packer init</code>)</p>
</li>
<li><p>Optionally, the <a href="https://github.com/hashicorp/packer-plugin-amazon">AWS Packer plugin</a> can be used for EC2 builds by adding an <code>amazon-ebs</code> source to <code>node.pkr.hcl</code></p>
</li>
<li><p>GCP project with Compute Engine API enabled (or AWS account with EC2 access)</p>
</li>
<li><p>GCP authentication (<code>gcloud auth application-default login</code>) or AWS credentials</p>
</li>
<li><p>Access to an NVIDIA GPU instance type (For example, A100, H100, L4 on GCP; p4d, p5, G6 on AWS)</p>
</li>
</ul>
<h2 id="heading-project-setup">Project Setup</h2>
<h3 id="heading-step-1-install-packer">Step 1: Install Packer</h3>
<p>To get started, you'll install Packer with the steps below if you're on macOS (or you can follow the official documentation for Linux and Windows installation <a href="https://developer.hashicorp.com/packer/tutorials/docker-get-started/get-started-install-cli#:~:text=Chocolatey%20on%20Windows-,Linux,-HashiCorp%20officially%20maintains">guides</a>).</p>
<p>First, you'll install the official Packer formula from the terminal.</p>
<p>Install the HashiCorp tap, a repository of all Hashicorp packages.</p>
<pre><code class="language-plaintext">$ brew tap hashicorp/tap
</code></pre>
<p>Now, install Packer with <code>hashicorp/tap/packer</code>.</p>
<pre><code class="language-plaintext">$ brew install hashicorp/tap/packer
</code></pre>
<h3 id="heading-step-2-set-up-project-directory">Step 2: Set Up Project Directory</h3>
<p>With Packer installed, you'll create your project directory. For clean code and separation of concerns, your project directory should look like the below. Go ahead and create these files in your <code>packer_demo</code> folder using the command below:</p>
<pre><code class="language-plaintext">mkdir -p packer_demo/script &amp;&amp; touch packer_demo/{build.pkr.hcl,source.pkr.hcl,variable.pkr.hcl,local.pkr.hcl,plugins.pkr.hcl,values.pkrvars.hcl} packer_demo/script/base.sh
</code></pre>
<p>Your file directory should look like this:</p>
<pre><code class="language-plaintext">packer_demo
├── build.pkr.hcl                 # Build pipeline — provisioner ordering
├── source.pkr.hcl                # GCP source definition (googlecompute)
├── variable.pkr.hcl              # Variable definitions with defaults
├── local.pkr.hcl                 # Local values
├── plugins.pkr.hcl                # Packer plugin requirements
├── values.pkrvars.hcl             # variable values (copy and customize)
├── script/
│   ├── base.sh                  # requirement script 
</code></pre>
<h3 id="heading-step-3-install-packers-plugins">Step 3: Install Packer's Plugins</h3>
<p>In your <code>plugins.pkr.hcl file,</code>, define your plugins in the <code>packer block.</code> The <code>packer {}</code> block contains Packer settings, including specifying a required plugin version. You'll find the <code>required_plugins</code> block in the Packer block, which specifies all the plugins required by the template to build your image. If you're on Azure or AWS, you can check for the latest plugin <a href="https://developer.hashicorp.com/packer/integrations">here</a>.</p>
<pre><code class="language-hcl">packer {
  required_plugins {
    googlecompute = {
      source  = "github.com/hashicorp/googlecompute"
      version = "~&gt; 1"
    }
  }
}
</code></pre>
<p>Then, initialize your Packer plugin with the command below:</p>
<pre><code class="language-plaintext">packer init .
</code></pre>
<h3 id="heading-step-4-define-your-source">Step 4: Define Your Source</h3>
<p>With your plugin initialized, you can now define your source block. The source block configures a specific builder plugin, which is then invoked by a build block. Source blocks contain your <code>project ID</code>, the zone where your machine will be created, the <code>source_image_family</code> (think of this as your base image, such as Debian, Ubuntu, and so on), and your <code>source_image_project_id</code>.</p>
<p>In GCP, each has an image project ID, such as "ubuntu-os-cloud" for Ubuntu. You'll set the <code>machine type</code> to a GPU machine type because you're building your base image for a GPU machine, so the machine on which it will be created needs to be able to run your commands.</p>
<pre><code class="language-hcl">source "googlecompute" "gpu-node" {
  project_id              = var.project_id
  zone                    = var.zone
  source_image_family     = var.image_family
  source_image_project_id = var.image_project_id
  ssh_username            = var.ssh_username
  machine_type            = var.machine_type



  image_name        = var.image_name
  image_description = var.image_description

  disk_size           = var.disk_size
  on_host_maintenance = "TERMINATE"

  tags = ["gpu-node"]

}
</code></pre>
<p>Setting <code>on_host_maintenance = "TERMINATE"</code> on Google Cloud Compute Engine ensures that a VM instance stops instead of live-migrating during infrastructure maintenance. This is important when using GPUs or specialized hardware that can't migrate, preventing data corruption.</p>
<p>You'll define all your variables in the <code>variable.pkr.hcl</code> file, and set the values in the <code>values.pkrvars.hcl</code>. Remember to always add your <code>values.pkrvars.hcl</code> file to Gitignore.</p>
<pre><code class="language-hcl">variable "image_name" {
  type        = string
  description = "The name of the resulting image"
}

variable "image_description" {
  type        = string
  description = "Description of the image"
}

variable "project_id" {
  type        = string
  description = "The GCP project ID where the image will be created"
}

variable "image_family" {
  type        = string
  description = "The image family to which the resulting image belongs"
}

variable "image_project_id" {
  type        = list(string)
  description = "The project ID(s) to search for the source image"
}

variable "zone" {
  type        = string
  description = "The GCP zone where the build instance will be created"
}

variable "ssh_username" {
  type        = string
  description = "The SSH username to use for connecting to the instance"
}
variable "machine_type" {
  type        = string
  description = "The machine type to use for the build instance"
}

variable "cuda_version" {
  type        = string
  description = "CUDA toolkit version"
  default     = "13.1"
}

variable "driver_version" {
  type        = string
  description = "NVIDIA driver version"
  default     = "590.48.01"
}

variable "disk_size" {
  type        = number
  description = "Boot disk size in GB"
  default     = 50
}
</code></pre>
<p><code>values.pkrvars.hcl</code></p>
<pre><code class="language-hcl">image_name        = "base-gpu-image-{{timestamp}}"
image_description = "Ubuntu 24.04 LTS with gpu drivers and health checks"
project_id        = "your gcp project id"
image_family      = "ubuntu-2404-lts-amd64"
image_project_id  = ["ubuntu-os-cloud"]
zone              = "us-central1-a"
ssh_username      = "packer"
machine_type      = "g2-standard-4"
disk_size        = 50
driver_version   = "590.48.01"
cuda_version      = "13.1" 
</code></pre>
<h3 id="heading-step-5-writing-the-build-template">Step 5: Writing the Build Template</h3>
<p>Create <code>build.pkr.hcl</code>. The <code>build</code> block creates a temporary instance, runs provisioners, and produces an image.</p>
<p>Provisioners in this template are organized as follows:</p>
<ul>
<li><p><strong>First provisioner</strong> runs system updates and upgrades.</p>
</li>
<li><p><strong>Second provisioner</strong> reboots the instance (<code>expect_disconnect = true</code>).</p>
</li>
<li><p><strong>Third provisioner</strong> waits for the instance to come back (<code>pause_before</code>), then runs <code>script/base.sh</code>. This provisioner sets <code>max_retries</code> to handle transient SSH timeouts and pass environment variables for <code>DRIVER_VERSION</code> and <code>CUDA_VERSION</code>.</p>
</li>
</ul>
<p>Lastly, you have the post-processor to tell you the image ID and completion status:</p>
<pre><code class="language-hcl">build {
  sources = ["source.googlecompute.gpu-node"]

  provisioner "shell" {
    inline = [
      "set -e",
      "sudo apt update",
      "sudo apt -y dist-upgrade"
    ]
  }

  provisioner "shell" {
    expect_disconnect = true
    inline            = ["sudo reboot"]
  }

  # Base: NVIDIA drivers, CUDA, DCGM
  provisioner "shell" {
    pause_before = "60s"
    script       = "script/base.sh"
    max_retries  = 2
    environment_vars = [
      "DRIVER_VERSION=${var.driver_version}",
      "CUDA_VERSION=${var.cuda_version}"
    ]
  }

  post-processor "shell-local" {
    inline = [
      "echo '=== Image Build Complete ==='",
      "echo 'Image ID: ${build.ID}'",
      "date"
    ]
  }
}
</code></pre>
<h3 id="heading-step-6-writing-the-gpu-provisioning-script">Step 6: Writing the GPU Provisioning Script</h3>
<p>Now we'll go through the base script, and break down some parts of it.</p>
<h3 id="heading-section-1-pre-installation-kernel-headers">Section 1: Pre-Installation (Kernel Headers)</h3>
<p>Before installing NVIDIA drivers, the system needs kernel headers and build tools. The NVIDIA driver compiles a kernel module during installation via DKMS, so if the headers for your running kernel aren't present, the build will fail silently, and the driver won't load on boot.</p>
<pre><code class="language-shellscript">log "Installing kernel headers and build tools..."
sudo apt-get install -qq -y \
  "linux-headers-$(uname -r)" \
  build-essential \
  dkms \
  curl \
  wget
</code></pre>
<h3 id="heading-section-2-installing-nvidias-apt-repository">Section 2: Installing NVIDIA's Apt Repository</h3>
<p>This snippet downloads and installs NVIDIA’s official keyring package based on your OS Linux distribution, which adds the trusted signing keys needed for the system to verify CUDA packages.</p>
<pre><code class="language-shellscript">log "Adding NVIDIA CUDA apt repository (${DISTRO})..."
wget -q "https://developer.download.nvidia.com/compute/cuda/repos/\({DISTRO}/\){ARCH}/cuda-keyring_1.1-1_all.deb" \
  -O /tmp/cuda-keyring.deb
sudo dpkg -i /tmp/cuda-keyring.deb
rm /tmp/cuda-keyring.deb
sudo apt-get update -qq
</code></pre>
<h3 id="heading-section-3-pinning-nvidia-drivers-version">Section 3: Pinning NVIDIA Drivers Version</h3>
<p>Pinning the NVIDIA driver to a specific version ensures that the system always installs and keeps using exactly that driver version, even when newer drivers appear in the repository.</p>
<p>NVIDIA drivers are tightly coupled with CUDA toolkit versions, Kernel versions, and container runtimes like Docker or NVIDIA Container Toolkit</p>
<p>A mismatch, such as the system auto‑upgrading to a newer driver, can cause CUDA to stop working, break GPU acceleration, or make the machine image inconsistent across deployments.</p>
<pre><code class="language-shellscript">log "Pinning driver to version ${DRIVER_VERSION}..."
sudo apt-get install -qq -y "nvidia-driver-pinning-${DRIVER_VERSION}"
</code></pre>
<h3 id="heading-section-4-installing-the-driver">Section 4: Installing the Driver</h3>
<p>The <code>libnvidia-compute</code> installs only the compute‑related user‑space libraries (CUDA driver components), while the <code>nvidia-dkms-open;</code> installs the <strong>open‑source NVIDIA kernel module</strong>, built locally via DKMS.</p>
<p>Together, these two packages give you a fully functional CUDA driver environment without any GUI or graphics dependencies.</p>
<p>Here, we're using <strong>NVIDIA’s compute‑only driver stack using the open‑source kernel modules</strong>, as it deliberately avoids installing any display-related components, which you don't need.</p>
<p>This method provides an installation module based on DKMS that's better aligned with Linux distros, as it's lightweight, and compute-focused.</p>
<pre><code class="language-shellscript">log "Installing NVIDIA compute-only driver (open kernel modules)..."
sudo apt-get -V install -y \
  libnvidia-compute \
  nvidia-dkms-open
</code></pre>
<h3 id="heading-section-5-cuda-toolkit-installation">Section 5: CUDA Toolkit Installation</h3>
<p>This part of the script installs the <strong>CUDA Toolkit</strong> for the specified version and then makes sure that CUDA’s executables and libraries are available system‑wide for every user and every shell session.</p>
<p>It adds CUDA binaries to PATH, so commands like <code>nvcc</code>, <code>cuda-gdb</code>, and <code>cuda-memcheck</code> work without specifying full paths. It also adds CUDA libraries to LD_LIBRARY_PATH, so applications can find CUDA’s shared libraries at runtime.</p>
<pre><code class="language-shellscript">log "Installing CUDA Toolkit ${CUDA_VERSION}..."
sudo apt-get install -qq -y "cuda-toolkit-${CUDA_VERSION}"

# Persist CUDA paths for all users and sessions
cat &lt;&lt;'EOF' | sudo tee /etc/profile.d/cuda.sh
export PATH=/usr/local/cuda/bin:$PATH
export LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH:-}
EOF
echo "/usr/local/cuda/lib64" | sudo tee /etc/ld.so.conf.d/cuda.conf
sudo ldconfig
</code></pre>
<h3 id="heading-section-6-nvidia-container-toolkit">Section 6: NVIDIA Container Toolkit</h3>
<p>This block installs the NVIDIA Container Toolkit and configures it so that containers (Docker or containerd) can access the GPU safely and correctly. It’s a critical step for Kubernetes GPU nodes, Docker GPU workloads, and any system that needs GPU acceleration inside containers.</p>
<pre><code class="language-shellscript">log "Installing NVIDIA Container Toolkit..."
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
  | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg

curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
  | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
  | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list

sudo apt-get update -qq
sudo apt-get install -qq -y nvidia-container-toolkit

# Configure for containerd (primary Kubernetes runtime)
sudo nvidia-ctk runtime configure --runtime=containerd

# Configure for Docker if present on this image
if systemctl list-unit-files | grep -q "^docker.service"; then
  sudo nvidia-ctk runtime configure --runtime=docker
fi
</code></pre>
<h3 id="heading-section-7-installing-dcgm-data-center-gpu-manager">Section 7: Installing DCGM (Data Center GPU Manager)</h3>
<p>This section covers the installation and validation of NVIDIA DCGM (Data Center GPU Manager), which is NVIDIA’s official management and telemetry framework for data center GPUs.</p>
<p>It offers health monitoring and diagnostics, telemetry (including temperature, clocks, power, and utilization), error reporting, and integration with Kubernetes, Prometheus, and monitoring agents. Your GPU monitoring stack relies on this.</p>
<p>The script extracts the installed version and checks that it meets the <strong>minimum required version</strong> for NVIDIA driver 590+. Then it enforces the version requirement. This prevents a mismatch between the GPU driver and DCGM, which would break monitoring and health checks. It also enables fabric manager for NVLink/NVswitches, if you're on a Multi‑GPU topologies like A100/H100 DGX or multi‑GPU servers.</p>
<pre><code class="language-shellscript">log "Installing DCGM..."
sudo apt-get install -qq -y datacenter-gpu-manager

DCGM_VER=\((dpkg -s datacenter-gpu-manager 2&gt;/dev/null | awk '/^Version:/{print \)2}' | sed 's/^[0-9]*://')
DCGM_MAJOR=\((echo "\){DCGM_VER}" | cut -d. -f1)
DCGM_MINOR=\((echo "\){DCGM_VER}" | cut -d. -f2)
if [[ "\({DCGM_MAJOR}" -lt 4 ]] || { [[ "\){DCGM_MAJOR}" -eq 4 ]] &amp;&amp; [[ "${DCGM_MINOR}" -lt 3 ]]; }; then
  error "DCGM ${DCGM_VER} is below the 4.3 minimum required for driver 590+. Check your CUDA repo."
fi
log "DCGM installed: ${DCGM_VER}"

sudo systemctl enable nvidia-dcgm
sudo systemctl start  nvidia-dcgm

# Fabric Manager — only needed for NVLink/NVSwitch GPUs (A100/H100 multi-GPU nodes)
if systemctl list-unit-files | grep -q "^nvidia-fabricmanager.service"; then
  log "Enabling nvidia-fabricmanager for NVLink GPUs..."
  sudo systemctl enable nvidia-fabricmanager
  sudo systemctl start  nvidia-fabricmanager
fi
</code></pre>
<h3 id="heading-section-8-enabling-persistence-mode">Section 8: Enabling Persistence Mode</h3>
<p>The NVIDIA driver normally unloads itself when the GPU is idle. When a new workload starts, the driver must reload, reinitialize the GPU, and set up memory mappings. This adds a delay of a few hundred milliseconds to several seconds, depending on the GPU and system.</p>
<p>Enabling nvidia‑persistenced keeps the NVIDIA driver loaded in memory even when no GPU workloads are running.</p>
<pre><code class="language-shellscript">log "Enabling nvidia-persistenced..."
sudo systemctl enable nvidia-persistenced
sudo systemctl start  nvidia-persistenced
</code></pre>
<h3 id="heading-section-9-system-tuning-for-gpu-compute-workloads">Section 9: System Tuning for GPU Compute Workloads</h3>
<p>This block applies a set of <strong>system‑level performance and stability tunings</strong> that are standard for high‑performance GPU servers, Kubernetes GPU nodes, and ML/AI workloads.</p>
<p>Each line targets a specific bottleneck or instability pattern that appears in real GPU production environments.</p>
<ul>
<li><p>Swap and memory behavior: Disabling swap and setting <code>vm.swappiness=0</code> prevents the kernel from pushing GPU‑bound processes into swap. GPU workloads are extremely sensitive to latency, and swapping can cause CUDA context resets and GPU driver timeouts.</p>
</li>
<li><p>Hugepages for large memory allocations: Setting <code>vm.nr_hugepages=2048</code> allocates a pool of hugepages, which reduces TLB pressure for large contiguous memory allocations.</p>
<p>CUDA, NCCL, and deep‑learning frameworks frequently allocate large buffers, and hugepages reduce page‑table overhead, improving memory bandwidth and lowering latency for large tensor operations. This is especially useful on multi‑GPU servers.</p>
</li>
<li><p>CPU frequency governor: Installing <code>cpupower</code> and forcing the CPU governor to <code>performance</code> ensures the CPU stays at maximum frequency instead of scaling down.</p>
<p>GPU workloads often become CPU‑bound during Data preprocessing, Kernel launches, and NCCL communication. Keeping CPUs at full speed reduces jitter and improves throughput.</p>
</li>
<li><p>NUMA and topology tools: Installing <code>numactl</code>, <code>libnuma-dev</code>, and <code>hwloc</code> provides tools for pinning processes to NUMA nodes, understanding CPU–GPU affinity, and optimizing multi‑GPU placement.</p>
</li>
<li><p>Disabling irqbalance: Stopping and disabling <code>irqbalance</code> it lets the NVIDIA driver manage interrupt affinity. For GPU servers, irqbalance can incorrectly move GPU interrupts to suboptimal CPUs, causing higher latency and lower throughput.</p>
</li>
</ul>
<pre><code class="language-shell">log "Applying system tuning..."

# Disable swap (critical for Kubernetes scheduler and ML stability)
sudo swapoff -a
sudo sed -i '/ swap / s/^/#/' /etc/fstab
echo "vm.swappiness=0"     | sudo tee /etc/sysctl.d/99-gpu-swappiness.conf

# Hugepages — reduces TLB pressure for large memory allocations
echo "vm.nr_hugepages=2048" | sudo tee /etc/sysctl.d/99-gpu-hugepages.conf

# CPU performance governor
sudo apt-get install -qq -y linux-tools-common "linux-tools-$(uname -r)" || true
sudo cpupower frequency-set -g performance || true

# NUMA and topology tools for GPU affinity tuning
sudo apt-get install -qq -y numactl libnuma-dev hwloc

# Disable irqbalance — let NVIDIA driver manage interrupt affinity
sudo systemctl disable irqbalance || true
sudo systemctl stop    irqbalance || true

# Apply all sysctl settings now
sudo sysctl --system
</code></pre>
<p>Full base.sh script here:</p>
<pre><code class="language-shell">#!/bin/bash
set -euo pipefail

log()   { echo "[BASE] $1"; }
error() { echo "[BASE][ERROR] $1" &gt;&amp;2; exit 1; }

###############################################################
###############################################################
[[ -z "${DRIVER_VERSION:-}" ]] &amp;&amp; error "DRIVER_VERSION is not set."
[[ -z "${CUDA_VERSION:-}"   ]] &amp;&amp; error "CUDA_VERSION is not set."

log "DRIVER_VERSION : ${DRIVER_VERSION}"
log "CUDA_VERSION   : ${CUDA_VERSION}"

DISTRO=\((. /etc/os-release &amp;&amp; echo "\){ID}${VERSION_ID}" | tr -d '.')
ARCH="x86_64"

export DEBIAN_FRONTEND=noninteractive

###############################################################
# 1. System update
###############################################################
log "Updating system packages..."
sudo apt-get update -qq
sudo apt-get upgrade -qq -y

###############################################################
# 2. Pre-installation — kernel headers
#    Source: https://docs.nvidia.com/datacenter/tesla/driver-installation-guide/ubuntu.html
###############################################################
log "Installing kernel headers and build tools..."
sudo apt-get install -qq -y \
  "linux-headers-$(uname -r)" \
  build-essential \
  dkms \
  curl \
  wget

###############################################################
# 3. NVIDIA CUDA Network Repository
###############################################################
log "Adding NVIDIA CUDA apt repository (${DISTRO})..."
wget -q "https://developer.download.nvidia.com/compute/cuda/repos/\({DISTRO}/\){ARCH}/cuda-keyring_1.1-1_all.deb" \
  -O /tmp/cuda-keyring.deb
sudo dpkg -i /tmp/cuda-keyring.deb
rm /tmp/cuda-keyring.deb
sudo apt-get update -qq

###############################################################
# 4. Pin driver version BEFORE installation (590+ requirement)
###############################################################
log "Pinning driver to version ${DRIVER_VERSION}..."
sudo apt-get install -qq -y "nvidia-driver-pinning-${DRIVER_VERSION}"

###############################################################
# 5. Compute-only (headless) driver — Open Kernel Modules
#    Source: NVIDIA Driver Installation Guide — Compute-only System (Open Kernel Modules)
#
#    libnvidia-compute  = compute libraries only (no GL/Vulkan/display)
#    nvidia-dkms-open   = open-source kernel module built via DKMS
#
#    Open kernel modules are the NVIDIA-recommended choice for
#    Ampere, Hopper, and Blackwell data centre GPUs (A100, H100, etc.)
###############################################################
log "Installing NVIDIA compute-only driver (open kernel modules)..."
sudo apt-get -V install -y \
  libnvidia-compute \
  nvidia-dkms-open

###############################################################
# 6. CUDA Toolkit
###############################################################
log "Installing CUDA Toolkit ${CUDA_VERSION}..."
sudo apt-get install -qq -y "cuda-toolkit-${CUDA_VERSION}"

# Persist CUDA paths for all users and sessions
cat &lt;&lt;'EOF' | sudo tee /etc/profile.d/cuda.sh
export PATH=/usr/local/cuda/bin:$PATH
export LD_LIBRARY_PATH=/usr/local/cuda/lib64:${LD_LIBRARY_PATH:-}
EOF
echo "/usr/local/cuda/lib64" | sudo tee /etc/ld.so.conf.d/cuda.conf
sudo ldconfig

###############################################################
# 7. NVIDIA Container Toolkit
#    Required for GPU workloads in Docker / containerd / Kubernetes
###############################################################
log "Installing NVIDIA Container Toolkit..."
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
  | sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg

curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
  | sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' \
  | sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list

sudo apt-get update -qq
sudo apt-get install -qq -y nvidia-container-toolkit

# Configure for containerd (primary Kubernetes runtime)
sudo nvidia-ctk runtime configure --runtime=containerd

# Configure for Docker if present on this image
if systemctl list-unit-files | grep -q "^docker.service"; then
  sudo nvidia-ctk runtime configure --runtime=docker
fi

###############################################################
# 8. DCGM — DataCenter GPU Manager
###############################################################
log "Installing DCGM..."
sudo apt-get install -qq -y datacenter-gpu-manager
 
DCGM_VER=\((dpkg -s datacenter-gpu-manager 2&gt;/dev/null | awk '/^Version:/{print \)2}' | sed 's/^[0-9]*://')
DCGM_MAJOR=\((echo "\){DCGM_VER}" | cut -d. -f1)
DCGM_MINOR=\((echo "\){DCGM_VER}" | cut -d. -f2)
if [[ "\({DCGM_MAJOR}" -lt 4 ]] || { [[ "\){DCGM_MAJOR}" -eq 4 ]] &amp;&amp; [[ "${DCGM_MINOR}" -lt 3 ]]; }; then
  error "DCGM ${DCGM_VER} is below the 4.3 minimum required for driver 590+. Check your CUDA repo."
fi
log "DCGM installed: ${DCGM_VER}"

sudo systemctl enable nvidia-dcgm
sudo systemctl start  nvidia-dcgm

# Fabric Manager — only needed for NVLink/NVSwitch GPUs (A100/H100 multi-GPU nodes)
if systemctl list-unit-files | grep -q "^nvidia-fabricmanager.service"; then
  log "Enabling nvidia-fabricmanager for NVLink GPUs..."
  sudo systemctl enable nvidia-fabricmanager
  sudo systemctl start  nvidia-fabricmanager
fi

###############################################################
# 9. NVIDIA Persistence Daemon
#    Keeps the driver loaded between jobs — reduces cold-start
#    latency on the first CUDA call in each new workload
###############################################################
log "Enabling nvidia-persistenced..."
sudo systemctl enable nvidia-persistenced
sudo systemctl start  nvidia-persistenced

###############################################################
# 10. System tuning for GPU compute workloads
###############################################################
log "Applying system tuning..."

# Disable swap (critical for Kubernetes scheduler and ML stability)
sudo swapoff -a
sudo sed -i '/ swap / s/^/#/' /etc/fstab
echo "vm.swappiness=0"     | sudo tee /etc/sysctl.d/99-gpu-swappiness.conf

# Hugepages — reduces TLB pressure for large memory allocations
echo "vm.nr_hugepages=2048" | sudo tee /etc/sysctl.d/99-gpu-hugepages.conf

# CPU performance governor
sudo apt-get install -qq -y linux-tools-common "linux-tools-$(uname -r)" || true
sudo cpupower frequency-set -g performance || true

# NUMA and topology tools for GPU affinity tuning
sudo apt-get install -qq -y numactl libnuma-dev hwloc

# Disable irqbalance — let NVIDIA driver manage interrupt affinity
sudo systemctl disable irqbalance || true
sudo systemctl stop    irqbalance || true

# Apply all sysctl settings now
sudo sysctl --system

###############################################################
# Done
###############################################################
log "============================================"
log "Base layer provisioning complete."
log "  OS      : ${DISTRO}"
log "  Driver  : ${DRIVER_VERSION} (open kernel modules, compute-only)"
log "  CUDA    : cuda-toolkit-${CUDA_VERSION}"
log "  DCGM    : ${DCGM_VER}"
log "============================================"
</code></pre>
<h2 id="heading-step-7-assembling-and-running-the-build">Step 7: Assembling and Running the Build</h2>
<p>Validate the template first, then run the build. Validation catches syntax or variable errors early, so the build doesn’t start on a broken config.</p>
<pre><code class="language-shellscript">packer validate -var-file=values.pkrvars.hcl .
</code></pre>
<p>If validation succeeds, you’ll see a short confirmation like <code>The configuration is valid.</code>. After that, start the build. You should expect the process to create a temporary VM, run your provisioners, and produce an image:</p>
<pre><code class="language-plaintext">packer build -var-file=values.pkrvars.hcl .
</code></pre>
<p>The build typically takes <strong>15–20 minutes,</strong> depending on network speed and package installs. Watch the Packer log for three key checkpoints:</p>
<ul>
<li><p><strong>Instance creation</strong> — confirms the temporary VM was provisioned.</p>
</li>
<li><p><strong>Provisioner output</strong> — shows each script step (updates, reboot, <code>script/base.sh</code>) and any errors.</p>
</li>
<li><p><strong>Image creation</strong> — indicates the build finished and an image artifact was written.</p>
</li>
</ul>
<p>If the build fails, copy the failing provisioner’s log lines and re-run the build after fixing the script or variables. For quick troubleshooting, re-run the failing provisioner locally on a matching test VM to iterate faster.</p>
<pre><code class="language-plaintext">googlecompute.gpu-node: output will be in this color.

==&gt; googlecompute.gpu-node: Checking image does not exist...
==&gt; googlecompute.gpu-node: Creating temporary RSA SSH key for instance...
==&gt; googlecompute.gpu-node: no persistent disk to create
==&gt; googlecompute.gpu-node: Using image: ubuntu-2404-noble-amd64-v20260225
==&gt; googlecompute.gpu-node: Creating instance...
==&gt; googlecompute.gpu-node: Loading zone: us-central1-a
==&gt; googlecompute.gpu-node: Loading machine type: g2-standard-4
==&gt; googlecompute.gpu-node: Requesting instance creation...
==&gt; googlecompute.gpu-node: Waiting for creation operation to complete...
==&gt; googlecompute.gpu-node: Instance has been created!
==&gt; googlecompute.gpu-node: Waiting for the instance to become running...
==&gt; googlecompute.gpu-node: IP: 34.58.58.214
==&gt; googlecompute.gpu-node: Using SSH communicator to connect: 34.58.58.214
==&gt; googlecompute.gpu-node: Waiting for SSH to become available...
systemd-logind.service
==&gt; googlecompute.gpu-node:  systemctl restart unattended-upgrades.service
==&gt; googlecompute.gpu-node:
==&gt; googlecompute.gpu-node: No containers need to be restarted.
==&gt; googlecompute.gpu-node:
==&gt; googlecompute.gpu-node: User sessions running outdated binaries:
==&gt; googlecompute.gpu-node:  packer @ session #1: sshd[1535]
==&gt; googlecompute.gpu-node:  packer @ user manager service: systemd[1540]
==&gt; googlecompute.gpu-node: Pausing 1m0s before the next provisioner...
==&gt; googlecompute.gpu-node: Provisioning with shell script: script/base.sh
==&gt; googlecompute.gpu-node: [BASE] DRIVER_VERSION : 590.48.01
==&gt; googlecompute.gpu-node: [BASE] CUDA_VERSION   : 13.1
==&gt; googlecompute.gpu-node: [BASE] Updating system packages...
==&gt; googlecompute.gpu-node: [BASE] Installing kernel headers and build tools...
==&gt; googlecompute.gpu-node: [BASE] Installing CUDA Toolkit 13.1...
==&gt; googlecompute.gpu-node: [BASE] Installing DCGM...
==&gt; googlecompute.gpu-node: [BASE] Enabling nvidia-persistenced...
==&gt; googlecompute.gpu-node: [BASE] Applying system tuning...
==&gt; googlecompute.gpu-node: vm.swappiness=0
==&gt; googlecompute.gpu-node: vm.nr_hugepages=2048
==&gt; googlecompute.gpu-node: Setting cpu: 0
==&gt; googlecompute.gpu-node: Error setting new values. Common errors:
==&gt; googlecompute.gpu-node: [BASE] ============================================
==&gt; googlecompute.gpu-node: [BASE] Base layer provisioning complete.
==&gt; googlecompute.gpu-node: [BASE]   OS      : ubuntu2404
==&gt; googlecompute.gpu-node: [BASE]   Driver  : 590.48.01 (open kernel modules, compute-only)
==&gt; googlecompute.gpu-node: [BASE]   CUDA    : cuda-toolkit-13.1
==&gt; googlecompute.gpu-node: [BASE]   DCGM    : 1:3.3.9
==&gt; googlecompute.gpu-node: [BASE] ============================================
==&gt; googlecompute.gpu-node: Deleting instance...
==&gt; googlecompute.gpu-node: Instance has been deleted!
==&gt; googlecompute.gpu-node: Creating image...
==&gt; googlecompute.gpu-node: Deleting disk...
==&gt; googlecompute.gpu-node: Disk has been deleted!
==&gt; googlecompute.gpu-node: Running post-processor:  (type shell-local)
==&gt; googlecompute.gpu-node (shell-local): Running local shell script: 
==&gt; googlecompute.gpu-node (shell-local): === Image Build Complete ===
==&gt; googlecompute.gpu-node (shell-local): Image ID: packer-69b6c2ee-883a-3602-7bb5-059f1ba27c8b
==&gt; googlecompute.gpu-node (shell-local): Sun Mar 15 15:50:09 WAT 2026
Build 'googlecompute.gpu-node' finished after 17 minutes 55 seconds.

==&gt; Wait completed after 17 minutes 55 seconds

==&gt; Builds finished. The artifacts of successful builds are:
--&gt; googlecompute.gpu-node: A disk image was created in the 'my_project-00000' project: base-gpu-image-1773585134
</code></pre>
<h3 id="heading-step-8-test-the-image-and-verify-the-gpu-stack">Step 8: Test the Image and Verify the GPU Stack</h3>
<p>Confirm the image exists in the GCP Console: <strong>Compute → Storage → Images</strong> and locate your newly created OS image.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5eacc4c926e78ca711dfbbdc/90f304eb-3fe7-4304-b2ad-d86701dde607.png" alt="Your Image information on GCP" style="display:block;margin:0 auto" width="1686" height="692" loading="lazy">

<p>Create a test VM from the image:</p>
<pre><code class="language-plaintext">gcloud compute instances create my-gpu-vm \
  --machine-type=g2-standard-4 \
  --accelerator=count=1,type=nvidia-l4 \
  --image=base-gpu-image-1772718104 \
  --image-project=YOUR_PROJECT_ID \
  --boot-disk-size=50GB \
  --maintenance-policy=TERMINATE \
  --restart-on-failure \
  --zone=us-central1-a

Created [https://www.googleapis.com/compute/v1/projects/my-project-000/zones/us-central1-a/instances/my-gpu-vm].
NAME       ZONE           MACHINE_TYPE   PREEMPTIBLE  INTERNAL_IP    EXTERNAL_IP      STATUS
my-gpu-vm  us-central1-a  g2-standard-4               10.128.15.227  104.154.184.217  RUNNING
</code></pre>
<p>Once the instance is <code>RUNNING</code>, verify the NVIDIA driver and GPU are visible:</p>
<img src="https://cdn.hashnode.com/uploads/covers/5eacc4c926e78ca711dfbbdc/364df8fc-7584-40df-8ab7-b3fe349d5065.png" alt="Output from the Nvidia-SMI command showing Driver and CUDA Version" style="display:block;margin:0 auto" width="1508" height="630" loading="lazy">

<img src="https://cdn.hashnode.com/uploads/covers/5eacc4c926e78ca711dfbbdc/0912c303-3bb0-47fa-aa34-1c91ff26874f.png" alt="Image verifying the persistence mode is enabled" style="display:block;margin:0 auto" width="1508" height="80" loading="lazy">

<p><strong>The</strong> <code>nvidia-smi</code> <strong>output confirms:</strong></p>
<ul>
<li><p>Driver 590.48.01 loaded</p>
</li>
<li><p>CUDA 13.1 available</p>
</li>
<li><p>Persistence Mode is <code>On</code></p>
</li>
<li><p>The L4 GPU is detected with 23GB VRAM</p>
</li>
<li><p>Zero ECC errors</p>
</li>
<li><p>No running processes (clean idle state).</p>
</li>
</ul>
<p>This is exactly what a healthy base image should look like. Notice <code>Disp.A: Off</code>? That confirms our compute-only driver choice is working — no display adapter is active.</p>
<p>Confirm the installed CUDA toolkit by running. <code>nvcc --version</code>. You can see that version 13.1 was installed as specified.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5eacc4c926e78ca711dfbbdc/cc744624-9408-4348-88d7-61da04b5e1d0.png" alt="Output from the NVCC -Version command" style="display:block;margin:0 auto" width="1508" height="202" loading="lazy">

<p>Let's confirm DCGM installation by running <code>dcgmi discovery -l</code>. Successful output indicates DCGM is running and communicating with the driver.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5eacc4c926e78ca711dfbbdc/114996c6-1f28-43d4-a3fa-13aa7ccd2c82.png" alt="Output from the DCGMI dicovery -l command showing device information" style="display:block;margin:0 auto" width="1508" height="714" loading="lazy">

<h2 id="heading-conclusion">Conclusion</h2>
<p>You now have a production‑grade, GPU‑optimized base image that includes the NVIDIA compute‑only driver built with open kernel modules, DCGM for monitoring, and the CUDA Toolkit. You also applied OS‑level tuning tailored to GPU compute workloads, providing a consistent, reproducible environment with no manual setup.</p>
<p>From here, you can extend the build by adding an application‑layer script to install frameworks such as PyTorch, TensorFlow, or vLLM, or create an instance template that uses this image to scale your GPU infrastructure.</p>
<p>The full Packer project includes additional scripts for training and inference workloads that you can use to extend your image.</p>
<h2 id="heading-references"><strong>References</strong></h2>
<ul>
<li><p>NVIDIA Driver Installation Guide (Ubuntu): <a href="https://docs.nvidia.com/datacenter/tesla/driver-installation-guide/">https://docs.nvidia.com/datacenter/tesla/driver-installation-guide/</a></p>
</li>
<li><p>NVIDIA CUDA Toolkit Documentation: <a href="https://docs.nvidia.com/cuda/">https://docs.nvidia.com/cuda/</a></p>
</li>
<li><p>NVIDIA Container Toolkit Installation Guide: <a href="https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html">https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/install-guide.html</a></p>
</li>
<li><p>NVIDIA DCGM Documentation: <a href="https://docs.nvidia.com/datacenter/dcgm/latest/index.html">https://docs.nvidia.com/datacenter/dcgm/latest/index.html</a></p>
</li>
<li><p>NVIDIA Persistence Daemon: <a href="https://docs.nvidia.com/deploy/driver-persistence/index.html">https://docs.nvidia.com/deploy/driver-persistence/index.html</a></p>
</li>
<li><p>HashiCorp Packer Documentation: <a href="https://developer.hashicorp.com/packer/docs">https://developer.hashicorp.com/packer/docs</a></p>
</li>
<li><p>Packer Google Compute Builder: <a href="https://developer.hashicorp.com/packer/integrations/hashicorp/googlecompute">https://developer.hashicorp.com/packer/integrations/hashicorp/googlecompute</a></p>
</li>
</ul>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
