<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lalit Bagga</title>
    <description>The latest articles on DEV Community by Lalit Bagga (@lbagga).</description>
    <link>https://dev.to/lbagga</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3970432%2F2c430d1f-c46a-41e9-9311-d5fce9dfd5c2.png</url>
      <title>DEV Community: Lalit Bagga</title>
      <link>https://dev.to/lbagga</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lbagga"/>
    <language>en</language>
    <item>
      <title>I Deployed My First AgentCore Harness and Asked It About Production</title>
      <dc:creator>Lalit Bagga</dc:creator>
      <pubDate>Thu, 17 Sep 2026 01:49:20 +0000</pubDate>
      <link>https://dev.to/lbagga/i-deployed-my-first-agentcore-harness-and-asked-it-about-production-41mi</link>
      <guid>https://dev.to/lbagga/i-deployed-my-first-agentcore-harness-and-asked-it-about-production-41mi</guid>
      <description>&lt;p&gt;My first CloudOps agent gave me the right answer by refusing to answer.&lt;/p&gt;

&lt;p&gt;I asked:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Is production healthy?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The agent said it could not verify the current state because it had no access to live infrastructure, deployment data, monitoring systems, or production environments.&lt;/p&gt;

&lt;p&gt;That was not a disappointing first result. It was the boundary I wanted to prove.&lt;/p&gt;

&lt;p&gt;This was Stage 1 of a larger project. The end goal is a small CloudOps agent that can answer the same question by calling a Lambda that only reads deployment status. Before adding that tool, I wanted to understand what Amazon Bedrock AgentCore gives me when the agent has only a model, a prompt, and a managed agent loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Started with Harness
&lt;/h2&gt;

&lt;p&gt;The AgentCore CLI currently offers two paths. I can bring an agent loop written with a framework such as Strands, or I can use a managed Harness. With the Harness, I declare the model, prompt, tools, and memory in configuration while AgentCore runs the loop.&lt;/p&gt;

&lt;p&gt;I chose Harness because I wanted the first experiment to isolate the managed platform. No custom orchestration code. No Lambda. No Gateway. No durable memory.&lt;/p&gt;

&lt;p&gt;The architecture was deliberately small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User
  |
  | prompt
  v
AgentCore managed Harness
  |  CloudOps system prompt
  |  memory disabled
  |  no infrastructure tools configured by me
  v
Amazon Nova Micro
  |
  v
CloudWatch logs, metrics, and traces
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/harness.html" rel="noopener noreferrer"&gt;AWS describes the managed Harness&lt;/a&gt; as the path where AgentCore runs the agent loop from configuration. The alternative gives the developer control of the loop in code. The distinction sounds simple in documentation. Deploying both the configuration and its infrastructure made it concrete.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Boundary Was the Product
&lt;/h2&gt;

&lt;p&gt;I named the Harness &lt;code&gt;CloudOpsHarness&lt;/code&gt; and gave it a narrow system prompt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;You are a CloudOps assistant in an early learning stage.

You currently have no access to live infrastructure, deployment data,
monitoring systems, or production environments.

Never claim that an environment is healthy or unhealthy without current
tool evidence.

Do not imply that you can deploy, roll back, restart, or modify resources.
Those capabilities have not been provided.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Memory was disabled, and I configured no external tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Deployment Failed Before It Reached AWS
&lt;/h2&gt;

&lt;p&gt;My first Harness name was descriptive enough to become a problem.&lt;/p&gt;

&lt;p&gt;The physical name generated by the CLI combined the project and Harness names. That composed value exceeded the Harness name limit, so CDK synthesis failed. &lt;code&gt;agentcore validate&lt;/code&gt; still said the project was valid because the local schema was valid, the failure appeared when the infrastructure was synthesized.&lt;/p&gt;

&lt;p&gt;Shortening the resource name to &lt;code&gt;CloudOpsHarness&lt;/code&gt; fixed it.&lt;/p&gt;

&lt;p&gt;That gave me the first useful lesson: a friendly logical name can become a longer physical name after a deployment system adds context. Local validation and infrastructure synthesis are different checks.&lt;/p&gt;

&lt;p&gt;The next dry run reached another boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;✓ Synthesize CloudFormation
✗ Check bootstrap status
AWS environment needs bootstrapping.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I reran the dry run with permission to bootstrap the environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;agentcore validate
agentcore deploy --dry-run --yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This time the dry run completed and the stack was ready to deploy.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model Access Was a Separate Failure Domain
&lt;/h2&gt;

&lt;p&gt;The initial configuration used an Anthropic model. The Harness deployed, began producing the expected refusal, and then the Bedrock streaming call failed because the account had not submitted the details required for Anthropic model access.&lt;/p&gt;

&lt;p&gt;That was not an AgentCore hosting failure. It was a prerequisite for model access.&lt;/p&gt;

