<?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[ Osomudeya Zudonu - 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[ Osomudeya Zudonu - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Sun, 06 Sep 2026 11:14:09 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/Osomudeya/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 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 Use Bash & Python for Real DevOps Automation – Full Handbook with 5 Production Use Cases ]]>
                </title>
                <description>
                    <![CDATA[ Automation scripts often validate process completion instead of system health. A Kubernetes pod can be running while the application inside it can't authenticate to the database. A Terraform deploymen ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-bash-python-for-real-devops-automation-handbook-with-production-use-cases/</link>
                <guid isPermaLink="false">6a171310badcd8afcb060460</guid>
                
                    <category>
                        <![CDATA[ Python ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Bash ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Osomudeya Zudonu ]]>
                </dc:creator>
                <pubDate>Wed, 27 May 2026 15:51:44 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/73f2a745-c1b5-4cbb-8f97-2ba6c5230592.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Automation scripts often validate process completion instead of system health.</p>
<p>A Kubernetes pod can be running while the application inside it can't authenticate to the database. A Terraform deployment can return clean while someone has manually changed infrastructure in the cloud console. A canary rollout can show zero errors while users wait five seconds for every request.</p>
<p>The problem isn't the tooling. The problem is that the system can look healthy when it really is not.</p>
<p>This handbook walks through five production-style automation scenarios using Bash and Python for:</p>
<ul>
<li><p>Detecting abnormal AWS spend before the monthly invoice arrives</p>
</li>
<li><p>Correlating logs across multiple services using trace IDs</p>
</li>
<li><p>Finding infrastructure drift outside Terraform</p>
</li>
<li><p>Validating secret rotation at the application level</p>
</li>
<li><p>Automatically rolling back slow deployments before users complain</p>
</li>
</ul>
<p>By the end of this handbook, you'll be able to build small scripts that help you notice when something is wrong in a system, even when the tools say everything is fine.</p>
<p>The scripts are intentionally small. The important part is the operational thinking behind them like what signal the script measures, what failure mode it can detect, and what assumptions the platform is making underneath.</p>
<p>Each use case includes a runnable demo environment, the complete script, a breakdown of the system behaviour involved, and an intentional failure you can trigger yourself.</p>
<p>If you're new to this workflow, start with use case 1 and work forward. The later sections build on the same pattern: automation is useful when it verifies reality, not just process completion.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, set up the following:</p>
<ul>
<li><p><strong>Python 3.8 or higher</strong> – check with <code>python3 --version</code></p>
</li>
<li><p><strong>A Python virtual environment</strong> – create one before installing anything:</p>
</li>
</ul>
<pre><code class="language-plaintext">python3 -m venv venv
source venv/bin/activate  

 # on Windows: 

venv\Scripts\activate
</code></pre>
<p>This keeps your installed packages isolated from your system Python and prevents permission errors on shared machines.</p>
<ul>
<li><p><strong>pip</strong> – Python's package installer, included with Python</p>
</li>
<li><p><strong>AWS CLI</strong> configured with a working profile – a free-tier AWS account is enough for use cases 1, 3, and 4. Verify it's working with:</p>
<pre><code class="language-plaintext">aws sts get-caller-identity
</code></pre>
</li>
<li><p><strong>Docker and Docker Compose</strong> – needed for use cases 2, 4, and 5</p>
</li>
<li><p><strong>Kind</strong> (Kubernetes in Docker) – a way to run Kubernetes locally for use cases 4 and 5. Install with <code>brew install kind</code> on macOS, or follow the <a href="https://kind.sigs.k8s.io/docs/user/quick-start/">Kind quick start guide</a></p>
</li>
<li><p><strong>kubectl</strong> – the command-line tool for talking to a Kubernetes cluster. After installing Kind, run <code>kind create cluster</code> and kubectl is configured automatically</p>
</li>
<li><p><strong>Helm</strong> – a package manager for Kubernetes, needed for use case 5. Install with <code>brew install helm</code> or the <a href="https://helm.sh/docs/intro/install/">Helm install guide</a></p>
</li>
<li><p><strong>Terraform</strong> – needed for use case 3. Install with <code>brew install terraform</code> on macOS or follow the <a href="https://developer.hashicorp.com/terraform/install">Terraform install guide</a>. Check with <code>terraform version</code>.</p>
</li>
<li><p><strong>bc</strong> – a calculator utility used by the canary watch scripts for floating-point comparison. Install with <code>brew install bc</code> on macOS or <code>apt install bc</code> on Ubuntu. Run <code>bc --version</code> to confirm it is available before starting use case 5.</p>
</li>
</ul>
<h3 id="heading-knowledge-and-skills">Knowledge and Skills</h3>
<ul>
<li><p>You should be comfortable reading Python and Bash scripts without needing to write them from scratch.</p>
</li>
<li><p>You should have basic Linux terminal comfort – navigating directories, running scripts, reading output, and so on.</p>
</li>
<li><p>You should know what Kubernetes pods and deployments are at a basic level – you don't need deep Kubernetes expertise, as use cases 4 and 5 will introduce the Kubernetes concepts they rely on as they go.</p>
</li>
<li><p>Familiarity with AWS basics such as what EC2, IAM, and Secrets Manager will help with use cases 1, 3, and 4, while use case 2 runs entirely on your local machine and requires no AWS knowledge at all.</p>
</li>
<li><p>For use case 3, knowing what Terraform is and what a state file does will help. You don't need to write any Terraform, but understanding that Terraform tracks and what it created is the foundation of the whole use case.</p>
</li>
</ul>
<h3 id="heading-aws-iam-permissions-required">AWS IAM Permissions Required</h3>
<p>The scripts in this article make real AWS API calls. Your IAM user or role needs the following minimum permissions. (If you see an <code>AccessDenied</code> error, this is the first place to look.):</p>
<table>
<thead>
<tr>
<th>Use Case</th>
<th>Required IAM Permission</th>
</tr>
</thead>
<tbody><tr>
<td>1 - Cost Anomaly Detection</td>
<td><code>ce:GetCostAndUsage</code></td>
</tr>
<tr>
<td>3 - Drift Detection</td>
<td><code>ec2:DescribeSecurityGroups</code></td>
</tr>
<tr>
<td>4 - Secrets Rotation</td>
<td><code>secretsmanager:GetSecretValue</code>, <code>secretsmanager:PutSecretValue</code></td>
</tr>
</tbody></table>
<p>If you're using a fresh AWS free-tier account with <code>AdministratorAccess</code> attached, these permissions are already included and you can skip this step.</p>
<p>If you're on a restricted IAM user, here's how to add them. In the AWS Console, go to IAM, click Users, then click your username. Under the Permissions tab, click Add permissions, then Create inline policy.</p>
<p>Switch to the JSON tab and paste a policy document granting the permissions in the table above, then save it.</p>
<p>If your company manages AWS through an organization and you don't have permission to edit your own IAM policies, ask your administrator to add these permissions to your role.</p>
<h3 id="heading-companion-github-repository">Companion GitHub Repository</h3>
<p>All demo projects live at: <a href="https://github.com/Osomudeya/devops-scripting-labs"><strong>https://github.com/irvingtalks/devops-scripting-labs</strong></a></p>
<p>Each use case has its own numbered folder with the complete script, supporting files, a <code>setup.sh</code> to prepare the environment, and a <code>break_it.sh</code> that injects the specific failure each use case is built around.</p>
<p>Clone the repo before starting:</p>
<pre><code class="language-plaintext">git clone https://github.com/irvingtalks/devops-scripting-labs
cd devops-scripting-labs
</code></pre>
<p>Before running any use case, check that you have everything installed:</p>
<pre><code class="language-plaintext">./preflight.sh
</code></pre>
<p>This checks for every tool the lab needs like Python, AWS CLI, Docker, Kind, Helm, Terraform, and <code>bc</code> and tells you exactly what's missing with the install command for each one.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-use-case-1-cost-anomaly-detection">Use Case 1 - Cost Anomaly Detection</a></p>
</li>
<li><p><a href="#heading-use-case-2-log-correlation-across-services">Use Case 2 - Log Correlation Across Services</a></p>
</li>
<li><p><a href="#heading-use-case-3-infrastructure-drift-detection">Use Case 3 - Infrastructure Drift Detection</a></p>
</li>
<li><p><a href="#heading-use-case-4-secrets-rotation-with-zero-downtime">Use Case 4 - Secrets Rotation with Zero Downtime</a></p>
</li>
<li><p><a href="#heading-use-case-5-automated-canary-rollback-trigger">Use Case 5 - Automated Canary Rollback Trigger</a></p>
</li>
<li><p><a href="#heading-what-you-can-do-now">What You Can Do Now</a></p>
</li>
</ul>
<h2 id="heading-use-case-1-cost-anomaly-detection">Use Case 1 - Cost Anomaly Detection</h2>
<p><strong>Environment:</strong> AWS Cost Explorer API (read-only, available in all accounts) <strong>Language:</strong> Python</p>
<h3 id="heading-the-production-problem">The Production Problem</h3>
<p>A junior engineer is testing a Kubernetes configuration. They spin up a managed node group in AWS (a set of EC2 virtual machines that the Kubernetes cluster uses to run workloads) and configure the cluster autoscaler, which is the Kubernetes component responsible for adding more machines when the cluster needs more capacity. The test goes well, and on Friday afternoon, they forget to tear the environment down.</p>
<p>Over the weekend, the autoscaler keeps provisioning new nodes because the test workloads are still running and requesting resources. By Monday morning you have a node group that has been quietly growing for two and a half days, and nobody noticed until the invoice landed three weeks later.</p>
<p>The script in this use case exists because your AWS bill isn't just a monthly number. It's a time series, and you can monitor it the same way you monitor application metrics. Check it daily, know your baseline, and you catch this kind of event in hours instead of weeks.</p>
<h3 id="heading-whats-actually-happening-at-the-system-level">What's Actually Happening at the System Level</h3>
<p><strong>What this is not:</strong> This isn't a finance dashboard. It's an operational anomaly detector and the signal it monitors is cost. But the thing it's actually detecting is unexpected infrastructure behavior such as resources left running, autoscaler events, and forgotten environments.</p>
<p>AWS Cost Explorer is a service that stores your billing data and exposes it through an API, and when you call it, you're running a query against your account's billing records by specifying the time range, the granularity, and how you want results grouped.</p>
<p>One thing to know before you start investigating any flagged cost is that AWS decides which service category to put a charge under, not you. An EBS snapshot copy running across regions might appear under the EC2 line item rather than data transfer, which means a spike in EC2 spend doesn't necessarily mean something went wrong with your EC2 instances. The script flags the spike correctly, but investigating it means asking <em>"what changed in my infrastructure on this date"</em> rather than <em>"what is running in EC2 right now."</em></p>
<p>The billing label is a starting point, not a diagnosis.</p>
<h3 id="heading-set-up-the-demo-environment">Set Up the Demo Environment</h3>
<p>Navigate to <code>01-cost-anomaly/</code> in the <a href="https://github.com/Osomudeya/devops-scripting-labs">companion repo</a>. No cluster setup is needed for this use case because the script runs against your AWS account directly, and the only dependency is boto3:</p>
<pre><code class="language-plaintext">cd 01-cost-anomaly
pip install boto3
</code></pre>
<p>Before running against your real account, make sure your AWS credentials are configured. The script uses whatever credentials the AWS CLI is set up with. If you haven't done this yet:</p>
<pre><code class="language-plaintext">aws configure
</code></pre>
<p>This will ask for your AWS Access Key ID, Secret Access Key, default region (use <code>us-east-1</code> if unsure), and output format (type <code>json</code>). You can find your access keys in the AWS Console under IAM → Users → your username → Security credentials → Create access key.</p>
<p>Your account needs the <code>ce:GetCostAndUsage</code> permission also, if you're on a fresh account with AdministratorAccess that's already included.</p>
<p>If you have an AWS account with a few weeks of billing history, you can run the script directly against your real data:</p>
<pre><code class="language-plaintext">python detect_cost_anomaly.py
</code></pre>
<p>Two things to know before running against a real account. First, Cost Explorer data has a 24-hour lag. This means spend from today won't appear until tomorrow, so the script automatically excludes the most recent day to avoid incomplete results.</p>
<p>Second, the script uses unblended costs, which is what you actually pay on a single-account setup. Blended costs are a weighted average used in multi-account organisations sharing reserved capacity and will give different numbers.</p>
<p>If you have a new account or prefer not to use real billing data, the script includes a <code>--sample</code> flag that uses built-in data and calls no AWS APIs at all.<br>Run this first to see what the output looks like before reading the code:</p>
<pre><code class="language-plaintext">python detect_cost_anomaly.py --sample
</code></pre>
<h3 id="heading-the-script">The Script</h3>
<pre><code class="language-python">#!/usr/bin/env python3
# detect_cost_anomaly.py — Use Case 1: Cost Anomaly Detection
# Full explanation of every function is in the article.

import statistics
import sys
from datetime import datetime, timedelta

import boto3

def build_sample_data(days=30):
    """Synthetic Cost Explorer rows for the last `days` (ending yesterday).

    The EC2 spike is placed on yesterday (device local date) so sample output
    always matches the same window as live Cost Explorer mode.
    """
    last_day = datetime.today().date() - timedelta(days=1)
    first_day = last_day - timedelta(days=days - 1)
    anomaly_day_index = days - 1
    results = []
    for i in range(days):
        day = first_day + timedelta(days=i)
        d = i + 1
        results.append(
            {
                "TimePeriod": {
                    "Start": str(day),
                    "End": str(day + timedelta(days=1)),
                },
                "Groups": [
                    {
                        "Keys": ["Amazon EC2"],
                        "Metrics": {
                            "UnblendedCost": {
                                "Amount": str(
                                    round(
                                        18.50
                                        if i == anomaly_day_index
                                        else 1.10 + (d % 3) * 0.10,
                                        2,
                                    )
                                )
                            }
                        },
                    },
                    {
                        "Keys": ["Amazon S3"],
                        "Metrics": {
                            "UnblendedCost": {
                                "Amount": str(round(0.04 + (d % 5) * 0.01, 2))
                            }
                        },
                    },
                    {
                        "Keys": ["Amazon RDS"],
                        "Metrics": {
                            "UnblendedCost": {
                                "Amount": str(round(0.85 + (d % 4) * 0.05, 2))
                            }
                        },
                    },
                ],
            }
        )
    return results, str(last_day)


def get_daily_costs(days=30):
    ce = boto3.client("ce", region_name="us-east-1")
    end = datetime.today().date() - timedelta(days=1)
    start = end - timedelta(days=days)
    response = ce.get_cost_and_usage(
        TimePeriod={"Start": str(start), "End": str(end)},
        Granularity="DAILY",
        Metrics=["UnblendedCost"],
        GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
    )
    return response["ResultsByTime"]


def build_service_timeseries(results):
    services = {}
    for day in results:
        date_str = day["TimePeriod"]["Start"]
        for group in day["Groups"]:
            service = group["Keys"][0]
            cost = float(group["Metrics"]["UnblendedCost"]["Amount"])
            if service not in services:
                services[service] = []
            services[service].append({"date": date_str, "cost": cost})
    return services


def detect_anomalies(services, baseline_days=7, multiplier=2.0, recent_days=None):
    """Flag days where cost exceeds prior `baseline_days` average + 2σ.

    Uses a rolling baseline (each day vs the previous week). If `recent_days`
    is set, only returns anomalies on or after today - recent_days.
    """
    cutoff = None
    if recent_days is not None:
        cutoff = datetime.today().date() - timedelta(days=recent_days)

    anomalies = []
    for service, daily in services.items():
        if len(daily) &lt; baseline_days + 1:
            continue
        for i in range(baseline_days, len(daily)):
            day = daily[i]
            day_date = datetime.strptime(day["date"], "%Y-%m-%d").date()
            if cutoff is not None and day_date &lt; cutoff:
                continue
            baseline_costs = [d["cost"] for d in daily[i - baseline_days : i]]
            avg = statistics.mean(baseline_costs)
            if avg &lt; 0.01:
                continue
            try:
                std = statistics.stdev(baseline_costs)
            except statistics.StatisticsError:
                continue
            threshold = avg + (multiplier * std)
            if day["cost"] &gt; threshold:
                anomalies.append(
                    {
                        "service": service,
                        "date": day["date"],
                        "actual": round(day["cost"], 4),
                        "baseline_avg": round(avg, 4),
                        "threshold": round(threshold, 4),
                        "pct_above": round(((day["cost"] - avg) / avg) * 100, 1),
                    }
                )
    return sorted(anomalies, key=lambda x: x["date"])


def parse_args(argv):
    use_sample = "--sample" in argv
    recent_days = None
    for arg in argv[1:]:
        if arg.startswith("--recent-days="):
            recent_days = int(arg.split("=", 1)[1])
    return use_sample, recent_days


def run(use_sample=False, recent_days=None):
    if use_sample:
        results, anomaly_date = build_sample_data()
        print("Running against sample data (--sample mode).")
        print(
            f"This data represents 30 days of billing ending yesterday, "
            f"with a realistic EC2 anomaly on {anomaly_date}.\n"
        )
    else:
        print("Fetching 30 days of daily AWS costs by service...")
        print("Note: today is excluded — Cost Explorer has a 24-hour billing lag.\n")
        results = get_daily_costs(days=30)

    if recent_days is not None:
        since = datetime.today().date() - timedelta(days=recent_days)
        print(
            f"Checking for spikes in the last {recent_days} days only "
            f"(on or after {since}), each vs its prior 7-day average.\n"
        )

    services = build_service_timeseries(results)
    anomalies = detect_anomalies(services, recent_days=recent_days)

    if not anomalies:
        print("No anomalies detected.")
        print("\nNote: this script flags statistical outliers against your own baseline.")
        print("A consistently elevated spend level will not trigger — only sudden increases.")
        return

    print(f"{'=' * 60}")
    print(f"ANOMALIES DETECTED: {len(anomalies)}")
    print(f"{'=' * 60}\n")

    for a in anomalies:
        print(f"Service:      {a['service']}")
        print(f"Date:         {a['date']}")
        print(f"Actual cost:  ${a['actual']}")
        print(f"Baseline avg: ${a['baseline_avg']} (prior 7-day average)")
        print(f"Threshold:    ${a['threshold']}")
        print(f"Overage:      {a['pct_above']}% above baseline")
        print()

    print("=" * 60)
    print("A note on AWS cost attribution:")
    print("The service label in Cost Explorer is assigned by AWS, not by the resource")
    print("that caused the cost. An EC2 spike may be caused by EBS snapshot copies,")
    print("cross-region data transfer, or autoscaling events that AWS categorizes under")
    print("EC2 in billing — not a running EC2 instance you can find in the console.")
    print()
    print("Before investigating the flagged service directly, ask:")
    print("What changed in my infrastructure on or before the flagged date?")
    print("Work backward from the operational change, not forward from the billing label.")


if __name__ == "__main__":
    use_sample, recent_days = parse_args(sys.argv)
    run(use_sample=use_sample, recent_days=recent_days)
</code></pre>
<h3 id="heading-how-the-script-works">How the Script Works</h3>
<p><code>get_daily_costs</code> pulls your AWS billing data for the last 30 days.</p>
<p><code>build_service_timeseries</code> takes the raw data from AWS and reorganises it. AWS groups the data by day first, then by service. This function flips that around so each service has its own list of daily costs, which is what the detection step needs to work with.</p>
<p><code>detect_anomalies</code> is where the actual check happens. For each service, it compares each day's spend to the 7 days right before it. If yesterday cost dramatically more than the week before, the script flags it. That's all it does.</p>
<p><code>--recent-days=7</code> means <em>"only show me anomalies from the last 7 days."</em> The script still fetches 30 days of data because it needs that history to calculate the comparison, but the results are filtered to the window you care about. This is good for a quick Monday morning check.</p>
<p><code>--sample</code> runs without touching your AWS account at all. It uses built-in fake billing data with a spike baked into yesterday's date so the detection always fires. Use this first to see what the output looks like before connecting it to real data.</p>
<h3 id="heading-what-the-output-looks-like">What the Output Looks Like</h3>
<p>Running <code>--sample</code> (the spike date will show as yesterday's actual date, not a fixed value):</p>
<pre><code class="language-plaintext">Running against sample data (--sample mode).
30 days of billing ending yesterday, with an EC2 spike on 2026-05-14.

============================================================
ANOMALIES DETECTED: 1
============================================================

Service:      Amazon EC2
Date:         2026-05-14
Actual cost:  $18.5
Baseline avg: $1.2143 (prior 7-day average)
Threshold:    $1.3939
Overage:      1423.4% above baseline

============================================================
A note on AWS cost attribution:
The service label in Cost Explorer is assigned by AWS, not by the resource
that caused the cost. An EC2 spike may be caused by EBS snapshot copies,
cross-region data transfer, or autoscaling events that AWS categorizes under
EC2 in billing - not a running EC2 instance you can find in the console.

Before investigating the flagged service directly, ask:
What changed in my infrastructure on or before the flagged date?
Work backward from the operational change, not forward from the billing label.
</code></pre>
<p>Your numbers will differ slightly from the above because the sample data generates dates from today dynamically. The spike always shows up on yesterday and the surrounding baseline numbers shift depending on the day you run it.</p>
<h3 id="heading-the-decision-the-script-cant-make-for-you">The Decision the Script Can't Make for You</h3>
<p>The anomaly is on the EC2 line, and the instinct is to go look at running EC2 instances. But as the output warns, the attribution is AWS's choice, not yours.</p>
<p>Before opening the EC2 console, check your deployment history for that date. What was deployed? Was a new environment created? Did an autoscaler event run? Start from the operational change and follow the thread to the billing data, because starting from the billing label and working backward is slower and frequently misleading.</p>
<h3 id="heading-break-it-on-purpose">Break it On Purpose</h3>
<pre><code class="language-bash"># See the spike immediately with no AWS account needed
python detect_cost_anomaly.py --sample

# Run against your real account
python detect_cost_anomaly.py

# Only show anomalies from the last 7 days, good for a quick this-week check
python detect_cost_anomaly.py --recent-days=7

# Combine both flags - sample data filtered to the last 7 days
python detect_cost_anomaly.py --sample --recent-days=7
</code></pre>
<p><strong>If your real account returns "No anomalies detected" that's not a failure.</strong> It means your spend has been consistent. A clean account returns clean output. The script is doing exactly what it should.</p>
<p>When a real event happens on your account such as an autoscaler left running, a forgotten environment or an unexpected data transfer, this is what catches it before the invoice does.</p>
<h2 id="heading-use-case-2-log-correlation-across-services">Use Case 2 – Log Correlation Across Services</h2>
<p><strong>Environment:</strong> Fully local – Docker Compose, three Python services<br><strong>Language:</strong> Python</p>
<h3 id="heading-the-production-problem">The Production Problem</h3>
<p>A user reports that their payment failed. You open your logging tool and search. The auth service logged a successful authentication. The ledger service logged a successful transaction but the notification service which should have sent a payment confirmation email has logged nothing at all.</p>
<p>Two services reported success while one service stay silent. The payment still failed, and you have three logs and no clear answer about where the chain broke.</p>
<h3 id="heading-whats-actually-happening-at-the-system-level">What's Actually Happening at the System Level</h3>
<p><strong>What this is not:</strong> This isn't a guide to installing a log aggregation tool. It's about the data structure that makes log correlation possible in the first place and what happens when that structure breaks on one service's error path.</p>
<p>In a system with a single service, debugging is simple: one service, one log file, one timeline. But when a user request passes through multiple services, you need a way to link all the logs together. That link is called a trace ID.</p>
<p>Think of it like a ticket number at a government office. When you walk in, you get a number, say, A247. Every desk that handles your case writes A247 on your file. If something goes wrong, the manager pulls every record with A247 and sees exactly what happened, in order, across every desk. That is a trace ID. One number, shared across every service that touched the request.</p>
<p>In the demo, when a payment comes in, the auth service creates a unique ID for it. Every log line that auth, ledger, and notification write for that payment includes the same ID. When something breaks, you run <code>correlate.py</code> with that ID and it finds every related log line across all three services and sorts them by time:</p>
<pre><code class="language-plaintext">python correlate.py pay-abc123
</code></pre>
<p>Here's what those logs look like. Notice that every line has the same <code>trace_id</code>:</p>
<pre><code class="language-json">{"timestamp": "2026-05-01T14:23:01.234Z", "trace_id": "pay-abc123", "service": "auth", "event": "user_authenticated", "level": "INFO", "user_id": "u_789", "duration_ms": 12}
{"timestamp": "2026-05-01T14:23:01.891Z", "trace_id": "pay-abc123", "service": "ledger", "event": "transaction_recorded", "level": "INFO", "amount": 50.0, "currency": "USD"}
{"timestamp": "2026-05-01T14:23:02.103Z", "trace_id": "pay-abc123", "service": "notification", "event": "email_queued", "level": "INFO", "recipient": "user@example.com"}
</code></pre>
<p>Now here's what breaks it. The notification service hits a timeout connecting to the email provider. The developer who wrote the error handler forgot to include the trace ID, so instead of a proper log line, it writes this:</p>
<pre><code class="language-plaintext">2026-05-01T14:23:02.415Z ERROR Connection timeout to email provider smtp.example.com:587
</code></pre>
<p>The error happened, the log line exists. But because it has no <code>trace_id</code>, <code>correlate.py</code> can't find it.</p>
<p>The notification still appears in the timeline, and you can see <code>email_send_attempt</code> – but <code>email_queued</code> never follows it.</p>
<pre><code class="language-plaintext">Timeline — 5 events across 3 service(s):

  [2026-05-15T21:59:00.605307+00:00] [AUTH] [INFO] payment_request_received
  [2026-05-15T21:59:00.606008+00:00] [AUTH] [INFO] user_authenticated
  [2026-05-15T21:59:00.617331+00:00] [LEDGER] [INFO] transaction_recorded
  [2026-05-15T21:59:00.630313+00:00] [NOTIFICATION] [INFO] email_send_attempt
  [2026-05-15T21:59:00.685182+00:00] [AUTH] [INFO] payment_complete
</code></pre>
<p>The attempt is there but the failure is not. The developer just forgot one field.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/22b7d7b0-8ae5-4573-bcb0-faaf5d807e8a.png" alt="log correlation attempt terminal output - ERROR Connection timeout" style="display:block;margin:0 auto" width="1041" height="82" loading="lazy">

<h3 id="heading-set-up-the-demo-environment">Set Up the Demo Environment</h3>
<p>Navigate to <code>02-log-correlation/</code> and start the three services:</p>
<pre><code class="language-plaintext">cd 02-log-correlation
docker compose up -d
</code></pre>
<p>This starts the auth, ledger, and notification services. Trigger a payment request to generate some logs:</p>
<pre><code class="language-plaintext">./trigger_request.sh
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/06977757-e7fd-43c3-aaca-dbc6d17c951a.png" alt="trigger_request.sh terminal output - also showing the traceid" style="display:block;margin:0 auto" width="630" height="131" loading="lazy">

<p>The script prints the trace ID it used. Copy the ID and Run the correlation script against it now, before we break anything, to see the full working path:</p>
<pre><code class="language-plaintext">python correlate.py pay-5831e1bf
</code></pre>
<p>You should see something like this (your trace ID will be different but the structure is the same):</p>
<pre><code class="language-plaintext">Loading logs from ./logs/...
Loaded 6 structured log lines.

============================================================
Trace ID: pay-5831e1bf
============================================================

Timeline - 6 events across 3 service(s):

  [2026-05-15T21:42:28.079046+00:00] [AUTH] [INFO] payment_request_received
    service: auth
    user_id: u_789
    amount: 50.0
  [2026-05-15T21:42:28.080718+00:00] [AUTH] [INFO] user_authenticated
    service: auth
    user_id: u_789
    duration_ms: 12
  [2026-05-15T21:42:28.145528+00:00] [LEDGER] [INFO] transaction_recorded
    service: ledger
    user_id: u_789
    amount: 50.0
    currency: USD
  [2026-05-15T21:42:28.210088+00:00] [NOTIFICATION] [INFO] email_send_attempt
    service: notification
    recipient: user@example.com
  [2026-05-15T21:42:28.347893+00:00] [NOTIFICATION] [INFO] email_queued
    service: notification
    recipient: user@example.com
    amount: 50.0
  [2026-05-15T21:42:28.378402+00:00] [AUTH] [INFO] payment_complete
    service: auth
    user_id: u_789
    amount: 50.0
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/26a0b226-4e3d-4967-a7c6-6d367409fb1d.png" alt="terminal output showing the full payment journey" style="display:block;margin:0 auto" width="1100" height="559" loading="lazy">

<p>That's the full payment journey with auth, ledger, notification in the exact order it happened. Now let's look at how the script works.</p>
<h3 id="heading-the-script">The Script</h3>
<pre><code class="language-python"># correlate.py
import json
import os
import sys

SERVICES = ["auth", "ledger", "notification"]
LOG_DIR = "./logs"


def load_logs(log_dir):
    """
    Read each service's log file and parse every line as JSON.
    Lines that fail JSON parsing are printed as warnings.
    They are not silently dropped - a plain-text error line in a service
    that should emit structured logs is itself evidence worth seeing.
    """
    all_lines = []

    for service in SERVICES:
        log_file = os.path.join(log_dir, f"{service}.log")

        if not os.path.exists(log_file):
            print(f"  WARNING: No log file for '{service}' at {log_file}")
            continue

        with open(log_file) as f:
            for line_num, line in enumerate(f, 1):
                line = line.strip()
                if not line:
                    continue
                try:
                    parsed = json.loads(line)
                    parsed["_source"] = service
                    all_lines.append(parsed)
                except json.JSONDecodeError:
                    # This line exists in the log but cannot be correlated.
                    print(f"  WARNING: {service}.log line {line_num} is not structured JSON:")
                    print(f"           {line[:100]}")
                    print(f"           This line will NOT appear in any trace-based search.")

    return all_lines


def correlate(trace_id, all_lines):
    """
    Find every log line with this trace_id and sort by timestamp.
    The sorted result is the reconstructed timeline of the request.
    """
    matched = [line for line in all_lines if line.get("trace_id") == trace_id]
    matched.sort(key=lambda x: x.get("timestamp", ""))
    return matched


def find_missing_services(matched):
    """
    Check which services produced zero trace-tagged lines for this request.
    A missing service is not just an absence - it is a signal.
    Either the request never reached that service, or an error path swallowed
    the trace ID. Both are worth investigating.
    """
    services_seen = {line["_source"] for line in matched}
    return [s for s in SERVICES if s not in services_seen]


def print_timeline(trace_id, matched, missing):
    print(f"\n{'=' * 60}")
    print(f"Trace ID: {trace_id}")
    print(f"{'=' * 60}")

    if not matched:
        print("\nNo structured log lines found with this trace ID.")
        print("Either the trace ID is wrong, or no service emitted")
        print("a structured log line for this request.")
        return

    services_count = len({line["_source"] for line in matched})
    print(f"\nTimeline - {len(matched)} events across {services_count} service(s):\n")

    for line in matched:
        ts = line.get("timestamp", "unknown")
        service = line.get("_source", "unknown").upper()
        event = line.get("event", "unknown event")
        level = line.get("level", "INFO")
        extras = {k: v for k, v in line.items()
                  if k not in ("timestamp", "trace_id", "event", "level", "_source")}

        print(f"  [{ts}] [{service}] [{level}] {event}")
        for k, v in extras.items():
            print(f"    {k}: {v}")

    if missing:
        print(f"\n{'=' * 60}")
        print("MISSING TELEMETRY")
        print(f"{'=' * 60}")
        print(f"These services produced no trace-tagged events for trace {trace_id}:\n")
        for s in missing:
            print(f"  - {s}")
        print()
        print("This means one of three things:")
        print("  1. The request never reached this service.")
        print("  2. The service received it but an error path swallowed the trace ID,")
        print("     leaving a plain-text log line that trace correlation cannot find.")
        print("  3. This service's log file was not included in this run.")
        print()
        print("Check the raw log file for a plain-text error line around the same timestamp.")
        print("If one exists, that is your root cause - and a structured logging gap to fix.")


def run(trace_id):
    print(f"Loading logs from {LOG_DIR}/...")
    all_lines = load_logs(LOG_DIR)
    print(f"Loaded {len(all_lines)} structured log lines.\n")

    matched = correlate(trace_id, all_lines)
    missing = find_missing_services(matched)
    print_timeline(trace_id, matched, missing)


if __name__ == "__main__":
    if len(sys.argv) &lt; 2:
        print("Usage: python correlate.py &lt;trace_id&gt;")
        print("Example: python correlate.py pay-abc123")
        sys.exit(1)
    run(sys.argv[1])
</code></pre>
<h3 id="heading-how-the-script-works">How the Script Works</h3>
<p><code>load_logs</code> reads log files from each service. Each line should be JSON. If a line isn't JSON, it prints a warning that usually means an error log is missing a trace ID and can't be tracked.</p>
<p><code>correlate</code> finds all logs that match the given trace ID and sorts them by time. This rebuilds the full request flow across services.</p>
<p><code>find_missing_services</code> checks which services have no logs for that trace ID. This tells you where the request stopped or where the trace ID was lost.</p>
<p><code>print_timeline</code> displays the full request timeline in order. It also shows which services are missing if something didn't log correctly.</p>
<p>One thing worth knowing for when you use this in a real Kubernetes environment:<br>in Kubernetes, <code>kubectl logs</code> only shows the current running container.<br>If a pod restarts, you can use this:</p>
<pre><code class="language-plaintext">kubectl logs &lt;pod-name&gt; --previous
</code></pre>
<p>But this only works for the last restart. Older logs are gone unless you use a logging system like Loki or CloudWatch.</p>
<h3 id="heading-what-the-output-looks-like-after-breaking-it">What the Output Looks Like After Breaking it</h3>
<p>The point of this section is to show you what happens when a service fails silently, – when the error exists in the logs but the script can't find it because the developer forgot one field.</p>
<p><code>break_it.sh</code> forces the notification service to fail when it tries to send an email, and because the error handler was written without a trace ID, the failure gets logged as plain text with no way to tie it back to the original request.</p>
<p>Run it:</p>
<pre><code class="language-plaintext">./break_it.sh
</code></pre>
<p>Then trigger a new request:</p>
<pre><code class="language-plaintext">./trigger_request.sh
</code></pre>
<p>Copy the trace ID it prints, then correlate it:</p>
<pre><code class="language-plaintext">python correlate.py pay-xxxxxxxx
</code></pre>
<p>Here is what you'll see:</p>
<pre><code class="language-plaintext">Loading logs from ./logs/...
  WARNING: notification.log line 10 is not structured JSON:
           2026-05-15T21:59:00.681583+00:00 ERROR Connection timeout to email
           provider http://mock-email:80/ after 0.001s - failed to send
           confirmation to user@example.com
           This line will NOT appear in any trace-based search.
Loaded 29 structured log lines.

============================================================
Trace ID: pay-6cf69a8c
============================================================

Timeline - 5 events across 3 service(s):

  [2026-05-15T21:59:00.605307+00:00] [AUTH] [INFO] payment_request_received
  [2026-05-15T21:59:00.606008+00:00] [AUTH] [INFO] user_authenticated
  [2026-05-15T21:59:00.617331+00:00] [LEDGER] [INFO] transaction_recorded
  [2026-05-15T21:59:00.630313+00:00] [NOTIFICATION] [INFO] email_send_attempt
  [2026-05-15T21:59:00.685182+00:00] [AUTH] [INFO] payment_complete
</code></pre>
<p>Look at this carefully. The notification is in the timeline, and it logged <code>email_send_attempt</code>. But <code>email_queued</code> is missing, which means the email never actually sent and the error that explains why isn't in the timeline at all. It's hiding in the WARNING at the very top, where the script told you it found a line it couldn't parse.</p>
<p>That's the problem: where the attempt is visible but the failure is invisible.</p>
<p>Run <code>cat logs/notification.log</code> and scroll to the bottom:</p>
<pre><code class="language-plaintext">{"timestamp": "2026-05-15T21:59:00.630313+00:00", "trace_id": "pay-6cf69a8c",
 "service": "notification", "event": "email_send_attempt", ...}
2026-05-15T21:59:00.681583+00:00 ERROR Connection timeout to email provider
http://mock-email:80/ after 0.001s - failed to send confirmation to user@example.com
</code></pre>
<p>Two lines to note: the first has a trace ID, which the script found and showed in the timeline. The second doesn't – the script flagged it as a warning and skipped it. The error happened 0.075 seconds after the attempt. The log file has both lines. The timeline only has one.</p>
<p>That is what <em>"invisible failure"</em> looks like in production. The payment went through. The confirmation email never sent. The error is sitting right there in the log file, <code>Connection timeout to email provider after 0.001s</code> but in the correlation output above, the timeline shows <code>email_send_attempt</code> and then jumps straight to <code>payment_complete</code> with nothing in between: no error, no failure, no gap. It looks like everything worked.</p>
<p>The fix is in <code>02-log-correlation/services/notification/main.py</code>. Here's the broken error handler:</p>
<pre><code class="language-python">except httpx.TimeoutException:
    emit_plain(f"Connection timeout to email provider {EMAIL_PROVIDER_URL}")
    return {"status": "ok"}
</code></pre>
<p>And here's the fixed version. The only change is passing <code>req.trace_id</code> into <code>emit</code> instead of calling <code>emit_plain</code>:</p>
<pre><code class="language-python">except httpx.TimeoutException:
    emit(req.trace_id, "email_timeout", level="ERROR",
         provider=EMAIL_PROVIDER_URL)
    return {"status": "ok"}
</code></pre>
<p>Once that change is made, the timeout error shows up in the timeline like everything else:</p>
<pre><code class="language-plaintext">  [2026-05-15T21:59:00.681583+00:00] [NOTIFICATION] [ERROR] email_timeout
    provider: http://mock-email:80/
</code></pre>
<p>One command, one trace ID, the full picture.</p>
<h3 id="heading-the-decision-the-script-cant-make-for-you">The Decision the Script Can't Make For You</h3>
<p>The correlation script identifies notification as the gap. When you check the raw <code>notification.log</code>, you find the plain-text timeout error, that the request reached the service, that authentication and transaction recording both succeeded, but that the email failed.</p>
<p>Whether a notification failure is a payment failure depends entirely on how your system was designed. If notification is a soft dependency, this error shouldn't have surfaced to the user as a payment failure, and something else in your system design is wrong. If it's a hard dependency, the transaction itself should have rolled back. The script found where things broke, but the right response depends on the design.</p>
<h3 id="heading-break-it-on-purpose">Break it On Purpose</h3>
<ol>
<li><p>Run <code>./break_it.sh</code> – this switches the notification service to a mode where its error handler drops the trace ID</p>
</li>
<li><p>Run <code>./trigger_request.sh</code> to generate a new payment request and get a new trace ID</p>
</li>
<li><p>Run <code>python correlate.py &lt;new trace ID&gt;</code> – the notification will be missing from the timeline</p>
</li>
<li><p>Run <code>cat logs/notification.log</code> – the timeout error is right there, without a trace ID, invisible to the script</p>
</li>
</ol>
<h2 id="heading-use-case-3-infrastructure-drift-detection">Use Case 3 - Infrastructure Drift Detection</h2>
<p><strong>Environment:</strong> AWS free tier (one security group) + Terraform<br><strong>Language:</strong> Python</p>
<h3 id="heading-the-production-problem">The Production Problem</h3>
<p>Your Terraform plan shows no changes. Your deployment is behaving differently than it did yesterday, and when you ask around, someone eventually remembers: a colleague made a quick manual change to a security group in the AWS console last week to unblock a staging test. They meant to go back and apply it through Terraform but they forgot.</p>
<p>Your Terraform state file and your actual AWS infrastructure have been quietly disagreeing ever since. Not that anything broke loudly or an alert fired. Terraform wouldn't even know unless someone ran <code>terraform plan</code> to check, and in this scenario, nobody did.</p>
<p>This is called infrastructure drift, and it's far more common than most teams want to admit.</p>
<h3 id="heading-whats-actually-happening-at-the-system-level">What's Actually Happening at the System Level</h3>
<p><strong>What this is not:</strong> This isn't the same as running <code>terraform plan</code>. A plan shows you what Terraform <em>would</em> change. This script shows you what has <em>already</em> changed in AWS without Terraform knowing.</p>
<p>The script itself doesn't run any Terraform commands. It reads the state file Terraform already produced. In the demo, Terraform creates that file. In a real environment, it already exists from your normal workflow.</p>
<p>Think of Terraform's state file as a receipt. When Terraform creates a security group, it writes down exactly what it created, the rules, the ports, the CIDRs. That receipt is the state file.</p>
<p>The script compares that receipt against what AWS actually has right now. If someone went into the AWS console and added a rule that isn't on the receipt, the script flags it as drift.</p>
<p>The blind spot is that, if someone creates a completely new security group in the console and never uses Terraform at all, there's no receipt for it. The script can't compare something it has never seen. It returns clean, and that group sits in your account undetected.</p>
<p>The demo shows both. First you break a known resource. Then the <code>--invisible</code> scenario creates a new one outside Terraform entirely, and the script returns clean even though your account now has an extra security group.</p>
<h3 id="heading-set-up-the-demo-environment">Set Up the Demo Environment</h3>
<p>Navigate to <code>03-drift-detection/</code> in the companion repo:</p>
<pre><code class="language-plaintext">cd 03-drift-detection
pip install -r requirements.txt
</code></pre>
<p>Run setup. This uses real Terraform, not a mock:</p>
<pre><code class="language-plaintext">./setup.sh
</code></pre>
<p>This runs <code>terraform init</code> and <code>terraform apply</code>, which creates a real AWS security group:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/d5617704-d04e-40cc-8ca9-aa9d6b806e5e.png" alt="screenshot of AWS dashboard showing security group created" style="display:block;margin:0 auto" width="1189" height="260" loading="lazy">

<p>It also writes a genuine <code>terraform.tfstate</code> file. Open it in any text editor if you want to see what Terraform actually produces. It's JSON, it's readable, and it's the real thing.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/3ea8e237-a1d7-43bc-be3d-50dcb6ff4b76.png" alt="screenshot of IDE folder structure showing terraform.tfstate file being created" style="display:block;margin:0 auto" width="215" height="230" loading="lazy">

<p>Once setup completes, run the script:</p>
<pre><code class="language-plaintext">python detect_drift.py terraform.tfstate
</code></pre>
<p>You should see something like this, but your actual security group ID will be different:</p>
<pre><code class="language-plaintext">Loading Terraform state from: terraform.tfstate

Checking: sg-0a1b2c3d4e5f6a7b8

  OK - No drift detected.
</code></pre>
<p>The lab is alive and both sides of the contract match. Now let's look at what the script is doing.</p>
<h3 id="heading-the-script-code-files">The Script (<a href="https://github.com/Osomudeya/devops-scripting-labs">Code Files</a>)</h3>
<pre><code class="language-python"># detect_drift.py
import boto3
import json
import sys


def load_tfstate(path):
    """
    The Terraform state file is plain JSON - open it in any text editor
    and you will see a 'resources' array listing everything Terraform knows about.
    This function reads that file and returns the parsed contents.
    """
    with open(path) as f:
        return json.load(f)


def get_security_groups_from_state(tfstate):
    """
    Walk through the resources array and collect every security group entry.
    Each resource has a 'type', a 'name', and an 'instances' array holding
    the attribute values Terraform recorded when it last ran.
    We extract the resource ID and the ingress (inbound) rules.
    """
    resources = {}
    for resource in tfstate.get("resources", []):
        if resource["type"] == "aws_security_group":
            for instance in resource.get("instances", []):
                sg_id = instance["attributes"]["id"]
                resources[sg_id] = {
                    "ingress": instance["attributes"].get("ingress", [])
                }
    return resources


def get_security_group_from_aws(sg_id):
    """
    Call the AWS EC2 API to fetch the live current state of this security group.
    Under the hood, boto3 constructs an authenticated HTTPS request, signs it with
    your AWS credentials, sends it to the EC2 API endpoint in your configured region,
    and parses the response. The response contains far more data than we need -
    we extract only the inbound rules.
    """
    ec2 = boto3.client("ec2")
    response = ec2.describe_security_groups(GroupIds=[sg_id])
    sg = response["SecurityGroups"][0]
    return {"ingress": sg.get("IpPermissions", [])}


def normalize_state_rules(rules):
    """
    Terraform stores ingress rules in its own format.
    We normalize them into a set of tuples for easy comparison.
    Each tuple is: (from_port, to_port, protocol, cidr_block)
    """
    normalized = set()
    for rule in rules:
        for cidr in rule.get("cidr_blocks", []):
            normalized.add((
                rule.get("from_port", 0),
                rule.get("to_port", 0),
                rule.get("protocol", "-1"),
                cidr
            ))
    return normalized


def normalize_aws_rules(rules):
    """
    AWS returns ingress rules in a different format from Terraform's.
    We normalize them into the same tuple shape so the comparison works.
    """
    normalized = set()
    for rule in rules:
        from_port = rule.get("FromPort", 0)
        to_port = rule.get("ToPort", 0)
        protocol = rule.get("IpProtocol", "-1")
        for ip_range in rule.get("IpRanges", []):
            normalized.add((from_port, to_port, protocol, ip_range["CidrIp"]))
    return normalized


def detect_drift(tfstate_path):
    print(f"Loading Terraform state from: {tfstate_path}")
    tfstate = load_tfstate(tfstate_path)
    state_sgs = get_security_groups_from_state(tfstate)

    if not state_sgs:
        print("No security groups found in state file. Nothing to compare.")
        return

    drift_found = False

    for sg_id, state_data in state_sgs.items():
        print(f"\nChecking: {sg_id}")

        try:
            aws_data = get_security_group_from_aws(sg_id)
        except Exception as e:
            print(f"  ERROR: Could not fetch {sg_id} from AWS - {e}")
            print(f"  Check your IAM permissions: ec2:DescribeSecurityGroups is required.")
            continue

        state_rules = normalize_state_rules(state_data["ingress"])
        aws_rules = normalize_aws_rules(aws_data["ingress"])

        # Rules in AWS that Terraform does not know about (manual additions)
        added_in_aws = aws_rules - state_rules
        # Rules Terraform expects that no longer exist in AWS (manual deletions)
        removed_from_aws = state_rules - aws_rules

        if added_in_aws:
            drift_found = True
            print("  DRIFT - Rules present in AWS but missing from state file:")
            for rule in added_in_aws:
                print(f"    Port {rule[0]}-{rule[1]} | Protocol: {rule[2]} | CIDR: {rule[3]}")

        if removed_from_aws:
            drift_found = True
            print("  DRIFT - Rules in state file but removed from AWS:")
            for rule in removed_from_aws:
                print(f"    Port {rule[0]}-{rule[1]} | Protocol: {rule[2]} | CIDR: {rule[3]}")

        if not added_in_aws and not removed_from_aws:
            print("  OK - No drift detected.")

    print("\n" + "=" * 60)
    if drift_found:
        print("Drift detected. See above for details.")
    else:
        print("No drift detected in monitored resources.")

    print("\nIMPORTANT: This script only checks resources tracked in your state file.")
    print("Resources created manually in AWS without Terraform are invisible to this check.")
    print("A clean output here does not mean your AWS account is clean - it means")
    print("the resources you are watching match what Terraform last recorded.")


if __name__ == "__main__":
    tfstate_path = sys.argv[1] if len(sys.argv) &gt; 1 else "terraform.tfstate"
    detect_drift(tfstate_path)
</code></pre>
<h3 id="heading-how-the-script-works">How the Script Works</h3>
<p><code>load_tfstate</code> opens <code>terraform.tfstate</code> and reads it. Run <code>cat terraform.tfstate</code> after setup and you'll see that it's just a text file and everything Terraform knows about your infrastructure is stored in there.</p>
<p><code>get_security_groups_from_state</code> pulls out every security group from that file, the ID AWS assigned it, and the inbound rules Terraform last recorded. These are the expected values.</p>
<p><code>get_security_group_from_aws</code> calls the AWS API and fetches the same security group's current inbound rules. These are the actual values. The script now has two versions of the same thing.</p>
<p><code>normalize_state_rules</code> and <code>normalize_aws_rules</code> exist because Terraform and AWS store the same rule in slightly different formats. These two functions convert both into the same format so the comparison works.</p>
<p>The comparison is the last step. Rules in AWS but not in the state file were added manually. Rules in the state file but not in AWS were deleted manually. The script prints both.</p>
<h3 id="heading-what-the-output-looks-like">What the Output Looks Like</h3>
<p>A clean run with no drift:</p>
<pre><code class="language-plaintext">Loading Terraform state from: terraform.tfstate

Checking: sg-0a1b2c3d4e5f6a7b8

  OK - No drift detected.

============================================================
No drift detected in monitored resources.

IMPORTANT: This script only checks resources tracked in your state file.
Resources created manually in AWS without Terraform are invisible to this check.
A clean output here does not mean your AWS account is clean - it means
the resources you are watching match what Terraform last recorded.
</code></pre>
<p>After injecting drift:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/61ae6f66-36e4-4e03-8e76-f250e2489dab.png" alt="screenshot of AWS dashboard showing security group inbound rule created" style="display:block;margin:0 auto" width="1170" height="220" loading="lazy">

<pre><code class="language-plaintext">Loading Terraform state from: terraform.tfstate

Checking: sg-0a1b2c3d4e5f6a7b8

  DRIFT - Rules present in AWS but missing from state file:
    Port 22-22 | Protocol: tcp | CIDR: 0.0.0.0/0

============================================================
Drift detected. See above for details.
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/5744ac0c-2e4f-4015-9b72-a1fa7084587e.png" alt="screenshot of terminal output after injecting drift showing &quot;drift detected&quot;" style="display:block;margin:0 auto" width="952" height="183" loading="lazy">

<h3 id="heading-the-decision-the-script-cant-make-for-you">The Decision the Script Can't Make For You</h3>
<p>The script finds drift, an inbound rule that Terraform doesn't know about. The instinct is to revert it immediately by running <code>terraform apply</code>, but before doing that, ask one question: was this change an emergency hotfix? Someone may have manually opened a port at 2am to restore a broken service while a proper fix was being prepared. And if you revert it automatically, you might undo something that was deliberately placed there to keep a service running.</p>
<p>Drift detection tells you that things are different. It doesn't tell you which version is correct, and investigating that is the work that comes after the script runs.</p>
<h3 id="heading-break-it-on-purpose">Break it On Purpose</h3>
<ol>
<li><p>Run <code>./break_it.sh</code>. This adds an SSH inbound rule (port 22) directly via the AWS CLI, simulating a manual console change.</p>
</li>
<li><p>Run <code>python detect_drift.py terraform.tfstate</code>. The drift appears in the output.</p>
</li>
<li><p>Run <code>./break_it.sh --invisible</code> to create a brand new security group that's not in the state file at all, then run the script again. It returns clean even though a new resource exists in your account, making the coverage gap visible.</p>
</li>
<li><p>Run <code>./teardown.sh</code>. When finished, this runs <code>terraform destroy</code> to delete the security group and clean up all AWS resources. No charges will remain after this.</p>
</li>
</ol>
<h2 id="heading-use-case-4-secrets-rotation-with-zero-downtime">Use Case 4 - Secrets Rotation with Zero Downtime</h2>
<p><strong>Environment:</strong> AWS Secrets Manager + local Kind cluster<br><strong>Language:</strong> Python</p>
<h3 id="heading-the-production-problem">The Production Problem</h3>
<p><strong>The goal of this use case:</strong> Kubernetes says a pod is healthy, but your users are getting database errors. The script catches that gap before the users are affected by running one extra check that Kubernetes never runs.</p>
<p>You rotate your database credentials. The pod restarts. <code>kubectl get pods</code> shows Running. Ten minutes later, users can't log in.</p>
<p>The rotation worked, but the problem is that Kubernetes checked whether the HTTP server was alive, not whether it could authenticate with the database. Those are two different things.</p>
<h3 id="heading-whats-actually-happening">What's Actually Happening</h3>
<p><strong>What this is not:</strong> This isn't about how to store secrets in Kubernetes. It's about what happens after the secret is rotated.</p>
<p>When a pod is already running, it holds a pool of open database connections that were authenticated before the rotation happened. Those connections stay alive after the password changes because they were authenticated before the change and the database does not kick them out. But when the pool needs to open a new connection, it uses the current environment credentials, which still have the old password. That new connection fails immediately.</p>
<p>Meanwhile, Kubernetes sees the pod responding to HTTP and marks it Running, so your users are hitting the failures with no indication from the cluster that anything is wrong.</p>
<h3 id="heading-what-the-healthzdb-endpoint-does">What the <code>/healthz/db</code> Endpoint Does</h3>
<p><code>/healthz</code> returns 200 if the HTTP server is alive. That is all Kubernetes checks.</p>
<p><code>/healthz/db</code> opens a fresh database connection using the current credentials and runs <code>SELECT 1</code>. If that fails after a rotation, the pod is Running but can't serve database requests. The rotation script calls this endpoint as its final step – the check Kubernetes never runs.</p>
<p>Here's what that looks like in the demo FastAPI application (<a href="https://github.com/Osomudeya/devops-scripting-labs">code files</a>):</p>
<pre><code class="language-python"># app.py (relevant section)
import os
import asyncpg
from fastapi import FastAPI, HTTPException

app = FastAPI()

DB_HOST = os.environ.get("DB_HOST", "postgres")
DB_PORT = int(os.environ.get("DB_PORT", "5432"))
DB_NAME = os.environ.get("DB_NAME", "appdb")
DB_USERNAME = os.environ.get("DB_USERNAME", "appuser")
DB_PASSWORD = os.environ.get("DB_PASSWORD", "")

@app.get("/healthz")
async def healthz():
    # Always returns 200 if the HTTP server is alive.
    # This is all the Kubernetes readiness probe checks.
    return {"status": "ok"}

@app.get("/healthz/db")
async def healthz_db():
    # Opens a fresh connection using the current environment credentials.
    # If the password was rotated and this pod has not restarted yet,
    # the environment still has the old password - this connection fails.
    # /healthz above would still return 200. Your users would see errors.
    try:
        conn = await asyncpg.connect(
            host=DB_HOST, port=DB_PORT,
            database=DB_NAME, user=DB_USERNAME, password=DB_PASSWORD,
        )
        await conn.execute("SELECT 1")
        await conn.close()
        return {"status": "ok", "db": "authenticated"}

    except asyncpg.InvalidPasswordError:
        raise HTTPException(
            status_code=503,
            detail=(
                f"Authentication failed for '{DB_USERNAME}'. "
                "Password may have been rotated. "
                "Readiness probe does not check this."
            )
        )
    except Exception as e:
        raise HTTPException(status_code=503, detail=f"Database error: {str(e)}")
</code></pre>
<p>The difference between these two endpoints is the entire lesson of this use case.</p>
<h3 id="heading-set-up-the-demo-environment">Set Up the Demo Environment</h3>
<p>Navigate to <code>04-secrets-rotation/</code> and run the setup script:</p>
<pre><code class="language-plaintext">cd 04-secrets-rotation
./setup.sh
</code></pre>
<p>This starts a Kind cluster, deploys real PostgreSQL with the <code>appuser</code> account already created, deploys the demo FastAPI app connected to it, and creates an initial secret in AWS Secrets Manager.</p>
<p>Once setup completes, install the dependencies:</p>
<pre><code class="language-plaintext">pip install boto3 kubernetes
</code></pre>
<p>Before running the rotation, confirm everything is running:</p>
<pre><code class="language-plaintext">kubectl get pods
</code></pre>
<p>You should see <code>myapp</code> and <code>postgres</code> pods both in the Running state. If any pod shows Pending or Error, wait 30 seconds and check again. PostgreSQL takes a moment to finish initialising.</p>
<p>You can also verify that the secret was created in AWS. In the console, go to AWS Secrets Manager and look for <code>myapp/db-credentials</code>:</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/c5d481f7-c938-43f8-91ec-09640c137897.png" alt="screenshot showing AWS secret created" style="display:block;margin:0 auto" width="1414" height="337" loading="lazy">

<p>If you prefer the CLI:</p>
<pre><code class="language-plaintext">aws secretsmanager get-secret-value --secret-id myapp/db-credentials
</code></pre>
<p>Once both pods are Running and the secret exists, run the rotation to see the full path:</p>
<pre><code class="language-plaintext">python rotate_secret.py
</code></pre>
<p><strong>If Step 6 shows FAILED on this first clean run</strong>, it's almost always a timing issue: the app pod restarted successfully but <code>/healthz/db</code> ran before the new pod finished establishing its first database connection. Wait 20 seconds and run <code>python rotate_secret.py</code> again. If it fails repeatedly, run <code>kubectl logs deployment/myapp</code> to see what the app is reporting.</p>
<p>You should see all six steps complete cleanly, ending with:</p>
<pre><code class="language-plaintext">Rotation complete. Credential verified at the application level.
  AWS Secrets Manager: updated
  PostgreSQL:          updated (ALTER USER)
  Kubernetes Secret:   updated
  Application pod:     restarted, authenticated
</code></pre>
<p>The lab is alive and the full rotation chain works end to end. Now let's look at what the script is doing.</p>
<h3 id="heading-the-script-code-files">The Script (<a href="https://github.com/Osomudeya/devops-scripting-labs">Code Files</a>)</h3>
<pre><code class="language-python"># rotate_secret.py
import boto3
import base64
import json
import subprocess
import sys
from kubernetes import client, config


def get_current_secret(secret_name):
    """
    Fetch the current credential from AWS Secrets Manager.
    The secret is stored as a JSON string with 'username' and 'password' fields.
    """
    sm = boto3.client("secretsmanager")
    response = sm.get_secret_value(SecretId=secret_name)
    return json.loads(response["SecretString"])


def rotate_in_aws(secret_name, username, new_password):
    """
    Write the new credential to AWS Secrets Manager.
    put_secret_value creates a new version - the previous version is
    not deleted immediately, giving you a short rollback window.
    """
    sm = boto3.client("secretsmanager")
    new_value = json.dumps({"username": username, "password": new_password})
    sm.put_secret_value(SecretId=secret_name, SecretString=new_value)
    print("  [AWS] Secret updated in Secrets Manager.")


def update_kubernetes_secret(namespace, k8s_secret_name, username, new_password):
    """
    Patch the Kubernetes Secret object with the new credential values.
    Kubernetes requires secret data to be base64-encoded - this is encoding,
    not encryption. Anyone with access to the Secret object can decode the values.
    Real encryption at rest requires separate etcd encryption configuration.
    """
    config.load_kube_config()
    v1 = client.CoreV1Api()

    secret_data = {
        "username": base64.b64encode(username.encode()).decode(),
        "password": base64.b64encode(new_password.encode()).decode()
    }

    v1.patch_namespaced_secret(
        name=k8s_secret_name,
        namespace=namespace,
        body={"data": secret_data}
    )
    print(f"  [K8s] Kubernetes Secret '{k8s_secret_name}' updated.")


def rolling_restart(namespace, deployment_name):
    """
    Trigger a rolling restart of the deployment.
    Rolling restart means Kubernetes creates one new pod, waits for it to pass
    its readiness probe, then terminates one old pod - and repeats until all
    pods have been replaced. Availability is preserved throughout.
    This is very different from deleting all pods at once.
    """
    result = subprocess.run(
        ["kubectl", "rollout", "restart",
         f"deployment/{deployment_name}", "-n", namespace],
        capture_output=True, text=True
    )
    if result.returncode != 0:
        raise RuntimeError(f"Rolling restart failed: {result.stderr}")
    print(f"  [K8s] Rolling restart triggered for '{deployment_name}'.")


def wait_for_rollout(namespace, deployment_name, timeout=120):
    """
    Block until the rolling restart finishes or times out.
    'Finished' means all new pods are Running and their readiness probes passed.
    This does NOT mean the application can authenticate with the new credential.
    That is what verify_credential checks next.
    """
    print(f"  [K8s] Waiting for rollout (timeout: {timeout}s)...")
    result = subprocess.run(
        ["kubectl", "rollout", "status",
         f"deployment/{deployment_name}",
         "-n", namespace,
         f"--timeout={timeout}s"],
        capture_output=True, text=True
    )
    if result.returncode != 0:
        raise RuntimeError(f"Rollout did not complete: {result.stderr}")
    print("  [K8s] Rollout complete. All pods report Ready.")


def verify_credential(namespace, deployment_name):
    """
    This is the check the readiness probe does not make.
    We exec into the running pod and call /healthz/db - an endpoint that
    makes an actual authenticated query to the database.
    If this passes: the credential is working at the application level.
    If this fails after the readiness probe passed: the contract mismatch is confirmed.
    The pod is Running. The application cannot serve database requests.
    """
    print("  [Verify] Running post-rotation credential check...")

    result = subprocess.run(
        ["kubectl", "get", "pods", "-n", namespace,
         "-l", f"app={deployment_name}",
         "-o", "jsonpath={.items[0].metadata.name}"],
        capture_output=True, text=True
    )
    pod_name = result.stdout.strip()

    if not pod_name:
        print("  [Verify] ERROR: No running pod found for this deployment.")
        return False

    verify = subprocess.run(
        ["kubectl", "exec", pod_name, "-n", namespace,
         "--", "curl", "-sf", "http://localhost:8000/healthz/db"],
        capture_output=True, text=True
    )

    if verify.returncode != 0:
        print("  [Verify] FAILED - Pod is Running but database authentication failed.")
        print("           The readiness probe validated HTTP reachability.")
        print("           The application cannot authenticate with the new credential.")
        print("           These are two different contracts. Only one was checked automatically.")
        return False

    print("  [Verify] PASSED - Application confirmed it can authenticate with the new credential.")
    return True


def rotate(secret_name, new_password, namespace, k8s_secret_name, deployment_name):
    print("\n[Step 1/6] Reading current secret from AWS Secrets Manager...")
    current = get_current_secret(secret_name)
    username = current["username"]

    print("[Step 2/6] Updating AWS Secrets Manager...")
    rotate_in_aws(secret_name, username, new_password)

    print("[Step 3/6] Rotating password at the database level (ALTER USER)...")
    rotate_postgres_password(namespace, new_password)

    print("[Step 4/6] Updating Kubernetes Secret object...")
    update_kubernetes_secret(namespace, k8s_secret_name, username, new_password)

    print("[Step 5/6] Triggering rolling restart...")
    rolling_restart(namespace, deployment_name)
    wait_for_rollout(namespace, deployment_name)

    print("[Step 6/6] Verifying the new credential works at the application level...")
    success = verify_credential(namespace, deployment_name)

    print("\n" + "=" * 60)
    if success:
        print("Rotation complete. Credential verified at the application level.")
    else:
        print("Rotation incomplete. Readiness probe passed but credential verification failed.")
        print("Recommended action: force-restart all pods to flush the connection pool,")
        print("or investigate the database session timeout configuration.")
        sys.exit(1)


if __name__ == "__main__":
    import secrets as _secrets
    rotate(
        secret_name="myapp/db-credentials",
        new_password=_secrets.token_urlsafe(16),
        namespace="default",
        k8s_secret_name="db-credentials",
        deployment_name="myapp"
    )
</code></pre>
<h3 id="heading-how-the-script-works">How the Script Works</h3>
<p><code>get_current_secret</code> reads the current credential from AWS Secrets Manager so the script knows the username before it generates a new password.</p>
<p><code>rotate_in_aws</code> writes the new credential to Secrets Manager. It creates a new version rather than overwriting the old one, so you have a short window to roll back if something goes wrong.</p>
<p><code>_pg_password_literal</code> and <code>rotate_postgres_password</code> handle the step that most rotation scripts skip, which is actually changing the password inside PostgreSQL. This is done by running <code>ALTER USER appuser PASSWORD '...'</code> directly on the live PostgreSQL pod. Before this step, the database still accepts the old password. After this step, it does not.</p>
<p><code>update_kubernetes_secret</code> writes the new password into the Kubernetes Secret so that any new pod that starts will get the new credential from the beginning.</p>
<p><code>rolling_restart</code> and <code>wait_for_rollout</code> restart the application pods one at a time so the deployment stays available throughout. When this step completes, all pods are Running and their readiness probes have passed – but keep in mind that "Running" only means <code>/healthz</code> returned 200, which is exactly the problem this use case is about.</p>
<p><code>verify_credential</code> is the extra step Kubernetes never runs. It reaches inside the new pod and calls <code>/healthz/db</code>, which opens a real database connection with the credentials in the pod's current environment. If this passes, the rotation is genuinely complete. If this fails after the readiness probe passed, you have confirmed the gap: the pod looks healthy but can't serve database requests.</p>
<h3 id="heading-what-the-output-looks-like">What the Output Looks Like</h3>
<p>Successful rotation:</p>
<pre><code class="language-plaintext">[Step 1/6] Reading current secret from AWS Secrets Manager...
[Step 2/6] Updating AWS Secrets Manager...
  [AWS] Secrets Manager updated.
[Step 3/6] Rotating password at the database level (ALTER USER)...
  [DB]  Running ALTER USER on PostgreSQL...
  [DB]  Password changed at the database level.
        New connections now require the new password.
        Existing pool connections remain valid until they close.
[Step 4/6] Updating Kubernetes Secret object...
  [K8s] Kubernetes Secret 'db-credentials' updated.
[Step 5/6] Triggering rolling restart...
  [K8s] Rolling restart triggered for 'myapp'.
  [K8s] Waiting for rollout (timeout: 120s)...
  [K8s] Rollout complete. All pods report Ready.
[Step 6/6] Verifying the new credential works at the application level...
  [Verify] Running post-rotation credential check...
  [Verify] PASSED - Application confirmed it can authenticate with the new credential.

============================================================
Rotation complete. Credential verified at the application level.
  AWS Secrets Manager: updated
  PostgreSQL:          updated (ALTER USER)
  Kubernetes Secret:   updated
  Application pod:     restarted, authenticated
</code></pre>
<p>The lab is alive and the full rotation chain works end to end.</p>
<p>Before you break anything, confirm the pod is healthy:</p>
<pre><code class="language-plaintext">kubectl get pods
</code></pre>
<p>You should see <code>myapp</code> in Running state. That is the baseline: everything working as expected. Now let's break it.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/caabd892-562a-40f4-81c1-2c59fd15240b.png" alt="terminal screenshot showing output of 'kubectl get pods&quot;" style="display:block;margin:0 auto" width="753" height="187" loading="lazy">

<h3 id="heading-break-it-on-purpose">Break it On Purpose</h3>
<h4 id="heading-step-1-desync-the-db">Step 1: Desync the DB</h4>
<pre><code class="language-plaintext">./break_it.sh
</code></pre>
<p>This runs <code>ALTER USER</code> directly on PostgreSQL with a wrong password. The K8s Secret still has the old password, so the pod's environment and the database are now out of sync.</p>
<h4 id="heading-step-2-check-what-kubernetes-sees">Step 2: Check what Kubernetes sees</h4>
<pre><code class="language-plaintext">kubectl exec deployment/myapp -- curl -s http://localhost:8000/healthz
</code></pre>
<p>You will see <code>{"status":"ok"}</code>. The pod is still showing Ready in <code>kubectl get pods</code>. Kubernetes has no idea anything is wrong – that's the contract gap made visible in your terminal.</p>
<h4 id="heading-step-3-check-what-your-users-experience">Step 3: Check what your users experience</h4>
<pre><code class="language-plaintext">kubectl exec deployment/myapp -- curl -s http://localhost:8000/healthz/db
</code></pre>
<p>You'll see a <code>503</code> error. Fresh database connections are failing. Your users are already seeing this.</p>
<h4 id="heading-step-4-see-the-mixed-pattern-optional">Step 4: See the mixed pattern (optional)</h4>
<pre><code class="language-plaintext">./load_test.sh
</code></pre>
<p>Some requests succeed because they hit old pool connections that were authenticated before the break. Some fail because they need a fresh connection. The pod looks healthy, but half your traffic is failing.</p>
<h4 id="heading-step-5-run-the-rotation-script">Step 5: Run the rotation script</h4>
<pre><code class="language-plaintext">python rotate_secret.py
</code></pre>
<p>This time, Step 6 catches the failure. Here's what you'll see:</p>
<pre><code class="language-plaintext">[Step 5/6] Triggering rolling restart...
  [K8s] Rollout complete. All pods report Ready.
[Step 6/6] Verifying the new credential works at the application level...
  [Verify] Running post-rotation credential check...
  [Verify] FAILED - Pod is Running but database authentication failed.
           The readiness probe validated HTTP reachability.
           The application cannot authenticate with the new credential.
           These are two different contracts. Only one was checked automatically.

============================================================
Rotation incomplete. Readiness probe passed but credential verification failed.
</code></pre>
<p>The pod is Running and shows Ready in <code>kubectl get pods</code>. The rotation script says the credential is broken. That's the contract gap visible in your terminal, caught before your users hit it.</p>
<p><strong>The lesson:</strong> <code>/healthz</code> tells you the HTTP server is alive. <code>/healthz/db</code> tells you the application can actually connect to the database. Kubernetes only checks the first one unless you add a database probe. The rotation script adds that check at the end of every rotation so you catch the failure before your users do.</p>
<h3 id="heading-the-decision-the-script-cant-make-for-you">The Decision the Script Can't Make For You</h3>
<p>The verification failed, the pod is Running, and requests to the database are failing. You have two options:</p>
<ol>
<li><p>force-restart all pods at once to flush the connection pool (which is faster but causes a brief capacity reduction), or</p>
</li>
<li><p>wait for old sessions to expire naturally (which avoids downtime but leaves requests failing intermittently until the pool cycles).</p>
</li>
</ol>
<p>The script found the problem, but deciding what to do next belongs to an engineer who knows the system.</p>
<h3 id="heading-teardown">Teardown</h3>
<pre><code class="language-plaintext">./teardown.sh
</code></pre>
<h2 id="heading-use-case-5-automated-canary-rollback-trigger">Use Case 5 - Automated Canary Rollback Trigger</h2>
<p><strong>Environment:</strong> Fully local – Kind, Prometheus via Helm<br><strong>Language:</strong> Bash</p>
<h3 id="heading-what-this-use-case-does-and-why-it-matters">What This Use Case Does and Why it Matters</h3>
<p>This use case runs a script that watches your new deployment and automatically rolls it back if something goes wrong, before your users flood your support queue.</p>
<p>This matters in production because, when you ship a new version, you don't send all traffic to it immediately. You send a small slice, say 20% to the new version while 80% still goes to the old one. If the new version is broken, only 20% of users are affected and you can roll back before the damage spreads. But the rollback only works if you're watching the right things.</p>
<p><strong>The takehome:</strong> Two scripts watch the same failing canary. One reports everything is fine. The other fires the rollback. The only difference is what they measure. Your automation is only as good as what it watches.</p>
<p><strong>What to watch for:</strong> <code>canary_watch_v1.sh</code> watches errors only and stays silent while the canary is slow. <code>canary_watch_v2.sh</code> watches errors AND response time and fires the rollback. The difference between them is the lesson.</p>
<p><strong>What this is not:</strong> This isn't a guide to canary deployments. It's about what your monitoring misses when it only watches one signal.</p>
<h3 id="heading-how-it-works">How it Works</h3>
<p>Three things run in the cluster: the stable app (three pods, handles most traffic), the canary app (one pod, handles a small slice), and Prometheus (collects response times and error counts from both every 15 seconds).</p>
<p>The watch script asks Prometheus every 15 seconds: <em>"Is the canary behaving normally?"</em> If the answer is no for three checks in a row, it rolls back the canary automatically.</p>
<p>The question is that what does <em>"behaving normally"</em> mean? That is the entire use case.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/204f992e-44c2-4404-a6ff-f2279ea23aeb.png" alt="terminal screenshot showing output result of 'kubectl get pods&quot;" style="display:block;margin:0 auto" width="753" height="187" loading="lazy">

<h3 id="heading-set-up-the-demo-environment">Set Up the Demo Environment</h3>
<p>Navigate to <code>05-canary-rollback/</code> and run:</p>
<pre><code class="language-plaintext">cd 05-canary-rollback
./setup.sh
</code></pre>
<p>Setup takes a few minutes. It installs Prometheus, deploys both versions of the demo app, and starts a load generator pod that sends continuous traffic to both so Prometheus always has data.</p>
<p>When setup finishes, confirm everything is running:</p>
<pre><code class="language-plaintext">kubectl get pods
</code></pre>
<p>You should see output like this:</p>
<pre><code class="language-plaintext">NAME                                                   READY   STATUS    RESTARTS   AGE
load-generator-68c59698b7-kws2l                        1/1     Running   0          4m54s
myapp-canary-6d6979c66f-g9lgw                          1/1     Running   0          32s
myapp-stable-6bcf994fc4-b4k9l                          1/1     Running   0          4m55s
myapp-stable-6bcf994fc4-ndhxc                          1/1     Running   0          4m55s
myapp-stable-6bcf994fc4-z97kx                          1/1     Running   0          4m55s
prometheus-kube-prometheus-operator-59b847d96c-mp72s   1/1     Running   0          5m58s
prometheus-prometheus-kube-prometheus-prometheus-0     2/2     Running   0          5m1s
</code></pre>
<p>Three stable pods, one canary pod, one load generator, Prometheus running. The lab is alive.</p>
<p><strong>Wait 60 seconds before running anything else.</strong> Prometheus needs time to scrape the first metrics from the pods. If you skip this, the watch scripts return empty data with no explanation.</p>
<h3 id="heading-three-terminal-windows">Three Terminal Windows</h3>
<p>You need three separate command prompts running at the same time.</p>
<p><strong>On macOS:</strong> open Terminal and press <code>Cmd+T</code> twice. You now have three tabs, each an independent terminal.<br><strong>On Linux:</strong> press <code>Ctrl+Shift+T</code> in most terminal apps, or right-click and choose "Open new tab."</p>
<p>Label them Terminal 1 for the watch script, Terminal 2 for injecting failures, Terminal 3 for watching latency.</p>
<h3 id="heading-the-scripts">The Scripts</h3>
<h4 id="heading-version-1-watches-errors-only-code-here">Version 1: watches errors only (<a href="https://github.com/Osomudeya/devops-scripting-labs.git">code here</a>)</h4>
<pre><code class="language-bash">#!/usr/bin/env bash
# canary_watch_v1.sh

PROMETHEUS="http://localhost:9090"
DEPLOYMENT="myapp-canary"
NAMESPACE="default"
ERROR_THRESHOLD="0.05"
CHECK_INTERVAL=15
STRIKE_LIMIT=3

strikes=0

echo "Canary monitor running (v1 - error rate only)."
echo "Rollback triggers if error rate exceeds \({ERROR_THRESHOLD} for \){STRIKE_LIMIT} checks."
echo ""

while true; do
    ts=$(date '+%Y-%m-%dT%H:%M:%S')

    error_query='sum(rate(http_requests_total{app="myapp-canary",status=~"5.."}[1m])) / sum(rate(http_requests_total{app="myapp-canary"}[1m]))'

    error_rate=\((curl -sf "\){PROMETHEUS}/api/v1/query" \
        --data-urlencode "query=${error_query}" | \
        python3 -c "
import sys, json
d = json.load(sys.stdin)
result = d['data']['result']
print(result[0]['value'][1] if result else '0')
" 2&gt;/dev/null)

    error_rate=${error_rate:-0}
    above=\((echo "\)error_rate &gt; $ERROR_THRESHOLD" | bc -l)

    echo "[\(ts] error_rate=\){error_rate} | threshold=\({ERROR_THRESHOLD} | breach=\)([ "$above" = "1" ] &amp;&amp; echo YES || echo NO)"

    if [ "$above" = "1" ]; then
        strikes=$((strikes + 1))
        echo "  Strike \({strikes}/\){STRIKE_LIMIT}"
        if [ "\(strikes" -ge "\)STRIKE_LIMIT" ]; then
            echo "  ROLLBACK TRIGGERED"
            kubectl rollout undo deployment/"\({DEPLOYMENT}" -n "\){NAMESPACE}"
            exit 0
        fi
    else
        strikes=0
    fi

    sleep "${CHECK_INTERVAL}"
done
</code></pre>
<h4 id="heading-version-2-watches-error-rate-and-response-time">Version 2: watches error rate AND response time</h4>
<pre><code class="language-bash">#!/usr/bin/env bash
# canary_watch_v2.sh

PROMETHEUS="http://localhost:9090"
DEPLOYMENT="myapp-canary"
NAMESPACE="default"
ERROR_THRESHOLD="0.05"
LATENCY_THRESHOLD="2.0"
CHECK_INTERVAL=15
STRIKE_LIMIT=3

strikes=0

echo "Canary monitor running (v2 - error rate + P99 latency)."
echo "Error threshold: \({ERROR_THRESHOLD} | Latency P99 threshold: \){LATENCY_THRESHOLD}s"
echo ""

while true; do
    ts=$(date '+%Y-%m-%dT%H:%M:%S')

    error_query='sum(rate(http_requests_total{app="myapp-canary",status=~"5.."}[1m])) / sum(rate(http_requests_total{app="myapp-canary"}[1m]))'
    error_rate=\((curl -sf "\){PROMETHEUS}/api/v1/query" \
        --data-urlencode "query=${error_query}" | \
        python3 -c "
import sys, json
d = json.load(sys.stdin)
result = d['data']['result']
print(result[0]['value'][1] if result else '0')
" 2&gt;/dev/null)

    latency_query='histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{app="myapp-canary"}[1m])) by (le))'
    latency=\((curl -sf "\){PROMETHEUS}/api/v1/query" \
        --data-urlencode "query=${latency_query}" | \
        python3 -c "
import sys, json
d = json.load(sys.stdin)
result = d['data']['result']
print(result[0]['value'][1] if result else '0')
" 2&gt;/dev/null)

    error_rate=${error_rate:-0}
    latency=${latency:-0}

    error_breach=\((echo "\)error_rate &gt; $ERROR_THRESHOLD" | bc -l)
    latency_breach=\((echo "\)latency &gt; $LATENCY_THRESHOLD" | bc -l)

    triggered_by=""
    [ "\(error_breach" = "1" ] &amp;&amp; triggered_by="error_rate(\){error_rate})"
    [ "\(latency_breach" = "1" ] &amp;&amp; triggered_by="\){triggered_by:+\({triggered_by}, }latency_p99(\){latency}s)"

    echo "[\(ts] error_rate=\){error_rate} | latency_p99=\({latency}s | breach=\){triggered_by:-none}"

    if [ "\(error_breach" = "1" ] || [ "\)latency_breach" = "1" ]; then
        strikes=$((strikes + 1))
        echo "  Strike \({strikes}/\){STRIKE_LIMIT} | Triggered by: ${triggered_by}"
        if [ "\(strikes" -ge "\)STRIKE_LIMIT" ]; then
            echo ""
            echo "  ROLLBACK TRIGGERED"
            echo "  Signal: ${triggered_by}"
            kubectl rollout undo deployment/"\({DEPLOYMENT}" -n "\){NAMESPACE}"
            exit 0
        fi
    else
        strikes=0
    fi

    sleep "${CHECK_INTERVAL}"
done
</code></pre>
<h3 id="heading-how-the-scripts-work">How the Scripts Work</h3>
<p>The <code>error rate query</code> asks Prometheus: <em>"What fraction of requests to the canary returned an error in the last minute?"</em> A result of <code>0.0</code> means no errors. A result of <code>0.06</code> means 6% of requests are failing, above the 5% threshold. You see this in the output as:</p>
<pre><code class="language-plaintext">error_rate=0.06 | threshold=0.05 | breach=YES
</code></pre>
<p>The <code>latency query</code> asks: <em>"How slow is the slowest 1% of requests to the canary right now?"</em> A result of <code>5.234</code> means 1 in every 100 requests is taking over 5 seconds. You see this as:</p>
<pre><code class="language-plaintext">latency_p99=5.234s | breach=latency_p99(5.234s)
</code></pre>
<p>V1 only runs the first query. V2 runs both. Same canary, same problem, different answers.</p>
<p>The three-strike rule means a single bad check doesn't trigger a rollback – three in a row does. The tradeoff is 45 seconds (three checks at 15 seconds each) of exposure before the rollback fires.</p>
<p>When three strikes hit, the watch script itself runs:</p>
<pre><code class="language-plaintext">kubectl rollout undo deployment/myapp-canary -n default
</code></pre>
<p>That one line is what triggers the rollback. It lives inside <code>canary_watch_v2.sh</code> and runs automatically – you don't have to do anything. The script detects, decides, and acts.</p>
<h3 id="heading-break-it-on-purpose">Break it On Purpose</h3>
<p><strong>In Terminal 1</strong>, start the v1 monitor:</p>
<pre><code class="language-plaintext">./canary_watch_v1.sh
</code></pre>
<p>You will see this repeating every 15 seconds:</p>
<pre><code class="language-plaintext">Canary monitor running (v1 - error rate only).
Rollback triggers if error rate exceeds 0.05 for 3 checks.

[2026-05-17T11:53:12] error_rate=0 | threshold=0.05 | breach=NO
[2026-05-17T11:53:27] error_rate=0 | threshold=0.05 | breach=NO
[2026-05-17T11:53:42] error_rate=0 | threshold=0.05 | breach=NO
</code></pre>
<p><code>breach=NO</code> means the canary looks healthy. Leave this running and move to Terminal 2.</p>
<p><strong>In Terminal 2</strong>, inject latency into the canary:</p>
<pre><code class="language-plaintext">./break_it.sh
</code></pre>
<p>This makes every request to the canary take 5 seconds. Requests still return 200 – no errors, just slowness. You will see:</p>
<pre><code class="language-plaintext">Injecting latency into the canary deployment...
deployment "myapp-canary" successfully rolled out
Latency injection is active.

The canary pod is Running and passing its readiness probe.
Every request to the canary now takes 5 seconds.
Error rate: 0%   |   P99 latency: ~5s
</code></pre>
<p>Now look back at Terminal 1. The v1 monitor keeps printing <code>breach=NO</code>. The canary is taking 5 seconds per request and your monitoring says everything is fine. That's the failure.</p>
<p><strong>In Terminal 3</strong>, see what your users are actually experiencing:</p>
<pre><code class="language-plaintext">./check_latency.sh
</code></pre>
<pre><code class="language-plaintext">TIMESTAMP                   STABLE (ms)   CANARY (ms)   STATUS
---------                   -----------   -----------   ------
2026-05-17T11:55:14         18ms          5008ms        CANARY DEGRADED
2026-05-17T11:55:20         7ms           5008ms        CANARY DEGRADED
2026-05-17T11:55:27         6ms           5008ms        CANARY DEGRADED
</code></pre>
<p>Stable is responding in 6–18 milliseconds. Canary is taking over 5 seconds. Users on the canary are waiting 5 seconds for every page load. The v1 monitor in Terminal 1 still says <code>breach=NO</code>.</p>
<p>This is the lesson: the monitoring and the user experience are completely disconnected. The script isn't broken. It's watching the wrong thing.</p>
<p>Now let's see the fix. Press <code>Ctrl+C</code> in Terminal 1 to stop v1. Start v2 in the same terminal:</p>
<pre><code class="language-plaintext">./canary_watch_v2.sh
</code></pre>
<p>In Terminal 2, re-inject the latency:</p>
<pre><code class="language-plaintext">./break_it.sh
</code></pre>
<p>Watch Terminal 1. V2 catches the latency and fires the rollback after three strikes:</p>
<pre><code class="language-plaintext">Canary monitor running (v2 - error rate + P99 latency).
Error threshold: 0.05 | Latency P99 threshold: 2.0s

[2026-05-15T14:30:00] error_rate=0.0 | latency_p99=0.082s | breach=none
[2026-05-15T14:30:15] error_rate=0.0 | latency_p99=5.234s | breach=latency_p99(5.234s)
  Strike 1/3 | Triggered by: latency_p99(5.234s)
[2026-05-15T14:30:30] error_rate=0.0 | latency_p99=5.891s | breach=latency_p99(5.891s)
  Strike 2/3 | Triggered by: latency_p99(5.891s)
[2026-05-15T14:30:45] error_rate=0.0 | latency_p99=6.102s | breach=latency_p99(6.102s)
  Strike 3/3 | Triggered by: latency_p99(6.102s)

  ROLLBACK TRIGGERED
  Signal: latency_p99(6.102s)

deployment.apps/myapp-canary rolled back
</code></pre>
<p>The error rate never moved from 0. V2 rolled back anyway because latency crossed the threshold. That's the difference one extra measurement makes.</p>
<p>After the rollback, confirm the canary is dormant but not deleted:</p>
<pre><code class="language-plaintext">kubectl rollout history deployment/myapp-canary -n default
</code></pre>
<pre><code class="language-plaintext">REVISION  CHANGE-CAUSE
1         &lt;none&gt;
2         &lt;none&gt;
</code></pre>
<p>Two revisions. The rollback scaled revision 2 down to zero and restored revision 1. Nothing was deleted, and you can re-deploy if you decide the rollback was a false alarm.</p>
<h3 id="heading-the-decision-the-script-cant-make-for-you">The Decision the Script Can't Make For You</h3>
<p>V2 rolled back based on latency with zero errors. Before re-deploying, ask if the latency was a real regression in the new code, or a temporary spike, like a database cache warming up on first use? Both produce the same signal. Only you know which is more likely given what changed.</p>
<p>False positive rollbacks slow down deployments and erode confidence in automation. The right thresholds depend on your users and your system.<br>What the script enforces is whatever you configure.</p>
<h3 id="heading-teardown">Teardown</h3>
<pre><code class="language-plaintext">./teardown.sh
</code></pre>
<h2 id="heading-what-you-can-do-now">What You Can Do Now</h2>
<p>Each use case in this handbook was a script solving a specific problem the standard tooling wasn't catching. Here's where you land:</p>
<p>You can catch AWS cost spikes before the invoice and you know that the service label is AWS's attribution, not a pointer to what actually caused the cost. Start from what changed operationally, not from the billing label.</p>
<p>You can reconstruct the full timeline of any failed request across multiple services from a single trace ID, and you know that a missing service in that timeline is evidence, not just an absence.</p>
<p>You can detect infrastructure drift by comparing what Terraform believes against what AWS actually contains, and you know that a clean result means the resources Terraform manages are in sync, not that your entire AWS account is clean.</p>
<p>You can validate a secret rotation at the application level, not just at the infrastructure level, and you know the difference between a readiness probe passing and the application actually being able to connect to the database.</p>
<p>You can build a canary rollback trigger that watches the right signals, and you know why watching only error rates can leave a slow, broken deployment running while users wait.</p>
<p>The pattern across all five use cases is the same: the standard tooling reported everything as fine while something was actually broken. The cost script returned clean, the pod showed Running, and the canary showed zero errors – not because the tools were wrong but because they were only checking what was easy to check. These scripts check what the standard tooling skips.</p>
<p><strong>GitHub repo:</strong> <a href="https://github.com/Osomudeya/devops-scripting-labs.git">https://github.com/Osomudeya/devops-scripting-labs</a></p>
<p>I write about DevOps weekly, covering real systems, interview, CV tips and tricks, and real incidents – <a href="https://osomudeya.kit.com/23db7ca59f"><strong>Join the newsletter</strong></a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Local DevOps HomeLab with Docker, Kubernetes, and Ansible ]]>
                </title>
                <description>
                    <![CDATA[ The first time I tried to follow a DevOps tutorial, it told me to sign up for AWS. I did. I spun up an EC2 instance, followed along for an hour, and then forgot to shut it down. A week later I had a $ ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-local-devops-homelab-with-docker-kubernetes-and-ansible/</link>
                <guid isPermaLink="false">69dd667c217f5dfcbd55b7b4</guid>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Homelab ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops articles ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Osomudeya Zudonu ]]>
                </dc:creator>
                <pubDate>Mon, 13 Apr 2026 21:56:12 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/1e970f8b-eb52-4582-9c98-13cbce867c89.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>The first time I tried to follow a DevOps tutorial, it told me to sign up for AWS.</p>
<p>I did. I spun up an EC2 instance, followed along for an hour, and then forgot to shut it down. A week later I had a $34 bill for a machine running nothing.</p>
<p>That was the last time I practiced on someone else's infrastructure.</p>
<p>Everything in this guide runs on your laptop. No cloud account, no credit card, no bill at the end of the month. By the end, you'll be able to spin up a multi-server environment from scratch, configure it automatically with Ansible, serve a site you wrote yourself, and diagnose what breaks when you intentionally destroy it.</p>
<p>That last part is where the actual learning happens.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you start, make sure you have:</p>
<ul>
<li><p>A laptop with at least 8GB of RAM (16GB is better)</p>
</li>
<li><p>At least 20GB of free disk space</p>
</li>
<li><p>Windows, macOS, or Linux operating system</p>
</li>
<li><p>Administrator access to your computer</p>
</li>
<li><p>Virtualization enabled in your BIOS/UEFI settings</p>
</li>
<li><p>A stable internet connection for the initial downloads</p>
</li>
</ul>
<p>Knowledge and comfort level:</p>
<ul>
<li><p>You should be comfortable using a terminal (running commands, changing directories, and editing small text files with whatever editor you like).</p>
</li>
<li><p>Basic familiarity with concepts like “a server,” “SSH,” and “a port” helps, but you don't need prior experience with Docker, Kubernetes, Vagrant, or Ansible. This guide introduces them as you go.</p>
</li>
</ul>
<p>If you can follow step-by-step instructions and read error output without panicking, you're ready.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-what-is-devops">What is DevOps?</a></p>
</li>
<li><p><a href="#heading-why-build-a-local-lab">Why Build a Local Lab?</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-docker">How to Set Up Docker</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-kubernetes">How to Set Up Kubernetes</a></p>
</li>
<li><p><a href="#heading-how-to-install-kubectl">How to Install kubectl</a></p>
</li>
<li><p><a href="#heading-how-to-set-up-vagrant">How to Set Up Vagrant</a></p>
</li>
<li><p><a href="#heading-how-to-install-ansible">How to Install Ansible</a></p>
</li>
<li><p><a href="#heading-how-to-build-your-first-devops-project">How to Build Your First DevOps Project</a></p>
</li>
<li><p><a href="#heading-how-to-break-your-lab-on-purpose">How to Break Your Lab on Purpose</a></p>
</li>
<li><p><a href="#heading-what-you-can-now-do">What You Can Now Do</a></p>
</li>
</ol>
<h2 id="heading-what-is-devops">What is DevOps?</h2>
<p>DevOps is the practice of breaking down the wall between software development and IT operations teams.</p>
<p>Traditionally, developers write code and hand it off to operations teams to deploy and maintain. That handoff causes delays, misunderstandings, and outages. DevOps is what happens when both teams work together from the start.</p>
<p>The tools you'll install in this guide each solve a specific part of that process:</p>
<ul>
<li><p><strong>Docker</strong> packages your application and everything it needs into a portable container that runs the same way on any machine.</p>
</li>
<li><p><strong>Kubernetes</strong> manages multiple containers at scale, handling restarts, networking, and load balancing automatically.</p>
</li>
<li><p><strong>Vagrant</strong> creates and manages virtual machine environments so your whole team always works on identical setups.</p>
</li>
<li><p><strong>Ansible</strong> automates repetitive configuration tasks across many servers without writing a script for each one.</p>
</li>
</ul>
<h2 id="heading-why-build-a-local-lab">Why Build a Local Lab?</h2>
<p>A local lab gives you a safe place to break things, fix them, and learn from that process without any cost or risk.</p>
<p>Here's what you get with a local setup:</p>
<ul>
<li><p><strong>Zero cost.</strong> No cloud bills, no surprise charges, and no credit card required.</p>
</li>
<li><p><strong>Works offline.</strong> Practice anywhere, even without internet after the initial setup.</p>
</li>
<li><p><strong>Full control.</strong> You manage every layer from the OS up to the application.</p>
</li>
<li><p><strong>Safe experimentation.</strong> Break things freely. Nothing here affects production.</p>
</li>
<li><p><strong>Fast feedback.</strong> No waiting for cloud resources to spin up. Everything runs on your machine.</p>
</li>
</ul>
<p>The tradeoff is resource limits. Your laptop's CPU and RAM are the ceiling. You can't simulate large-scale deployments, and some cloud-native services like AWS Lambda or S3 have no direct local equivalent. But for learning core DevOps workflows, none of that matters.</p>
<h2 id="heading-how-to-set-up-docker">How to Set Up Docker</h2>
<p>Docker is the foundation of this lab. Every other tool in this guide either runs inside Docker containers or works alongside them.</p>
<h3 id="heading-how-to-install-docker-on-windows">How to Install Docker on Windows</h3>
<p>First, enable virtualization in your BIOS:</p>
<ol>
<li><p>Restart your computer and enter BIOS/UEFI setup. The key is usually F2, F10, Del, or Esc during boot.</p>
</li>
<li><p>Find the virtualization setting. It's usually listed as Intel VT-x, AMD-V, SVM, or Virtualization Technology.</p>
</li>
<li><p>Enable it, save your changes, and exit.</p>
</li>
</ol>
<p>Then install Docker Desktop:</p>
<ol>
<li><p>Download Docker Desktop from <a href="https://www.docker.com/products/docker-desktop/">Docker's official website</a>.</p>
</li>
<li><p>Run the installer and follow the prompts.</p>
</li>
<li><p>Enable WSL 2 (Windows Subsystem for Linux) when asked.</p>
</li>
<li><p>Restart your computer.</p>
</li>
<li><p>Open Docker Desktop from the Start menu and wait for the whale icon in the taskbar to stop animating.</p>
</li>
</ol>
<p><strong>Troubleshooting:</strong> If Docker fails to start, run this in PowerShell as Administrator to verify virtualization is active:</p>
<pre><code class="language-powershell">systeminfo | findstr "Hyper-V Requirements"
</code></pre>
<p>All items should show "Yes". If they don't, revisit your BIOS settings.</p>
<h3 id="heading-how-to-install-docker-on-mac">How to Install Docker on Mac</h3>
<ol>
<li><p>Download Docker Desktop for Mac from <a href="https://www.docker.com/products/docker-desktop/">Docker's website</a>.</p>
</li>
<li><p>Open the downloaded <code>.dmg</code> file and drag Docker to your Applications folder.</p>
</li>
<li><p>Open Docker from Applications.</p>
</li>
<li><p>Enter your password when prompted.</p>
</li>
<li><p>Wait for the whale icon in the menu bar to stop animating.</p>
</li>
</ol>
<h3 id="heading-how-to-install-docker-on-linux">How to Install Docker on Linux</h3>
<p>Run these commands in order:</p>
<pre><code class="language-bash"># Update your package lists
sudo apt-get update

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

#watch pods start
kubectl get pods -w
</code></pre>
<p>Press Ctrl+C when you're done watching. Some pods may stay <strong>Pending</strong> while others are <strong>Running</strong>.</p>
</li>
<li><p>Pick one Pending pod name from <code>kubectl get pods</code> and inspect it:</p>
<pre><code class="language-plaintext">kubectl describe pod &lt;pod-name&gt;
</code></pre>
<p>Under Events, look for FailedScheduling and a line similar to:</p>
<pre><code class="language-plaintext">Warning  FailedScheduling  0/1 nodes are available: 1 Insufficient cpu.
</code></pre>
<p>You might see <strong>Insufficient memory</strong> instead, depending on your machine.</p>
</li>
<li><p>Fix the lab by scaling back so the cluster can catch up:</p>
<pre><code class="language-plaintext">kubectl scale deployment stress --replicas=2
</code></pre>
<p>You can delete the deployment entirely when finished: <code>kubectl delete deployment stress</code>.</p>
</li>
</ol>
<p><strong>What to observe:</strong></p>
<ul>
<li><p>You should see Pending pods stay unscheduled until capacity frees up. That means the scheduler hasn't placed them on any <strong>node</strong> yet, usually because the node is out of CPU or memory for that workload.</p>
</li>
<li><p>You should read <code>kubectl describe pod &lt;pod-name&gt;</code> and scroll to <strong>Events</strong>. Messages like Insufficient cpu or Insufficient memory mean the cluster ran out of schedulable capacity, not that the container image image is corrupt.</p>
</li>
<li><p>You should contrast that with a pod that reaches Running and then CrashLoopBackOff, which usually means the process inside the container keeps exiting. that is an application or config problem, not a “nowhere to run” problem.</p>
</li>
</ul>
<h2 id="heading-what-you-can-now-do">What You Can Now Do</h2>
<p>You didn't just install tools in this tutorial. You also used them.</p>
<p>You can now spin up two servers from a single file. You can write a playbook that installs software and deploys a container without touching either machine manually.</p>
<p>You can serve a page you wrote from inside a Docker container running on a Vagrant VM, and bring the whole thing back from scratch in one command.</p>
<p>You also broke it. You saw what a container conflict looks like, what Ansible prints when it can't reach a machine, what disk pressure does to a running service, and what a Kubernetes scheduler says when it runs out of resources. Those error messages aren't unfamiliar anymore.</p>
<p>That's the difference between someone who has read about DevOps and someone who has run it.</p>
<p><strong>Here are four free projects you can run in this same lab to go further:</strong></p>
<ul>
<li><p><strong>DevOps Home-Lab 2026</strong> — Build a multi-service app (frontend, API, PostgreSQL, Redis) end-to-end with Docker Compose, Kubernetes, Prometheus/Grafana monitoring, GitOps with ArgoCD, and Cloudflare for global exposure.</p>
</li>
<li><p><strong>KubeLab</strong> — Trigger real Kubernetes failure scenarios, pod crashes, OOMKills, node drains, cascading failures, and watch how the cluster responds using live metrics.</p>
</li>
<li><p><strong>K8s Secrets Lab</strong> — Build a full secret management pipeline from AWS Secrets Manager into your cluster, including rotation behavior and IRSA.</p>
</li>
<li><p><strong>DevOps Troubleshooting Toolkit</strong> — Structured debugging guides across Linux, containers, Kubernetes, cloud, databases, and observability with copy-paste commands for real incidents.</p>
</li>
</ul>
<p>All free and open source: <a href="https://github.com/Osomudeya/List-Of-DevOps-Projects">github.com/Osomudeya/List-Of-DevOps-Projects</a>.</p>
<p>If you want to go deeper, you can find six full chapters covering Terraform, Ansible, monitoring, CI/CD, and a simulated three-VM production environment at <a href="https://osomudeya.gumroad.com/l/BuildYourOwnDevOpsLab">Build Your Own DevOps Lab</a>.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Sync AWS Secrets Manager Secrets into Kubernetes with the External Secrets Operator ]]>
                </title>
                <description>
                    <![CDATA[ If someone asked you how secrets flow from AWS Secrets Manager into a running pod, could you explain it confidently? Storing them is straightforward. But handling rotation, stale env vars, and the gap ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-sync-aws-secrets-manager-secrets-into-kubernetes-with-the-external-secrets-operator/</link>
                <guid isPermaLink="false">69c541f010e664c5dadc877e</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cybersecurity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Terraform ]]>
                    </category>
                
                    <category>
                        <![CDATA[ secrets management ]]>
                    </category>
                
                    <category>
                        <![CDATA[ SRE ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Osomudeya Zudonu ]]>
                </dc:creator>
                <pubDate>Thu, 26 Mar 2026 14:25:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/6cca126e-dd50-4400-ae9d-65449581345b.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If someone asked you how secrets flow from AWS Secrets Manager into a running pod, could you explain it confidently?</p>
<p>Storing them is straightforward. But handling rotation, stale env vars, and the gap between what your pod reads and what AWS actually holds is where many engineers go quiet.</p>
<p>In this guide, you'll build a complete secrets pipeline from AWS Secrets Manager into Kubernetes pods. You'll provision the infrastructure with Terraform, sync secrets using the External Secrets Operator, and run a sample application that reads the same credentials in two different ways: via environment variables and via a volume mount.</p>
<p>By the end, you'll be able to:</p>
<ul>
<li><p>Explain the full architecture from vault to pod</p>
</li>
<li><p>Run the lab locally in about 15 minutes</p>
</li>
<li><p>Prove why environment variables go stale after rotation, while mounted secret files stay fresh</p>
</li>
<li><p>Deploy the same pattern on Amazon Elastic Kubernetes Service with OpenID Connect-based CI/CD</p>
</li>
<li><p>Troubleshoot the most common failures</p>
</li>
</ul>
<p>Below is an architecture diagram showing secrets flowing from AWS Secrets Manager through the External Secrets Operator into a Kubernetes Secret, then splitting into environment variables set at pod start and a volume mount that updates within 60 seconds.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/ac8bfc9e-304e-41b8-b6a3-7ce1795b29a9.png" alt="Architecture diagram showing secrets flowing from AWS Secrets Manager through the External Secrets Operator into a Kubernetes Secret, then splitting into environment variables set at pod start and a volume mount that updates within 60 seconds." style="display:block;margin:0 auto" width="1024" height="1536" loading="lazy">

<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-how-to-understand-the-secret-flow">How to Understand the Secret Flow</a></p>
</li>
<li><p><a href="#heading-how-to-run-the-local-lab">How to Run the Local Lab</a></p>
</li>
<li><p><a href="#heading-how-to-inspect-the-externalsecret-and-the-application">How to Inspect the ExternalSecret and the Application</a></p>
</li>
<li><p><a href="#heading-how-to-test-secret-rotation">How to Test Secret Rotation</a></p>
</li>
<li><p><a href="#heading-how-to-choose-between-external-secrets-operator-and-the-csi-driver">How to Choose Between External Secrets Operator and the CSI Driver</a></p>
</li>
<li><p><a href="#heading-how-to-deploy-the-pattern-on-amazon-elastic-kubernetes-service">How to Deploy the Pattern on Amazon Elastic Kubernetes Service</a></p>
</li>
<li><p><a href="#heading-how-to-configure-github-actions-without-stored-aws-credentials">How to Configure GitHub Actions Without Stored AWS Credentials</a></p>
</li>
<li><p><a href="#heading-how-to-troubleshoot-the-most-common-failures">How to Troubleshoot the Most Common Failures</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before you begin, make sure you have the following tools installed and configured.</p>
<p><strong>For the local lab:</strong></p>
<ul>
<li><p>An AWS account with access to AWS Secrets Manager</p>
</li>
<li><p>The AWS CLI installed and configured. Run <code>aws configure</code> and provide your access key, secret key, default region, and output format. The credentials need permission to read and write secrets in AWS Secrets Manager.</p>
</li>
<li><p><code>kubectl</code> installed. For Microk8s, run <code>microk8s kubectl config view --raw &gt; ~/.kube/config</code> after installation to connect kubectl to your local cluster.</p>
</li>
<li><p>Terraform installed</p>
</li>
<li><p>Helm installed</p>
</li>
<li><p>Docker installed</p>
</li>
<li><p>A local Kubernetes cluster: the lab supports Microk8s and kind. If you do not have either installed, follow the <a href="https://microk8s.io/">Microk8s install guide</a> before continuing.</p>
</li>
</ul>
<p><strong>For the Amazon Elastic Kubernetes Service sections:</strong></p>
<ul>
<li><p>An Amazon Elastic Kubernetes Service cluster you can create or manage</p>
</li>
<li><p>A GitHub repository you can configure for workflows and secrets</p>
</li>
</ul>
<p>The lab repository includes two deployment paths: a local path for fast learning and an Amazon Elastic Kubernetes Service path for a production-like setup. All the exact commands for each path live in the repo's <a href="https://github.com/Osomudeya/k8s-secret-lab/blob/main/docs/DEPLOY-LOCAL.md"><code>docs/DEPLOY-LOCAL.md</code></a> and <a href="https://github.com/Osomudeya/k8s-secret-lab/blob/main/docs/DEPLOY-EKS.md"><code>docs/DEPLOY-EKS.md</code></a>.</p>
<h2 id="heading-how-to-understand-the-secret-flow">How to Understand the Secret Flow</h2>
<p>Before you run any command, you need to understand how the pieces connect.</p>
<p>The flow has four stages:</p>
<ol>
<li><p>A developer or automated system updates a secret in AWS Secrets Manager.</p>
</li>
<li><p>The External Secrets Operator polls AWS Secrets Manager on a schedule and creates or updates a Kubernetes Secret.</p>
</li>
<li><p>Your pod reads that Kubernetes Secret.</p>
</li>
<li><p>During rotation, the Kubernetes Secret updates, but your two consumption modes behave differently.</p>
</li>
</ol>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/9dc52f99-add4-490a-ad86-25a30d0ae306.png" alt="A step-by-step flow diagram showing the four stages of secret flow above" style="display:block;margin:0 auto" width="1536" height="1024" loading="lazy">

<h3 id="heading-how-the-external-secrets-operator-sync-works">How the External Secrets Operator Sync Works</h3>
<p>The External Secrets Operator reads a custom Kubernetes resource called <code>ExternalSecret</code>. That resource tells the operator three things:</p>
<ul>
<li><p>Which secret store to connect to</p>
</li>
<li><p>Which Kubernetes Secret name to create or update</p>
</li>
<li><p>How often to refresh</p>
</li>
</ul>
<p>In this lab, the <code>ExternalSecret</code> creates a Kubernetes Secret named <code>myapp-database-creds</code>. The operator also adds a template annotation that can trigger a pod restart when the secret rotates.</p>
<h3 id="heading-how-the-app-consumes-secrets">How the App Consumes Secrets</h3>
<p>The sample application exposes three endpoints so you can validate behavior at any time.</p>
<ul>
<li><p><code>/secrets/env</code> shows what environment variables the pod sees</p>
</li>
<li><p><code>/secrets/volume</code> shows what files in the mounted secret directory look like</p>
</li>
<li><p><code>/secrets/compare</code> compares both and reports whether rotation has been detected</p>
</li>
</ul>
<p>The app checks four keys: <code>DB_USERNAME</code>, <code>DB_PASSWORD</code>, <code>DB_HOST</code>, and <code>DB_PORT</code>.</p>
<h2 id="heading-how-to-run-the-local-lab">How to Run the Local Lab</h2>
<p>The local lab gives you a fast learning loop. You can see the full pipeline working and test rotation without waiting for a cloud deployment.</p>
<h3 id="heading-step-1-clone-the-repo">Step 1: Clone the Repo</h3>
<pre><code class="language-bash">git clone https://github.com/Osomudeya/k8s-secret-lab
cd k8s-secret-lab
</code></pre>
<h3 id="heading-step-2-run-the-spin-up-script">Step 2: Run the Spin-Up Script</h3>
<pre><code class="language-bash">bash spinup.sh
</code></pre>
<p>The script will ask you to choose a local cluster type. Pick Microk8s or kind, depending on what you have installed. The script installs the External Secrets Operator via Helm, applies the Terraform configuration, and deploys the sample application.</p>
<p>If the script fails at any point, check <a href="https://github.com/Osomudeya/k8s-secret-lab/blob/main/docs/TROUBLESHOOTING.md"><code>docs/TROUBLESHOOTING.md</code></a> before retrying. The most common causes are missing AWS credentials, a misconfigured kubeconfig, or a Microk8s storage add-on that is not enabled.</p>
<h3 id="heading-important-run-the-lab-ui">Important: Run the Lab UI</h3>
<p>The lab ships with a separate guided tutorial interface that runs on your laptop. This is not the in-cluster application, it's a React-based checklist at <code>lab-ui/</code> that walks you through each concept and checkpoint as you work through the lab.</p>
<p>To start it, open a second terminal and run:</p>
<pre><code class="language-bash">cd lab-ui &amp;&amp; npm install &amp;&amp; npm run dev
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/873166e9-6bff-4e56-a18d-e58b9e9a5af9.png" alt="Screenshot of npm run dev lab ui terminal" style="display:block;margin:0 auto" width="849" height="435" loading="lazy">

<p>Then open <a href="http://localhost:5173"><code>http://localhost:5173</code></a>. You'll see a module-by-module guide covering the full flow from external secrets to rotation to CI/CD.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/5a5b220b-3f23-4c7c-8388-f2e23d122e2c.png" alt="Screenshot of The Lab UI, a guided tutorial interface that runs alongside the lab and walks you through each concept and checkpoint." style="display:block;margin:0 auto" width="1399" height="1287" loading="lazy">

<p>Keep this terminal running alongside your lab. The Lab UI and the in-cluster app (<code>localhost:3000</code>) are two separate things, the UI guides you through the steps, the app shows you the live secrets.</p>
<h3 id="heading-step-3-access-the-application">Step 3: Access the Application</h3>
<p>Once the lab finishes, port-forward the service.</p>
<pre><code class="language-bash">kubectl port-forward svc/myapp 3000:80 -n default
</code></pre>
<p>Open <code>http://localhost:3000</code>. You should see a table showing each secret key and whether the environment variable value matches the volume mount value.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/dbe122ac-b787-40d0-96f4-4b1276bab017.png" alt="Screenshot of the running application at localhost:3000. Every row in the table should show &quot;Match ✓" style="display:block;margin:0 auto" width="1213" height="902" loading="lazy">

<h3 id="heading-step-4-validate-that-secrets-match">Step 4: Validate That Secrets Match</h3>
<p>Run the compare endpoint directly from the terminal.</p>
<pre><code class="language-bash">curl -s http://localhost:3000/secrets/compare | python3 -m json.tool
</code></pre>
<p>When everything is working, the response will include <code>"all_match": true</code>.</p>
<h2 id="heading-how-to-inspect-the-externalsecret-and-the-application">How to Inspect the ExternalSecret and the Application</h2>
<p>At this point the lab is running. Now you'll want to inspect the manifests so you understand what each part does.</p>
<h3 id="heading-step-1-read-the-externalsecret-manifest">Step 1: Read the ExternalSecret Manifest</h3>
<p>Open <a href="https://github.com/Osomudeya/k8s-secret-lab/blob/main/k8s/aws/external-secret.yaml"><code>k8s/aws/external-secret.yaml</code></a>. Focus on these four fields:</p>
<ul>
<li><p><code>refreshInterval</code>: how often the operator polls AWS Secrets Manager</p>
</li>
<li><p><code>secretStoreRef</code>: which store the operator authenticates against</p>
</li>
<li><p><code>target</code>: the name of the Kubernetes Secret to create</p>
</li>
<li><p><code>data</code>: the mapping from AWS Secrets Manager JSON keys to Kubernetes Secret keys</p>
</li>
</ul>
<p>Here is what that mapping looks like in this lab:</p>
<pre><code class="language-yaml">spec:
  refreshInterval: 1h
  secretStoreRef:
    name: aws-secrets-manager
    kind: ClusterSecretStore
  target:
    name: myapp-database-creds
    creationPolicy: Owner
  data:
    - secretKey: DB_USERNAME
      remoteRef:
        key: prod/myapp/database
        property: username
</code></pre>
<p>The <code>property</code> field tells the operator which JSON key inside the AWS secret to extract. If your secret in AWS Secrets Manager is a JSON object, each field gets its own entry here.</p>
<p>Two fields here are worth understanding before you move on. <code>creationPolicy: Owner</code> means the operator owns the Kubernetes Secret it creates. If you delete the <code>ExternalSecret</code>, the Secret is deleted too. <code>ClusterSecretStore</code> is a cluster-scoped store, meaning any namespace in the cluster can use it. A plain <code>SecretStore</code> is namespace-scoped. For this lab, cluster-scoped is the right choice because it keeps the setup simple.</p>
<h3 id="heading-step-2-read-the-deployment-manifest">Step 2: Read the Deployment Manifest</h3>
<p>Open <a href="http://github.com/Osomudeya/k8s-secret-lab/blob/main/k8s/aws/deployment.yaml"><code>k8s/aws/deployment.yaml</code></a>. You are looking for two sections: <code>envFrom</code> and <code>volumeMounts</code>.</p>
<pre><code class="language-yaml">envFrom:
  - secretRef:
      name: myapp-database-creds

volumeMounts:
  - name: db-secret-vol
    mountPath: /etc/secrets
    readOnly: true
</code></pre>
<p>Both paths read from the same Kubernetes Secret, <code>myapp-database-creds</code>. The <code>envFrom</code> block injects all keys as environment variables at pod start.<br>The <code>volumeMounts</code> block mounts the same secret as files under <code>/etc/secrets</code>.</p>
<p>This is the core of the rotation lesson. Both paths read the same source. But they behave differently after that source changes.</p>
<h3 id="heading-step-3-read-the-app-comparison-logic">Step 3: Read the App Comparison Logic</h3>
<p>Open <a href="https://github.com/Osomudeya/k8s-secret-lab/blob/main/app/server.js"><code>app/server.js</code></a>. The comparison logic reads environment variables from <code>process.env</code> and reads mounted secret files from <code>/etc/secrets/&lt;key&gt;</code>. Then it computes a per-key match and a global <code>all_match</code> value.</p>
<p>The <code>/secrets/compare</code> endpoint sets <code>rotation_detected: true</code> when any key differs between env and volume.</p>
<h2 id="heading-how-to-test-secret-rotation">How to Test Secret Rotation</h2>
<p>Secret rotation is where real teams feel pain. This lab makes that pain visible so you can explain it clearly and fix it confidently.</p>
<h3 id="heading-how-the-rotation-gap-works"><strong>How the Rotation Gap Works</strong></h3>
<p>When a pod starts, Kubernetes gives it two ways to read a secret.</p>
<p>The first way is environment variables. Think of these like sticky notes written on the wall of the container the moment it boots up. The value gets written once, at startup, and never changes. Even if the secret in AWS gets updated ten minutes later, the sticky note still says the old value. The container cannot see the update because nobody rewrote the note.</p>
<p>The second way is a volume mount. Think of this like a shared folder that someone else can update remotely. Kubernetes creates a small folder inside the container and puts the secret value in a file there. When the secret changes in AWS and ESO syncs it into Kubernetes, the kubelet quietly updates that file within about 60 seconds. The container reads the file fresh every time it needs the value, so it sees the new password automatically.</p>
<p>Same secret, two paths. One goes stale while one stays fresh.</p>
<p>The problem happens when your app reads the database password from the environment variable, the sticky note, and someone rotates the password in AWS. ESO updates Kubernetes. The file gets the new password. But your app is still reading the sticky note, which has the old one. Connection fails.</p>
<p>That difference isn't a bug. It's how the Linux process model and the kubelet work. Understanding it is the difference between knowing Kubernetes secrets and actually operating them.</p>
<p>Here is what you're about to observe in the lab:</p>
<ul>
<li><p>The rotation script updates the secret in AWS</p>
</li>
<li><p>ESO syncs the new value into Kubernetes within seconds</p>
</li>
<li><p>The volume file updates automatically</p>
</li>
<li><p>The environment variable stays stale until the pod restarts</p>
</li>
<li><p>The <code>/secrets/compare</code> endpoint shows both values side by side so you can see the gap live</p>
</li>
</ul>
<h3 id="heading-step-1-confirm-the-lab-is-ready">Step 1: Confirm the Lab Is Ready</h3>
<p>Make sure your pod and the External Secrets Operator are both running before you start.</p>
<pre><code class="language-bash">kubectl get pods -n external-secrets
kubectl get pods -n default
</code></pre>
<p>Both should show <code>Running</code>.</p>
<h3 id="heading-step-2-run-the-rotation-test-script">Step 2: Run the Rotation Test Script</h3>
<pre><code class="language-bash">bash rotation/test-rotation.sh
</code></pre>
<p>The script performs these actions in order:</p>
<ol>
<li><p>Reads the current <code>DB_PASSWORD</code> from the volume mount at <code>/etc/secrets/DB_PASSWORD</code></p>
</li>
<li><p>Reads the current <code>DB_PASSWORD</code> from the environment variable</p>
</li>
<li><p>Updates AWS Secrets Manager with a new password using <code>put-secret-value</code></p>
</li>
<li><p>Forces an immediate ESO sync by annotating the <code>ExternalSecret</code> with <code>force-sync</code></p>
</li>
<li><p>Reads the volume value again</p>
</li>
<li><p>Reads the environment variable again</p>
</li>
</ol>
<p>After the script runs, the volume and the env var will show different values.</p>
<h3 id="heading-step-3-validate-with-the-compare-endpoint">Step 3: Validate With the Compare Endpoint</h3>
<p>Hit the compare endpoint and look at the output.</p>
<pre><code class="language-bash">curl -s http://localhost:3000/secrets/compare | python3 -m json.tool
</code></pre>
<p>You'll see something like this:</p>
<pre><code class="language-json">{
  "comparison": {
    "DB_PASSWORD": {
      "env": "old-password-value",
      "volume": "new-password-value",
      "match": false
    }
  },
  "all_match": false,
  "rotation_detected": true,
  "message": "Volume has new value; env still has old value."
}
</code></pre>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/c4ebb09f-e605-4f68-8e12-1361d94199b2.png" alt="Rotation mismatch, the volume file updated with the new password but the env var still holds the old value from pod startup." style="display:block;margin:0 auto" width="832" height="290" loading="lazy">

<h3 id="heading-step-4-restart-the-deployment-to-sync-env-vars">Step 4: Restart the Deployment to Sync Env Vars</h3>
<p>Env vars don't update in place. You need a pod restart so new containers start with the updated Kubernetes Secret.</p>
<pre><code class="language-bash">kubectl rollout restart deployment/myapp -n default
kubectl rollout status deployment/myapp -n default
</code></pre>
<p>Then hit <code>/secrets/compare</code> again. All rows should now show <code>"all_match": true</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/0040274d-a398-408c-9486-ce0a9e527479.png" alt="After a rolling restart, new pods pick up fresh env vars and all keys match." style="display:block;margin:0 auto" width="821" height="436" loading="lazy">

<h3 id="heading-how-to-automate-restarts-with-reloader">How to Automate Restarts With Reloader</h3>
<p>If you don't want to restart deployments manually after every rotation, you can install <a href="https://github.com/stakater/reloader"><strong>Stakater Reloader</strong></a>. It watches an annotation on the <code>Deployment</code> and triggers a rolling restart automatically when the referenced Kubernetes Secret changes. New pods start with fresh env vars, while old pods drain cleanly. The repo's local deployment guide includes the install steps.</p>
<h2 id="heading-how-to-choose-between-external-secrets-operator-and-the-csi-driver">How to Choose Between External Secrets Operator and the CSI Driver</h2>
<p>Two patterns dominate when it comes to pulling external secrets into Kubernetes: the External Secrets Operator and the <a href="https://secrets-store-csi-driver.sigs.k8s.io/">Secrets Store CSI Driver</a>.</p>
<p>Both get cloud secrets into pods, but they do it differently. Here's a plain comparison:</p>
<table>
<thead>
<tr>
<th>Feature</th>
<th>External Secrets Operator</th>
<th>Secrets Store CSI Driver</th>
</tr>
</thead>
<tbody><tr>
<td>Creates a Kubernetes Secret</td>
<td>Yes</td>
<td>No by default</td>
</tr>
<tr>
<td>Supports <code>envFrom</code></td>
<td>Yes</td>
<td>No (workaround only)</td>
</tr>
<tr>
<td>Secret stored in etcd</td>
<td>Yes (base64)</td>
<td>No, if you skip sync</td>
</tr>
<tr>
<td>Rotation</td>
<td>ESO updates the Secret, Reloader restarts pods</td>
<td>Volume file can update in place</td>
</tr>
<tr>
<td>Best for</td>
<td>Most teams. Multi-cloud, env var support</td>
<td>Security policies that prohibit secrets in etcd</td>
</tr>
</tbody></table>
<p>This lab uses the External Secrets Operator for two reasons. First, it produces a native Kubernetes Secret, which means your application and deployment patterns match standard Kubernetes workflows. Second, having both <code>envFrom</code> and a volume mount point to the same Secret makes the rotation behavior easy to observe side by side.</p>
<p>Use the CSI Driver when your security team prohibits storing secrets in etcd through a Kubernetes Secret. The driver mounts secret data directly into the pod file system without creating a Kubernetes Secret. The tradeoff is that you lose the native <code>envFrom</code> model.</p>
<h2 id="heading-how-to-deploy-the-pattern-on-amazon-elastic-kubernetes-service">How to Deploy the Pattern on Amazon Elastic Kubernetes Service</h2>
<p>The local lab is ideal for learning. The Amazon Elastic Kubernetes Service path adds the production-like pieces: IAM role-based permissions for the operator, a load balancer for the app, and a full CI/CD workflow.</p>
<h3 id="heading-step-1-prepare-terraform-and-openid-connect-access">Step 1: Prepare Terraform and OpenID Connect Access</h3>
<p>The repository includes a one-time setup guide for OpenID Connect-based access from GitHub Actions to AWS. Run these commands in the <a href="https://github.com/Osomudeya/k8s-secret-lab/tree/main/terraform/github-oidc"><code>terraform/github-oidc</code></a> folder.</p>
<pre><code class="language-bash">cd terraform/github-oidc
terraform init
terraform plan -var="github_repo=YOUR_ORG/YOUR_REPO"
terraform apply -var="github_repo=YOUR_ORG/YOUR_REPO"
terraform output role_arn
</code></pre>
<p>Copy the role ARN from the output. You'll need it in the next step.</p>
<h3 id="heading-step-2-set-the-required-environment-variable">Step 2: Set the Required Environment Variable</h3>
<p>The Amazon Elastic Kubernetes Service spin-up path needs your GitHub Actions role ARN so Terraform can grant the CI/CD runner access to the cluster.</p>
<p>To find your AWS account ID, run:</p>
<pre><code class="language-bash">aws sts get-caller-identity --query Account --output text
</code></pre>
<p>Then set the variable, replacing <code>ACCOUNT</code> with the number that command returns.</p>
<pre><code class="language-bash">export GITHUB_ACTIONS_ROLE_ARN=arn:aws:iam::ACCOUNT:role/your-github-oidc-role
</code></pre>
<h3 id="heading-step-3-run-the-spin-up-script-for-amazon-elastic-kubernetes-service">Step 3: Run the Spin-Up Script for Amazon Elastic Kubernetes Service</h3>
<pre><code class="language-bash">bash spinup.sh --cluster eks
</code></pre>
<p>When the script finishes, it prints the application URL. Open that URL in a browser and confirm that you see the same secrets table you saw locally, with all keys showing <code>Match ✓</code>.</p>
<h3 id="heading-step-4-test-rotation-on-the-deployed-app">Step 4: Test Rotation on the Deployed App</h3>
<p>After you confirm normal operation, run the rotation test the same way you did locally.</p>
<pre><code class="language-bash">bash rotation/test-rotation.sh
</code></pre>
<p>Then use <code>/secrets/compare</code> on the Amazon Elastic Kubernetes Service load balancer URL to validate behavior in the cloud environment.</p>
<p>⚠️ <strong>Cost warning:</strong> Amazon Elastic Kubernetes Service runs at approximately $0.16 per hour. When you're done with the lab, run <a href="https://github.com/Osomudeya/k8s-secret-lab/blob/main/teardown.sh"><code>bash teardown.sh</code></a> from the repo root to destroy all AWS resources and stop charges.</p>
<img src="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/56f05ace-9ab6-4b67-ade6-a0bd1fa3962c.png" alt="Screenshot of the app running on the ALB URL, showing all keys matched" style="display:block;margin:0 auto" width="912" height="891" loading="lazy">

<h2 id="heading-how-to-configure-github-actions-without-stored-aws-credentials">How to Configure GitHub Actions Without Stored AWS Credentials</h2>
<p>The typical CI/CD setup stores <code>AWS_ACCESS_KEY_ID</code> and <code>AWS_SECRET_ACCESS_KEY</code> in GitHub repository secrets. Those keys never rotate. Anyone with repo access can read them. When someone leaves the team, you have to revoke keys and update every workflow.</p>
<p>OpenID Connect eliminates that problem entirely.</p>
<h3 id="heading-how-openid-connect-works-for-github-actions">How OpenID Connect Works for GitHub Actions</h3>
<p>GitHub can issue a short-lived token for each workflow run. That token identifies the run: the repository, branch, and workflow name. You create an IAM role in AWS whose trust policy says: only accept requests that come from this specific GitHub repository and branch. The GitHub Actions runner exchanges that token for temporary AWS credentials via <code>AssumeRoleWithWebIdentity</code>. No long-lived keys are ever stored anywhere.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/48e72210-a669-440e-b42e-81b0c15746ec.png" alt="The full OIDC authentication flow for GitHub Actions deploying to EKS — from minting the JWT token through AssumeRoleWithWebIdentity to temporary credentials, kubeconfig retrieval, and final kubectl apply steps." style="display:block;margin:0 auto" width="1536" height="1024" loading="lazy">

<h3 id="heading-step-1-create-the-iam-role-with-terraform">Step 1: Create the IAM Role With Terraform</h3>
<p>The <a href="https://github.com/Osomudeya/k8s-secret-lab/tree/main/terraform/github-oidc"><code>terraform/github-oidc</code></a> folder creates the OpenID Connect provider and the IAM role for you. You already ran this in the Amazon Elastic Kubernetes Service setup above. The role ARN is the only value you need to store.</p>
<h3 id="heading-step-2-add-the-role-arn-to-github-repository-secrets">Step 2: Add the Role ARN to GitHub Repository Secrets</h3>
<p>In your GitHub repository:</p>
<ol>
<li><p>Go to Settings → Secrets and variables → Actions</p>
</li>
<li><p>Click New repository secret</p>
</li>
<li><p>Name it <code>AWS_ROLE_ARN</code></p>
</li>
<li><p>Paste the role ARN from the Terraform output</p>
</li>
</ol>
<p>That is the only secret you store. The role ARN isn't sensitive. It's an identifier, not a credential.</p>
<h3 id="heading-step-3-configure-terraform-state">Step 3: Configure Terraform State</h3>
<p>For CI/CD to work consistently across runs, Terraform needs a shared state backend. The lab stores Terraform state in an Amazon S3 bucket and uses an Amazon DynamoDB table for state locking. The Amazon Elastic Kubernetes Service deployment guide in the repo covers the backend setup in full.</p>
<h3 id="heading-step-4-push-to-main-and-let-workflows-run">Step 4: Push to Main and Let Workflows Run</h3>
<p>After your first spin-up, every push to the <code>main</code> branch drives the CI/CD pipeline. The repo includes separate workflow files for Terraform infrastructure changes and application deployment changes. Once your application is reachable, use <code>/secrets/compare</code> to validate rotation behavior on the live environment.</p>
<h2 id="heading-how-to-troubleshoot-the-most-common-failures">How to Troubleshoot the Most Common Failures</h2>
<p>Here's a shortlist of the most common symptoms and their fixes.</p>
<table>
<thead>
<tr>
<th>Symptom</th>
<th>Most Likely Cause</th>
<th>Fix</th>
</tr>
</thead>
<tbody><tr>
<td><code>ExternalSecret</code> is not syncing</td>
<td>Missing credentials or wrong store reference</td>
<td>Confirm the operator can access AWS Secrets Manager and that <code>secretStoreRef</code> points to the correct store</td>
</tr>
<tr>
<td>Pod is stuck in <code>Pending</code></td>
<td>Missing storage setup for local cluster</td>
<td>For Microk8s, enable the storage add-on</td>
</tr>
<tr>
<td>Env and volume still match after rotation</td>
<td>Rotation happened but the pod never restarted</td>
<td>Run <code>kubectl rollout restart</code> or install Reloader</td>
</tr>
<tr>
<td>CRD or API version mismatch</td>
<td>ESO version and manifest <code>apiVersion</code> don't match</td>
<td>Verify the <code>apiVersion</code> for <code>ClusterSecretStore</code> and <code>ExternalSecret</code> match your installed ESO version</td>
</tr>
<tr>
<td>Amazon Elastic Kubernetes Service node group never joins</td>
<td>Networking or IAM permissions for nodes are wrong</td>
<td>Fix internet routing and review the node IAM policy</td>
</tr>
</tbody></table>
<h3 id="heading-how-to-inspect-the-operator-and-the-externalsecret">How to Inspect the Operator and the ExternalSecret</h3>
<p>When something isn't syncing, start with these two commands.</p>
<pre><code class="language-bash"># Check the ExternalSecret status
kubectl describe externalsecret app-db-secret -n default

# Check the ESO operator logs
kubectl logs -n external-secrets -l app.kubernetes.io/name=external-secrets
</code></pre>
<p>The status conditions on the <code>ExternalSecret</code> resource will usually tell you exactly what failed.</p>
<h3 id="heading-how-to-validate-rotation-from-the-app-side">How to Validate Rotation From the App Side</h3>
<p>When you are debugging rotation, don't rely only on Kubernetes resource state. Use the <code>/secrets/compare</code> endpoint to see what the running application actually observes. The endpoint tells you whether env and volume match and whether rotation has been detected. That is the ground truth for your application's behavior.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You now have a complete secrets pipeline from AWS Secrets Manager into Kubernetes pods using Terraform and the External Secrets Operator. You ran the local lab, inspected the <code>ExternalSecret</code> and <code>Deployment</code> manifests, and validated that the application sees the right credentials.</p>
<p>You also tested secret rotation and observed the key behavior firsthand: mounted secret files update within the kubelet sync period, while environment variables stay stale until the pod restarts. That single observation explains a large class of production incidents.</p>
<p>Finally, you saw how the same design extends to Amazon Elastic Kubernetes Service with OpenID Connect-based CI/CD, and you have a troubleshooting checklist for the failures most teams hit.</p>
<p>The lab repository is at <a href="https://github.com/Osomudeya/k8s-secret-lab">github.com/Osomudeya/k8s-secret-lab</a>. If you ran the local lab, the natural next step is phases 4 and 5 from the repo's staged learning path: try the CSI driver path on Microk8s, then follow the EKS setup to see the same pipeline with a real CI/CD workflow and no credentials stored in GitHub. Both are documented in the repo and take less than 30 minutes each.</p>
<p>If this helped you, star the repo and share it with someone who is learning Kubernetes.</p>
<p><em>I send weekly breakdowns of real production incidents and how engineers actually fix them, not tutorials but real failures<br>→</em> <a href="https://osomudeya.gumroad.com/subscribe"><em>Join the newsletter</em></a></p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How Does Kubernetes Self-Healing Work? Understand Self-Healing By Breaking a Real Cluster ]]>
                </title>
                <description>
                    <![CDATA[ I have noticed that many engineers who run Kubernetes in production have never actually watched it heal itself. They know it does. They have read the docs. But they have never seen a ReplicaSet contro ]]>
                </description>
                <link>https://www.freecodecamp.org/news/kubernetes-self-healing-explained/</link>
                <guid isPermaLink="false">69aae80e78c5adcd0e1c63bc</guid>
                
                    <category>
                        <![CDATA[ Kubernetes ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Testing ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Security ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Osomudeya Zudonu ]]>
                </dc:creator>
                <pubDate>Fri, 06 Mar 2026 14:43:26 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5fc16e412cae9c5b190b6cdd/ef1ba178-622f-4a28-b58a-7fb8a58be964.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>I have noticed that many engineers who run Kubernetes in production have never actually watched it heal itself. They know it does. They have read the docs. But they have never seen a ReplicaSet controller fire, an OOMKill from <code>kubectl describe</code>, or watched pod endpoints go empty during a cascading failure. That's where 3 am incidents find you. This tutorial puts you on the other side of it.</p>
<p>You will clone one repo, spin up a real 3-node cluster, break it seven different ways, and watch it fix itself each time. No simulated output or fake clusters. Real Kubernetes, real failures, and real recovery. By the end, you will recognize these failure patterns when they show up in your production environment.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-kubelab-is">What KubeLab Is?</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-to-get-the-lab-running">How to Get the Lab Running</a></p>
</li>
<li><p><a href="#heading-simulation-1-kill-random-pod">Simulation 1 — Kill Random Pod</a></p>
</li>
<li><p><a href="#heading-simulation-2-drain-a-worker-node">Simulation 2 — Drain a Worker Node</a></p>
</li>
<li><p><a href="#heading-simulation-3-cpu-stress-and-throttling">Simulation 3 — CPU Stress and Throttling</a></p>
</li>
<li><p><a href="#heading-simulation-4-memory-stress-and-oomkill">Simulation 4 — Memory Stress and OOMKill</a></p>
</li>
<li><p><a href="#heading-simulation-5-database-failure">Simulation 5 — Database Failure</a></p>
</li>
<li><p><a href="#heading-simulation-6-cascading-pod-failure">Simulation 6 — Cascading Pod Failure</a></p>
</li>
<li><p><a href="#heading-simulation-7-readiness-probe-failure">Simulation 7 — Readiness Probe Failure</a></p>
</li>
<li><p><a href="#heading-how-to-read-the-signals-in-grafana">How to Read the Signals in Grafana</a></p>
</li>
<li><p><a href="#heading-how-to-use-this-for-production-debugging">How to Use This for Production Debugging</a></p>
</li>
</ul>
<h2 id="heading-what-is-kubelab"><strong>What is KubeLab?</strong></h2>
<p>KubeLab is an open-source Kubernetes failure simulation lab. It runs a real Node.js backend, a PostgreSQL database, Prometheus and Grafana, all inside a real cluster. When you click "Kill Pod", the backend calls the Kubernetes API and deletes an actual running pod. Nothing is fake.</p>
<table>
<thead>
<tr>
<th>Simulation</th>
<th>What it teaches</th>
</tr>
</thead>
<tbody><tr>
<td>Kill Random Pod</td>
<td>ReplicaSet self-healing, pod immutability</td>
</tr>
<tr>
<td>Drain Worker Node</td>
<td>Zero-downtime maintenance, PodDisruptionBudgets</td>
</tr>
<tr>
<td>CPU Stress</td>
<td>Throttling vs crashing, invisible latency</td>
</tr>
<tr>
<td>Memory Stress</td>
<td>OOMKill, exit code 137, silent restart loops</td>
</tr>
<tr>
<td>Database Failure</td>
<td>StatefulSets, PVC persistence</td>
</tr>
<tr>
<td>Cascading Pod Failure</td>
<td>Why replicas: 2 isn't enough</td>
</tr>
<tr>
<td>Readiness Probe Failure</td>
<td>Liveness vs readiness, traffic control</td>
</tr>
</tbody></table>
<p>Plan about 90 minutes for the full path. Or jump directly to any simulation if you have a specific production problem you want to reproduce.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/698d563262d4ce66226a844a/1cd2a06d-7a7a-4250-ab5d-8a78d24af7b5.png" alt="KubeLab cluster map — pods grouped by node, color-coded by status. During simulations, chips change color and move between nodes in real time." style="display:block;margin:0 auto" width="920" height="505" loading="lazy">

<h2 id="heading-prerequisites"><strong>Prerequisites</strong></h2>
<p>You need basic familiarity with Docker and comfort with the command line, but no prior Kubernetes experience is required.</p>
<p><strong>Hardware:</strong> 8GB RAM minimum, 16GB recommended. The lab can run on Mac, Linux, or Windows with WSL2. You'll need to install three tools. Multipass spins up Ubuntu VMs for the cluster. kubectl is the Kubernetes CLI you will use for every simulation. Git clones the repo. If you cannot run three VMs, the repo includes a Docker Compose preview at <a href="https://github.com/Osomudeya/kubelab/blob/main/setup/docker-compose-preview.md">setup/docker-compose-preview.md</a> full UI with mock data, no real cluster needed.</p>
<h2 id="heading-how-to-get-the-lab-running"><strong>How to Get the Lab Running</strong></h2>
<p>Full cluster setup lives at <a href="https://github.com/Osomudeya/kubelab/blob/main/setup/k8s-cluster-setup.md">setup/k8s-cluster-setup.md</a> in the repo. It walks through creating three VMs with Multipass, installing MicroK8s, joining the worker nodes, and deploying KubeLab. Follow it until all eleven pods show Running:</p>
<pre><code class="language-bash">kubectl get pods -n kubelab
# All 11 pods should show STATUS: Running
</code></pre>
<p>Then open two port-forwards in separate terminal tabs and keep them running for the entire tutorial:</p>
<pre><code class="language-bash"># Tab 1 — KubeLab UI at http://localhost:8080
kubectl port-forward -n kubelab svc/frontend 8080:80

# Tab 2 — Grafana at http://localhost:3000
kubectl port-forward -n kubelab svc/grafana 3000:3000
</code></pre>
<p>Grafana login: <code>admin</code> / <code>kubelab-grafana-2026</code>.</p>
<blockquote>
<p>Position the KubeLab UI and Grafana side by side. Left half of the screen is the app. Right half is Grafana. You will watch both simultaneously from Simulation 3 onward.</p>
</blockquote>
<h2 id="heading-simulation-1-kill-random-pod"><strong>Simulation 1: Kill Random Pod</strong></h2>
<p>This simulation deletes a running backend pod via the Kubernetes API. Without Kubernetes, you would SSH to the server, find the crashed process, and restart it manually, usually discovered by a user alert at 3am.</p>
<p><strong>Before you click:</strong> Run <code>kubectl get pods -n kubelab -w</code>. Watch for a pod to go Terminating then a new one to appear.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/698d563262d4ce66226a844a/3d3cb733-407a-482f-82e7-cbeea496157b.png" alt="Terminals running side by side before clicking Run, events streaming, pod watch, frontend and grafana port forwarding." style="display:block;margin:0 auto" width="706" height="1250" loading="lazy">

<pre><code class="language-bash">kubectl get pods -n kubelab -w
# backend-abc123  1/1   Terminating   0   2m
# backend-xyz789  1/1   Running       0   0s   ← ReplicaSet created a replacement
</code></pre>
<p><strong>What happened:</strong> The ReplicaSet controller noticed actual(1) did not match desired(2) and created a replacement in parallel with the shutdown. The Endpoints controller removed the dying pod from the Service before SIGTERM fired, so zero traffic hit a dying pod.</p>
<p><strong>The production trap:</strong> A missing readiness probe means the new pod receives traffic before it has opened a DB connection. You get 500s on every deployment for 2–3 seconds.</p>
<p><strong>The fix:</strong> Set <code>replicas: 2</code>, add a readiness probe, and set <code>terminationGracePeriodSeconds</code> to match your longest request timeout.</p>
<h2 id="heading-simulation-2-drain-a-worker-node"><strong>Simulation 2: Drain a Worker Node</strong></h2>
<p>This simulation cordons a worker node, then evicts all its pods to the remaining node.</p>
<p>To <em><strong>"cordon"</strong></em> a worker node means to mark it as unschedulable. When you run <code>kubectl cordon &lt;node-name&gt;</code>, the Kubernetes control plane adds the <code>node.kubernetes.io/unschedulable:NoSchedule</code> taint to the node. (A <strong>taint</strong> is a marker that tells the scheduler to avoid placing pods on that node unless they have a matching "toleration.") This tells the scheduler to stop placing any new pods onto that node. It does <strong>not</strong> affect the pods that are already running there.</p>
<p>Cordoning is the first, safe step in preparing a node for maintenance. It ensures that while you are draining the node, the scheduler isn't simultaneously trying to schedule new workloads onto it, which would defeat the purpose of the drain.</p>
<p>Without Kubernetes you would drain the server manually, guess when in-flight requests finish, patch it, and bring it back, the window of downtime is unpredictable.</p>
<p><strong>Before you click:</strong> Run <code>kubectl get pods -n kubelab -o wide -w</code>. Watch which node each pod runs on.</p>
<pre><code class="language-bash">kubectl get pods -n kubelab -o wide -w
</code></pre>
<pre><code class="language-plaintext">NAME                     NODE               STATUS
backend-abc123-xk2qp    kubelab-worker-1   Terminating   ← evicted
backend-abc123-n7mw3    kubelab-worker-2   Running       ← rescheduled
</code></pre>
<p>In <code>kubectl get nodes</code> the node shows <code>Ready,SchedulingDisabled</code> until you run <code>kubectl uncordon</code>.</p>
<p><strong>What happened:</strong> The node spec got <code>spec.unschedulable=true</code>. The Eviction API ran per pod. That path goes through PodDisruptionBudget policy checks before proceeding, unlike a raw delete. A raw <code>kubectl delete pod</code> bypasses this check entirely — which is why draining with <code>kubectl drain</code> is always safer than deleting pods manually during maintenance.</p>
<p><strong>The production trap:</strong> Two replicas with no pod anti-affinity often land on the same node. Drain that node and both pods evict at once. Complete downtime despite <code>replicas: 2</code>.</p>
<p><strong>The fix:</strong> Use pod anti-affinity with topology key: <code>kubernetes.io/hostname</code> and a PodDisruptionBudget with <code>minAvailable: 1</code>.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/698d563262d4ce66226a844a/1161cbf9-2482-41c7-9b5c-751762d3baaa.png" alt="Node drain CLI output: cordoned node shows Ready,SchedulingDisabled; pods reschedule to the other node." style="display:block;margin:0 auto" width="729" height="128" loading="lazy">

<h2 id="heading-simulation-3-cpu-stress-and-throttling"><strong>Simulation 3: CPU Stress and Throttling</strong></h2>
<p>This simulation burns CPU inside a backend pod for 60 seconds, hitting the 200m limit. Without Kubernetes, one runaway process can consume all CPU on the host and starve every other service.</p>
<p><strong>Before you click:</strong> Run <code>watch -n 2 kubectl top pods -n kubelab</code> and open the Grafana CPU Usage panel.</p>
<pre><code class="language-bash">kubectl top pods -n kubelab
# backend-abc123   200m   ← pegged at limit for 60s; the other pod stays ~15m
</code></pre>
<p><strong>What happened:</strong> The Linux CFS scheduler enforces the cgroup limit by granting 20ms of CPU per 100ms period then freezing all processes in the cgroup for 80ms. The pod is not slow because it is broken. It is slow because it is frozen 80% of the time.</p>
<p><strong>The production trap:</strong> <code>kubectl top</code> shows the pod using 95-150m, which looks normal. The metric shows usage at the ceiling, not the throttle rate. Teams spend hours checking application code for a latency bug that is actually a CPU limit set too low.</p>
<p><strong>The fix:</strong> For latency-sensitive workloads, set CPU requests but remove CPU limits. Requests tell the scheduler where to place the pod without throttling at runtime. Confirm throttling with <code>rate(container_cpu_cfs_throttled_seconds_total{namespace="kubelab"}[5m])</code>.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/5e3fd49b-c9a0-4271-9be7-b7fec3122c1a.png" alt="One backend pod flatlined at exactly 95-150m for 60 seconds. A healthy pod's CPU fluctuates, this flat ceiling is the throttle." style="display:block;margin:0 auto" width="1476" height="788" loading="lazy">

<h2 id="heading-simulation-4-memory-stress-and-oomkill"><strong>Simulation 4: Memory Stress and OOMKill</strong></h2>
<p>This simulation allocates memory in 50MB chunks inside a backend pod until the kernel kills it. Without Kubernetes the process dies, the server goes down, and someone gets paged.</p>
<p><strong>Before you click:</strong> Run <code>kubectl get pods -n kubelab -l app=backend -w</code> and open the Grafana Memory Usage panel.</p>
<pre><code class="language-bash">kubectl get pods -n kubelab -l app=backend -w
# backend-abc123   0/1   OOMKilled   3   5m   ← no Terminating phase; SIGKILL bypasses graceful shutdown
</code></pre>
<p><strong>What happened:</strong> The cgroup memory limit crossed 256Mi. The Linux kernel OOM killer scored processes in the container's cgroup and sent SIGKILL (exit code 137) to the top consumer. Not Kubernetes, the kernel. SIGKILL cannot be caught or handled, so no preStop hook runs and in-memory data or open transactions can be lost. Kubernetes only observed the exit, labeled it OOMKilled, and started a fresh container.</p>
<p><strong>The production trap:</strong> The pod runs fine for 8 hours, OOMKills, and restarts. Memory resets to zero and everything looks healthy again. This repeats every 8 hours. The restart count climbs to 7, then 15, then 30, but no alert fires because the metrics look normal between crashes. You find out when a user emails saying the app has been "a bit glitchy lately."</p>
<p><strong>The fix:</strong> Alert on <code>rate(kube_pod_container_status_restarts_total{namespace="kubelab"}[1h]) &gt; 3</code> before users notice.<br>The Prometheus expression means: look at how many times containers in the <code>kubelab</code> namespace have restarted over the last hour, calculate how fast that number is increasing per second, and fire an alert if that rate exceeds the equivalent of 3 restarts per hour. A healthy pod rarely restarts. Several restarts in an hour usually means the container is hitting its memory limit, dying, and coming back in a loop, so this alert catches the silent OOMKill pattern before users do.</p>
<p>Confirm it happened:</p>
<pre><code class="language-bash">kubectl describe pod -n kubelab &lt;pod-name&gt; | grep -A 5 "Last State:"
# Reason: OOMKilled
# Exit Code: 137
</code></pre>
<p>To see the last output before the kernel killed the process, run <code>kubectl logs -n kubelab &lt;pod-name&gt; --previous</code>. The log stream stops abruptly with no shutdown message, SIGKILL leaves no time for cleanup or final logs.</p>
<img src="https://cdn.hashnode.com/uploads/covers/698d563262d4ce66226a844a/8ced107b-9d14-4d40-b6d6-7ae0fe35b1b7.png" alt="One backend pod's memory climbs, then the line drops at the OOMKill and reappears as the container restarts. The other pod's line stays flat the whole time" style="display:block;margin:0 auto" width="735" height="298" loading="lazy">

<h2 id="heading-simulation-5-database-failure"><strong>Simulation 5: Database Failure</strong></h2>
<p>This simulation scales the PostgreSQL StatefulSet to 0 replicas. The pod terminates completely. Without Kubernetes, the database server crashes and data recovery depends on whether backups exist and when they ran.</p>
<p><strong>Before you click:</strong> Run <code>kubectl get pods,pvc -n kubelab</code>. Note that the PVC exists before you start.</p>
<pre><code class="language-bash">kubectl get pods,pvc -n kubelab
# postgres-0   (gone)
# postgres-data-postgres-0   Bound   ← PVC stays; data lives on the volume
</code></pre>
<p>A PVC, or PersistentVolumeClaim, is a request for storage by a user. Think of it as a pod's way of saying, "I need a certain amount of durable, persistent storage." In the context of a stateful application like PostgreSQL, the PVC is critical. When the database pod is deleted, the PVC (and the underlying PersistentVolume it is bound to) remains. This is where the actual database files are stored. When a new <code>postgres-0</code> pod is created, the StatefulSet knows to re-attach the same PVC, ensuring the new pod has access to all the old data, preventing data loss.</p>
<p><strong>What happened:</strong> The StatefulSet controller deleted the pod but left the PersistentVolumeClaim untouched. StatefulSets guarantee stable names and stable PVC binding. <code>postgres-0</code> always mounts <code>postgres-data-postgres-0</code>. When you restore, the same pod name comes back and reattaches the same volume. PostgreSQL replays WAL to reach a consistent state.</p>
<p><strong>The production trap:</strong> Apps without connection retry logic return 500s and stay broken even after PostgreSQL restores. Connection pools that do not validate on acquire hold dead connections forever.</p>
<p><strong>The fix:</strong> Add connection retry with exponential backoff in your app. Use network-attached storage (EBS, GCE PD) in production so the pod can reschedule to any node.</p>
<h2 id="heading-simulation-6-cascading-pod-failure"><strong>Simulation 6: Cascading Pod Failure</strong></h2>
<p>This simulation deletes both backend replicas at the same time. If everything is down, without Kubernetes, you'd have to restart every service manually, and hope they come up in the right order.</p>
<p><strong>Before you click:</strong> Run <code>kubectl get endpoints -n kubelab backend-service -w</code>. Watch the IP list.</p>
<pre><code class="language-bash">kubectl get endpoints -n kubelab backend-service -w
# ENDPOINTS   &lt;none&gt;   ← every request in this window gets Connection refused
</code></pre>
<p><strong>What happened:</strong> Both pods were deleted. The Service had zero endpoints. The ReplicaSet created two replacements in parallel, but traffic stayed broken until both passed their readiness probes. The endpoint list went empty and came back. You can see the exact downtime window in Grafana's HTTP Request Rate panel.</p>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/698d563262d4ce66226a844a/6cae14e0-faf2-4d42-90f4-32d00a1b4119.png" alt="The 5xx spike during Cascading Failure, 5 to 15 seconds of real downtime with the exact window timestamped" style="display:block;margin:0 auto" width="746" height="291" loading="lazy">

<p><strong>The production trap:</strong> <code>replicas: 2</code> protects you from one pod dying at a time, nothing more.<br>If both replicas land on the same node and that node goes down, you have zero replicas and full downtime.<br>Check right now with <code>kubectl get pods -n kubelab -o wide | grep backend</code>, and if both pods show the same NODE, you are one node failure away from an outage.</p>
<p><strong>The fix:</strong> Use pod anti-affinity to force replicas onto different nodes and a PodDisruptionBudget with <code>minAvailable: 1</code> to block any voluntary action that would leave zero replicas.</p>
<h2 id="heading-simulation-7-readiness-probe-failure"><strong>Simulation 7: Readiness Probe Failure</strong></h2>
<p>This simulation makes one backend pod fail its readiness probe for 120 seconds without restarting it. Without Kubernetes, you'd have no way to take a pod out of traffic rotation without killing it. This is what happens in production when your app connects to a database on startup but the DB is slow. The pod is alive, but it's not ready. Kubernetes holds it out of rotation until it is.</p>
<p><strong>Before you click:</strong> Run <code>kubectl get pods -n kubelab -w</code> in one tab and <code>kubectl get endpoints -n kubelab backend-service -w</code> in another.</p>
<pre><code class="language-bash"># Pods tab: STATUS Running, RESTARTS 0 — almost nothing changes
# Endpoints tab: one IP disappears — the pod is alive but not receiving traffic
</code></pre>
<p><strong>What happened:</strong> <code>/ready</code> returned 503. The kubelet marked the pod <code>Ready=False</code>. The Endpoints controller removed its IP from the Service. The liveness probe <code>/health</code>) still returned 200, so no restart. After 120 seconds <code>/ready</code> recovered and the pod rejoined. Run <code>kubectl logs -n kubelab &lt;failing-pod&gt; -f</code> to see the app log 503s for the readiness endpoint while the pod stays Running and receives no traffic.</p>
<p><strong>The production trap:</strong> Readiness probes that check external dependencies (database, cache, downstream API) will remove all pods from rotation when that dependency goes down. Instead of degrading gracefully, your entire app goes offline.</p>
<p><strong>The fix:</strong> Readiness probes should test only what the pod itself controls. Use a separate deep health endpoint for dependency checks and never tie readiness to external service availability.</p>
<h2 id="heading-4-how-to-read-the-signals-in-grafana"><strong>4. How to Read the Signals in Grafana</strong></h2>
<img src="https://cloudmate-test.s3.us-east-1.amazonaws.com/uploads/covers/698d563262d4ce66226a844a/e6709c25-2d80-489c-b7fb-418ef303b7e2.png" alt="A screenshot showing my grafana dashboards" style="display:block;margin:0 auto" width="1110" height="1201" loading="lazy">

<p><code>kubectl</code> shows current state. Grafana shows what happened over time. That history is essential when you are debugging something that started 4 hours ago.</p>
<h3 id="heading-the-four-panels-that-matter"><strong>The Four Panels that Matter</strong></h3>
<p><strong>Pod Restarts:</strong> A flat line is good. A step up every few hours is a silent OOMKill loop — the most common invisible production failure.</p>
<p><strong>CPU Usage:</strong> A healthy pod's CPU fluctuates. A throttled pod's CPU is unnaturally flat at its limit. That flat ceiling is the signal, not the number.</p>
<p><strong>Memory Usage:</strong> Watch for a line that climbs steadily then disappears. That disappearance is an OOMKill. The line reappearing from zero is the restart.</p>
<p><strong>HTTP Request Rate:</strong> During Cascading Failure you see a spike of 5xx for 5–15 seconds, the exact downtime window, timestamped.</p>
<h3 id="heading-5-how-to-read-the-terminal-signals"><strong>5. How to Read the Terminal Signals</strong></h3>
<p>What you see in the terminal during and after each simulation tells you things Grafana cannot. Five commands matter.</p>
<p>The <code>-w</code> flag on <code>kubectl get pods -n kubelab -w</code> streams changes in real time. The columns that matter are READY, STATUS, and RESTARTS. READY shows containers ready vs total — <code>1/2</code> means one container is alive but not passing its readiness probe. STATUS shows the pod lifecycle phase: Running, Pending, Terminating, OOMKilled. RESTARTS is the most important column in production. A number climbing silently over days is a memory leak or a crash loop nobody has noticed yet.</p>
<p><code>kubectl get events -n kubelab --sort-by=.lastTimestamp</code> is the control plane's diary. Every action the cluster took is here: Killing, SuccessfulCreate, Scheduled, Pulled, Started, OOMKilling, BackOff. When something breaks and you do not know why, read the events. The timestamp gap between a Killing event and the next Started event is your actual downtime window — not an estimate, the exact number.</p>
<p><code>kubectl describe pod -n kubelab &lt;pod-name&gt;</code> is the deepest single-pod view. Three sections matter: Conditions (Ready: True/False tells you if the pod is in the Service endpoints), Last State (shows the previous container's exit reason — OOMKilled, exit code 137, or a crash), and Events at the bottom (the scheduler's reasoning for every placement decision). This is the first command to run when a pod is misbehaving.</p>
<p><code>kubectl get endpoints -n kubelab backend-service</code> shows which pod IPs are actually receiving traffic right now. A pod can show Running in <code>kubectl get pods</code> and be completely absent from this list. That is a readiness probe failure. If this list is empty, no request to that Service will succeed regardless of how many pods show Running. Check this whenever users report errors but pods look healthy.</p>
<p><code>kubectl logs -n kubelab &lt;pod-name&gt;</code> shows the container's stdout and stderr. Use <code>-f</code> to follow the stream. After a pod restarts, use <code>--previous</code> to see the logs from the container that just exited, essential when you need to know what the app was doing right before an OOMKill or crash. Logs are per container and are gone once the pod is replaced, so grab them before the ReplicaSet creates a new pod with a new name.</p>
<p>A full event sequence during Kill Pod recovery looks like this:</p>
<pre><code class="language-bash">kubectl get events -n kubelab --sort-by=.lastTimestamp | tail -10
</code></pre>
<pre><code class="language-plaintext">REASON            MESSAGE
Killing           Stopping container backend          ← SIGTERM sent
SuccessfulCreate  Created pod backend-xyz789          ← ReplicaSet fired
Scheduled         Successfully assigned to worker-2   ← Scheduler placed it
Pulled            Container image already present     ← no pull delay
Started           Started container backend           ← running
</code></pre>
<p>The line between Killing and Started is your actual recovery time. In a healthy cluster with a cached image it is 3–8 seconds. If it takes longer, check the Scheduled line, the scheduler may have struggled to find a node.</p>
<h3 id="heading-two-prometheus-queries-worth-memorizing"><strong>Two Prometheus Queries Worth Memorizing</strong></h3>
<p><strong>First query: silent restart loop.</strong> <code>rate(kube_pod_container_status_restarts_total{namespace="kubelab"}[1h])</code> counts how many times containers in that namespace have restarted over the last hour and expresses it as a rate (restarts per second). A healthy workload rarely restarts. If this rate is high (for example more than 3 restarts per hour), something is killing the container repeatedly, often an OOMKill or a crash. Alert when it exceeds a threshold so you see the pattern before users report errors.</p>
<p><strong>Second query: invisible CPU throttling.</strong> <code>rate(container_cpu_cfs_throttled_seconds_total{namespace="kubelab"}[5m])</code> measures how much time, per second, the Linux scheduler spent throttling containers in that namespace over the last 5 minutes. A result of 0.25 means the container was frozen 25% of the time. High latency with no restarts and "normal" CPU usage in <code>kubectl top</code> often means the CPU limit is too low and the kernel is throttling the process. Alert when this rate exceeds about 0.25 (25% throttled).</p>
<pre><code class="language-plaintext"># Silent restart loop — alert when this exceeds 3 per hour
rate(kube_pod_container_status_restarts_total{namespace="kubelab"}[1h])

# Invisible throttling — alert when this exceeds 25%
rate(container_cpu_cfs_throttled_seconds_total{namespace="kubelab"}[5m])
</code></pre>
<p>Run these against your own cluster. Not just KubeLab. These are production queries.</p>
<h2 id="heading-6-how-to-use-this-for-production-debugging"><strong>6. How to Use This for Production Debugging</strong></h2>
<p>The repo includes <a href="https://github.com/Osomudeya/kubelab/blob/main/docs/diagnose.md">docs/diagnose.md</a>, a symptom-to-simulation map. Find the simulation that reproduces your issue, run it in KubeLab, and understand the mechanics before you touch production.</p>
<p><strong>Exit code 137, pods restarting.</strong> Run the Memory Stress simulation. Confirm with <code>kubectl describe pod | grep -A 5 "Last State:"</code> and look for <code>Reason: OOMKilled</code>. Raise limits or find the leak. The simulation shows both.</p>
<p><strong>High latency, pods look healthy, zero restarts.</strong> Run the CPU Stress simulation. Check <code>container_cpu_cfs_throttled_seconds_total</code> in Prometheus. If it climbs, your CPU limit is too low and the pod is frozen by CFS.</p>
<p><strong>503 on some requests, pods show Running.</strong> Run the Readiness Probe Failure simulation. Check <code>kubectl get endpoints</code> — one pod IP is missing despite Running. The pod gets zero traffic.</p>
<p><strong>Pods stuck Pending after a node went down.</strong> Run the Drain Node simulation. Run <code>kubectl describe pod &lt;pending-pod&gt;</code> and read Events. The scheduler will state why it cannot place the pod, often insufficient capacity or a PVC on the failed node.</p>
<h2 id="heading-conclusion"><strong>Conclusion</strong></h2>
<p>You just broke a real Kubernetes cluster seven ways and watched it fix itself each time. You have seen the ReplicaSet controller fire, read an OOMKill from <code>kubectl describe</code>, watched endpoints go empty during a cascading failure, and understood why a pod can be Running and receiving zero traffic at the same time.</p>
<p>What you practiced here applies to other clusters, staging or production you can read but not safely break. That muscle memory (events, endpoints, restart counter) is what you reach for at 3 am when something is wrong. KubeLab is the safe place to build that reflex.</p>
<p>The repo holds more than this article covered. Explore mode lets you run simulations without the guided flow. The full interview prep doc at <a href="https://github.com/Osomudeya/kubelab/blob/main/docs/interview-prep.md">docs/interview-prep.md</a> has answers to the 13 most common Kubernetes interview questions. The observability guide at <a href="https://github.com/Osomudeya/kubelab/blob/main/docs/observability.md">docs/observability.md</a> covers Prometheus and Grafana setup in detail.</p>
<p>If this helped you, star the repo at <a href="https://github.com/Osomudeya/kubelab">https://github.com/Osomudeya/kube-lab</a> and share it with someone who is learning Kubernetes the hard way.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
