<?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: Knirl Amboy</title>
    <description>The latest articles on DEV Community by Knirl Amboy (@knirl).</description>
    <link>https://dev.to/knirl</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%2F4100311%2Fcddabbd0-ea8e-4a45-97d7-e7fc56ead8b2.jpg</url>
      <title>DEV Community: Knirl Amboy</title>
      <link>https://dev.to/knirl</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/knirl"/>
    <language>en</language>
    <item>
      <title>How I Built a Self-Healing, Multi-AZ Infrastructure on AWS — And What Broke Along the Way</title>
      <dc:creator>Knirl Amboy</dc:creator>
      <pubDate>Thu, 17 Sep 2026 05:29:40 +0000</pubDate>
      <link>https://dev.to/knirl/how-i-built-a-self-healing-multi-az-infrastructure-on-aws-and-what-broke-along-the-way-72j</link>
      <guid>https://dev.to/knirl/how-i-built-a-self-healing-multi-az-infrastructure-on-aws-and-what-broke-along-the-way-72j</guid>
      <description>&lt;h2&gt;
  
  
  The Problem I Set Out to Solve
&lt;/h2&gt;

&lt;p&gt;AWS can projects look the same: spin up one EC2 instance, install a web server, call it done. The moment that instance crashes or traffic spikes, the whole thing falls over.&lt;/p&gt;

&lt;p&gt;I wanted to build something that actually answers the question when it comes to "high availability": &lt;strong&gt;what happens when something fails, and does the system recover without a human stepping in&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So I set out to build a web architecture with no single point of failure — spanning two Availability Zones, load-balanced, auto-scaling, with a self-healing database — and then, critically, to actually &lt;em&gt;break it on purpose&lt;/em&gt; to prove it recovers, instead of just trusting the console said everything was configured correctly.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Architecture, Briefly
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A VPC spanning 2 Availability Zones, with public subnets for the load balancer and private subnets for everything else&lt;/li&gt;
&lt;li&gt;An Application Load Balancer distributing traffic to an Auto Scaling Group of EC2 instances&lt;/li&gt;
&lt;li&gt;RDS running MySQL in Multi-AZ mode, with a live standby ready to take over&lt;/li&gt;
&lt;li&gt;CloudWatch monitoring the whole thing, with an alarm wired to actually notify me&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the "what." The interesting part — the part that actually taught me something — is everything that didn't work on the first try.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Debugging Journey
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Incident 1: The ALB That Wouldn't Respond
&lt;/h3&gt;

&lt;p&gt;After wiring up the VPC, security groups, target group, and load balancer, I hit the ALB's DNS name in my browser and got:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERR_CONNECTION_TIMED_OUT
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first assumption was a security group misconfiguration. So I worked through it methodically:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Checked the ALB's security group — inbound HTTP 80 from &lt;code&gt;0.0.0.0/0&lt;/code&gt; was correctly set&lt;/li&gt;
&lt;li&gt;Checked subnet placement — the ALB was correctly deployed across both public subnets&lt;/li&gt;
&lt;li&gt;Checked the ALB's scheme — confirmed "Internet-facing," not "Internal"&lt;/li&gt;
&lt;li&gt;Checked the listener — HTTP:80 was correctly forwarding to my target group&lt;/li&gt;
&lt;li&gt;Checked Network ACLs at the subnet level — default allow-all rules were untouched&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Every single AWS-side configuration was correct. That's the moment it clicked that the problem probably wasn't in AWS at all — it was somewhere between my browser and AWS. I tried the same URL from my phone on cellular data, and then just paid closer attention to what was actually in my address bar.&lt;/p&gt;

&lt;p&gt;The issue: I'd pasted the bare DNS name into Edge, and the browser had silently prepended &lt;code&gt;https://&lt;/code&gt;. My ALB only had an HTTP listener configured — no HTTPS/443 listener existed at all. The browser was trying to reach a port that wasn't listening for that protocol, and quietly timing out instead of giving a clear "connection refused."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix:&lt;/strong&gt; type the URL explicitly as &lt;code&gt;http://your-alb-dns-name...&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this taught me:&lt;/strong&gt; when every layer of your infrastructure checks out, stop re-checking the same layer and start questioning your assumptions about the client side. I spent close to 20 minutes re-verifying security groups I'd already verified, when the actual bug was in how I was typing a URL.&lt;/p&gt;