&lt;p&gt;For this learning stage, I changed the model to Amazon Nova Micro:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;{
  "modelProvider": "bedrock",
  "modelId": "us.amazon.nova-micro-v1:0",
  "tools": [],
  "skills": [],
  "memory": {
    "enabled": false
  }
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The next deployment completed successfully.&lt;/p&gt;

&lt;h2&gt;
  
  
  What &lt;code&gt;agentcore dev&lt;/code&gt; Taught Me
&lt;/h2&gt;

&lt;p&gt;I expected a command named &lt;code&gt;dev&lt;/code&gt; to mean “run only on my machine.” The managed Harness flow challenged that assumption.&lt;/p&gt;

&lt;p&gt;The command validated the project, synchronized CDK dependencies, built and synthesized the CDK project, checked AWS bootstrap and stack status, and persisted deployment state.&lt;/p&gt;

&lt;p&gt;The lesson was not that the command was wrong. The lesson was that names such as &lt;code&gt;dev&lt;/code&gt; are not a security or cost boundary. I need to read the proposed actions and inspect status before assuming where something will run.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tests I Ran
&lt;/h2&gt;

&lt;p&gt;Once the Harness was running with Nova, I tested the questions that mattered for this stage.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Can it admit that it does not know?
&lt;/h3&gt;

&lt;p&gt;When I asked whether production was healthy, it explained that it could not verify the environment without current operational evidence. It suggested signals such as application metrics, logs, health checks, database performance, and user feedback.&lt;/p&gt;

&lt;p&gt;That response passed the test. It did not turn general CloudOps knowledge into a fabricated observation.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Can a user talk it into a capability it does not have?
&lt;/h3&gt;

&lt;p&gt;I tried to override the prompt and also requested a production rollback. Neither test gave the assistant new infrastructure access.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Does a session behave like memory?
&lt;/h3&gt;

&lt;p&gt;A follow up in the same session recognized that I had asked about production health before. A new session provided the comparison.&lt;/p&gt;

&lt;p&gt;That did not mean I had added AgentCore Memory. &lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-sessions.html" rel="noopener noreferrer"&gt;AgentCore Runtime sessions&lt;/a&gt; can preserve ephemeral context across invocations in one session. AgentCore Memory is the separate capability intended for structured information that must persist beyond that lifecycle.&lt;/p&gt;

&lt;p&gt;That distinction is going to matter later. A remembered user preference may be useful. A remembered statement that production was healthy can become dangerously stale.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding the Trace Was Its Own Experiment
&lt;/h2&gt;

&lt;p&gt;The Harness worked, but these commands did not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;agentcore traces list
agentcore logs --since 30m
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;No runtimes defined in agentcore.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The reason was visible in the project structure. The managed resource was declared under &lt;code&gt;harnesses&lt;/code&gt;, while those CLI commands looked for configured &lt;code&gt;runtimes&lt;/code&gt;. The Harness still had a backing Runtime log group in CloudWatch. it simply was not discovered through that configuration path.&lt;/p&gt;

&lt;p&gt;I went to CloudWatch Logs directly and found standard Runtime logs plus OpenTelemetry output. At first, the trace exporter reported HTTP 400. CloudWatch Transaction Search had not finished enabling span ingestion.&lt;/p&gt;

&lt;p&gt;AWS documents Transaction Search as a setup that each account needs once to view AgentCore spans and traces. After its status changed to enabled, I invoked the Harness again and opened the trace in CloudWatch.&lt;/p&gt;

&lt;p&gt;The complete path was visible:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuei2kh6a34db6ehks93y.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fuei2kh6a34db6ehks93y.webp" width="800" height="530"&gt;&lt;/a&gt;The model span reported:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fol97uvysd7jzylc32dmt.webp" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fol97uvysd7jzylc32dmt.webp" width="799" height="494"&gt;&lt;/a&gt;CloudWatch displayed the cost as &lt;code&gt;$0.000&lt;/code&gt;. I treat that as a rounded console value for one small invocation, not proof that the call was free.&lt;/p&gt;

&lt;p&gt;The same token attributes appeared on several parent spans. Those spans describe one nested request path, not several model calls, so I did not add the repeated numbers together.&lt;/p&gt;

&lt;p&gt;AWS's &lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-get-started.html" rel="noopener noreferrer"&gt;AgentCore Observability guide&lt;/a&gt; says agents hosted on Runtime receive automatic OpenTelemetry instrumentation through the CLI deployment path. Seeing the root invocation, agent loop, event loop cycle, and model call in one timeline made that statement much more useful than reading it alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleaning Up Without Deleting the Learning
&lt;/h2&gt;

&lt;p&gt;After the tests, I removed the AWS Harness and deployed that deletion. I then restored the local Harness files so the experiment remained reproducible.&lt;/p&gt;

&lt;p&gt;The final check was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AgentCore Status (target: default, us-east-2)

Harnesses
  CloudOpsHarness: Local only
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That status is stronger evidence than a successful deletion command by itself. It says the CLI can still see the local definition but no deployed Harness for the selected target.&lt;/p&gt;

&lt;p&gt;The CDK bootstrap stack and CloudWatch evidence are separate resources with separate lifecycles. Removing the Harness does not mean the AWS account has returned to a completely untouched state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The biggest lesson is that an agent can be correctly deployed and still be unable to answer the business question.&lt;/p&gt;

&lt;p&gt;That is not a prompt problem. It is an architecture problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/agentcore-get-started-cli.html" rel="noopener noreferrer"&gt;Get started with Amazon Bedrock AgentCore&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/harness.html" rel="noopener noreferrer"&gt;AgentCore Harness&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-sessions.html" rel="noopener noreferrer"&gt;Use isolated sessions for agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-get-started.html" rel="noopener noreferrer"&gt;Get started with AgentCore Observability&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/observability-view.html" rel="noopener noreferrer"&gt;View AgentCore observability data&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>aws</category>
      <category>agentcore</category>
      <category>ai</category>
      <category>devops</category>
    </item>
    <item>
      <title>My Kubernetes App Moved to EKS Unchanged. Everything Around It Didn't.</title>
      <dc:creator>Lalit Bagga</dc:creator>
      <pubDate>Fri, 11 Sep 2026 02:25:02 +0000</pubDate>
      <link>https://dev.to/lbagga/my-kubernetes-app-moved-to-eks-unchanged-everything-around-it-didnt-e6j</link>
      <guid>https://dev.to/lbagga/my-kubernetes-app-moved-to-eks-unchanged-everything-around-it-didnt-e6j</guid>
      <description>&lt;p&gt;My Kubernetes application moved from my home server to Amazon EKS without an application code change.&lt;/p&gt;

&lt;p&gt;The platform around it did not.&lt;/p&gt;

&lt;p&gt;The EKS control plane took &lt;strong&gt;5 minutes 51 seconds&lt;/strong&gt; to create. Once the delivery path was working, Argo CD deployed two healthy application replicas and corrected a manual scaling change in &lt;strong&gt;5 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;But reaching that result exposed three assumptions about networking, worker size and AWS access that did not exist in my home lab.&lt;/p&gt;

&lt;p&gt;That was the test I wanted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I Returned to EKS
&lt;/h2&gt;

&lt;p&gt;I started this series because I wanted an always on Kubernetes environment without an always on EKS bill.&lt;/p&gt;

&lt;p&gt;My Debian server and k3s gave me an inexpensive place to learn every day. I then built a pull based delivery path: CI tested and published an immutable image, Git recorded the approved image digest, and Argo CD made the cluster follow Git.&lt;/p&gt;

&lt;p&gt;The home lab proved that the design worked on k3s. It did not prove that I could move the same workload to a managed cloud cluster.&lt;/p&gt;

&lt;p&gt;So I created a temporary EKS environment for one question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Could I keep the application and delivery contract while changing the Kubernetes platform underneath them?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This was a portability test, not a production EKS design.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Kept the Same
&lt;/h2&gt;

&lt;p&gt;I reused the important parts of the existing project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the same Node.js application&lt;/li&gt;
&lt;li&gt;the same container image, selected by an immutable digest&lt;/li&gt;
&lt;li&gt;the same Helm chart&lt;/li&gt;
&lt;li&gt;the same Git repository as the deployment record&lt;/li&gt;
&lt;li&gt;the same pull-based Argo CD model &lt;/li&gt;
&lt;li&gt;the same health, readiness and version endpoints.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The application CI still had no Kubernetes credentials. It could test code, build an image and propose a digest change in Git, but it could not deploy directly to either cluster.&lt;/p&gt;

&lt;p&gt;The delivery path remained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CI tests and publishes the image
  -&amp;gt; Git records the approved digest
  -&amp;gt; Argo CD reads Git from inside the cluster
  -&amp;gt; Kubernetes runs the declared release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I made one portable extension to the Helm chart. The home environment could keep a private &lt;code&gt;ClusterIP&lt;/code&gt; Service, while the EKS values could request an AWS load balancer. The chart stayed shared, the environment-specific choice stayed in a small values file.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Smallest Useful AWS Test
&lt;/h2&gt;

&lt;p&gt;I used Terraform to create only what the experiment needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one EKS 1.36 cluster&lt;/li&gt;
&lt;li&gt;one managed AMD64 worker node&lt;/li&gt;
&lt;li&gt;two public subnets in separate Availability Zones&lt;/li&gt;
&lt;li&gt;private control plane access for the worker&lt;/li&gt;
&lt;li&gt;a public control plane endpoint restricted to my current IP&lt;/li&gt;
&lt;li&gt;the standard EKS networking add-ons&lt;/li&gt;
&lt;li&gt;AWS Load Balancer Controller&lt;/li&gt;
&lt;li&gt;Argo CD Core with no public user interface; and&lt;/li&gt;
&lt;li&gt;one temporary Network Load Balancer for the application.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;One worker reduced the cost, but it did not provide high availability. If that node failed, the workload had nowhere else to run. That limitation was acceptable for a short portability experiment.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Worker Could Not Join
&lt;/h2&gt;

&lt;p&gt;The EKS control plane became active, but the worker did not join the cluster.&lt;/p&gt;

&lt;p&gt;I had restricted the public API to my computer and disabled private access. That protected the public endpoint, but it also left the worker without a path to the control plane.&lt;/p&gt;

&lt;p&gt;I enabled the private endpoint for communication inside AWS while keeping public administrative access restricted.&lt;/p&gt;

&lt;p&gt;The application had moved unchanged. Its network paths still required an AWS specific decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  AWS Rejected My Planned Worker
&lt;/h2&gt;

&lt;p&gt;I planned to use a &lt;code&gt;t3.medium&lt;/code&gt; worker. AWS rejected that instance type for the account's eligible configuration.&lt;/p&gt;

&lt;p&gt;Instead of choosing a larger machine at random, I queried the eligible AMD64 choices and selected one &lt;code&gt;c7i-flex.large&lt;/code&gt; worker with 2 vCPUs and 4 GiB of memory.&lt;/p&gt;

&lt;p&gt;This changed the infrastructure, not the application. The same container and Helm release still ran after the worker choice changed.&lt;/p&gt;

&lt;p&gt;That is a useful portability boundary: Kubernetes can preserve the workload contract, but the cloud provider still controls which underlying compute choices are available.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Access Problem Was Operational, Not Kubernetes
&lt;/h2&gt;

&lt;p&gt;The long-running AWS operation also exposed an authentication problem. My interactive login stopped refreshing reliably while I was creating and checking the cluster.&lt;/p&gt;

&lt;p&gt;For this temporary lab, I created a temporary role for the remaining setup and teardown work, then removed that access during cleanup.&lt;/p&gt;

&lt;p&gt;The important lesson was not to weaken authentication. It was to treat operator access as part of the platform design. A repeatable EKS workflow needs a stable, non-personal AWS role with only the permissions the workflow requires.&lt;/p&gt;

&lt;h2&gt;
  
  
  Git Deployed the Workload
&lt;/h2&gt;

&lt;p&gt;After the cluster components were ready, I merged the EKS configuration through a pull request. The CI checks completed successfully in &lt;strong&gt;13 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Argo CD, running privately inside EKS, then read the merged Git state and applied the Helm release. CI never received a kubeconfig and never called the cluster.&lt;/p&gt;

&lt;p&gt;The final checks showed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Argo CD was &lt;code&gt;Synced&lt;/code&gt; and &lt;code&gt;Healthy&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;both application replicas were ready&lt;/li&gt;
&lt;li&gt;the running image matched the immutable digest in Git&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/health&lt;/code&gt; returned a healthy response&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;/version&lt;/code&gt; returned the expected application version and&lt;/li&gt;
&lt;li&gt;Argo CD had no public Service or user interface.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The same application and delivery decision had crossed from k3s to EKS. Git remained the durable record on both platforms.&lt;/p&gt;

&lt;h2&gt;
  
  
  I Changed the Cluster Behind Git's Back
&lt;/h2&gt;

&lt;p&gt;I then repeated the simplest self-healing test from my home lab.&lt;/p&gt;

&lt;p&gt;Git declared two application replicas. I manually scaled the live EKS Deployment down to one.&lt;/p&gt;

&lt;p&gt;Argo CD detected that the cluster no longer matched Git and restored the second replica in &lt;strong&gt;5 seconds&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;On k3s, the same type of test had taken 42 seconds. This single observation does not prove that EKS is generally faster. Reconciliation timing depends on polling, refresh timing and cluster conditions. It proves only that this EKS run corrected this specific drift in 5 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Cost Test
&lt;/h2&gt;

&lt;p&gt;I also treated cost as an engineering result rather than an afterthought.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;EKS control-plane creation:&lt;/strong&gt; 5 minutes 51 seconds&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Estimated environment cost:&lt;/strong&gt; approximately &lt;strong&gt;US$0.24-US$0.27 per hour&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Final charge:&lt;/strong&gt; to be replaced with the AWS billing result when it becomes available&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The estimate covered the temporary control plane, worker, storage, public IPv4 use and load balancer. I kept it as an estimate because billing data had not appeared when I prepared the first draft.&lt;/p&gt;

&lt;h2&gt;
  
  
  Teardown Was Part of the Test
&lt;/h2&gt;

&lt;p&gt;A temporary cloud environment is not finished when the application works. It is finished when its chargeable resources are gone.&lt;/p&gt;

&lt;p&gt;I removed the application load balancer first, destroyed the Terraform-managed infrastructure and removed the temporary access used for the experiment. The teardown script completed successfully.&lt;/p&gt;

&lt;p&gt;I deliberately kept identifiers, cluster endpoints, credentials, IP addresses and kubeconfig data out of the repository and article evidence.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Was Actually Portable
&lt;/h2&gt;

&lt;p&gt;The test gave me a more precise answer than “Kubernetes is portable.”&lt;/p&gt;

&lt;p&gt;The application code, container contract, immutable image, Helm release structure and GitOps delivery model moved cleanly.&lt;/p&gt;

&lt;p&gt;The cloud platform still required AWS-specific engineering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;worker-to-control-plane networking&lt;/li&gt;
&lt;li&gt;available instance selection&lt;/li&gt;
&lt;li&gt;operator and controller identity&lt;/li&gt;
&lt;li&gt;load balancer integration and&lt;/li&gt;
&lt;li&gt;cost-aware teardown.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Kubernetes made the workload portable. It did not make every platform decision portable.&lt;/p&gt;

&lt;p&gt;That is the boundary I wanted to find. My home k3s server remains the practical always on learning environment. EKS becomes a short-lived validation target when I need to prove that the platform contract still works in AWS.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>eks</category>
      <category>terraform</category>
      <category>argocd</category>
    </item>
    <item>
      <title>Argo CD Fixed My Drift, Then Deployed My Bad Release</title>
      <dc:creator>Lalit Bagga</dc:creator>
      <pubDate>Thu, 03 Sep 2026 21:38:24 +0000</pubDate>
      <link>https://dev.to/lbagga/argo-cd-fixed-my-drift-then-deployed-my-bad-release-327b</link>
      <guid>https://dev.to/lbagga/argo-cd-fixed-my-drift-then-deployed-my-bad-release-327b</guid>
      <description>&lt;p&gt;This project started with a simple goal: run Kubernetes without keeping an EKS&lt;br&gt;
cluster online every day.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://blog.lalitbagga.com/posts/why-i-chose-k3s-debian-homelab" rel="noopener noreferrer"&gt;I Wanted Kubernetes Without an Always-On EKS Bill&lt;/a&gt;,&lt;br&gt;
I built an always-on k3s lab on my home server and proved that I could deploy,&lt;br&gt;
update, and roll back an application.&lt;/p&gt;

&lt;p&gt;The rollback worked, but it exposed the next problem. Kubernetes restored&lt;br&gt;
Version 2 while the saved YAML still declared Version 3. I corrected the file&lt;br&gt;
manually, but the recovery depended on repairing the running cluster and its&lt;br&gt;
saved instructions separately.&lt;/p&gt;

&lt;p&gt;In &lt;a href="https://blog.lalitbagga.com/posts/kubernetes-rollback-yaml-gitops-design" rel="noopener noreferrer"&gt;The Rollback Worked. My Next Deploy Could Break It Again&lt;/a&gt;,&lt;br&gt;
I designed a safer path. The automated build process would test and publish an&lt;br&gt;
exact image, then stop at a Git pull request. Git would record the reviewed&lt;br&gt;
version. Argo CD, running inside Kubernetes, would make the cluster follow that&lt;br&gt;
record.&lt;/p&gt;

&lt;p&gt;Now I needed to prove that the design worked outside a diagram.&lt;/p&gt;

&lt;p&gt;I followed one release from source code to running Pods. Then I tested two&lt;br&gt;
opposite failures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The cluster was wrong while Git was correct.&lt;/li&gt;
&lt;li&gt;Git contained a bad setting while the cluster followed it correctly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those experiments showed both the value and the limit of GitOps. Automation can&lt;br&gt;
make the cluster match Git, but it cannot decide whether the human-approved&lt;br&gt;
version in Git is a good one.&lt;/p&gt;
&lt;h2&gt;
  
  
  CI Built the Release but Did Not Deploy It
&lt;/h2&gt;

&lt;p&gt;The GitHub Actions workflow—my continuous integration, or CI, worker—ran the&lt;br&gt;
application tests and checked the Kubernetes package before building anything.&lt;br&gt;
Its job was to prove and publish a release, not to change the cluster.&lt;/p&gt;

&lt;p&gt;After validation, Buildx created a Linux AMD64 image with the full source commit&lt;br&gt;
baked into &lt;code&gt;/version&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker buildx build &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--platform&lt;/span&gt; linux/amd64 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--build-arg&lt;/span&gt; &lt;span class="s2"&gt;"APP_VERSION=&lt;/span&gt;&lt;span class="nv"&gt;$GITHUB_SHA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--tag&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$image_name&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;$GITHUB_SHA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--provenance&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;mode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;max &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--sbom&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;true&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--push&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  application
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After publishing the image, CI read its registry digest. A digest is the image's&lt;br&gt;
content fingerprint: if the image changes, the digest changes. CI updated one&lt;br&gt;
field in &lt;code&gt;gitops/environments/homelab/values.yaml&lt;/code&gt;. For the final measured&lt;br&gt;
release, the automated pull request contained this one-line change:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;-  digest: sha256:170bcc91b9c4bfcbdc194c004cb38d6c89094e9b49a1a128ed3a0507976fef97
&lt;/span&gt;&lt;span class="gi"&gt;+  digest: sha256:f8303e07e737f40fa5479b73a148261cf4221f4d4b52ebf095b74d2cb4ef5be5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The workflow opened the pull request and stopped. A human reviewed and merged&lt;br&gt;
the digest. That merge—not a deployment command in CI—was the release&lt;br&gt;
instruction.&lt;/p&gt;
&lt;h2&gt;
  
  
  Argo CD Made the Cluster Follow Git
&lt;/h2&gt;

&lt;p&gt;Argo CD was the worker inside Kubernetes. It repeatedly compared the version&lt;br&gt;
recorded in Git with the version running in the cluster and worked to make them&lt;br&gt;
agree. This comparison-and-correction loop is called reconciliation.&lt;/p&gt;

&lt;p&gt;I installed a pinned, resource-limited Argo CD Core deployment with no permanent&lt;br&gt;
API server or public UI. Its Services remained internal &lt;code&gt;ClusterIP&lt;/code&gt; Services.&lt;/p&gt;

&lt;p&gt;The Application reads both the Helm chart and homelab values from the public&lt;br&gt;
repository. Automated sync, pruning, and self-healing are enabled:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;syncPolicy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;automated&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;prune&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;selfHeal&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At the healthy baseline, Argo CD reported &lt;code&gt;Synced&lt;/code&gt;, &lt;code&gt;Healthy&lt;/code&gt;, and operation&lt;br&gt;
&lt;code&gt;Succeeded&lt;/code&gt;. Two application Pods were ready with zero restarts.&lt;/p&gt;

&lt;p&gt;I retained the complete artifact trace:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;application commit
    -&amp;gt; registry digest
    -&amp;gt; Git desired state
    -&amp;gt; Deployment image
    -&amp;gt; both Pod runtime image IDs
    -&amp;gt; /version response
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Git declaration, Deployment, and both Pod image IDs agreed on the same&lt;br&gt;
registry digest. &lt;code&gt;/version&lt;/code&gt; returned the source commit baked into that image.&lt;/p&gt;

&lt;p&gt;The four Argo CD components used a combined 19 millicores of CPU and 85 MiB of&lt;br&gt;
memory in the retained steady-state sample. That measurement describes this&lt;br&gt;
home lab; it is not production sizing guidance.&lt;/p&gt;
&lt;h2&gt;
  
  
  First, I Changed the Cluster Behind Git’s Back
&lt;/h2&gt;

&lt;p&gt;Git declared two replicas. I deliberately changed the live Deployment to one:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;kubectl &lt;span class="nt"&gt;-n&lt;/span&gt; delivery-api scale deployment/delivery-api &lt;span class="nt"&gt;--replicas&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first poll already showed one replica and Argo CD &lt;code&gt;OutOfSync&lt;/code&gt;. Git remained&lt;br&gt;
unchanged with &lt;code&gt;replicaCount: 2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Forty-two seconds after the mutation, the Deployment was back to two replicas&lt;br&gt;
and the Application was &lt;code&gt;Synced&lt;/code&gt; again.&lt;/p&gt;

&lt;p&gt;This proved self-healing for that specific field. Git remained correct, so Argo&lt;br&gt;
CD could restore the live cluster from the durable declaration.&lt;/p&gt;

&lt;p&gt;The next failure reversed that relationship.&lt;/p&gt;
&lt;h2&gt;
  
  
  Then I Put a Bad Setting in Git
&lt;/h2&gt;

&lt;p&gt;I merged a reviewed configuration change from:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;readinessMode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ready&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;to:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;readinessMode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fail&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The change was valid YAML. It passed the Helm schema and lint checks. At runtime,&lt;br&gt;
the new Pod stayed alive but &lt;code&gt;/ready&lt;/code&gt; returned HTTP 503.&lt;/p&gt;

&lt;p&gt;Forty-nine seconds after the merge, the evidence showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Argo CD sync:    Synced
Argo CD health:  Progressing
New Pod:         Running, not Ready
Old Pods:        Running, Ready
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Synced&lt;/code&gt; meant the cluster matched Git. &lt;code&gt;Progressing&lt;/code&gt; meant Kubernetes could not&lt;br&gt;
complete the rollout.&lt;/p&gt;

&lt;p&gt;GitOps had not malfunctioned. It had correctly delivered a harmful declaration.&lt;/p&gt;

&lt;p&gt;The Deployment used &lt;code&gt;maxUnavailable: 0&lt;/code&gt;, so the two old ready replicas remained&lt;br&gt;
while the replacement failed readiness. One sampled request through the private&lt;br&gt;
Service returned &lt;code&gt;{"status":"ready"}&lt;/code&gt;. That proves availability at that moment,&lt;br&gt;
not zero downtime across the experiment.&lt;/p&gt;
&lt;h2&gt;
  
  
  Recovery Had to Correct Git
&lt;/h2&gt;

&lt;p&gt;Patching only the live Deployment would leave Git declaring the bad readiness&lt;br&gt;
mode. Argo CD could then reapply it.&lt;/p&gt;

&lt;p&gt;I reverted the bad merge in Git:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git revert &lt;span class="nt"&gt;--mainline&lt;/span&gt; 1 69ac4fe6de392aed4affc573ea20a2bb3f4ee59f
git push
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Argo CD pulled the revert and restored &lt;code&gt;readinessMode: ready&lt;/code&gt;. Forty-two seconds&lt;br&gt;
after the recovery commit, the Application was &lt;code&gt;Synced&lt;/code&gt; and &lt;code&gt;Healthy&lt;/code&gt; with two&lt;br&gt;
updated, ready replicas.&lt;/p&gt;

&lt;p&gt;Argo CD did not choose the known-good version. I chose it by reverting Git; Argo&lt;br&gt;
CD reconciled that decision.&lt;/p&gt;
&lt;h2&gt;
  
  
  I Timed a Fresh Release End to End
&lt;/h2&gt;

&lt;p&gt;The first image promotion happened before Argo CD was installed, so I did not&lt;br&gt;
use it for a continuous promotion-to-healthy claim. I ran another application&lt;br&gt;
release with Argo CD already active and started the monitor before merging the&lt;br&gt;
digest pull request.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Event&lt;/th&gt;
&lt;th&gt;Observed result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CI workflow window&lt;/td&gt;
&lt;td&gt;74 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Validation job&lt;/td&gt;
&lt;td&gt;14 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Image publication and promotion job&lt;/td&gt;
&lt;td&gt;50 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Promotion merge to Argo CD operation start&lt;/td&gt;
&lt;td&gt;32 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Argo CD operation start to healthy&lt;/td&gt;
&lt;td&gt;11 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Promotion merge to healthy release&lt;/td&gt;
&lt;td&gt;43 seconds&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The monitor sampled every two seconds, so the healthy timestamp has up to two&lt;br&gt;
seconds of sampling uncertainty.&lt;/p&gt;

&lt;p&gt;During the rolling update, the Deployment already declared the new digest while&lt;br&gt;
a request still reached an old ready Pod and returned the previous commit. A few&lt;br&gt;
seconds later, both updated replicas were ready and &lt;code&gt;/version&lt;/code&gt; returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"service"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"delivery-api"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"version"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"84db89f7f968c5860b07a7efd6a42e21895d9e5c"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That was expected. A rolling update changes replicas gradually; the Deployment&lt;br&gt;
declaration and every serving Pod do not switch at the same instant.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Implementation Proved
&lt;/h2&gt;

&lt;p&gt;Application CI published the artifact and proposed the environment change&lt;br&gt;
without receiving cluster authority. Git retained the reviewed digest. Argo CD&lt;br&gt;
pulled and reconciled it. Kubernetes reported rollout health.&lt;/p&gt;

&lt;p&gt;The failure tests also exposed the limits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Self-healing corrected live drift only because Git remained correct.&lt;/li&gt;
&lt;li&gt;Argo CD faithfully applied the bad readiness declaration from Git.&lt;/li&gt;
&lt;li&gt;Recovery required a human decision recorded as a Git revert.&lt;/li&gt;
&lt;li&gt;One successful request did not prove zero request loss.&lt;/li&gt;
&lt;li&gt;A single-node k3s cluster could not demonstrate high availability.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;GitOps did not remove operational judgment. It moved release and recovery&lt;br&gt;
decisions into a place where they were reviewable, durable, and reconcilable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The first article created an affordable, always-on Kubernetes lab. The second&lt;br&gt;
used its rollback problem to design one reviewable release path. This&lt;br&gt;
implementation proved how that path behaved when either the cluster or Git was&lt;br&gt;
wrong.&lt;/p&gt;

&lt;p&gt;The common lesson across the three articles is that a working cluster is only&lt;br&gt;
one part of a delivery platform. The approved release, permission to deploy,&lt;br&gt;
health signals, and recovery decision also need clear owners.&lt;/p&gt;

&lt;p&gt;The code, chart, GitOps configuration, runbooks, and sanitized evidence are in&lt;br&gt;
the public&lt;br&gt;
&lt;a href="https://github.com/lalitbagga/pull-based-kubernetes-delivery" rel="noopener noreferrer"&gt;pull-based-kubernetes-delivery repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>gitops</category>
      <category>argocd</category>
      <category>devops</category>
    </item>
    <item>
      <title>The Rollback Worked. My Next Deploy Could Break It Again.</title>
      <dc:creator>Lalit Bagga</dc:creator>
      <pubDate>Thu, 27 Aug 2026 19:06:29 +0000</pubDate>
      <link>https://dev.to/lbagga/the-rollback-worked-my-next-deploy-could-break-it-again-440p</link>
      <guid>https://dev.to/lbagga/the-rollback-worked-my-next-deploy-could-break-it-again-440p</guid>
      <description>&lt;p&gt;My application was working again. That should have been the end of the problem.&lt;/p&gt;

&lt;p&gt;It was not.&lt;/p&gt;

&lt;p&gt;In [the first article in this series(&lt;a href="https://blog.lalitbagga.com/posts/why-i-chose-k3s-debian-homelab" rel="noopener noreferrer"&gt;https://blog.lalitbagga.com/posts/why-i-chose-k3s-debian-homelab&lt;/a&gt;), I built an always on Kubernetes lab on my home server. I deployed a small application, changed it from Version 2 to Version 3, and then practised a rollback.&lt;/p&gt;

&lt;p&gt;Kubernetes restored Version 2 successfully. But the YAML file on my computer the file containing the instructions for the application still said Version 3.&lt;/p&gt;

&lt;p&gt;I had repaired what was running, but I had not repaired the instructions that I would use next time. If I applied that file again, I could accidentally bring Version 3 back.&lt;/p&gt;

&lt;p&gt;I corrected the YAML manually and confirmed that the file and cluster agreed. That solved the immediate problem, but it also exposed a weakness in my process: recovery depended on me remembering to repair two places.&lt;/p&gt;

&lt;p&gt;That became the starting point for this project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Real Problem Was Two Different Answers
&lt;/h2&gt;

&lt;p&gt;Kubernetes always has a live answer to the question, “What should run now?” My&lt;br&gt;
YAML file had a saved answer to the same question.&lt;/p&gt;

&lt;p&gt;After the rollback, those answers were different:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Running in Kubernetes: Version 2
Saved in YAML:          Version 3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Neither answer was hidden. The weakness was that nothing continuously checked&lt;br&gt;
whether they agreed.&lt;/p&gt;

&lt;p&gt;I wanted one durable place to record the version I had chosen. If somebody changed the cluster directly, the system should notice the difference and restore the recorded choice. If the recorded choice itself was bad, recovery should mean changing that record not making another temporary cluster edit.&lt;/p&gt;

&lt;p&gt;That is the problem GitOps is meant to address.&lt;/p&gt;
&lt;h2&gt;
  
  
  I Considered Who Should Be Allowed to Change the Cluster
&lt;/h2&gt;

&lt;p&gt;My first idea was the most direct one: let the application pipeline deploy the new version.&lt;/p&gt;

&lt;p&gt;A pipeline, usually called continuous integration or &lt;strong&gt;CI&lt;/strong&gt;, is an automated worker. It runs tests and builds the application whenever code changes. I could also give it the password like Kubernetes credentials needed to change the cluster.&lt;/p&gt;

&lt;p&gt;That would be convenient, but it would give the application build process a second, much more powerful job. A mistake or compromised workflow could move directly from building code to changing the cluster.&lt;/p&gt;

&lt;p&gt;I wanted a smaller boundary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CI may build and publish a release.
CI may propose which release should run.
CI may not change Kubernetes directly.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The proposal would become a pull request. I would review it and merge it into Git, the same version history system used for the application code. Git would therefore hold the durable answer to “Which version should this environment run?”&lt;/p&gt;

&lt;p&gt;This was not about removing every credential from CI. It still needed limited GitHub permission to publish an image and open a pull request. The important restriction was precise: &lt;strong&gt;application CI would not receive a Kubernetes credential or permission to deploy to the cluster.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  I Designed the Process Before Choosing the Tools
&lt;/h2&gt;

&lt;p&gt;At this point, I could describe the process without naming a Kubernetes tool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Developer changes the application
        ↓
CI tests it and builds a release
        ↓
CI opens a pull request with the exact release
        ↓
A human reviews and merges the change
        ↓
A worker inside Kubernetes reads Git
        ↓
That worker makes the cluster match Git
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This operating model is called &lt;strong&gt;GitOps&lt;/strong&gt;. Git stores the desired state the version we intend to run. Software inside the cluster repeatedly compares that record with reality and corrects differences.&lt;/p&gt;

&lt;p&gt;I chose &lt;strong&gt;Argo CD&lt;/strong&gt; for that inside-the-cluster worker. Argo CD watches the Git repository and reconciles the cluster. “Reconcile” simply means compare what is running with what should be running, then work to make them agree.&lt;/p&gt;

&lt;p&gt;This separation gave each part one main responsibility:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;CI proves that the application can be built.&lt;/li&gt;
&lt;li&gt;Git records the reviewed version choice.&lt;/li&gt;
&lt;li&gt;Argo CD makes Kubernetes follow that choice.&lt;/li&gt;
&lt;li&gt;Kubernetes reports whether the application becomes healthy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  I Needed an Exact Version, Not a Moving Name
&lt;/h2&gt;

&lt;p&gt;A release also needed an identity that could not silently change.&lt;/p&gt;

&lt;p&gt;Container images often use names such as &lt;code&gt;latest&lt;/code&gt; or &lt;code&gt;version-3&lt;/code&gt;. These are tags: convenient labels that can be moved to different image contents later. That would weaken Git as the record of exactly what I approved.&lt;/p&gt;

&lt;p&gt;I therefore chose an image &lt;strong&gt;digest&lt;/strong&gt;. A digest is a fingerprint calculated from the image contents. If the contents change, the fingerprint changes. Git would store that exact fingerprint, so the selected release could not change while keeping the same name.&lt;/p&gt;

&lt;p&gt;CI would publish the image, learn its digest, and open a pull request changing only that value. It would then stop and wait for review.&lt;/p&gt;

&lt;h2&gt;
  
  
  I Used Helm to Keep the Kubernetes Instructions Together
&lt;/h2&gt;

&lt;p&gt;The application needed several Kubernetes instructions: how to run it, how to reach it, how to check its health, and which image digest to use.&lt;/p&gt;

&lt;p&gt;I packaged those instructions with &lt;strong&gt;Helm&lt;/strong&gt;. Helm is a way to create a reusable Kubernetes template. The structure stays the same while selected values such as the image digest or number of copies can change.&lt;/p&gt;

&lt;p&gt;Before allowing Argo CD to use the Helm package, I planned to test it on its own: install it, verify the application, upgrade it, roll it back, remove it, and install it again in a clean location.&lt;/p&gt;

&lt;p&gt;This separated two questions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Does the Kubernetes package work correctly?&lt;/li&gt;
&lt;li&gt;Does the automated delivery process use it correctly?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If both failed at the same time, troubleshooting would be much harder.&lt;/p&gt;

&lt;h2&gt;
  
  
  I Chose a Small Application That Could Fail Safely
&lt;/h2&gt;

&lt;p&gt;A successful first deployment would not prove much. I needed an application that could help test both healthy and unhealthy releases without risking real data.&lt;/p&gt;

&lt;p&gt;I built a small Node.js service that could answer four simple questions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/health   Is the program alive?
/ready    Should Kubernetes send it user traffic?
/version  Which source version is running?
/metrics  How many requests has it handled?
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The health and readiness checks were deliberately separate. This allowed me to create a safe bad release: the program would keep running, but its readiness check would fail, so Kubernetes would refuse to send new traffic to it.&lt;/p&gt;

&lt;p&gt;The version response would help trace one change from source code, through the built image and Git, to the running application.&lt;/p&gt;

&lt;h2&gt;
  
  
  I Kept Argo CD Private
&lt;/h2&gt;

&lt;p&gt;My home server already ran other services and had limited free memory. I did not need another public management screen.&lt;/p&gt;

&lt;p&gt;I chose the smaller Argo CD Core installation. Its management components would remain reachable only inside the cluster, and I gave them explicit CPU and memory limits.&lt;/p&gt;

&lt;p&gt;Argo CD still needed meaningful power inside Kubernetes to do its job. Keeping it private did not make it harmless. I therefore limited this application to its repository, its Kubernetes namespace, and the types of resources its Helm package created.&lt;/p&gt;

&lt;h2&gt;
  
  
  I Defined the Failure Tests Before Claiming Success
&lt;/h2&gt;

&lt;p&gt;A green installation screen would only prove that the tools started. I wrote the tests before implementing the system:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Test&lt;/th&gt;
&lt;th&gt;Question it had to answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Follow one release&lt;/td&gt;
&lt;td&gt;Can I connect the source change to the built image, the version recorded in Git, and the running application?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Change the cluster by hand&lt;/td&gt;
&lt;td&gt;Does Argo CD notice that the cluster no longer matches Git and correct it?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Approve a bad readiness setting&lt;/td&gt;
&lt;td&gt;What happens when Git is followed correctly but the chosen configuration is harmful?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Revert the Git change&lt;/td&gt;
&lt;td&gt;Can I recover by correcting the durable record instead of editing only the cluster?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time a normal release&lt;/td&gt;
&lt;td&gt;How long does a reviewed version take to become healthy?&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Measure resources&lt;/td&gt;
&lt;td&gt;Does Argo CD fit on the shared home server without creating pressure?&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The two failure cases were intentionally different.&lt;/p&gt;

&lt;p&gt;Changing the cluster by hand would make the cluster wrong while Git remained correct. Argo CD should restore the version recorded in Git. This disagreement is called &lt;strong&gt;drift&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The readiness experiment would test the opposite problem: Git itself would contain a harmful setting. Argo CD should apply it faithfully, while Kubernetes should report that the new copy was not ready. Recovery would require reverting the Git change.&lt;/p&gt;

&lt;p&gt;That distinction became the most important result of the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Design Did Not Promise
&lt;/h2&gt;

&lt;p&gt;This design did not make Git automatically correct. A reviewer could still approve a bad value, and Argo CD could apply it perfectly. Argo CD also still needed permission to change the cluster.&lt;/p&gt;

&lt;p&gt;The single server lab could not prove high availability. One successful request also could not prove that no user request was ever lost.&lt;/p&gt;

&lt;p&gt;The goal was smaller and testable: keep direct cluster access out of application CI, record the exact selected release in Git, and observe how the system behaved when either the cluster or Git was wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Came Next
&lt;/h2&gt;

&lt;p&gt;The rollback in my first article taught me that recovering the running application and correcting its saved instructions were separate jobs. This design joined them through one reviewable path.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://blog.lalitbagga.com/posts/argocd-drift-bad-release-git-recovery" rel="noopener noreferrer"&gt;Part 2&lt;/a&gt;&lt;br&gt;
implements that path. It follows one release from CI to Git and into Kubernetes, then records what happened when I changed the cluster by hand, approved a bad readiness setting, and recovered by reverting Git.&lt;/p&gt;

&lt;p&gt;The complete implementation and sanitized evidence are in the public&lt;br&gt;
&lt;a href="https://github.com/lalitbagga/pull-based-kubernetes-delivery" rel="noopener noreferrer"&gt;pull-based-kubernetes-delivery repository&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>gitops</category>
      <category>devops</category>
      <category>argocd</category>
    </item>
    <item>
      <title>I Turned a $7 Lightsail VM Into a Staging Server</title>
      <dc:creator>Lalit Bagga</dc:creator>
      <pubDate>Thu, 20 Aug 2026 19:37:38 +0000</pubDate>
      <link>https://dev.to/lbagga/i-turned-a-7-lightsail-vm-into-a-staging-server-43n5</link>
      <guid>https://dev.to/lbagga/i-turned-a-7-lightsail-vm-into-a-staging-server-43n5</guid>
      <description>&lt;p&gt;My side projects had a staging gap.&lt;/p&gt;

&lt;p&gt;Local Docker was useful for checking an application before a release, but it still ran on my laptop. A larger AWS setup with a VPC, load balancer, container platform, and deployment pipeline would answer a different question and create much more infrastructure than I needed.&lt;/p&gt;

&lt;p&gt;I wanted something in the middle: one server with a stable address, controlled access, repeatable deployment, a health check, and enough recovery behavior to be useful during development.&lt;/p&gt;

&lt;p&gt;So I tested Amazon Lightsail as a small staging environment instead of treating it as a simplified hosting tutorial.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Smallest Useful Staging Stack
&lt;/h2&gt;

&lt;p&gt;I deliberately kept the architecture narrow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Developer laptop
  ├── Terraform → Lightsail instance, SSH key, static IP, firewall
  └── deploy.sh → SSH/SCP → Docker Compose
                              └── Node.js app
                                   ├── /health
                                   └── /version
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The instance ran Ubuntu 24.04 in &lt;code&gt;ca-central-1&lt;/code&gt; on the Micro 1 GB Linux plan with public IPv4. &lt;a href="https://docs.aws.amazon.com/lightsail/latest/userguide/amazon-lightsail-bundles.html" rel="noopener noreferrer"&gt;AWS currently lists that bundle at $7 per month&lt;/a&gt;, billed hourly up to the monthly maximum.&lt;/p&gt;

&lt;p&gt;There was no database, load balancer, Kubernetes cluster, or CI/CD system. The point was to learn whether one developer could operate a useful staging VM without quietly building a small platform team around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Define More Than an Instance
&lt;/h2&gt;

&lt;p&gt;The Terraform resource for the VM was only one part of the environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lightsail_instance"&lt;/span&gt; &lt;span class="s2"&gt;"lightsail_instance"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project_name}-lightsail-instance"&lt;/span&gt;
  &lt;span class="nx"&gt;availability_zone&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;availability_zone&lt;/span&gt;
  &lt;span class="nx"&gt;blueprint_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;blueprint_id&lt;/span&gt;
  &lt;span class="nx"&gt;bundle_id&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bundle_id&lt;/span&gt;
  &lt;span class="nx"&gt;key_pair_name&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lightsail_key_pair&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lightsail_instance_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;user_data&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"${path.module}/../scripts/bootstrap.sh"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also needed a custom SSH public key, a static IP, the attachment connecting that address to the instance, and explicit firewall rules.&lt;/p&gt;

&lt;p&gt;The static IP was not just extra Terraform. Lightsail's default public IPv4 address can change when an instance is stopped and started. A staging endpoint is much easier to use when its address stays stable, and Lightsail lets the same static address move to a replacement instance later.&lt;/p&gt;

&lt;p&gt;The SSH key had a similarly clear boundary. Terraform uploaded only the public key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lightsail_key_pair"&lt;/span&gt; &lt;span class="s2"&gt;"lightsail_instance_key"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${var.project_name}-key"&lt;/span&gt;
  &lt;span class="nx"&gt;public_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pathexpand&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ssh_public_key_path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The private key never entered Terraform, the repository, or the article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Replace the Default Firewall Rules
&lt;/h2&gt;

&lt;p&gt;A base Lightsail Linux instance can start with SSH on port 22 and HTTP on port 80 open to every address. That is convenient for a first connection, but it was wider than this experiment required.&lt;/p&gt;

&lt;p&gt;I replaced those defaults with two rules restricted to my temporary public &lt;code&gt;/32&lt;/code&gt; address:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lightsail_instance_public_ports"&lt;/span&gt; &lt;span class="s2"&gt;"lightsail_instance_firewall"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;instance_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lightsail_instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;lightsail_instance&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;

  &lt;span class="nx"&gt;port_info&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
    &lt;span class="nx"&gt;from_port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;
    &lt;span class="nx"&gt;to_port&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;22&lt;/span&gt;
    &lt;span class="nx"&gt;cidrs&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;developer_cidr&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;port_info&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
    &lt;span class="nx"&gt;from_port&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
    &lt;span class="nx"&gt;to_port&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
    &lt;span class="nx"&gt;cidrs&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;developer_cidr&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;a href="https://docs.aws.amazon.com/lightsail/latest/userguide/understanding-firewall-and-port-mappings-in-amazon-lightsail.html" rel="noopener noreferrer"&gt;Lightsail firewall documentation&lt;/a&gt; recommends limiting SSH to the address that needs administrative access. I restricted HTTP as well because this was a private experiment, not a public site.&lt;/p&gt;

&lt;p&gt;For a real shared staging environment, the source ranges and access model would need another review. A developer's changing home IP is manageable for a short test but awkward for a team.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Make Application State Observable
&lt;/h2&gt;

&lt;p&gt;The application was intentionally small. It used Node's built-in HTTP module and exposed two endpoints:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/health&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;request&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/version&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;sendJson&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/health&lt;/code&gt; answered whether the process was responding. &lt;code&gt;/version&lt;/code&gt; answered whether the expected deployment was running.&lt;/p&gt;

&lt;p&gt;Those endpoints were more useful than a generic home page because each test had a precise success condition.&lt;/p&gt;

&lt;p&gt;Docker Compose added the recovery policy and its own health check:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;context&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;APP_VERSION&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${APP_VERSION:-local}&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;${HOST_PORT:-8080}:3000"&lt;/span&gt;
    &lt;span class="na"&gt;restart&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;unless-stopped&lt;/span&gt;
    &lt;span class="na"&gt;healthcheck&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;CMD&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;node&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;-e&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;-&lt;/span&gt;
          &lt;span class="s"&gt;require('node:http').get('http://127.0.0.1:3000/health',&lt;/span&gt;
          &lt;span class="s"&gt;response =&amp;gt; process.exit(response.statusCode === 200 ? 0 : 1))&lt;/span&gt;
          &lt;span class="s"&gt;.on('error', () =&amp;gt; process.exit(1))&lt;/span&gt;
      &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;5s&lt;/span&gt;
      &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;3s&lt;/span&gt;
      &lt;span class="na"&gt;retries&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;6&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The container also ran as a non-root application user. No application secret was required, and the generated &lt;code&gt;.env&lt;/code&gt; file on the server had mode &lt;code&gt;0600&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Keep Deployment Separate From Terraform
&lt;/h2&gt;

&lt;p&gt;Terraform created the server. A separate deployment script copied the application files over SSH and ran Compose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./scripts/deploy.sh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; v2 &lt;span class="s2"&gt;"&amp;lt;SSH_PRIVATE_KEY_PATH&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On the server, the important command was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt; &lt;span class="nt"&gt;--detach&lt;/span&gt; &lt;span class="nt"&gt;--remove-orphans&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That separation mattered. Changing &lt;code&gt;v1&lt;/code&gt; to &lt;code&gt;v2&lt;/code&gt; did not require replacing the instance or changing Terraform state.&lt;/p&gt;

&lt;p&gt;The verification script then checked both endpoints until they agreed with the requested release:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./scripts/verify.sh &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; v2 120
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The observed result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PASS health=ok version=v2 recovery_seconds=0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The complete &lt;code&gt;v1&lt;/code&gt; to &lt;code&gt;v2&lt;/code&gt; deployment took &lt;strong&gt;6 seconds&lt;/strong&gt;, and the first verification request already returned the new version.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the Runtime Tests Showed
&lt;/h2&gt;

&lt;p&gt;Configuration alone does not prove that recovery works, so I tested two common situations.&lt;/p&gt;

&lt;p&gt;First, I stopped the application process inside the container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./scripts/test-container-restart.sh &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&amp;lt;HOST&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"http://&amp;lt;HOST&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&amp;lt;SSH_PRIVATE_KEY_PATH&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker Compose restarted the application. The health and version endpoints returned after &lt;strong&gt;3 seconds&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PASS health=ok version=v1 recovery_seconds=3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Next, I rebooted the complete Lightsail server:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;./scripts/test-instance-reboot.sh &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&amp;lt;HOST&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  v1 &lt;span class="se"&gt;\&lt;/span&gt;
  lightsail-staging-lab-lightsail-instance &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"&amp;lt;AWS_PROFILE&amp;gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  ca-central-1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application returned without another deployment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PASS health=ok version=v1 recovery_seconds=25
PASS reboot_recovery_seconds=29
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The complete reboot test took &lt;strong&gt;29 seconds&lt;/strong&gt;. This confirmed that Docker started with the server and Compose brought the application back automatically.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Lightsail Simplified
&lt;/h2&gt;

&lt;p&gt;For this experiment, Lightsail removed a large amount of AWS setup.&lt;/p&gt;

&lt;p&gt;I did not have to design a VPC, public subnet, internet gateway, route table, EC2 security group, or separate EBS volume. The instance bundle made the compute, memory, SSD storage, public addressing, and transfer allowance understandable as one small plan.&lt;/p&gt;

&lt;p&gt;The static IP and firewall were also easier to reason about than a larger network stack. Terraform still made those decisions explicit, but there were fewer components to connect.&lt;/p&gt;

&lt;p&gt;That simplicity made Lightsail a reasonable fit for one small staging server.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Lightsail Still Leaves to the Developer
&lt;/h2&gt;

&lt;p&gt;Lightsail simplified the AWS layer. It did not operate the application for me.&lt;/p&gt;

&lt;p&gt;I still owned:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;operating-system updates;&lt;/li&gt;
&lt;li&gt;Docker installation and service startup;&lt;/li&gt;
&lt;li&gt;application deployment and rollback;&lt;/li&gt;
&lt;li&gt;logs and alerting;&lt;/li&gt;
&lt;li&gt;SSH-key handling;&lt;/li&gt;
&lt;li&gt;firewall source-address changes;&lt;/li&gt;
&lt;li&gt;backups and data recovery; and&lt;/li&gt;
&lt;li&gt;verification after every change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It also remained one VM. There was no multi-zone availability, autoscaling, managed rollout controller, or automatic load-balancer health replacement.&lt;/p&gt;

&lt;p&gt;I would not choose this setup for a system that needs private application tiers, multiple instances, team-scale access control, managed deployments, or strong availability guarantees. At that point, ECS, App Runner, Elastic Beanstalk, or a deliberately designed EC2 platform would deserve comparison.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cost and Cleanup
&lt;/h2&gt;

&lt;p&gt;The selected plan was &lt;strong&gt;$7 per month&lt;/strong&gt;, but this experiment did not run for a month. AWS bills Lightsail instance bundles hourly up to the monthly maximum.&lt;/p&gt;

&lt;p&gt;I did not collect final billing data, so I will not present an exact invoice. The resources existed for less than one day, making the theoretical instance-cost upper bound less than &lt;strong&gt;$0.24&lt;/strong&gt;, before any free-tier discount. That is an estimate, not a billing record.&lt;/p&gt;

&lt;p&gt;The experiment ended with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform &lt;span class="nt"&gt;-chdir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;terraform destroy &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-var-file&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;terraform.tfvars
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Terraform removed the instance, firewall configuration, static-IP attachment, static IP, and Lightsail key pair. A final state check returned no managed resources. The project created no snapshots, extra disks, load balancer, database, DNS zone, or Lightsail container service.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Would Use It For
&lt;/h2&gt;

&lt;p&gt;Lightsail passed the part of the experiment that mattered most to my daily development workflow.&lt;/p&gt;

&lt;p&gt;It gave me a small remote staging server with controlled access, a stable endpoint, fast redeployment, observable health, automatic process recovery, and reboot recovery. Terraform kept the AWS resources understandable, while Docker Compose kept application deployment separate.&lt;/p&gt;

&lt;p&gt;I would use this pattern for a personal project, a short-lived demo, or a small integration environment where one VM is an acceptable availability tradeoff.&lt;/p&gt;

&lt;p&gt;The next validation is narrower: run the final portable launch script through a completely unattended replacement and retain that result. Until then, this is a tested staging server pattern, not a production platform and not a claim of complete disaster recovery.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>terraform</category>
      <category>devops</category>
      <category>lightsail</category>
    </item>
    <item>
      <title>I Wanted Kubernetes Without an Always On EKS Bill</title>
      <dc:creator>Lalit Bagga</dc:creator>
      <pubDate>Tue, 11 Aug 2026 23:52:08 +0000</pubDate>
      <link>https://dev.to/lbagga/i-wanted-kubernetes-without-an-always-on-eks-bill-39j4</link>
      <guid>https://dev.to/lbagga/i-wanted-kubernetes-without-an-always-on-eks-bill-39j4</guid>
      <description>&lt;p&gt;I wanted a Kubernetes lab that would still be there the next morning.&lt;/p&gt;

&lt;p&gt;A temporary cluster on my laptop would be useful for testing manifests, but it would disappear when I closed the lid. Amazon EKS would give me the managed AWS experience I eventually want to test, but keeping it online just so I could practise Deployments and Services did not make sense for me. The EKS control plane alone is currently priced at $0.10 per cluster-hour under standard support, before worker nodes, storage, and networking.&lt;/p&gt;

&lt;p&gt;I already had another option: an Intel N100 home server with 16 GiB of memory running Debian and OpenMediaVault.&lt;/p&gt;

&lt;p&gt;It was already always on. It was also already busy.&lt;/p&gt;

&lt;p&gt;At the time of my retained snapshot, the server was running 28 Docker containers. Kubernetes had to fit beside those workloads, not take over the machine. That constraint shaped the entire decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lab I Actually Needed
&lt;/h2&gt;

&lt;p&gt;My goal was not to reproduce a production cluster at home. Four CPU cores cannot provide a convincing multi-node failure lab while also protecting the services already using the server.&lt;/p&gt;

&lt;p&gt;I needed something narrower:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Existing Debian/OpenMediaVault server
        ├── Existing Docker workloads
        └── Single-node Kubernetes lab
                ├── Always available
                ├── Privately administered
                └── Small enough to share the host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The home server would be my persistent learning environment. EKS could come later as a short-lived environment for AWS-specific behavior such as IAM, cloud load balancers, and managed control-plane operations.&lt;/p&gt;

&lt;p&gt;That made the selection criteria practical:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;It had to run directly on Debian.&lt;/li&gt;
&lt;li&gt;It had to support a useful single-node cluster.&lt;/li&gt;
&lt;li&gt;I needed control over the resources left for Kubernetes.&lt;/li&gt;
&lt;li&gt;Bundled components could not claim ports already used by the server.&lt;/li&gt;
&lt;li&gt;The setup had to remain close enough to Kubernetes that the manifests could move to EKS later.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why k3s Fit the Server
&lt;/h2&gt;

&lt;p&gt;I considered MicroK8s, k0s, and EKS, but k3s matched this host and the way I wanted to operate the lab.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://canonical.com/microk8s/docs/getting-started" rel="noopener noreferrer"&gt;MicroK8s&lt;/a&gt; has a convenient add-on workflow and is a natural choice when Ubuntu and snap are already part of the environment. &lt;a href="https://docs.k0sproject.io/stable/system-requirements/" rel="noopener noreferrer"&gt;k0s&lt;/a&gt; is also lightweight and packages Kubernetes as a single binary with a configurable architecture.&lt;/p&gt;

&lt;p&gt;Both were reasonable options. My server was already Debian-based, and I wanted a small native service with straightforward configuration and a large body of homelab examples. k3s gave me that operating model without asking me to rebuild the host around a different workflow.&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://docs.k3s.io/installation/requirements" rel="noopener noreferrer"&gt;official k3s requirements&lt;/a&gt; list two CPU cores and 2 GB of memory for a server node. A single-server cluster can use embedded SQLite by default, while &lt;a href="https://docs.k3s.io/datastore" rel="noopener noreferrer"&gt;other datastore options&lt;/a&gt; remain available if the topology grows later.&lt;/p&gt;

&lt;p&gt;On this server, the installed k3s binary measured about 78.2 MiB. More important than the number was what I could configure around it: resource reservations, eviction thresholds, and the bundled components I wanted to disable.&lt;/p&gt;

&lt;p&gt;That was the real reason I selected k3s. It was not simply “lightweight Kubernetes.” It gave me a Kubernetes control plane I could shape around a server that already had a job.&lt;/p&gt;

&lt;h2&gt;
  
  
  Protecting a Server That Was Already Busy
&lt;/h2&gt;

&lt;p&gt;Running the k3s installer was the easy part. The real risk was forgetting that this server already had a job.&lt;/p&gt;

&lt;p&gt;Before installing anything, I wrote a host-audit script. It records CPU, memory, swap, disk space, listening ports, systemd services, Docker containers, and recent kernel warnings. This gave me a clear picture of the server before Kubernetes touched it.&lt;/p&gt;

&lt;p&gt;The raw audit stays outside Git because it may contain hostnames, IP addresses, mount paths, and internal service names. Only reviewed and sanitized results belong in the repository.&lt;/p&gt;

&lt;p&gt;I also made the installation script stop when it finds something unexpected. It refuses to continue when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it is not running with the required privileges;&lt;/li&gt;
&lt;li&gt;k3s is already running;&lt;/li&gt;
&lt;li&gt;an existing k3s installation or configuration is present; or&lt;/li&gt;
&lt;li&gt;the Kubernetes API or kubelet ports are already occupied.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The k3s configuration was deliberately conservative:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;write-kubeconfig-mode&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0640"&lt;/span&gt;
&lt;span class="na"&gt;write-kubeconfig-group&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;users"&lt;/span&gt;
&lt;span class="na"&gt;disable&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;traefik&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;servicelb&lt;/span&gt;
&lt;span class="na"&gt;node-label&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;environment=homelab"&lt;/span&gt;
&lt;span class="na"&gt;kubelet-arg&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system-reserved=cpu=1,memory=6Gi"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kube-reserved=cpu=250m,memory=512Mi"&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eviction-hard=memory.available&amp;lt;1Gi,nodefs.available&amp;lt;10%"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ports 80 and 443 were already being used by services on the server. Because the bundled Traefik ingress controller and ServiceLB also expect to use those ports, I disabled both components.&lt;/p&gt;

&lt;p&gt;The resource settings were equally important. Kubernetes was told to leave one CPU core and 6 GiB of memory available for the host. I also allowed another 250 millicores and 512 MiB for Kubernetes itself.&lt;/p&gt;

&lt;p&gt;These settings are not a hard wall around the existing Docker containers. They simply reduce the resources Kubernetes can assign to Pods and give the kubelet thresholds for reacting to memory or disk pressure.&lt;/p&gt;

&lt;p&gt;That leaves less capacity for the lab, but that is intentional. On a shared four-core server, protecting the existing workload matters more than giving Kubernetes every available resource.&lt;/p&gt;

&lt;h2&gt;
  
  
  Installing and Checking the Cluster
&lt;/h2&gt;

&lt;p&gt;Once the checks passed and the configuration was written, the script used the official k3s installer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--proto&lt;/span&gt; &lt;span class="s1"&gt;'=https'&lt;/span&gt; &lt;span class="nt"&gt;--tlsv1&lt;/span&gt;.2 &lt;span class="nt"&gt;-sfL&lt;/span&gt; https://get.k3s.io | sh -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It then waited for the node to report that it was ready:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;k3s kubectl &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--for&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;condition&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Ready &lt;span class="se"&gt;\&lt;/span&gt;
  node &lt;span class="nt"&gt;--all&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;180s
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I kept installation and verification separate. A successful installer only proves that the installation command completed. It does not prove that the cluster is healthy or that the rest of the server survived the change.&lt;/p&gt;

&lt;p&gt;The verification script checks:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the k3s systemd service;&lt;/li&gt;
&lt;li&gt;the installed k3s version;&lt;/li&gt;
&lt;li&gt;the Kubernetes node and Pods;&lt;/li&gt;
&lt;li&gt;the resources available to workloads; and&lt;/li&gt;
&lt;li&gt;whether Traefik or ServiceLB appeared unexpectedly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The installed server was running k3s v1.36.3+k3s1. At the time of the retained snapshot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Kubernetes could allocate 2750 millicores of CPU to Pods;&lt;/li&gt;
&lt;li&gt;about 7.9 GiB of memory was allocatable to Pods;&lt;/li&gt;
&lt;li&gt;all six observed Kubernetes Pods were running;&lt;/li&gt;
&lt;li&gt;all 28 existing Docker containers were still running;&lt;/li&gt;
&lt;li&gt;none of the Docker containers reported being unhealthy; and&lt;/li&gt;
&lt;li&gt;no systemd unit had failed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The k3s service was using approximately 726 MiB of memory, with a recorded peak of 1.19 GiB. The host still had about 9.7 GiB of available memory and 296 GiB of available disk space.&lt;/p&gt;

&lt;p&gt;One warning remained: 834 MiB of the server's 976 MiB swap was in use. That does not automatically mean the server was under active memory pressure, but it was something I still needed to investigate.&lt;/p&gt;

&lt;p&gt;These measurements are a snapshot, not a performance benchmark. They do not prove that the server will remain healthy under every workload.&lt;/p&gt;

&lt;p&gt;They answer a smaller and more useful question: after adding k3s, was the cluster running without an obvious failure in the services already sharing the machine?&lt;/p&gt;

&lt;p&gt;At that moment, yes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then I Made Kubernetes Do Real Work
&lt;/h2&gt;

&lt;p&gt;A ready node proves that Kubernetes started. It does not prove that I can operate an application through it.&lt;/p&gt;

&lt;p&gt;I deployed a small Nginx workload with three replicas, readiness and liveness probes, and CPU and memory constraints. A Service gave the replaceable Pods one stable internal address, and NodePort exposed the same application over my private network.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Private client
      ↓
Home server:30081
      ↓
Kubernetes Service
      ↓
EndpointSlice
      ↓
Three Nginx Pods
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I verified the Service from inside the cluster:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;klab &lt;span class="nt"&gt;-n&lt;/span&gt; lab &lt;span class="nb"&gt;exec &lt;/span&gt;deployment/hello-nginx &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  wget &lt;span class="nt"&gt;-qO-&lt;/span&gt; http://hello-nginx:80
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The request returned the expected page and identified the Pod that served it. The same application was reachable through the private NodePort path.&lt;/p&gt;

&lt;p&gt;I then changed the page and watched the Deployment replace the Pods through a rolling update. Kubernetes created a new ReplicaSet, scaled it up, and scaled the previous one down while the Service remained stable.&lt;/p&gt;

&lt;p&gt;Finally, I rolled the Deployment back and reconciled the YAML with the running version. A clean &lt;code&gt;kubectl diff&lt;/code&gt; confirmed that the source and live cluster agreed again.&lt;/p&gt;

&lt;p&gt;That sequence mattered more to me than a successful installer message. The server was now a persistent place to practise the controller relationships I wanted to understand: Deployment to ReplicaSet, Service to EndpointSlice, rollout to rollback, and desired state to live state.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why EKS Still Belongs in the Plan
&lt;/h2&gt;

&lt;p&gt;Choosing k3s did not remove EKS from the journey. It gave EKS a more focused role.&lt;/p&gt;

&lt;p&gt;According to the &lt;a href="https://aws.amazon.com/eks/pricing/" rel="noopener noreferrer"&gt;current EKS pricing page&lt;/a&gt;, a standard-support cluster costs $0.10 per hour before the compute and supporting AWS resources are added. That is roughly $73 for a 730-hour month for the control plane alone.&lt;/p&gt;

&lt;p&gt;I do not need to pay that continuously while learning what a ReplicaSet does or why a Service follows labels. The home k3s cluster can hold that state and remain available every day.&lt;/p&gt;

&lt;p&gt;When I need to verify AWS-specific behavior, I can create EKS deliberately, capture the evidence, and remove it. The local cluster handles repetition; the cloud cluster handles cloud-specific proof.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Comes Next
&lt;/h2&gt;

&lt;p&gt;The first application lab exposed the next platform problem.&lt;/p&gt;

&lt;p&gt;I rolled Kubernetes back to Version 2, but my YAML still declared Version 3. The cluster and source disagreed, and Kubernetes alone had no reason to repair that relationship.&lt;/p&gt;

&lt;p&gt;The next milestone is GitOps with Argo CD: make Git the declared source of truth, reproduce the drift deliberately, and observe reconciliation instead of correcting it by hand.&lt;/p&gt;

&lt;p&gt;That is where this stops being only a Kubernetes lab and starts becoming a delivery platform.&lt;/p&gt;

</description>
      <category>kubernetes</category>
      <category>k3s</category>
      <category>devops</category>
      <category>homelab</category>
    </item>
    <item>
      <title>My Terraform Drift Pipeline Fixed the Change, Then Forgot It</title>
      <dc:creator>Lalit Bagga</dc:creator>
      <pubDate>Fri, 07 Aug 2026 00:51:46 +0000</pubDate>
      <link>https://dev.to/lbagga/my-terraform-drift-pipeline-fixed-the-change-then-forgot-it-gpg</link>
      <guid>https://dev.to/lbagga/my-terraform-drift-pipeline-fixed-the-change-then-forgot-it-gpg</guid>
      <description>&lt;p&gt;My Terraform drift pipeline could detect a manual EC2 tag change, classify it as LOW, and run Terraform to remove it.&lt;/p&gt;

&lt;p&gt;Then the pipeline moved on.&lt;/p&gt;

&lt;p&gt;The evidence existed, but it was spread across CodeBuild output, Lambda logs, and an SNS message. If I wanted to know what changed, how it was classified, and whether remediation started, I had to reconstruct the event from multiple AWS services.&lt;/p&gt;

&lt;p&gt;The pipeline could act on drift. It could not remember drift.&lt;/p&gt;

&lt;p&gt;Phase 4 added that memory: a durable DynamoDB record, a read only API, and a small dashboard that turns the event history into something I can inspect without opening three AWS consoles.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Terraform drift event
        ↓
SNS
        ↓
Severity Lambda
        ├── classifies HIGH / MEDIUM / LOW
        ├── starts remediation for eligible LOW drift
        └── writes the audit event to DynamoDB
                            ↓
                    API Gateway HTTP API
                            ↓
                    Read only Lambda
                            ↓
                    DynamoDB Query
                            ↓
             CloudFront → static dashboard
                            ↑
                      private S3 bucket
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The browser receives static HTML, CSS, and JavaScript from CloudFront. JavaScript calls API Gateway, the API Lambda queries DynamoDB, and the returned JSON becomes the live dashboard.&lt;/p&gt;

&lt;p&gt;There is no EC2 web server and no application process running continuously.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Store Every Classified Event
&lt;/h2&gt;

&lt;p&gt;I created a DynamoDB table with a composite key:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_dynamodb_table"&lt;/span&gt; &lt;span class="s2"&gt;"drift_events"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-drift-events"&lt;/span&gt;
  &lt;span class="nx"&gt;billing_mode&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PAY_PER_REQUEST"&lt;/span&gt;
  &lt;span class="nx"&gt;hash_key&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"project"&lt;/span&gt;
  &lt;span class="nx"&gt;range_key&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"timestamp"&lt;/span&gt;

  &lt;span class="nx"&gt;attribute&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"project"&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"S"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;attribute&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"timestamp"&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"S"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;project&lt;/code&gt; groups the history for one Terraform project. The ISO 8601 &lt;code&gt;timestamp&lt;/code&gt; orders its events.&lt;/p&gt;

&lt;p&gt;DynamoDB only requires attribute definitions for keys and indexes. Fields such as &lt;code&gt;high_count&lt;/code&gt;, &lt;code&gt;changes&lt;/code&gt;, and &lt;code&gt;status&lt;/code&gt; still belong in each item, but they do not belong in the table schema block.&lt;/p&gt;

&lt;p&gt;I passed the table name into the existing severity Lambda instead of putting it directly in the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;environment&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;variables&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;DRIFT_EVENTS_TABLE&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_dynamodb_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;drift_events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
    &lt;span class="nx"&gt;REMEDIATION_PROJECT_NAME&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_codebuild_project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remediation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same Lambda role received permission to write to that specific table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;Effect&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
  &lt;span class="nx"&gt;Action&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;"dynamodb:PutItem"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"dynamodb:UpdateItem"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"dynamodb:GetItem"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"dynamodb:Query"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;"dynamodb:Scan"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_dynamodb_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;drift_events&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After classification, Lambda builds one audit item:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;drift_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classified&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HIGH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classified&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MEDIUM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classified&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;changes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;classified&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action_taken&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;action_taken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;remediation_build_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;remediation_build_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;remediation_build_id&lt;/span&gt;

&lt;span class="n"&gt;drift_table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put_item&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Item&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The record stores the decision alongside the evidence used to make it. I do not need to correlate an SNS payload with a separate classification log just to understand one event.&lt;/p&gt;

&lt;p&gt;For a change that needs human review, the item records &lt;code&gt;manual_review&lt;/code&gt;. When Lambda starts the remediation build, it records &lt;code&gt;remediation_triggered&lt;/code&gt; and includes the CodeBuild build ID.&lt;/p&gt;

&lt;p&gt;That turns the audit item into the connection between detection, classification, and the action taken by the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Add a Read Only History API
&lt;/h2&gt;

&lt;p&gt;The dashboard should not receive DynamoDB credentials. It calls an HTTP API instead.&lt;/p&gt;

&lt;p&gt;I created a second Lambda whose only data permission is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;Action&lt;/span&gt;   &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"dynamodb:Query"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_dynamodb_table&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;drift_events&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The route requires a project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /drift-history?project=Three-Tier-Infra
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Lambda queries the partition key and returns newest events first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;table&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;KeyConditionExpression&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#project = :project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ExpressionAttributeNames&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;#project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;ExpressionAttributeValues&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;:project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
    &lt;span class="n"&gt;ScanIndexForward&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is why the table uses &lt;code&gt;project&lt;/code&gt; as its partition key. The API does not need to scan the complete table to retrieve one project's history.&lt;/p&gt;

&lt;p&gt;DynamoDB numbers arrive in Python as &lt;code&gt;Decimal&lt;/code&gt;, which &lt;code&gt;json.dumps&lt;/code&gt; cannot serialize directly. I added a small serializer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;json_serializer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Decimal&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;TypeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Cannot serialize &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;type&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;API Gateway uses an HTTP API with a Lambda proxy integration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_apigatewayv2_route"&lt;/span&gt; &lt;span class="s2"&gt;"get_drift_events"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;api_id&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_apigatewayv2_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;drift_api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;route_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"GET /drift-history"&lt;/span&gt;
  &lt;span class="nx"&gt;target&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"integrations/${aws_apigatewayv2_integration.drift_api.id}"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Calling the deployed route returned &lt;code&gt;HTTP 200&lt;/code&gt; and the stored event:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://8zg1x51wne.execute-api.us-east-2.amazonaws.com/drift-history?project=Three-Tier-Infra"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The response contained:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"events"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"project"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Three-Tier-Infra"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"timestamp"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-26T15:29:32Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"drift_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"high_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"medium_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"low_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"action_taken"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"remediation_triggered"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"REMEDIATION_STARTED"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The changed resource was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;module.compute.aws_instance.bastion_host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Terraform reported it as an &lt;code&gt;aws_instance&lt;/code&gt; update.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Build a Dashboard Without a Web Server
&lt;/h2&gt;

&lt;p&gt;The dashboard is static in how it is hosted, not in the data it displays.&lt;/p&gt;

&lt;p&gt;S3 stores three files:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;dashboard/
├── index.html
├── app.js
└── styles.css
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CloudFront serves those files over HTTPS. &lt;code&gt;app.js&lt;/code&gt; calls the API whenever the page loads or the user refreshes the data.&lt;/p&gt;

&lt;p&gt;The dashboard shows total events, severity counts, detection timestamps, Terraform resource addresses, actions, and remediation status. A severity filter narrows the history, and the page refreshes automatically every 60 seconds.&lt;/p&gt;

&lt;p&gt;The S3 bucket remains private. CloudFront uses Origin Access Control to read objects:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudfront_origin_access_control"&lt;/span&gt; &lt;span class="s2"&gt;"drift_dashboard"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-drift-dashboard"&lt;/span&gt;
  &lt;span class="nx"&gt;origin_access_control_origin_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3"&lt;/span&gt;
  &lt;span class="nx"&gt;signing_behavior&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"always"&lt;/span&gt;
  &lt;span class="nx"&gt;signing_protocol&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sigv4"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The bucket policy grants &lt;code&gt;s3:GetObject&lt;/code&gt; to the CloudFront service only when the request comes from this distribution.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0q6ahyl4hrlxpbfk90b4.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F0q6ahyl4hrlxpbfk90b4.png" alt=" " width="800" height="695"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Keep the Public Surface Read Only
&lt;/h2&gt;

&lt;p&gt;I deliberately did not add a &lt;strong&gt;Fix drift&lt;/strong&gt; button.&lt;/p&gt;

&lt;p&gt;The first dashboard is for visibility. It cannot start CodeBuild, run Terraform, or change DynamoDB records.&lt;/p&gt;

&lt;p&gt;The API exposes one GET route. I added API Gateway throttling:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;default_route_settings&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;throttling_burst_limit&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;
  &lt;span class="nx"&gt;throttling_rate_limit&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also restricted browser CORS responses to the deployed CloudFront origin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;cors_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;allow_headers&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"content-type"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;allow_methods&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"GET"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;allow_origins&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;"https://${aws_cloudfront_distribution.drift_dashboard.domain_name}"&lt;/span&gt;
  &lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;max_age&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CORS is not authentication. Someone who knows the API URL can still call it outside a browser.&lt;/p&gt;

&lt;p&gt;That is an accepted limitation for this public dashboard. The API returns resource addresses, classifications, and actions. It does not return raw Terraform state, credentials, plan values, or CloudWatch logs.&lt;/p&gt;

&lt;p&gt;An operator remediation API would require authentication, authorization, review, concurrency controls, and an audit record of who approved the action. It does not belong in this first dashboard.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;I created real drift by changing the tag on:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;module.compute.aws_instance.bastion_host
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detection pipeline reported one LOW update. Lambda started remediation and wrote this event to DynamoDB:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;project:              Three-Tier-Infra
drift_count:          1
high_count:           0
medium_count:         0
low_count:            1
action_taken:         remediation_triggered
status:               REMEDIATION_STARTED
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The record also contained the remediation CodeBuild ID and the classified resource details.&lt;/p&gt;

&lt;p&gt;After remediation, I returned to the EC2 instance and confirmed the tag was restored to the value declared in Terraform.&lt;/p&gt;

&lt;p&gt;I then tested the deployed layers independently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CloudFront index.html: HTTP 200
CloudFront app.js:     HTTP 200
API history query:     HTTP 200
CORS origin:           CloudFront dashboard URL
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The API response to the dashboard origin included:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;access-control-allow-origin: https://dqjunwd8v0pry.cloudfront.net
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The complete verified path was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;EC2 tag changed
→ Terraform detected one update
→ Lambda classified it LOW
→ Lambda wrote the event to DynamoDB
→ remediation restored the tag
→ API returned the audit event
→ dashboard displayed the history
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DynamoDB status remains &lt;code&gt;REMEDIATION_STARTED&lt;/code&gt;. That proves CodeBuild accepted the build request, not that the build ultimately succeeded. I verified success separately by observing the restored EC2 tag.&lt;/p&gt;

&lt;p&gt;Updating the audit item from &lt;code&gt;REMEDIATION_STARTED&lt;/code&gt; to &lt;code&gt;REMEDIATION_SUCCEEDED&lt;/code&gt; is separate completion tracking work.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Comes Next
&lt;/h2&gt;

&lt;p&gt;The system can now detect drift, classify it, remediate the tested LOW tag scenario, remember the event, expose it through an API, and display it in a dashboard.&lt;/p&gt;

&lt;p&gt;The next step is making the audit lifecycle more complete. The current item proves that CodeBuild accepted the remediation request, but it does not record the final build result.&lt;/p&gt;

&lt;p&gt;Tracking that result would let the same event move from &lt;code&gt;REMEDIATION_STARTED&lt;/code&gt; to &lt;code&gt;REMEDIATION_SUCCEEDED&lt;/code&gt; or &lt;code&gt;REMEDIATION_FAILED&lt;/code&gt;.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>aws</category>
      <category>devops</category>
      <category>dynamodb</category>
    </item>
    <item>
      <title>I Added a Tag in AWS. Terraform Removed It Automatically.</title>
      <dc:creator>Lalit Bagga</dc:creator>
      <pubDate>Tue, 04 Aug 2026 22:30:26 +0000</pubDate>
      <link>https://dev.to/lbagga/i-added-a-tag-in-aws-terraform-removed-it-automatically-1467</link>
      <guid>https://dev.to/lbagga/i-added-a-tag-in-aws-terraform-removed-it-automatically-1467</guid>
      <description>&lt;p&gt;I opened the AWS console, selected an EC2 instance, added a tag, and saved it.&lt;/p&gt;

&lt;p&gt;The tag did not exist in Terraform. The deployed infrastructure now said one thing while the code said another.&lt;/p&gt;

&lt;p&gt;That was the test.&lt;/p&gt;

&lt;p&gt;My drift detector already knew how to find the difference and classify the EC2 update as LOW. The missing part was closing the loop: once the system understood that the change was low severity, it needed to restore the infrastructure without waiting for me to run Terraform manually.&lt;/p&gt;

&lt;p&gt;So I added a separate remediation pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Manual EC2 tag added in AWS
        ↓
Drift CodeBuild runs terraform plan
        ↓
SNS publishes the structured change
        ↓
Lambda classifies the update as LOW
        ↓
Remediation CodeBuild runs terraform apply
        ↓
Terraform removes the unmanaged tag
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Detection and remediation use different CodeBuild projects. The detector reads the infrastructure and reports differences. The remediation project has a separate IAM role because it can change AWS resources.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Make EC2 Updates Eligible for Remediation
&lt;/h2&gt;

&lt;p&gt;The classifier already grouped resource types into HIGH, MEDIUM, and LOW categories.&lt;/p&gt;

&lt;p&gt;An EC2 instance belongs to &lt;code&gt;MEDIUM_RISK_TYPES&lt;/code&gt;. Initially, that meant even a tag update was classified as MEDIUM and stopped before remediation.&lt;/p&gt;

&lt;p&gt;The resource type alone was not enough. The action mattered too.&lt;/p&gt;

&lt;p&gt;I updated the classifier so an in-place update on a MEDIUM resource becomes LOW, while creates remain MEDIUM and delete or replacement actions become HIGH:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resource_type&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;HIGH_RISK_TYPES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HIGH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;resource_type&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;MEDIUM_RISK_TYPES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HIGH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;update&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MEDIUM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For this test, Terraform reported the manually added EC2 tag as an update. That moved it into the LOW path.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Keep Deletions Out of the Automatic Path
&lt;/h2&gt;

&lt;p&gt;LOW did not automatically mean “start Terraform.” I also filtered out deletions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;low_changes_to_remediate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;classified&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;actions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This distinction matters. Updating an existing resource to match the code is different from recreating something that a person deliberately removed. Deletions stay in the manual-review path.&lt;/p&gt;

&lt;p&gt;If an eligible LOW change remains, Lambda starts the remediation CodeBuild project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;low_changes_to_remediate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;codebuild&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;start_build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;projectName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;REMEDIATION_PROJECT_NAME&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;terraform-drift-remediation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Lambda role only needs permission to start that specific project:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;Effect&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
  &lt;span class="nx"&gt;Action&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"codebuild:StartBuild"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;Resource&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_codebuild_project&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;remediation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lambda makes the routing decision. CodeBuild performs the repair.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Run Remediation Separately
&lt;/h2&gt;

&lt;p&gt;I did not add &lt;code&gt;terraform apply&lt;/code&gt; to the existing drift detector. Detection should remain read-only even when remediation fails or is disabled.&lt;/p&gt;

&lt;p&gt;The second build starts in a clean environment, installs Terraform, clones the Three-Tier repository, and applies the declared configuration:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.2&lt;/span&gt;

&lt;span class="na"&gt;phases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;install&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;curl -o terraform.zip https://releases.hashicorp.com/terraform/1.10.0/terraform_1.10.0_linux_amd64.zip&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;unzip terraform.zip -d /usr/local/bin&lt;/span&gt;
  &lt;span class="na"&gt;pre_build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;git clone https://github.com/lalitbagga/Three-Tier-Infra.git /tmp/Three-Tier-Infra&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cd /tmp/Three-Tier-Infra&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;terraform init&lt;/span&gt;
  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cd /tmp/Three-Tier-Infra&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;terraform apply -auto-approve -lock=false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The remediation project uses its own CodeBuild role with write access to the AWS services managed by the Three-Tier project. During implementation, Terraform exposed several missing permissions as it evaluated and changed resources, so I expanded the experimental role to cover the project.&lt;/p&gt;

&lt;p&gt;That role is intentionally separate from the detector. A job that only runs &lt;code&gt;terraform plan&lt;/code&gt; should not inherit the permissions required for &lt;code&gt;terraform apply&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Repository Was Part of the Control Loop
&lt;/h2&gt;

&lt;p&gt;One remediation run tried to include unrelated resource actions because CodeBuild was cloning a version of the Three-Tier repository that did not match the configuration I intended to operate.&lt;/p&gt;

&lt;p&gt;The problem was not the classifier. Lambda correctly identified the triggering drift. The remediation build was applying the complete repository it cloned from GitHub.&lt;/p&gt;

&lt;p&gt;I pushed the current Three-Tier configuration before running the test again.&lt;/p&gt;

&lt;p&gt;This is easy to miss in CI. Terraform does not know what exists only on a developer's laptop. If CodeBuild clones GitHub, the committed branch is the configuration Terraform will enforce.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Tag Disappeared
&lt;/h2&gt;

&lt;p&gt;With the current repository in GitHub, I repeated the test:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Opened the EC2 instance in the AWS console.&lt;/li&gt;
&lt;li&gt;Added a tag manually and saved it.&lt;/li&gt;
&lt;li&gt;Started the Terraform drift detector.&lt;/li&gt;
&lt;li&gt;The plan reported an update to the EC2 instance.&lt;/li&gt;
&lt;li&gt;Lambda classified the update as LOW.&lt;/li&gt;
&lt;li&gt;Lambda started the remediation CodeBuild project.&lt;/li&gt;
&lt;li&gt;CodeBuild ran Terraform apply.&lt;/li&gt;
&lt;li&gt;I returned to the EC2 Tags view and confirmed that the manually added tag was gone.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Terraform restored the infrastructure to what was declared in Git.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Git configuration: tag absent
AWS resource:       tag added manually
Terraform result:   tag removed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system was no longer only telling me about drift. For the LOW update I tested, it detected the change, classified it, and repaired it.&lt;/p&gt;

&lt;p&gt;MEDIUM, HIGH, and deletion paths still stop for human review. That boundary is intentional. Automatic remediation is useful only when the system can distinguish a small correction from a change that deserves a person.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Comes Next
&lt;/h2&gt;

&lt;p&gt;The infrastructure is back in the expected state, but the evidence is spread across CodeBuild and CloudWatch logs.&lt;/p&gt;

&lt;p&gt;The next part of the system will keep a remediation history in a database, expose it through an API, and visualize it in Grafana. I want to see what changed, how it was classified, whether remediation ran, and whether it succeeded without reconstructing the story from separate AWS services.&lt;/p&gt;

&lt;p&gt;Detection found the tag. Classification decided it was LOW. Remediation removed it.&lt;/p&gt;

&lt;p&gt;Now the system needs to remember that it happened.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>aws</category>
      <category>devops</category>
      <category>codebuild</category>
    </item>
    <item>
      <title>My Terraform Drift Alert Could Not Explain the Drift</title>
      <dc:creator>Lalit Bagga</dc:creator>
      <pubDate>Wed, 22 Jul 2026 02:40:34 +0000</pubDate>
      <link>https://dev.to/lbagga/my-terraform-drift-alert-could-not-explain-the-drift-597</link>
      <guid>https://dev.to/lbagga/my-terraform-drift-alert-could-not-explain-the-drift-597</guid>
      <description>&lt;p&gt;My Terraform drift detector had a problem.&lt;/p&gt;

&lt;p&gt;It could run &lt;code&gt;terraform plan&lt;/code&gt;, detect drift, and send me an email through SNS. But the message only told me that drift existed.&lt;/p&gt;

&lt;p&gt;It did not tell me which resources changed. It did not tell me whether the change affected IAM, networking, or something less critical.&lt;/p&gt;

&lt;p&gt;So I changed the alert into a structured event and sent it to two places: SQS for audit processing and Lambda for severity classification.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CodeBuild                  → runs terraform plan
        ↓
terraform show -json       → extracts the changed resources
        ↓
SNS                        → publishes one structured event
        ↓
        ├── SQS            → keeps the event for audit processing
        └── Lambda         → classifies changes as HIGH, MEDIUM, or LOW
                 ↓
            CloudWatch     → stores the classification logs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 1: Publish Structured Drift Details
&lt;/h2&gt;

&lt;p&gt;The build already saved the Terraform plan:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform plan &lt;span class="nt"&gt;-detailed-exitcode&lt;/span&gt; &lt;span class="nt"&gt;-out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;plan.tfplan &lt;span class="nt"&gt;-lock&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;EXIT_CODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exit code &lt;code&gt;2&lt;/code&gt; means Terraform found changes. When that happens, I convert the saved plan to JSON:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform show &lt;span class="nt"&gt;-json&lt;/span&gt; plan.tfplan &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/plan.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Terraform plan JSON is large. SNS and Lambda do not need all of it, so I used &lt;code&gt;jq&lt;/code&gt; to keep only the useful fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;DRIFT_CHANGES&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;jq &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'[
  .resource_changes[] |
  select(.change.actions != ["no-op"]) |
  {
    address: .address,
    type: .type,
    actions: .change.actions
  }
]'&lt;/span&gt; /tmp/plan.json&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each change now contains:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"module.compute.aws_iam_role.ec2_role"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"aws_iam_role"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"actions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"create"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part here is what I left out. I did not publish the complete before-and-after resource values. Resource address, type, and action are enough for classification without sending the entire Terraform plan through SNS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Send the Event to SQS
&lt;/h2&gt;

&lt;p&gt;I created an SQS queue called &lt;code&gt;terraform-drift-audit&lt;/code&gt; and subscribed it to the SNS topic.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_sqs_queue"&lt;/span&gt; &lt;span class="s2"&gt;"drift_audit"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-drift-audit"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_sns_topic_subscription"&lt;/span&gt; &lt;span class="s2"&gt;"sqs"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;topic_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sns_topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;drift&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sqs"&lt;/span&gt;
  &lt;span class="nx"&gt;endpoint&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sqs_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;drift_audit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also added the queue URL as a Terraform output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"sqs_queue_url"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"URL of the drift audit SQS queue"&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sqs_queue&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;drift_audit&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After &lt;code&gt;terraform apply&lt;/code&gt;, I can retrieve the URL without opening the AWS console:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform output &lt;span class="nt"&gt;-raw&lt;/span&gt; sqs_queue_url
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That URL is what I used with the AWS CLI to check whether SNS delivered the message.&lt;/p&gt;

&lt;p&gt;SQS also needs permission to receive messages from SNS. I restricted the queue policy to the drift topic instead of allowing every SNS topic:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;Condition&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;ArnEquals&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"aws:SourceArn"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sns_topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;drift&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;SQS is not the final audit database. It keeps the event available for a future consumer. Permanent drift history belongs to a later phase.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Queue Existed in the Wrong Region
&lt;/h2&gt;

&lt;p&gt;I copied the queue URL into this command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws sqs receive-message &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--queue-url&lt;/span&gt; https://sqs.us-east-2.amazonaws.com/&amp;lt;account-id&amp;gt;/terraform-drift-audit &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-number-of-messages&lt;/span&gt; 10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;AWS returned &lt;code&gt;NonExistentQueue&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The queue existed. My AWS CLI was using &lt;code&gt;us-east-1&lt;/code&gt;, while the Terraform project created the queue in &lt;code&gt;us-east-2&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix was adding the region explicitly to the same command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws sqs receive-message &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--queue-url&lt;/span&gt; https://sqs.us-east-2.amazonaws.com/&amp;lt;account-id&amp;gt;/terraform-drift-audit &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-number-of-messages&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The resource name was correct. The CLI was asking the wrong regional endpoint.&lt;/p&gt;

&lt;p&gt;After fixing the region, I could see the structured drift message in SQS.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Classify Every Change with Lambda
&lt;/h2&gt;

&lt;p&gt;The Lambda function receives the same SNS event that went to SQS.&lt;/p&gt;

&lt;p&gt;SNS does not send my drift JSON as the top-level Lambda event. It wraps the message inside &lt;code&gt;Records[].Sns.Message&lt;/code&gt;. The function must first extract that string and parse it back into JSON.&lt;/p&gt;

&lt;p&gt;This is the complete classifier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;

&lt;span class="c1"&gt;# Severity definitions
&lt;/span&gt;&lt;span class="n"&gt;HIGH_RISK_TYPES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_security_group&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_security_group_rule&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_iam_role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_iam_policy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_iam_role_policy&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_iam_user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_iam_group&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_iam_access_key&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;MEDIUM_RISK_TYPES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_instance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_db_instance&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_ecs_cluster&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_ecs_service&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_ecs_task_definition&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_lb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_lb_listener&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_lb_target_group&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_nat_gateway&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_route_table&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;aws_network_acl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Classify a single resource change by severity.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;resource_type&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;HIGH_RISK_TYPES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HIGH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;resource_type&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;MEDIUM_RISK_TYPES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Delete or replace is more concerning than create
&lt;/span&gt;        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;delete&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;replace&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HIGH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MEDIUM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;lambda_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Process SNS message with drift details and classify severity.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Received event: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Records&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
        &lt;span class="c1"&gt;# SNS message is in the SNS record
&lt;/span&gt;        &lt;span class="n"&gt;sns_message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Sns&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{}).&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Message&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;{}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;drift_data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sns_message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSONDecodeError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Failed to parse SNS message: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;sns_message&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;timestamp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;drift_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;project&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;drift_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;changes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;drift_data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;changes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;

        &lt;span class="c1"&gt;# Classify each change
&lt;/span&gt;        &lt;span class="n"&gt;classified&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HIGH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MEDIUM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]}&lt;/span&gt;

        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;address&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;resource_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;actions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;change&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;actions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&lt;/span&gt;

            &lt;span class="n"&gt;severity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;classify_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;classified&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;severity&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;address&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;actions&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;

        &lt;span class="c1"&gt;# Build summary
&lt;/span&gt;        &lt;span class="n"&gt;summary&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;timestamp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;project&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;project&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;total_changes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;changes&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;high_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classified&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;HIGH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;medium_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classified&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MEDIUM&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;low_count&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;classified&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LOW&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;classified&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;classified&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;

        &lt;span class="c1"&gt;# Log the classified drift
&lt;/span&gt;        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Drift classification: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="c1"&gt;# TODO Phase 3: Auto-remediate HIGH severity changes
&lt;/span&gt;        &lt;span class="c1"&gt;# TODO Phase 3: Alert on MEDIUM severity changes
&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;statusCode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;body&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Drift classified&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code does four things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Extracts the SNS message from the Lambda event.&lt;/li&gt;
&lt;li&gt;Parses the structured drift JSON.&lt;/li&gt;
&lt;li&gt;Classifies each resource by type and action.&lt;/li&gt;
&lt;li&gt;Writes the complete summary to CloudWatch Logs.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;IAM and security group resources are HIGH. Resources such as EC2, RDS, ECS, load balancers, NAT gateways, and route tables start as MEDIUM. Everything else starts as LOW.&lt;/p&gt;

&lt;p&gt;A delete or replacement on a MEDIUM resource becomes HIGH. This classifier is deliberately simple. It is a deterministic first version that I can read, test, and improve later.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Lambda Package Needed Another Provider
&lt;/h2&gt;