&lt;h3&gt;
  
  
  Incident 2: The Page That Loaded, But Told Me Nothing
&lt;/h3&gt;

&lt;p&gt;Once the ALB was reachable, my page loaded — but it was supposed to display the serving instance's ID and Availability Zone, and both fields were blank.&lt;/p&gt;

&lt;p&gt;My EC2 instances pull this data from AWS's internal instance metadata service, via a request like:&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;-s&lt;/span&gt; http://169.254.169.254/latest/meta-data/instance-id
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is a well-documented pattern, and I'd used it based on older references. Apache was running fine — the page loaded — so my instinct was that the problem was somewhere in my HTML generation logic. It wasn't. The metadata request itself was silently failing.&lt;/p&gt;

&lt;p&gt;The root cause: Amazon Linux 2023 requires &lt;strong&gt;IMDSv2&lt;/strong&gt; by default — a token-based authentication flow for metadata requests. My script was using the older tokenless (IMDSv1-style) request pattern, which gets rejected without an explicit error visible in the page output — &lt;code&gt;curl&lt;/code&gt; just returned empty, and my script happily wrote empty values into the HTML.&lt;/p&gt;

&lt;p&gt;The fix required requesting a session token first, then passing it as a header on every subsequent metadata call:&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;TOKEN&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; PUT &lt;span class="s2"&gt;"http://169.254.169.254/latest/api/token"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-aws-ec2-metadata-token-ttl-seconds: 21600"&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
&lt;span class="nv"&gt;INSTANCE_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-aws-ec2-metadata-token: &lt;/span&gt;&lt;span class="nv"&gt;$TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; http://169.254.169.254/latest/meta-data/instance-id&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But here's the part that actually tests whether you understand Auto Scaling Groups: fixing the script wasn't enough. My existing instances were already running the &lt;em&gt;old&lt;/em&gt; script — updating a Launch Template only affects instances launched &lt;em&gt;after&lt;/em&gt; the change, not ones already running. I had to publish the corrected script as a new Launch Template version, set it as default, and then trigger an &lt;strong&gt;Instance Refresh&lt;/strong&gt; — which gradually and safely replaced my running instances with new ones, one at a time, keeping at least one healthy throughout the process. That refresh took about 12–15 minutes for two instances, which is worth knowing going in so you don't assume something's stuck.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this taught me:&lt;/strong&gt; "no visible error" doesn't mean "no failure." A script can execute successfully from the OS's perspective while still failing at the thing it was actually meant to do. And infrastructure-as-config (Launch Templates) has a subtlety that infrastructure-as-code makes more obvious later: changing the definition doesn't retroactively change what's already running.&lt;/p&gt;

&lt;h3&gt;
  
  
  Incident 3: Testing the Alarm I'd Configured
&lt;/h3&gt;

&lt;p&gt;I didn't want to just configure a CloudWatch alarm and assume it worked — I wanted to actually watch it fire. So I deliberately broke connectivity between my load balancer and my instances by deleting the inbound rule on my EC2 security group that allowed traffic from the ALB.&lt;/p&gt;

&lt;p&gt;Within about two minutes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;My target group flipped both instances to &lt;code&gt;unhealthy&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;My CloudWatch alarm transitioned from &lt;code&gt;OK&lt;/code&gt; to &lt;code&gt;In alarm&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;I got an email notification via SNS&lt;/li&gt;
&lt;li&gt;My Auto Scaling Group, seeing unhealthy instances via its ELB health check integration, began trying to replace them — and kept trying, since the replacements hit the exact same blocked security group rule&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last part was actually a good thing to witness: it's the ASG correctly attempting to self-heal, even in a scenario where it structurally couldn't succeed until I fixed the actual cause. I screenshotted the failure state, then reverted the security group rule and confirmed everything returned to healthy within a couple of minutes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What this taught me:&lt;/strong&gt; monitoring you haven't tested is monitoring you're just hoping works. The five minutes it took to break this on purpose gave me something concrete to talk about, instead of a screenshot of an alarm sitting in a green "OK" state that never proves it does anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trade-Offs I Had to Reason Through
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Cost vs. Genuine Fault Tolerance: Multi-AZ RDS
&lt;/h3&gt;