&lt;p&gt;Terraform packages the Python file using &lt;code&gt;archive_file&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"archive_file"&lt;/span&gt; &lt;span class="s2"&gt;"severity_lambda"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"zip"&lt;/span&gt;
  &lt;span class="nx"&gt;source_file&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${path.module}/lambda/severity_classifier.py"&lt;/span&gt;
  &lt;span class="nx"&gt;output_path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${path.module}/lambda/severity_classifier.zip"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first run failed because the archive provider was missing from &lt;code&gt;.terraform.lock.hcl&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;terraform init &lt;span class="nt"&gt;-upgrade&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That downloaded &lt;code&gt;hashicorp/archive&lt;/code&gt; and updated the lock file.&lt;/p&gt;

&lt;p&gt;The generated ZIP does not need to be committed. Terraform creates it when the configuration runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;I triggered a real drift event and checked both destinations from the terminal.&lt;/p&gt;

&lt;p&gt;First I read up to 10 messages from SQS:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws sqs receive-message &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--queue-url&lt;/span&gt; https://sqs.us-east-2.amazonaws.com/&amp;lt;account-id&amp;gt;/terraform-drift-audit &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--max-number-of-messages&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The structured message appeared in the SQS queue. Lambda received the event and wrote this summary to CloudWatch:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws logs &lt;span class="nb"&gt;tail&lt;/span&gt; /aws/lambda/terraform-drift-severity &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--follow&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--follow&lt;/code&gt; keeps the terminal attached to the log group. When the next drift event invokes Lambda, the classification appears directly in the terminal.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"high_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"medium_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;13&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"low_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;38&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The HIGH results included an IAM role and an ALB security group:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;module.compute.aws_iam_role.ec2_role
module.security.aws_security_group.alb_sg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lambda classified all 59 resource changes reported by that Terraform plan. This confirmed that the structured SNS event reached Lambda and that each change was assigned a severity.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is Next
&lt;/h2&gt;

&lt;p&gt;The system can now tell me what changed and how serious each change might be.&lt;/p&gt;

&lt;p&gt;It still does not remediate anything.&lt;/p&gt;

&lt;p&gt;That is intentional. The next step is deciding what should happen after classification. I will start with LOW-severity updates that are safe to test automatically. MEDIUM, HIGH, and deletion paths will remain outside automatic remediation and will later require human review.&lt;/p&gt;

&lt;p&gt;That is Phase 3.&lt;/p&gt;

</description>
      <category>terraform</category>
      <category>aws</category>
      <category>sns</category>
      <category>lambda</category>
    </item>
    <item>
      <title>The Security Group Change Terraform Never Told Me About</title>
      <dc:creator>Lalit Bagga</dc:creator>
      <pubDate>Wed, 15 Jul 2026 14:06:02 +0000</pubDate>
      <link>https://dev.to/lbagga/the-security-group-change-terraform-never-told-me-about-1b2p</link>
      <guid>https://dev.to/lbagga/the-security-group-change-terraform-never-told-me-about-1b2p</guid>
      <description>&lt;p&gt;Terraform had a blind spot in my three-tier project.&lt;/p&gt;

&lt;p&gt;The code could create the infrastructure. The state lived in S3. But if someone changed a security group in the AWS console, Terraform would not tell me when it happened.&lt;/p&gt;