&lt;p&gt;AWS's Free Tier RDS template doesn't offer Multi-AZ as an option at all — it's locked behind the "Production" template, which roughly doubles the database's hourly cost. I had a choice: stay on Free Tier and only be able to &lt;em&gt;describe&lt;/em&gt; automatic failover in an interview, or spend a small amount of account credit to actually build and test it.&lt;/p&gt;

&lt;p&gt;I chose to spend the credit. The reasoning: the entire point of this project was proving fault tolerance, not just listing services on a resume. Being able to say "I triggered a forced failover and verified the database moved to a different Availability Zone" is a fundamentally different claim than "I know RDS supports Multi-AZ." For a project meant to demonstrate engineering competence, the second claim doesn't hold up well under a follow-up question.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cost vs. Architecture Purity: NAT Gateways per AZ
&lt;/h3&gt;

&lt;p&gt;The "textbook correct" production pattern is one NAT Gateway per Availability Zone, so that a NAT Gateway failure in one AZ doesn't take down internet access for the private subnet in that same AZ. Doing this at scale, for a real company, is the right call.&lt;/p&gt;

&lt;p&gt;For this project, I made a deliberate trade-off in the other direction: I chose to deploy a single NAT Gateway shared across both AZs to &lt;em&gt;manage hourly AWS costs&lt;/em&gt;. I recognize that a production-grade environment requires a NAT Gateway per AZ to prevent cross-AZ dependencies and ensure high availability. However, for a portfolio demonstration where network resiliency wasn't the focal point, &lt;em&gt;keeping costs low&lt;/em&gt; was the more practical choice.&lt;/p&gt;

&lt;h3&gt;
  
  
  Target Tracking vs. Manual Threshold Scaling
&lt;/h3&gt;

&lt;p&gt;I initially planned a simple two-threshold scaling setup — scale out at 70% CPU, scale in at 40%. I reconsidered this once I thought through the failure mode: if those two thresholds are tuned too close together, or traffic hovers right around the boundary, you get "flapping" — instances repeatedly launching and terminating in short succession, which wastes money and destabilizes the fleet under real load.&lt;/p&gt;

&lt;p&gt;I switched to a &lt;strong&gt;target tracking policy&lt;/strong&gt; at 50% CPU instead, which lets AWS's own algorithm manage the scale-out/scale-in thresholds and cooldown timing dynamically, rather than me hardcoding two static numbers that could misbehave under conditions I hadn't anticipated. I actually heard and read from someone about that two-threshold scaling setup but as I searched further more, I found that there is a better and safer way. This allowed me to not just copy the config from a tutorial or advise but instead understand the failure mode and choose a safer default.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Mattered Here
&lt;/h2&gt;

&lt;p&gt;If I'm honest, the value of this project wasn't in successfully clicking through the AWS console in the right order. It was in the hours I spent chasing down a browser protocol quirk and a metadata API version mismatch. Production systems don't fail in the ways tutorials describe; they fail in small, unglamorous, easy-to-miss ways, and the skill that matters is having a methodical process for narrowing down the cause instead of guessing.&lt;/p&gt;

&lt;p&gt;Next, I'm rebuilding this same architecture in Terraform — taking it from a manual console build to something version-controlled, reviewable, and deployable in minutes.&lt;/p&gt;

</description>
      <category>architecture</category>
      <category>aws</category>
      <category>devops</category>
      <category>infrastructure</category>
    </item>
  </channel>
</rss>