&lt;p&gt;I would discover it the next time I ran &lt;code&gt;terraform plan&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That could be tomorrow or six months later.&lt;/p&gt;

&lt;p&gt;So I built a drift detector that runs the same plan inside CodeBuild and sends an SNS email when Terraform finds a difference.&lt;/p&gt;

&lt;p&gt;I made CodeBuild the entry point. One run compares the deployed infrastructure with Terraform and reports any difference by email.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CodeBuild run                 → starts the detector
        ↓
CodeBuild                     → installs Terraform and clones the repo
        ↓
terraform plan                → compares code, state, and AWS
        ↓
SNS                           → sends an email when drift is detected
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why I Used CodeBuild Instead of Lambda
&lt;/h2&gt;

&lt;p&gt;My first idea was Lambda.&lt;/p&gt;

&lt;p&gt;A scheduled function could run &lt;code&gt;terraform plan&lt;/code&gt;, then SNS could send the result.&lt;/p&gt;

&lt;p&gt;The problem was packaging Terraform. I could build a Lambda container image and store it in ECR, but that adds a Dockerfile, image builds, image updates, and another repository to maintain.&lt;/p&gt;

&lt;p&gt;CodeBuild already does what this job needs: start a clean Linux environment, run a script, stream logs to CloudWatch, and exit.&lt;/p&gt;

&lt;p&gt;For a Terraform plan job, that was the simpler choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Build the CodeBuild Job
&lt;/h2&gt;

&lt;p&gt;The CodeBuild project does not build application source. Its source type is &lt;code&gt;NO_SOURCE&lt;/code&gt;, and Terraform injects the buildspec directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_codebuild_project"&lt;/span&gt; &lt;span class="s2"&gt;"drift"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-drift"&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Runs terraform plan against Three-Tier-Infra to detect drift"&lt;/span&gt;
  &lt;span class="nx"&gt;build_timeout&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;
  &lt;span class="nx"&gt;service_role&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_iam_role&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;codebuild&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;

  &lt;span class="nx"&gt;artifacts&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"NO_ARTIFACTS"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;environment&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;compute_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"BUILD_GENERAL1_SMALL"&lt;/span&gt;
    &lt;span class="nx"&gt;image&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"aws/codebuild/amazonlinux2-x86_64-standard:5.0"&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"LINUX_CONTAINER"&lt;/span&gt;

    &lt;span class="nx"&gt;environment_variable&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;name&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SNS_TOPIC_ARN"&lt;/span&gt;
      &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sns_topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;drift&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"NO_SOURCE"&lt;/span&gt;
    &lt;span class="nx"&gt;buildspec&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"${path.module}/buildspec.yml"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;logs_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;cloudwatch_logs&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;group_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/codebuild/terraform-drift"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Passing &lt;code&gt;SNS_TOPIC_ARN&lt;/code&gt; as an environment variable keeps the generated ARN out of the buildspec. If Terraform recreates the topic, the next apply updates CodeBuild with the new ARN.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Run Terraform Plan and Capture Drift
&lt;/h2&gt;

&lt;p&gt;This was the buildspec used by the detector:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;version&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;0.2&lt;/span&gt;

&lt;span class="na"&gt;phases&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;install&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;curl -o terraform.zip https://releases.hashicorp.com/terraform/1.10.0/terraform_1.10.0_linux_amd64.zip&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;unzip terraform.zip -d /usr/local/bin&lt;/span&gt;

  &lt;span class="na"&gt;pre_build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;git clone https://github.com/lalitbagga/Three-Tier-Infra.git /tmp/Three-Tier-Infra&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cd /tmp/Three-Tier-Infra&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;terraform init&lt;/span&gt;

  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;commands&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;cd /tmp/Three-Tier-Infra&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;EXIT_CODE=0&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;terraform plan -detailed-exitcode -out=plan.tfplan -lock=false || EXIT_CODE=$?&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
        &lt;span class="s"&gt;if [ "$EXIT_CODE" -eq 2 ]; then&lt;/span&gt;
          &lt;span class="s"&gt;echo "Drift detected. Publishing details to SNS."&lt;/span&gt;
          &lt;span class="s"&gt;aws sns publish \&lt;/span&gt;
            &lt;span class="s"&gt;--topic-arn "$SNS_TOPIC_ARN" \&lt;/span&gt;
            &lt;span class="s"&gt;--subject "Terraform Drift Detected" \&lt;/span&gt;
            &lt;span class="s"&gt;--message "Drift found in Three-Tier-Infra"&lt;/span&gt;
        &lt;span class="s"&gt;elif [ "$EXIT_CODE" -eq 1 ]; then&lt;/span&gt;
          &lt;span class="s"&gt;echo "Error during terraform plan."&lt;/span&gt;
          &lt;span class="s"&gt;exit 1&lt;/span&gt;
        &lt;span class="s"&gt;else&lt;/span&gt;
          &lt;span class="s"&gt;echo "No drift detected."&lt;/span&gt;
        &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important part is &lt;code&gt;-detailed-exitcode&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;terraform plan -detailed-exitcode&lt;/code&gt; returns:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;0 → plan succeeded, no changes
1 → Terraform failed
2 → plan succeeded, changes detected
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The buildspec captures that result before CodeBuild decides whether the job succeeded:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;EXIT_CODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;0
terraform plan &lt;span class="nt"&gt;-detailed-exitcode&lt;/span&gt; &lt;span class="nt"&gt;-out&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;plan.tfplan &lt;span class="nt"&gt;-lock&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;false&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nv"&gt;EXIT_CODE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That gives the pipeline three clear paths: do nothing when the infrastructure matches, stop when Terraform fails, and notify when Terraform finds drift.&lt;/p&gt;

&lt;h2&gt;
  
  
  The SSH Key Only Existed on My Laptop
&lt;/h2&gt;

&lt;p&gt;The Three-Tier project originally created its EC2 key pair from a local file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;public_key&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="err"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"~/.ssh/bastion-key.pub"&lt;/span&gt;&lt;span class="err"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That worked on my machine. CodeBuild starts in an ephemeral container where that file does not exist.&lt;/p&gt;

&lt;p&gt;I moved the public key to SSM Parameter Store and changed the Three-Tier repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="s2"&gt;"aws_ssm_parameter"&lt;/span&gt; &lt;span class="s2"&gt;"bastion_key"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/threeTier/bastionKeyPublic"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_key_pair"&lt;/span&gt; &lt;span class="s2"&gt;"bastion_key"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;key_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"bastion-key"&lt;/span&gt;
  &lt;span class="nx"&gt;public_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_ssm_parameter&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;bastion_key&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The CodeBuild role received &lt;code&gt;ssm:GetParameter&lt;/code&gt; for the &lt;code&gt;/threeTier/*&lt;/code&gt; path.&lt;/p&gt;

&lt;p&gt;This removed the developer-laptop dependency. The same Terraform plan can now run locally or in CodeBuild.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Send the Email Through SNS
&lt;/h2&gt;

&lt;p&gt;The SNS topic and email subscription are managed by Terraform:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_sns_topic"&lt;/span&gt; &lt;span class="s2"&gt;"drift"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"terraform-drift-alerts"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_sns_topic_subscription"&lt;/span&gt; &lt;span class="s2"&gt;"drift"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;topic_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_sns_topic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;drift&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"email"&lt;/span&gt;
  &lt;span class="nx"&gt;endpoint&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alert_email&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CodeBuild also needs &lt;code&gt;sns:Publish&lt;/code&gt; permission scoped to that topic. CloudWatch permissions allow the build to create its log group and write the execution logs.&lt;/p&gt;

&lt;p&gt;The role has four jobs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;S3 read              → read Terraform state
SSM read             → retrieve shared parameters
SNS publish          → send the drift alert
CloudWatch Logs      → record the build
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It does not have S3 write permissions and it cannot apply Terraform changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verification
&lt;/h2&gt;

&lt;p&gt;I started the CodeBuild project directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws codebuild start-build &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--project-name&lt;/span&gt; terraform-drift &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The command returns a build ID. Use it to inspect the result:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;aws codebuild batch-get-builds &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ids&lt;/span&gt; &amp;lt;build-id&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--region&lt;/span&gt; us-east-2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The successful drift path produced three pieces of evidence:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;CodeBuild completed the Terraform plan
        ↓
The build published to terraform-drift-alerts
        ↓
The subscribed email received "Terraform Drift Detected"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The detected change included the SSM refactor made in the Three-Tier repository.&lt;/p&gt;

&lt;p&gt;That confirmed the complete scope of this version: start the detector, compare the infrastructure, identify drift, and deliver the result by email.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Is Next
&lt;/h2&gt;

&lt;p&gt;Running the detector on demand proved the idea, but an email that says "drift happened" is not enough.&lt;/p&gt;

&lt;p&gt;The next phase converts the Terraform plan to JSON, sends the event to SQS, and uses Lambda to classify every change as HIGH, MEDIUM, or LOW.&lt;/p&gt;

&lt;p&gt;EventBridge will be added in an upcoming blog.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>sns</category>
      <category>terraform</category>
      <category>devops</category>
    </item>
    <item>
      <title>Observability for ECS: Prometheus, Grafana, and Crash Testing</title>
      <dc:creator>Lalit Bagga</dc:creator>
      <pubDate>Thu, 09 Jul 2026 14:10:47 +0000</pubDate>
      <link>https://dev.to/lbagga/observability-for-ecs-prometheus-grafana-and-crash-testing-1hdh</link>
      <guid>https://dev.to/lbagga/observability-for-ecs-prometheus-grafana-and-crash-testing-1hdh</guid>
      <description>&lt;p&gt;CloudWatch logs tell you what happened after something goes wrong. They are reactive. What I wanted was something that shows me what is happening right now. CPU, memory, request rates, the shape of the system while it is running. That is observability, and CloudWatch alone is not enough for it.&lt;/p&gt;

&lt;p&gt;So I added Prometheus and Grafana, ran a load test, and then deliberately crashed the app to watch ECS recover on its own.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Stack
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Node.js app on ECS Fargate  → exposes /metrics endpoint
        ↓
Prometheus on EC2            → scrapes /metrics every 15 seconds
        ↓
Grafana on EC2               → visualizes the data from Prometheus
        ↓
Uptime Robot                 → monitors public URL availability
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prometheus and Grafana run together on a t3.micro EC2 instance using Docker Compose. Free tier eligible. No managed services needed.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 1: Expose Metrics from the Node App
&lt;/h2&gt;

&lt;p&gt;Prometheus works by scraping an HTTP endpoint that exposes metrics in a specific format. The app needs to provide that endpoint.&lt;/p&gt;

&lt;p&gt;Install the Prometheus client library:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npm &lt;span class="nb"&gt;install &lt;/span&gt;prom-client
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Add the metrics endpoint to &lt;code&gt;index.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;express&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;express&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;require&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;prom-client&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;express&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;port&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;collectDefaultMetrics&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;collectDefaultMetrics&lt;/span&gt;
&lt;span class="nf"&gt;collectDefaultMetrics&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Welcome to Three Tier App&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/health&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;ok&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/metrics&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;register&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;contentType&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;register&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;

&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;listen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`App listening on port &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;collectDefaultMetrics()&lt;/code&gt; automatically collects Node.js runtime metrics, heap memory, CPU time, event loop lag, garbage collection. No manual instrumentation needed for the basics.&lt;/p&gt;

&lt;p&gt;Hitting &lt;code&gt;/metrics&lt;/code&gt; now returns something like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight prometheus"&gt;&lt;code&gt;&lt;span class="c"&gt;# HELP nodejs_heap_size_used_bytes Process heap size used from Node.js in bytes.&lt;/span&gt;
&lt;span class="c"&gt;# TYPE nodejs_heap_size_used_bytes gauge&lt;/span&gt;
&lt;span class="n"&gt;nodejs_heap_size_used_bytes&lt;/span&gt; &lt;span class="mi"&gt;4567890&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Push this change through the GitHub Actions pipeline and the running ECS task will expose metrics at &lt;code&gt;/metrics&lt;/code&gt; through the ALB.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 2: Run Prometheus and Grafana on EC2
&lt;/h2&gt;

&lt;p&gt;I added a monitoring EC2 instance to my existing compute Terraform module. The &lt;code&gt;user_data&lt;/code&gt; script installs Docker, installs Docker Compose, creates the configuration files, and starts both containers on boot.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Prometheus config:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;global&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;scrape_interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15s&lt;/span&gt;

&lt;span class="na"&gt;scrape_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;job_name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;three-tier-app'&lt;/span&gt;
    &lt;span class="na"&gt;static_configs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;targets&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;your-alb-dns-name'&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;metrics_path&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/metrics'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prometheus scrapes the ALB URL at &lt;code&gt;/metrics&lt;/code&gt; every 15 seconds. The ALB forwards requests to the healthy ECS task.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Docker Compose:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheus&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;prom/prometheus&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;9090:9090"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./prometheus.yml:/etc/prometheus/prometheus.yml&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;prometheus-data:/prometheus&lt;/span&gt;

  &lt;span class="na"&gt;grafana&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;grafana/grafana&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;3000:3000"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;GF_SECURITY_ADMIN_PASSWORD=admin123&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;grafana-data:/var/lib/grafana&lt;/span&gt;

&lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;prometheus-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;grafana-data&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The named volumes mean Prometheus data and Grafana dashboards survive container restarts. If the containers restart the historical data is still there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security group for the monitoring instance:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight conf"&gt;&lt;code&gt;&lt;span class="n"&gt;Port&lt;/span&gt; &lt;span class="m"&gt;3000&lt;/span&gt; → &lt;span class="n"&gt;open&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;.&lt;span class="m"&gt;0&lt;/span&gt;/&lt;span class="m"&gt;0&lt;/span&gt;  (&lt;span class="n"&gt;Grafana&lt;/span&gt;, &lt;span class="n"&gt;public&lt;/span&gt;)
&lt;span class="n"&gt;Port&lt;/span&gt; &lt;span class="m"&gt;9090&lt;/span&gt; → &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="n"&gt;open&lt;/span&gt;            (&lt;span class="n"&gt;Prometheus&lt;/span&gt;, &lt;span class="n"&gt;internal&lt;/span&gt; &lt;span class="n"&gt;only&lt;/span&gt;)
&lt;span class="n"&gt;Port&lt;/span&gt; &lt;span class="m"&gt;22&lt;/span&gt;   → &lt;span class="n"&gt;open&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;my&lt;/span&gt; &lt;span class="n"&gt;IP&lt;/span&gt; &lt;span class="n"&gt;only&lt;/span&gt;  (&lt;span class="n"&gt;SSH&lt;/span&gt; &lt;span class="n"&gt;management&lt;/span&gt;)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prometheus is not exposed publicly. It contains raw infrastructure metrics you do not want the public reading. Grafana talks to Prometheus internally using the Docker Compose service name, not localhost.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Docker Networking Lesson
&lt;/h2&gt;

&lt;p&gt;When I first tried to connect Grafana to Prometheus I used &lt;code&gt;http://localhost:9090&lt;/code&gt; as the URL. It failed immediately.&lt;/p&gt;

&lt;p&gt;The reason is that inside a Docker container localhost refers to that container itself, not the host machine and not other containers. Prometheus is a separate container. To reach it from Grafana you use the service name defined in Docker Compose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;http://prometheus:9090
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Docker Compose creates an internal network where containers can reference each other by service name. This is fundamental Docker networking and easy to miss the first time.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 3: Connect Grafana to Prometheus
&lt;/h2&gt;

&lt;p&gt;Once both containers are running:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Open Grafana at &lt;code&gt;http://your-ec2-ip:3000&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Go to Connections → Data Sources → Add data source → Prometheus&lt;/li&gt;
&lt;li&gt;URL: &lt;code&gt;http://prometheus:9090&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Save and Test&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The response should say the Prometheus API was queried successfully.&lt;/p&gt;

&lt;p&gt;Then create a dashboard. The most useful metrics to start with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;nodejs_heap_size_used_bytes   → memory usage over time
process_cpu_seconds_total     → CPU consumption
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 4: Load Testing
&lt;/h2&gt;

&lt;p&gt;With the dashboard open I ran a load test using &lt;code&gt;hey&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;hey &lt;span class="nt"&gt;-n&lt;/span&gt; 10000 &lt;span class="nt"&gt;-c&lt;/span&gt; 50 http://your-alb-url/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This sends 10,000 requests with 50 concurrent connections. Watching the Grafana dashboard during the test showed memory climbing and CPU spiking in real time. The metrics endpoint was doing its job.&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 5: Crash Testing
&lt;/h2&gt;

&lt;p&gt;The most interesting part of observability is proving your system recovers from failure. I added a crash endpoint to the app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight jsx"&gt;&lt;code&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/crash&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Crashing...&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Deployed it through the pipeline, then triggered it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl http://your-alb-url/crash
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What happened next:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Container process exited
        ↓
ECS detected unhealthy task
        ↓
Task deregistered from ALB target group
        ↓
ECS started a new task automatically
        ↓
New task passed health checks
        ↓
Task registered with ALB
        ↓
App responding again
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Recovery took about 1 to 2 minutes. During that window the ALB returned 503s. Uptime Robot sent a down alert within 5 minutes, then a recovery alert once the new task was healthy.&lt;/p&gt;

&lt;p&gt;The Grafana dashboard showed a gap in metrics during the crash window, then metrics resuming when the new task came up.&lt;/p&gt;




&lt;h2&gt;
  
  
  What This Proves
&lt;/h2&gt;

&lt;p&gt;Nobody manually intervened. The container crashed, ECS detected it, a new task started, and traffic resumed. That is self-healing infrastructure working as designed.&lt;/p&gt;

&lt;p&gt;The combination of Prometheus metrics, Grafana dashboards, and Uptime Robot gives three different views of the system:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Uptime Robot  → is it up or down right now
Grafana       → what is the system doing over time
CloudWatch    → what did the application log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each answers a different question. Together they give you enough visibility to understand your system under normal load and under failure.&lt;/p&gt;




&lt;h2&gt;
  
  
  #aws#ecs#prometheus#grafana#devops
&lt;/h2&gt;

</description>
      <category>aws</category>
      <category>ecs</category>
      <category>prometheus</category>
      <category>grafana</category>
    </item>
    <item>
      <title>Making My ECS App Public with an Application Load Balancer</title>
      <dc:creator>Lalit Bagga</dc:creator>
      <pubDate>Fri, 03 Jul 2026 14:46:57 +0000</pubDate>
      <link>https://dev.to/lbagga/making-my-ecs-app-public-with-an-application-load-balancer-2494</link>
      <guid>https://dev.to/lbagga/making-my-ecs-app-public-with-an-application-load-balancer-2494</guid>
      <description>&lt;p&gt;My Node.js app was running on ECS Fargate and the CloudWatch logs confirmed it was healthy. But there was no way to actually reach it. No public URL. No way to test it from a browser. The container was alive but invisible.&lt;/p&gt;

&lt;p&gt;The fix is an Application Load Balancer. Here is how I wired it up.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why the App Was Unreachable
&lt;/h2&gt;

&lt;p&gt;ECS Fargate tasks run in private subnets by design. No public IP, no direct internet access. That is the correct security posture for an application server. You never want your app directly exposed to the internet.&lt;/p&gt;

&lt;p&gt;The ALB sits in the public subnet and acts as the entry point. Traffic flows like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Internet
        ↓
ALB (public subnet, internet facing)
        ↓
ECS Fargate task (private subnet, no public IP)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The app never needs a public IP. Only the ALB does.&lt;/p&gt;




&lt;h2&gt;
  
  
  What You Need to Wire Together
&lt;/h2&gt;

&lt;p&gt;An ALB setup has three moving parts that work together:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security Group&lt;/strong&gt; controls who can connect to the ALB. Port 80 open to the world.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Target Group&lt;/strong&gt; is where the ALB sends traffic after it is allowed in. It holds a list of healthy ECS tasks and runs health checks against them. If a task fails health checks it gets removed from rotation.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Listener&lt;/strong&gt; watches a specific port on the ALB and decides what to do with incoming traffic. In this case listen on port 80 and forward to the target group.&lt;/p&gt;

&lt;p&gt;A common mistake is thinking the security group is enough. It is not. The security group decides whether traffic is allowed. The listener decides what to do with it. The target group decides where it goes. All three are required.&lt;/p&gt;




&lt;h2&gt;
  
  
  One Thing ALB Requires
&lt;/h2&gt;

&lt;p&gt;An ALB must span at least two subnets in different Availability Zones. This is an AWS requirement, not optional.&lt;/p&gt;

&lt;p&gt;The reason is high availability. If one AZ goes down the ALB continues serving traffic through the other. You get one ALB, one DNS name, one entry point. AWS handles the redundancy behind the scenes.&lt;/p&gt;

&lt;p&gt;This means your VPC needs public subnets in at least two different AZs. If you are defining subnets in Terraform always specify the availability zone explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_subnet"&lt;/span&gt; &lt;span class="s2"&gt;"main_subnet_public_1"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.1.0/24"&lt;/span&gt;
  &lt;span class="nx"&gt;availability_zone&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-2a"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_subnet"&lt;/span&gt; &lt;span class="s2"&gt;"main_subnet_public_2"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_vpc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_block&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"10.0.2.0/24"&lt;/span&gt;
  &lt;span class="nx"&gt;availability_zone&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-2b"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you leave out &lt;code&gt;availability_zone&lt;/code&gt; AWS picks one automatically. You may end up with both subnets in the same AZ and the ALB will refuse to deploy.&lt;/p&gt;




&lt;h2&gt;
  
  
  Terraform for the ALB
&lt;/h2&gt;

&lt;p&gt;I added an ALB module to my existing three tier Terraform project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security group for the ALB:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"alb_sg"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"alb_sg"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"alb_sg"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_vpc_security_group_ingress_rule"&lt;/span&gt; &lt;span class="s2"&gt;"alb_sg_ingress"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alb_sg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;from_port&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
  &lt;span class="nx"&gt;to_port&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
  &lt;span class="nx"&gt;ip_protocol&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_ipv4&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_vpc_security_group_egress_rule"&lt;/span&gt; &lt;span class="s2"&gt;"alb_sg_egress"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alb_sg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;ip_protocol&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"-1"&lt;/span&gt;
  &lt;span class="nx"&gt;cidr_ipv4&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The ALB, target group, and listener:&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lb"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;               &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"three-tier-alb"&lt;/span&gt;
  &lt;span class="nx"&gt;internal&lt;/span&gt;           &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;load_balancer_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"application"&lt;/span&gt;
  &lt;span class="nx"&gt;security_groups&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alb_sg_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;subnets&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public_subnet_1_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public_subnet_2_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"three-tier-alb"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lb_target_group"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"three-tier-tg"&lt;/span&gt;
  &lt;span class="nx"&gt;port&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HTTP"&lt;/span&gt;
  &lt;span class="nx"&gt;vpc_id&lt;/span&gt;      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;vpc_id&lt;/span&gt;
  &lt;span class="nx"&gt;target_type&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"ip"&lt;/span&gt;

  &lt;span class="nx"&gt;health_check&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;path&lt;/span&gt;                &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/health"&lt;/span&gt;
    &lt;span class="nx"&gt;healthy_threshold&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;
    &lt;span class="nx"&gt;unhealthy_threshold&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;
    &lt;span class="nx"&gt;interval&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_lb_listener"&lt;/span&gt; &lt;span class="s2"&gt;"main"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;load_balancer_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;port&lt;/span&gt;              &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
  &lt;span class="nx"&gt;protocol&lt;/span&gt;          &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HTTP"&lt;/span&gt;

  &lt;span class="nx"&gt;default_action&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt;             &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"forward"&lt;/span&gt;
    &lt;span class="nx"&gt;target_group_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_lb_target_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One thing worth noting on the target group: &lt;code&gt;target_type = "ip"&lt;/code&gt; is required for Fargate. Fargate tasks do not have EC2 instance IDs. They have IP addresses. If you use &lt;code&gt;instance&lt;/code&gt; as the target type the target group will never register any healthy targets and you will get 503s.&lt;/p&gt;




&lt;h2&gt;
  
  
  Locking Down the ECS Security Group
&lt;/h2&gt;

&lt;p&gt;Before adding the ALB my ECS security group allowed inbound traffic from anywhere on port 3000. Once the ALB is in place that should change. Only the ALB should be able to talk to the ECS tasks. Not the public internet.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_vpc_security_group_ingress_rule"&lt;/span&gt; &lt;span class="s2"&gt;"ecs_sg_ingress"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;security_group_id&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ecs_sg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;from_port&lt;/span&gt;                    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;
  &lt;span class="nx"&gt;to_port&lt;/span&gt;                      &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;
  &lt;span class="nx"&gt;ip_protocol&lt;/span&gt;                  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
  &lt;span class="nx"&gt;referenced_security_group_id&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_security_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alb_sg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Instead of &lt;code&gt;cidr_ipv4 = "0.0.0.0/0"&lt;/code&gt; the ingress rule now references the ALB security group directly. Only traffic coming from the ALB is allowed to reach the ECS task on port 3000.&lt;/p&gt;




&lt;h2&gt;
  
  
  Wiring ECS to the Target Group
&lt;/h2&gt;

&lt;p&gt;The ECS service needs to know about the target group so it can register tasks automatically. When a new task starts ECS registers its IP with the target group. When a task stops it deregisters.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_ecs_service"&lt;/span&gt; &lt;span class="s2"&gt;"app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"three-tier-app-service"&lt;/span&gt;
  &lt;span class="nx"&gt;cluster&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_ecs_cluster&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;main&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;
  &lt;span class="nx"&gt;task_definition&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_ecs_task_definition&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;arn&lt;/span&gt;
  &lt;span class="nx"&gt;desired_count&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="nx"&gt;launch_type&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"FARGATE"&lt;/span&gt;

  &lt;span class="nx"&gt;network_configuration&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;subnets&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;private_subnet_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;security_groups&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ecs_sg_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;load_balancer&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;target_group_arn&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;target_group_arn&lt;/span&gt;
    &lt;span class="nx"&gt;container_name&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"app-container"&lt;/span&gt;
    &lt;span class="nx"&gt;container_port&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3000&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;alb_listener_arn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;depends_on&lt;/code&gt; on the listener matters. ECS should not start registering tasks with the target group until the listener exists. Without this Terraform might try to create the ECS service before the listener is ready and health checks will fail in a loop.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;After &lt;code&gt;terraform apply&lt;/code&gt; the output shows the ALB DNS name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;alb_dns_name&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"three-tier-alb-119067097.us-east-2.elb.amazonaws.com"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Hitting that URL in the browser returns the app response. Hitting &lt;code&gt;/health&lt;/code&gt; returns a healthy status. The app is now publicly accessible through the load balancer with the ECS task completely hidden in the private subnet.&lt;/p&gt;




&lt;h2&gt;
  
  
  What Is Next
&lt;/h2&gt;

&lt;p&gt;The app is live but there is no visibility into what it is actually doing. The next step is adding Prometheus and Grafana to collect real metrics and build dashboards, along with load testing and crash recovery to prove the infrastructure holds up under pressure.&lt;/p&gt;

&lt;h1&gt;
  
  
  aws#ecs#terraform#devops#loadbalancer
&lt;/h1&gt;

</description>
      <category>aws</category>
      <category>ecs</category>
      <category>terraform</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
