<?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: Samuel Obeng</title>
    <description>The latest articles on DEV Community by Samuel Obeng (@samobeng14dev).</description>
    <link>https://dev.to/samobeng14dev</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%2F3487591%2Fdec1ff1a-4571-4558-a5d8-05d69cdf0e93.jpg</url>
      <title>DEV Community: Samuel Obeng</title>
      <link>https://dev.to/samobeng14dev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/samobeng14dev"/>
    <language>en</language>
    <item>
      <title>I Built a Website That Fails Over from AWS to Azure: A Terraform Walkthrough (and Every Error I Hit)</title>
      <dc:creator>Samuel Obeng</dc:creator>
      <pubDate>Thu, 05 Nov 2026 10:03:01 +0000</pubDate>
      <link>https://dev.to/samobeng14dev/i-built-a-website-that-fails-over-from-aws-to-azure-a-terraform-walkthrough-and-every-error-i-hit-2ooa</link>
      <guid>https://dev.to/samobeng14dev/i-built-a-website-that-fails-over-from-aws-to-azure-a-terraform-walkthrough-and-every-error-i-hit-2ooa</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;I built this while working through a project module from &lt;strong&gt;Lucy Wang's&lt;/strong&gt; advance cloud project course. This write-up is my own: my own Terraform files, updated for current provider versions, plus the real errors I hit and how I fixed them. Credit to the course for the project idea.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Repo:&lt;/strong&gt; &lt;a href="https://github.com/samobeng14Dev/aws_azure_multicloud_deployment_terraforms" rel="noopener noreferrer"&gt;samobeng14Dev/aws_azure_multicloud_deployment_terraforms&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I built this
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I'm building a cloud and DevOps portfolio.&lt;/li&gt;
&lt;li&gt;I kept reading about "disaster recovery" without ever seeing it work.&lt;/li&gt;
&lt;li&gt;So I built a site where one cloud can go down and visitors still get the page.&lt;/li&gt;
&lt;li&gt;I wrote down every place I got stuck, because those moments taught me the most.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What this project shows
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Skill&lt;/th&gt;
&lt;th&gt;Where it shows up&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Infrastructure as Code&lt;/td&gt;
&lt;td&gt;Terraform with two providers (AWS and Azure), staged applies, a provider alias, &lt;code&gt;for_each&lt;/code&gt;, &lt;code&gt;dynamic&lt;/code&gt; blocks&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS&lt;/td&gt;
&lt;td&gt;S3 static hosting, CloudFront, ACM certificates, Route 53 health checks and failover, IAM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure&lt;/td&gt;
&lt;td&gt;Storage static website, resource providers, CLI authentication&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Networking&lt;/td&gt;
&lt;td&gt;DNS delegation, CNAME rules, TTLs, failover timing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Troubleshooting&lt;/td&gt;
&lt;td&gt;14 problems diagnosed and fixed (see the troubleshooting table)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Security&lt;/td&gt;
&lt;td&gt;No secrets in the repo, git-ignored state, credentials via CLI profiles&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What you will build
&lt;/h2&gt;

&lt;p&gt;A weather app served from &lt;strong&gt;two clouds at once&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Primary:&lt;/strong&gt; AWS, with S3 static hosting behind CloudFront (HTTPS)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secondary:&lt;/strong&gt; Azure Blob Storage static website&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failover:&lt;/strong&gt; Route 53 health checks check each site every 30 seconds. After 3 failed checks, DNS answers with the other cloud&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Infrastructure:&lt;/strong&gt; all of it in Terraform&lt;/li&gt;
&lt;/ul&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%2Fkawhx3zpu1dhuw6zg3y1.jpg" 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%2Fkawhx3zpu1dhuw6zg3y1.jpg" alt="Architecture: Route 53 failover between AWS (CloudFront + S3) and Azure Blob Storage" width="800" height="537"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You will learn:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Terraform with two providers&lt;/li&gt;
&lt;li&gt;S3 static hosting and CloudFront with an ACM certificate&lt;/li&gt;
&lt;li&gt;Azure Blob static websites&lt;/li&gt;
&lt;li&gt;Route 53 failover, and how to test it&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Before you start
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Prerequisites&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An AWS account and an Azure subscription&lt;/li&gt;
&lt;li&gt;A registered domain (I used Namecheap)&lt;/li&gt;
&lt;li&gt;Terraform, AWS CLI and Azure CLI&lt;/li&gt;
&lt;li&gt;Basic command-line comfort. I was new to Terraform when I started.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Setup used in this post&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;AWS region&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;eu-north-1&lt;/code&gt; (the CloudFront certificate must be in &lt;code&gt;us-east-1&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure region&lt;/td&gt;
&lt;td&gt;&lt;code&gt;North Europe&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Shell&lt;/td&gt;
&lt;td&gt;Windows PowerShell&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terraform&lt;/td&gt;
&lt;td&gt;&amp;gt;= 1.5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost&lt;/td&gt;
&lt;td&gt;Small if you destroy it quickly. The running costs are the Route 53 hosted zone (about $0.50/month) and two health checks. The domain costs extra.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Warning:&lt;/strong&gt; this creates billable resources. Finish the cleanup in Step 10.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;Names used (replace with your own)&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Thing&lt;/th&gt;
&lt;th&gt;Name&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;S3 bucket&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;weather-tracker-&amp;lt;name&amp;gt;-&amp;lt;4 digits&amp;gt;&lt;/code&gt; (globally unique)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure storage account&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;weather&amp;lt;name&amp;gt;&amp;lt;4 digits&amp;gt;&lt;/code&gt; (3-24 characters, lowercase letters and digits only)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure resource group&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rg-weather-static-website&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain&lt;/td&gt;
&lt;td&gt;&lt;code&gt;&amp;lt;your-domain&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failover hostname&lt;/td&gt;
&lt;td&gt;&lt;code&gt;www.&amp;lt;your-domain&amp;gt;&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS CLI profile&lt;/td&gt;
&lt;td&gt;&lt;code&gt;weather&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Project structure&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;aws_azure_multicloud_deployment_terraforms/
├── .gitignore
├── README.md
├── docs/            architecture.png, troubleshooting.md, screenshots/
├── website/         index.html, script.js, styles.css, assets/
└── terraform/
    ├── .terraform.lock.hcl      (commit this)
    ├── versions.tf  providers.tf  variables.tf  outputs.tf
    ├── aws_s3.tf  aws_cloudfront.tf  azure_storage.tf  dns_failover.tf
    ├── terraform.tfvars.example (commit)
    └── terraform.tfvars         (NEVER commit)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;.gitignore&lt;/code&gt;&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;.terraform/
*.tfstate
*.tfstate.*
terraform.tfvars
secrets/
.env
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Always run Terraform from the &lt;code&gt;terraform/&lt;/code&gt; folder.&lt;/strong&gt; In the project root it fails with "No configuration files".&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 0: Prerequisites
&lt;/h2&gt;

&lt;h3&gt;
  
  
  0.1 Install the tools
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--version&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;az&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;version&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Run the three commands above. Each should print a version.&lt;/li&gt;
&lt;li&gt;Download and install any tool that is missing, then run it again.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  0.2 AWS identity (no keys in code)
&lt;/h3&gt;

&lt;ol&gt;
&lt;li&gt;Create an IAM user for this project.&lt;/li&gt;
&lt;li&gt;Attach these managed policies:

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;AmazonS3FullAccess&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;CloudFrontFullAccess&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AmazonRoute53FullAccess&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AWSCertificateManagerFullAccess&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Create an access key:

&lt;ul&gt;
&lt;li&gt;Sign in to the AWS Management Console and go to &lt;strong&gt;IAM → Users&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Select the user and open &lt;strong&gt;Security credentials&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Under &lt;strong&gt;Access keys&lt;/strong&gt;, click &lt;strong&gt;Create access key&lt;/strong&gt; and choose &lt;strong&gt;Command Line Interface (CLI)&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Copy the Access Key ID and Secret Access Key. AWS shows the secret only once.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;Configure a named profile. When prompted, enter the key pair, your default region and an output format:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;configure&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--profile&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;weather&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;AWS_PROFILE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"weather"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;sts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;get-caller-identity&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;Check the output of &lt;code&gt;aws sts get-caller-identity&lt;/code&gt;. The user name must be the IAM user you just created.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;These broad managed policies are fine for a throwaway learning account. For anything real, write a least-privilege policy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; &lt;code&gt;aws configure&lt;/code&gt; without &lt;code&gt;--profile&lt;/code&gt; overwrites your &lt;code&gt;default&lt;/code&gt; credentials, which can silently affect your other projects. A named profile keeps each project's credentials separate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My experience:&lt;/strong&gt; I use a separate IAM user and CLI profile for every portfolio project. The first time I ran Terraform, it deployed successfully even though I had put no credentials in my code, and I wasn't sure which identity it was using. Now I run &lt;code&gt;aws sts get-caller-identity&lt;/code&gt; before every &lt;code&gt;terraform apply&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  0.3 Azure identity
&lt;/h3&gt;

&lt;p&gt;Terraform needs two things for Azure: a signed-in account and a subscription to deploy into.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;az&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;login&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nv"&gt;$&lt;/span&gt;&lt;span class="nn"&gt;env&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="nv"&gt;ARM_SUBSCRIPTION_ID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;az&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;account&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;tsv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;az&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;register&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--namespace&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Microsoft.Storage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--wait&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;az&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;provider&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;show&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--namespace&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;Microsoft.Storage&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;registrationState&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;tsv&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What each command does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;az login&lt;/code&gt; opens a browser window and signs you in.&lt;/li&gt;
&lt;li&gt;The second command stores your subscription ID in &lt;code&gt;ARM_SUBSCRIPTION_ID&lt;/code&gt;, which the Azure provider reads.&lt;/li&gt;
&lt;li&gt;The last two register the &lt;code&gt;Microsoft.Storage&lt;/code&gt; resource provider. New subscriptions don't always have it enabled, and Azure can't create storage accounts without it. The final command should print &lt;code&gt;Registered&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; environment variables last only for the current PowerShell window. In every new window, set the subscription ID again before running Terraform:&lt;br&gt;
&lt;code&gt;$env:ARM_SUBSCRIPTION_ID = (az account show --query id -o tsv)&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why:&lt;/strong&gt; from AzureRM provider v4 onward, Terraform requires an explicit subscription ID. Without it, &lt;code&gt;init&lt;/code&gt; can succeed and then planning or deploying fails because the provider doesn't know which subscription to use.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My experience:&lt;/strong&gt; this was one of the easiest steps to overlook. Terraform initialised fine, but deployments failed because the Azure provider had no subscription context. Now I check &lt;code&gt;ARM_SUBSCRIPTION_ID&lt;/code&gt; as soon as I open a new terminal.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  0.4 How does Terraform know who I am?
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;AWS&lt;/th&gt;
&lt;th&gt;Azure&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Identity&lt;/td&gt;
&lt;td&gt;IAM user&lt;/td&gt;
&lt;td&gt;My Entra ID login&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Credential&lt;/td&gt;
&lt;td&gt;Long-lived access key&lt;/td&gt;
&lt;td&gt;Short-lived token from &lt;code&gt;az login&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stored in&lt;/td&gt;
&lt;td&gt;&lt;code&gt;~/.aws/credentials&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;The Azure CLI's token cache&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;In the repo?&lt;/td&gt;
&lt;td&gt;Never&lt;/td&gt;
&lt;td&gt;Never&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;AWS:&lt;/strong&gt; Terraform uses the credentials of the selected profile. AWS tools search in this order: environment variables, named profiles in &lt;code&gt;~/.aws/credentials&lt;/code&gt;, &lt;code&gt;~/.aws/config&lt;/code&gt;, then SSO.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure:&lt;/strong&gt; Terraform reuses the session created by &lt;code&gt;az login&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Result:&lt;/strong&gt; no access keys, passwords or tokens in any Terraform file.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The routine:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Authenticate once with each CLI.&lt;/li&gt;
&lt;li&gt;Let Terraform use those credentials.&lt;/li&gt;
&lt;li&gt;Check your identity before every apply.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;My experience:&lt;/strong&gt; before I understood this, I searched my Terraform files for the "hidden" credentials it was using. There were none. Terraform was simply using the identities I had already signed in with.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h3&gt;
  
  
  0.5 Domain
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Buy a domain (I used Namecheap).&lt;/li&gt;
&lt;li&gt;Turn on WHOIS privacy and two-factor authentication.&lt;/li&gt;
&lt;li&gt;Verify your contact email (the ICANN verification) within 15 days, or the domain can be suspended.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  0.6 Account limits to expect
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;A new AWS account can fail CloudFront creation with &lt;em&gt;"Your account must be verified before you can add new CloudFront resources"&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Open a free Support case (Account and billing) early, and never paste your account ID in public.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;My experience:&lt;/strong&gt; this hit me at Step 4 and was my longest delay. I opened a case, kept building other parts while I waited, and it was approved. On a new account, open the case on day one.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 1: Provider setup
&lt;/h2&gt;

&lt;p&gt;Four files set up Terraform itself. Create them in &lt;code&gt;terraform/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;versions.tf&lt;/code&gt;&lt;/strong&gt; pins the Terraform and provider versions.&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;terraform&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;required_version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 1.5"&lt;/span&gt;

  &lt;span class="nx"&gt;required_providers&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;aws&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/aws"&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;gt;= 5.0"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nx"&gt;azurerm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;source&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"hashicorp/azurerm"&lt;/span&gt;
      &lt;span class="c1"&gt;# Pin the MAJOR version you actually installed (check: terraform version).&lt;/span&gt;
      &lt;span class="c1"&gt;# Unpinned, a new major release changed resource syntax under me.&lt;/span&gt;
      &lt;span class="nx"&gt;version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"~&amp;gt; 5.0"&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;ul&gt;
&lt;li&gt;Pin the &lt;strong&gt;major&lt;/strong&gt; version of &lt;code&gt;azurerm&lt;/code&gt; that you actually installed. An unpinned provider changed its syntax under me (see Step 3).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;providers.tf&lt;/code&gt;&lt;/strong&gt; configures both clouds.&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="c1"&gt;# Providers for the multi-cloud deployment.&lt;/span&gt;
&lt;span class="c1"&gt;# AWS hosts the S3 and CloudFront primary site, while Azure hosts the failover website.&lt;/span&gt;
&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"aws"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="o"&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;aws_region&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"azurerm"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;features&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# CloudFront certificates must be requested in us-east-1 because CloudFront is a global service.&lt;/span&gt;
&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"aws"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;alias&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us_east_1"&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"us-east-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Neither provider has credentials in it (see 0.4).&lt;/li&gt;
&lt;li&gt;The second AWS provider, with the alias &lt;code&gt;us_east_1&lt;/code&gt;, exists only for the CloudFront certificate.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;variables.tf&lt;/code&gt;&lt;/strong&gt; holds the values you can change.&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="c1"&gt;# Shared configuration values used across the AWS and Azure deployment resources.&lt;/span&gt;
&lt;span class="c1"&gt;# These values control the target regions, regional naming constraints, custom-domain setup,&lt;/span&gt;
&lt;span class="c1"&gt;# and the Route53 health check used for failover.&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"aws_region"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"AWS region for the S3 bucket."&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-north-1"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"bucket_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Globally unique S3 bucket name for the website."&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"azure_location"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Azure region for the resource group and storage account."&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"North Europe"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"azure_storage_account_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Globally unique Azure storage account name (3-24 chars, lowercase letters and digits only)."&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;can&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"^[a-z0-9]{3,24}$"&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;azure_storage_account_name&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Use 3-24 characters: lowercase letters and digits only, no hyphens."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"domain_name"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Your registered domain, without www."&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"website_config_filename"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Website config file to upload: config.js.example or your local config.js."&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"config.js.example"&lt;/span&gt;

  &lt;span class="nx"&gt;validation&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;condition&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;contains&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="s2"&gt;"config.js"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"config.js.example"&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;website_config_filename&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nx"&gt;error_message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Set website_config_filename to either config.js or config.js.example."&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"enable_custom_domain"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Stage switch: false = default CloudFront certificate, true = www custom domain with ACM certificate."&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;bool&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;variable&lt;/span&gt; &lt;span class="s2"&gt;"primary_health_check_path"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Path the primary health check requests. Use a missing path to simulate an AWS outage."&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;string&lt;/span&gt;
  &lt;span class="nx"&gt;default&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;bucket_name&lt;/code&gt; and &lt;code&gt;azure_storage_account_name&lt;/code&gt; must be globally unique. The Azure name is validated: 3-24 lowercase letters and digits.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;website_config_filename&lt;/code&gt; chooses which config file is uploaded: &lt;code&gt;config.js.example&lt;/code&gt; (default) or your local &lt;code&gt;config.js&lt;/code&gt;. Everything uploaded to S3 and Azure is publicly readable, so never put a real secret in either.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;enable_custom_domain&lt;/code&gt; switches between the stage 1 and stage 2 setups (Steps 5 and 7).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;primary_health_check_path&lt;/code&gt; is how I simulate an outage in Step 9.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;terraform.tfvars&lt;/code&gt;&lt;/strong&gt; is git-ignored. Keep a placeholder copy named &lt;code&gt;terraform.tfvars.example&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;aws_region&lt;/span&gt;                 &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"eu-north-1"&lt;/span&gt;
&lt;span class="nx"&gt;bucket_name&lt;/span&gt;                &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"weather-tracker-&amp;lt;name&amp;gt;-&amp;lt;4digits&amp;gt;"&lt;/span&gt;
&lt;span class="nx"&gt;azure_storage_account_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"weather&amp;lt;name&amp;gt;&amp;lt;4digits&amp;gt;"&lt;/span&gt;
&lt;span class="nx"&gt;domain_name&lt;/span&gt;                &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;your-domain&amp;gt;"&lt;/span&gt;
&lt;span class="nx"&gt;enable_custom_domain&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  Step 2: S3 static website (primary origin)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;aws_s3.tf&lt;/code&gt;&lt;/strong&gt; creates the bucket and uploads the site.&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="c1"&gt;# AWS S3 hosts the primary static website used by CloudFront.&lt;/span&gt;
&lt;span class="c1"&gt;# This file creates the public website bucket, enables website hosting, and uploads the&lt;/span&gt;
&lt;span class="c1"&gt;# static frontend files with the correct content types for browsers to render them properly.&lt;/span&gt;

&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# Keep the website root files explicit; assets are discovered recursively below.&lt;/span&gt;
  &lt;span class="nx"&gt;site_dir&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${path.module}/../website"&lt;/span&gt;
  &lt;span class="nx"&gt;config_js_path&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${local.site_dir}/${var.website_config_filename}"&lt;/span&gt;

  &lt;span class="nx"&gt;site_files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="s2"&gt;"index.html"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"text/html"&lt;/span&gt;
    &lt;span class="s2"&gt;"styles.css"&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"text/css"&lt;/span&gt;
    &lt;span class="s2"&gt;"script.js"&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"application/javascript"&lt;/span&gt;
    &lt;span class="s2"&gt;"config.js"&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"application/javascript"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;site_file_sources&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;keys&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site_files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"config.js"&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;config_js_path&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"${local.site_dir}/${name}"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# Common static asset types; unknown extensions fall back to binary content.&lt;/span&gt;
  &lt;span class="nx"&gt;mime_types&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;png&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"image/png"&lt;/span&gt;
    &lt;span class="nx"&gt;jpg&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"image/jpeg"&lt;/span&gt;
    &lt;span class="nx"&gt;jpeg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"image/jpeg"&lt;/span&gt;
    &lt;span class="nx"&gt;gif&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"image/gif"&lt;/span&gt;
    &lt;span class="nx"&gt;svg&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"image/svg+xml"&lt;/span&gt;
    &lt;span class="nx"&gt;webp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"image/webp"&lt;/span&gt;
    &lt;span class="nx"&gt;ico&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"image/x-icon"&lt;/span&gt;
  &lt;span class="err"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Create the S3 bucket that hosts the static website content.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket"&lt;/span&gt; &lt;span class="s2"&gt;"weather_app"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="o"&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;bucket_name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Configure the bucket as a static website so it exposes the index document at the public endpoint.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket_website_configuration"&lt;/span&gt; &lt;span class="s2"&gt;"site"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;weather_app&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;index_document&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;suffix&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"index.html"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Allow public reads required for a static website served from S3.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket_public_access_block"&lt;/span&gt; &lt;span class="s2"&gt;"public_access"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt;                  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;weather_app&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;block_public_acls&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;block_public_policy&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;ignore_public_acls&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="nx"&gt;restrict_public_buckets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Grant anonymous visitors read access to website objects without requiring AWS credentials.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_bucket_policy"&lt;/span&gt; &lt;span class="s2"&gt;"public_read"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;weather_app&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;depends_on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_s3_bucket_public_access_block&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;public_access&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

  &lt;span class="nx"&gt;policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;jsonencode&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="nx"&gt;Version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"2012-10-17"&lt;/span&gt;
    &lt;span class="nx"&gt;Statement&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;
      &lt;span class="nx"&gt;Sid&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PublicReadGetObject"&lt;/span&gt;
      &lt;span class="nx"&gt;Effect&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Allow"&lt;/span&gt;
      &lt;span class="nx"&gt;Principal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"*"&lt;/span&gt;
      &lt;span class="nx"&gt;Action&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3:GetObject"&lt;/span&gt;
      &lt;span class="nx"&gt;Resource&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${aws_s3_bucket.weather_app.arn}/*"&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;span class="c1"&gt;# Upload the main HTML, CSS, and JavaScript files with the correct MIME types.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_object"&lt;/span&gt; &lt;span class="s2"&gt;"site_files"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site_files&lt;/span&gt;
  &lt;span class="nx"&gt;bucket&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;weather_app&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;key&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site_file_sources&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;etag&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;filemd5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site_file_sources&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="nx"&gt;content_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&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;span class="c1"&gt;# Upload static assets under website/assets while keeping the same relative path structure.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_s3_object"&lt;/span&gt; &lt;span class="s2"&gt;"site_assets"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fileset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"${local.site_dir}/assets"&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="nx"&gt;bucket&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;weather_app&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;key&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"assets/${each.value}"&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${local.site_dir}/assets/${each.value}"&lt;/span&gt;
  &lt;span class="nx"&gt;etag&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;filemd5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"${local.site_dir}/assets/${each.value}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="c1"&gt;# Derive the file extension from each path so nested assets also receive a correct MIME type.&lt;/span&gt;
  &lt;span class="nx"&gt;content_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mime_types&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;split&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="nx"&gt;each&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;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="s2"&gt;"application/octet-stream"&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;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creates the bucket and turns on static website hosting&lt;/li&gt;
&lt;li&gt;Allows public reads, because the bucket is a public website&lt;/li&gt;
&lt;li&gt;Uploads the site files and assets with the correct content types&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before you apply:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Check the website JavaScript for hardcoded API keys. Everything in &lt;code&gt;website/&lt;/code&gt; becomes public.&lt;/li&gt;
&lt;/ul&gt;

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;cd&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;project&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;\terraform&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;validate&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;apply&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;s3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ls&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;s3://&lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;bucket&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="nx"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--recursive&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The site opens at &lt;code&gt;http://&amp;lt;bucket&amp;gt;.s3-website.eu-north-1.amazonaws.com&lt;/code&gt;. S3 website endpoints are &lt;strong&gt;HTTP only&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The bucket lists your site files.&lt;/li&gt;
&lt;/ul&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%2Fbbetvihvihkfb00prdwk.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%2Fbbetvihvihkfb00prdwk.png" alt="terraform apply complete for the S3 part" width="800" height="182"&gt;&lt;/a&gt;&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%2Flprixvm33g69h2xddmiz.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%2Flprixvm33g69h2xddmiz.png" alt="S3 bucket showing the site files" width="800" height="321"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What went wrong for me:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;"No configuration files."&lt;/strong&gt; I ran &lt;code&gt;terraform init&lt;/code&gt; in the project root, twice. Terraform only reads &lt;code&gt;.tf&lt;/code&gt; files from the current folder, so I now open my terminal directly in &lt;code&gt;terraform/&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;403 on &lt;code&gt;s3:CreateBucket&lt;/code&gt;.&lt;/strong&gt; My IAM user existed (authentication worked) but nothing allowed S3 (authorization failed). Attaching &lt;code&gt;AmazonS3FullAccess&lt;/code&gt; and waiting a minute fixed it.&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 3: Azure static website (secondary)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;azure_storage.tf&lt;/code&gt;&lt;/strong&gt; creates the resource group, the storage account, and uploads the same site to the &lt;code&gt;$web&lt;/code&gt; container.&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="c1"&gt;# Azure Blob Storage hosts the secondary site used during Route53 failover.&lt;/span&gt;
&lt;span class="c1"&gt;# This configuration enables static website hosting and uploads the built site files into the&lt;/span&gt;
&lt;span class="c1"&gt;# Azure-managed "$web" container that serves the application.&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_resource_group"&lt;/span&gt; &lt;span class="s2"&gt;"rg"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"rg-weather-static-website"&lt;/span&gt;
  &lt;span class="nx"&gt;location&lt;/span&gt; &lt;span class="o"&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;azure_location&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;"azurerm_storage_account"&lt;/span&gt; &lt;span class="s2"&gt;"storage"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;                     &lt;span class="o"&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;azure_storage_account_name&lt;/span&gt;
  &lt;span class="nx"&gt;resource_group_name&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rg&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;location&lt;/span&gt;                 &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_resource_group&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;rg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;location&lt;/span&gt;
  &lt;span class="nx"&gt;account_tier&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Standard"&lt;/span&gt;
  &lt;span class="nx"&gt;account_replication_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"LRS"&lt;/span&gt;
  &lt;span class="nx"&gt;account_kind&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"StorageV2"&lt;/span&gt;

  &lt;span class="c1"&gt;# Static website hosting and public traffic are enabled for the failover endpoint.&lt;/span&gt;
  &lt;span class="nx"&gt;public_network_access&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Enabled"&lt;/span&gt;

  &lt;span class="nx"&gt;custom_domain&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;name&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"www.${var.domain_name}"&lt;/span&gt;
    &lt;span class="nx"&gt;use_subdomain&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="c1"&gt;# indirect (asverify) validation&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_route53_record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;azure_asverify&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Enabling static website hosting creates the special "$web" container that serves the site.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"azurerm_storage_account_static_website"&lt;/span&gt; &lt;span class="s2"&gt;"site"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;storage_account_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_storage_account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&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;index_document&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"index.html"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="c1"&gt;# Resource Manager ID of the Azure-managed "$web" container used for static hosting.&lt;/span&gt;
  &lt;span class="nx"&gt;web_container_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${azurerm_storage_account.storage.id}/blobServices/default/containers/$web"&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;"azurerm_storage_blob"&lt;/span&gt; &lt;span class="s2"&gt;"site_files"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site_files&lt;/span&gt;

  &lt;span class="nx"&gt;name&lt;/span&gt;                 &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;
  &lt;span class="nx"&gt;storage_container_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;web_container_id&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;                 &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Block"&lt;/span&gt;
  &lt;span class="nx"&gt;content_type&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;               &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site_file_sources&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;content_md5&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;filemd5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site_file_sources&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;azurerm_storage_account_static_website&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&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;"azurerm_storage_blob"&lt;/span&gt; &lt;span class="s2"&gt;"site_assets"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;fileset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"${local.site_dir}/assets"&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="nx"&gt;name&lt;/span&gt;                 &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"assets/${each.value}"&lt;/span&gt;
  &lt;span class="nx"&gt;storage_container_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;web_container_id&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;                 &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Block"&lt;/span&gt;
  &lt;span class="nx"&gt;content_type&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;lookup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;mime_types&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;reverse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;split&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="nx"&gt;each&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;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="s2"&gt;"application/octet-stream"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="nx"&gt;source&lt;/span&gt;               &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"${local.site_dir}/assets/${each.value}"&lt;/span&gt;
  &lt;span class="nx"&gt;content_md5&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;filemd5&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"${local.site_dir}/assets/${each.value}"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="nx"&gt;depends_on&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;azurerm_storage_account_static_website&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&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;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creates the resource group and storage account&lt;/li&gt;
&lt;li&gt;Turns on static website hosting, which creates the special &lt;code&gt;$web&lt;/code&gt; container&lt;/li&gt;
&lt;li&gt;Uploads the same files as S3&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;custom_domain&lt;/code&gt; block lets Azure accept &lt;code&gt;www.&amp;lt;your-domain&amp;gt;&lt;/code&gt;. It is explained in Step 8.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Heads-up:&lt;/strong&gt; this file references the &lt;code&gt;azure_asverify&lt;/code&gt; record from &lt;code&gt;dns_failover.tf&lt;/code&gt; (Step 5), so Terraform can't plan it alone. Either create the Step 5 file first, or comment out the &lt;code&gt;custom_domain&lt;/code&gt; block and the &lt;code&gt;depends_on&lt;/code&gt; until Step 8.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Run (with &lt;code&gt;ARM_SUBSCRIPTION_ID&lt;/code&gt; set and &lt;code&gt;az login&lt;/code&gt; done):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;init&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-upgrade&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;validate&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;apply&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;azure_website_url&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Open the URL. HTTPS works on the Azure endpoint.&lt;/li&gt;
&lt;li&gt;In the Azure portal: &lt;strong&gt;Storage accounts → your account → Data storage → Containers → &lt;code&gt;$web&lt;/code&gt;&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;The URL is also under &lt;strong&gt;Data management → Static website&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&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%2Fqd8742juamhhbnltrtci.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%2Fqd8742juamhhbnltrtci.png" alt="Azure $web container and the Azure site" width="799" height="433"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What went wrong for me:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;MissingSubscriptionRegistration&lt;/code&gt; for Microsoft.Storage.&lt;/strong&gt; A fresh subscription doesn't have every provider enabled. The &lt;code&gt;az provider register&lt;/code&gt; command in Step 0.3 fixes it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;My code stopped validating.&lt;/strong&gt; I hadn't pinned &lt;code&gt;azurerm&lt;/code&gt;, so I got the newest major release (5.x). The inline &lt;code&gt;static_website&lt;/code&gt; block was removed and blobs now take a &lt;code&gt;storage_container_id&lt;/code&gt;. I read the docs for the version I actually had, rewrote the file (the version above), and now pin &lt;code&gt;~&amp;gt; 5.0&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;"Connection may have been reset"&lt;/strong&gt; right after the storage account was created. Its DNS name hadn't resolved yet. I waited a minute and re-ran &lt;code&gt;terraform apply&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 4: CloudFront (primary, stage 1)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;aws_cloudfront.tf&lt;/code&gt;&lt;/strong&gt; puts HTTPS and caching in front of S3.&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="c1"&gt;# CloudFront distribution for the static website.&lt;/span&gt;
&lt;span class="c1"&gt;# This layer serves the site globally over HTTPS, caches static content at the edge,&lt;/span&gt;
&lt;span class="c1"&gt;# and optionally uses a custom domain certificate when the domain is enabled.&lt;/span&gt;

&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_cloudfront_distribution"&lt;/span&gt; &lt;span class="s2"&gt;"site"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;enabled&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;default_root_object&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"index.html"&lt;/span&gt;
  &lt;span class="nx"&gt;price_class&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PriceClass_100"&lt;/span&gt;

  &lt;span class="c1"&gt;# Attach the custom domain only when the user has enabled domain hosting.&lt;/span&gt;
  &lt;span class="nx"&gt;aliases&lt;/span&gt; &lt;span class="o"&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;enable_custom_domain&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"www.${var.domain_name}"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;

  &lt;span class="c1"&gt;# The S3 website endpoint is configured for website hosting and only supports HTTP.&lt;/span&gt;
  &lt;span class="c1"&gt;# CloudFront therefore uses an HTTP origin connection while still serving HTTPS to viewers.&lt;/span&gt;
  &lt;span class="nx"&gt;origin&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;domain_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_s3_bucket_website_configuration&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;website_endpoint&lt;/span&gt;
    &lt;span class="nx"&gt;origin_id&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3-website"&lt;/span&gt;

    &lt;span class="nx"&gt;custom_origin_config&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;http_port&lt;/span&gt;              &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;
      &lt;span class="nx"&gt;https_port&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;443&lt;/span&gt;
      &lt;span class="nx"&gt;origin_protocol_policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http-only"&lt;/span&gt;
      &lt;span class="nx"&gt;origin_ssl_protocols&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"TLSv1.2"&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;span class="c1"&gt;# Serve the site with a simple, cache-friendly policy for static content.&lt;/span&gt;
  &lt;span class="nx"&gt;default_cache_behavior&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;target_origin_id&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"s3-website"&lt;/span&gt;
    &lt;span class="nx"&gt;viewer_protocol_policy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"redirect-to-https"&lt;/span&gt;
    &lt;span class="nx"&gt;allowed_methods&lt;/span&gt;        &lt;span class="o"&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="s2"&gt;"HEAD"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;cached_methods&lt;/span&gt;         &lt;span class="o"&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="s2"&gt;"HEAD"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="nx"&gt;cache_policy_id&lt;/span&gt;        &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"658327ea-f89d-4fab-a63d-7e88639e58f6"&lt;/span&gt; &lt;span class="c1"&gt;# Managed-CachingOptimized&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# No geo-blocking restrictions are applied for this deployment.&lt;/span&gt;
  &lt;span class="nx"&gt;restrictions&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;geo_restriction&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;restriction_type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"none"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# Use the default CloudFront certificate when no custom domain is configured.&lt;/span&gt;
  &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="s2"&gt;"viewer_certificate"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="o"&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;enable_custom_domain&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="o"&gt;:&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="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;cloudfront_default_certificate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="c1"&gt;# Use the ACM certificate and custom TLS settings when a custom domain is configured.&lt;/span&gt;
  &lt;span class="nx"&gt;dynamic&lt;/span&gt; &lt;span class="s2"&gt;"viewer_certificate"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="o"&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;enable_custom_domain&lt;/span&gt; &lt;span class="o"&gt;?&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="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="nx"&gt;content&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;acm_certificate_arn&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_acm_certificate_validation&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;certificate_arn&lt;/span&gt;
      &lt;span class="nx"&gt;ssl_support_method&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"sni-only"&lt;/span&gt;
      &lt;span class="nx"&gt;minimum_protocol_version&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"TLSv1.2_2021"&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;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Uses the S3 website endpoint as the origin. That endpoint speaks only HTTP, so CloudFront connects over HTTP.&lt;/li&gt;
&lt;li&gt;Redirects viewers from HTTP to HTTPS&lt;/li&gt;
&lt;li&gt;Uses the default CloudFront certificate until you enable the custom domain (Step 7)&lt;/li&gt;
&lt;li&gt;Takes 5-15 minutes to deploy&lt;/li&gt;
&lt;/ul&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%2Fxejft76dwyifo35qk6bs.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%2Fxejft76dwyifo35qk6bs.png" alt="CloudFront distribution and the site on the CloudFront URL" width="800" height="364"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What went wrong for me:&lt;/strong&gt; &lt;code&gt;apply&lt;/code&gt; returned &lt;strong&gt;403 "Your account must be verified before you can add new CloudFront resources"&lt;/strong&gt;. Terraform can't fix this. I opened the Support case from Step 0.6, built other parts while I waited, and the distribution created normally once AWS approved it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 5: Route 53 zone, health checks and failover records
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;dns_failover.tf&lt;/code&gt;&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="c1"&gt;# DNS and failover configuration for the global website.&lt;/span&gt;
&lt;span class="c1"&gt;# This file creates the hosted zone, requests the public certificate, validates it,&lt;/span&gt;
&lt;span class="c1"&gt;# and routes traffic between the primary AWS endpoint and the secondary Azure endpoint.&lt;/span&gt;

&lt;span class="nx"&gt;locals&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;site_host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"www.${var.domain_name}"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Hosted zone for the custom domain used by the site.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_route53_zone"&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="o"&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;domain_name&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# ACM certificate for the CloudFront custom domain. This certificate must be issued in us-east-1&lt;/span&gt;
&lt;span class="c1"&gt;# because CloudFront is a global AWS service and expects certificates in that region.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_acm_certificate"&lt;/span&gt; &lt;span class="s2"&gt;"site"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;count&lt;/span&gt;             &lt;span class="o"&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;enable_custom_domain&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="nx"&gt;provider&lt;/span&gt;          &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;us_east_1&lt;/span&gt;
  &lt;span class="nx"&gt;domain_name&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site_host&lt;/span&gt;
  &lt;span class="nx"&gt;validation_method&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"DNS"&lt;/span&gt;

  &lt;span class="nx"&gt;lifecycle&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;create_before_destroy&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Create the DNS validation records required to verify the ACM certificate.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_route53_record"&lt;/span&gt; &lt;span class="s2"&gt;"cert_validation"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;for_each&lt;/span&gt; &lt;span class="o"&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;enable_custom_domain&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;dvo&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;aws_acm_certificate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;domain_validation_options&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;dvo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain_name&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;name&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dvo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_record_name&lt;/span&gt;
      &lt;span class="nx"&gt;type&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dvo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_record_type&lt;/span&gt;
      &lt;span class="nx"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;dvo&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;resource_record_value&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

  &lt;span class="nx"&gt;zone_id&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_route53_zone&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;zone_id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&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;span class="nx"&gt;name&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;each&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;span class="nx"&gt;type&lt;/span&gt;
  &lt;span class="nx"&gt;records&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;each&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;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;ttl&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
  &lt;span class="nx"&gt;allow_overwrite&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Wait for the ACM certificate to be valid before using it in CloudFront.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_acm_certificate_validation"&lt;/span&gt; &lt;span class="s2"&gt;"site"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;count&lt;/span&gt;                   &lt;span class="o"&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;enable_custom_domain&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="nx"&gt;provider&lt;/span&gt;                &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;us_east_1&lt;/span&gt;
  &lt;span class="nx"&gt;certificate_arn&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_acm_certificate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&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;validation_record_fqdns&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;for&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="nx"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;aws_route53_record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;cert_validation&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;fqdn&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Health checks allow Route53 to fail over automatically when the primary endpoint becomes unavailable.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_route53_health_check"&lt;/span&gt; &lt;span class="s2"&gt;"primary"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;              &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HTTPS"&lt;/span&gt;
  &lt;span class="nx"&gt;fqdn&lt;/span&gt;              &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_cloudfront_distribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain_name&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;443&lt;/span&gt;
  &lt;span class="nx"&gt;resource_path&lt;/span&gt;     &lt;span class="o"&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;primary_health_check_path&lt;/span&gt;
  &lt;span class="nx"&gt;request_interval&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
  &lt;span class="nx"&gt;failure_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"weather-primary-aws"&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_route53_health_check"&lt;/span&gt; &lt;span class="s2"&gt;"secondary"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;              &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HTTPS"&lt;/span&gt;
  &lt;span class="nx"&gt;fqdn&lt;/span&gt;              &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_storage_account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary_web_host&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;443&lt;/span&gt;
  &lt;span class="nx"&gt;resource_path&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"/"&lt;/span&gt;
  &lt;span class="nx"&gt;request_interval&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;
  &lt;span class="nx"&gt;failure_threshold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;

  &lt;span class="nx"&gt;tags&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;Name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"weather-secondary-azure"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Route53 failover records share the same host name and type, but are distinguished by their set identifier.&lt;/span&gt;
&lt;span class="c1"&gt;# The primary record points to the AWS CloudFront distribution and the secondary record points to Azure.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_route53_record"&lt;/span&gt; &lt;span class="s2"&gt;"primary"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;zone_id&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_route53_zone&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;zone_id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site_host&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CNAME"&lt;/span&gt;
  &lt;span class="nx"&gt;ttl&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
  &lt;span class="nx"&gt;records&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;aws_cloudfront_distribution&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;domain_name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;set_identifier&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"primary"&lt;/span&gt;
  &lt;span class="nx"&gt;health_check_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_route53_health_check&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary&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;failover_routing_policy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"PRIMARY"&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_route53_record"&lt;/span&gt; &lt;span class="s2"&gt;"secondary"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;zone_id&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_route53_zone&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;zone_id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;local&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;site_host&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;            &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CNAME"&lt;/span&gt;
  &lt;span class="nx"&gt;ttl&lt;/span&gt;             &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
  &lt;span class="nx"&gt;records&lt;/span&gt;         &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;azurerm_storage_account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary_web_host&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="nx"&gt;set_identifier&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"secondary"&lt;/span&gt;
  &lt;span class="nx"&gt;health_check_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_route53_health_check&lt;/span&gt;&lt;span class="err"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;secondary&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;failover_routing_policy&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"SECONDARY"&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;# Lets Azure verify that we own www.&amp;lt;domain&amp;gt; before it accepts requests for that name.&lt;/span&gt;
&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_route53_record"&lt;/span&gt; &lt;span class="s2"&gt;"azure_asverify"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;zone_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_route53_zone&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;zone_id&lt;/span&gt;
  &lt;span class="nx"&gt;name&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"asverify.www.${var.domain_name}"&lt;/span&gt;
  &lt;span class="nx"&gt;type&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"CNAME"&lt;/span&gt;
  &lt;span class="nx"&gt;ttl&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
  &lt;span class="nx"&gt;records&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"asverify.${var.azure_storage_account_name}.blob.core.windows.net"&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;What it does:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Creates the hosted zone for your domain&lt;/li&gt;
&lt;li&gt;Requests the ACM certificate in &lt;code&gt;us-east-1&lt;/code&gt; and validates it by DNS (used in stage 2)&lt;/li&gt;
&lt;li&gt;Creates one health check per cloud: HTTPS, every 30 seconds, 3 failures&lt;/li&gt;
&lt;li&gt;Creates two &lt;code&gt;www&lt;/code&gt; CNAME records, &lt;strong&gt;PRIMARY&lt;/strong&gt; (CloudFront) and &lt;strong&gt;SECONDARY&lt;/strong&gt; (Azure)&lt;/li&gt;
&lt;li&gt;Creates the &lt;code&gt;asverify&lt;/code&gt; record Azure uses to verify domain ownership (Step 8)&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A design lesson:&lt;/strong&gt; failover records must have the &lt;strong&gt;same name and type&lt;/strong&gt;. The version I started from used different names, which isn't failover at all. Azure can't be an alias target, so both records are CNAMEs on &lt;code&gt;www&lt;/code&gt;. The trade-off: the bare domain can't take part, because a CNAME can't sit at the root.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;outputs.tf&lt;/code&gt;&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="c1"&gt;# Deployment endpoints and DNS values exported for use after Terraform apply.&lt;/span&gt;
&lt;span class="c1"&gt;# These outputs provide the direct URLs for each platform and the DNS details needed&lt;/span&gt;
&lt;span class="c1"&gt;# to complete the custom domain setup.&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"s3_website_url"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Direct S3 static website endpoint over HTTP."&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"http://${aws_s3_bucket_website_configuration.site.website_endpoint}"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"azure_website_url"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Azure static website endpoint served over HTTPS."&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_storage_account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary_web_endpoint&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"azure_website_host"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Azure website hostname used as the secondary failover target."&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;azurerm_storage_account&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;primary_web_host&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"cloudfront_url"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Primary site URL served through CloudFront over HTTPS."&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://${aws_cloudfront_distribution.site.domain_name}"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"route53_name_servers"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Name servers to configure at the registrar for the custom domain."&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;aws_route53_zone&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;name_servers&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;output&lt;/span&gt; &lt;span class="s2"&gt;"site_url"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Custom domain URL for the production website after DNS is configured."&lt;/span&gt;
  &lt;span class="nx"&gt;value&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"https://www.${var.domain_name}"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run stage 1 (&lt;code&gt;enable_custom_domain = false&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;validate&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;apply&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cloudfront_url&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;output&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;route53_name_servers&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cloudfront_url&lt;/code&gt; opens the app over HTTPS.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;route53_name_servers&lt;/code&gt; prints four nameservers. You need them in Step 6.&lt;/li&gt;
&lt;li&gt;In Route 53, both health checks show &lt;strong&gt;Healthy&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&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%2Fetgqrr82z2wp5xmo8vav.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%2Fetgqrr82z2wp5xmo8vav.png" alt="Route 53 hosted zone with failover records and health checks" width="800" height="506"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Step 6: Point the domain at Route 53
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;In Namecheap: &lt;strong&gt;Domain List → Manage → Nameservers → Custom DNS&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Enter the four &lt;code&gt;awsdns&lt;/code&gt; nameservers. No trailing dot, no spaces.&lt;/li&gt;
&lt;li&gt;Save, then check:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;Resolve-DnsName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;your-domain&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;NS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Server&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;8.8.8.8&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Wait until it lists the &lt;code&gt;awsdns&lt;/code&gt; names. This can take up to about 30 minutes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Do not start stage 2 before this works.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Every &lt;code&gt;destroy&lt;/code&gt; and re-&lt;code&gt;apply&lt;/code&gt; creates a &lt;strong&gt;new&lt;/strong&gt; zone with &lt;strong&gt;new&lt;/strong&gt; nameservers, so repeat this step each time.&lt;/li&gt;
&lt;/ul&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%2Fgjd5h07jvrc2dfav989u.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%2Fgjd5h07jvrc2dfav989u.png" alt="Registrar Custom DNS page and the Resolve-DnsName result" width="799" height="349"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What went wrong for me:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;After switching nameservers I kept seeing my registrar's parking page. Google's resolver (&lt;code&gt;-Server 8.8.8.8&lt;/code&gt;) already had the right answer, so it was cached DNS on my own PC. &lt;code&gt;ipconfig /flushdns&lt;/code&gt; and a private window fixed it.&lt;/li&gt;
&lt;li&gt;I also had to verify my domain contact email (ICANN) at the registrar, or the domain could be suspended.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 7: Stage 2, custom domain with HTTPS
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Make sure the IAM user has &lt;code&gt;AWSCertificateManagerFullAccess&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Set &lt;code&gt;enable_custom_domain = true&lt;/code&gt; in &lt;code&gt;terraform.tfvars&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Run:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;apply&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What happens:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Terraform requests the certificate in &lt;code&gt;us-east-1&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;It creates the DNS validation record and waits for validation (2-10 minutes)&lt;/li&gt;
&lt;li&gt;It updates CloudFront with the alias and certificate (5-15 minutes)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;https://www.&amp;lt;your-domain&amp;gt;&lt;/code&gt; loads with a padlock.&lt;/li&gt;
&lt;li&gt;If you still see an old page, run &lt;code&gt;ipconfig /flushdns&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&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%2Fq0x3p3mte1pdd4txqv13.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%2Fq0x3p3mte1pdd4txqv13.png" alt="The site loading on the custom domain over HTTPS" width="800" height="388"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What went wrong for me:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;RequestCertificate&lt;/code&gt; failed with &lt;strong&gt;AccessDenied&lt;/strong&gt;. I had given my IAM user S3, CloudFront and Route 53 permissions but forgot ACM. I attached &lt;code&gt;AWSCertificateManagerFullAccess&lt;/code&gt; and re-ran.&lt;/li&gt;
&lt;li&gt;CloudFront only accepts certificates from &lt;strong&gt;us-east-1&lt;/strong&gt;, even though my infrastructure is in &lt;code&gt;eu-north-1&lt;/code&gt;. That is why &lt;code&gt;providers.tf&lt;/code&gt; has a second, aliased AWS provider.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 8: Make Azure accept the custom domain
&lt;/h2&gt;

&lt;p&gt;Without this, &lt;code&gt;http://www.&amp;lt;your-domain&amp;gt;&lt;/code&gt; returns &lt;strong&gt;400 InvalidUri&lt;/strong&gt; when DNS points at Azure. Azure rejects requests whose Host header it doesn't know.&lt;/p&gt;

&lt;p&gt;In the code above, two things fix it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;code&gt;azure_asverify&lt;/code&gt; CNAME in &lt;code&gt;dns_failover.tf&lt;/code&gt; proves you own the name.&lt;/li&gt;
&lt;li&gt;The &lt;code&gt;custom_domain&lt;/code&gt; block and &lt;code&gt;depends_on&lt;/code&gt; in &lt;code&gt;azure_storage.tf&lt;/code&gt; register &lt;code&gt;www.&amp;lt;your-domain&amp;gt;&lt;/code&gt; on the storage account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you commented them out in Step 3, uncomment them now, then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;plan&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;apply&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;Azure checks the &lt;code&gt;asverify&lt;/code&gt; record when it applies the custom domain, so complete Step 6 first.&lt;/li&gt;
&lt;li&gt;Before applying, &lt;code&gt;plan&lt;/code&gt; should show the storage account updated &lt;strong&gt;in place&lt;/strong&gt;, not replaced.&lt;/li&gt;
&lt;li&gt;Limit: this works over HTTP only. HTTPS with a custom domain on a plain storage account shows a certificate warning. The production fix is Azure Front Door.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What went wrong for me:&lt;/strong&gt; opening the failover hostname while it pointed at Azure first returned &lt;strong&gt;&lt;code&gt;400 InvalidUri&lt;/code&gt;&lt;/strong&gt;. After the custom domain was registered with the &lt;code&gt;asverify&lt;/code&gt; record and the &lt;code&gt;custom_domain&lt;/code&gt; block, the Azure site loaded on &lt;code&gt;www.&amp;lt;your-domain&amp;gt;&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 9: Test the failover
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Before.&lt;/strong&gt; In Route 53 → Health checks, both are &lt;strong&gt;Healthy&lt;/strong&gt;. Then query one of your four nameservers, which avoids local caching:
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Resolve-DnsName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;www.&lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;CNAME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Server&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;one-awsdns-nameserver&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NameHost&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Expect the CloudFront name.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Break the primary&lt;/strong&gt; to fake an outage, in the AWS console:

&lt;ul&gt;
&lt;li&gt;Go to &lt;strong&gt;Route 53 → Health checks&lt;/strong&gt; and select &lt;code&gt;weather-primary-aws&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Click &lt;strong&gt;Edit&lt;/strong&gt; and change &lt;strong&gt;Path&lt;/strong&gt; from &lt;code&gt;/&lt;/code&gt; to &lt;code&gt;/does-not-exist&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Save. That path doesn't exist, so the check starts failing.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The Terraform alternative does the same thing from code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="w"&gt;   &lt;/span&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;apply&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-var&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"primary_health_check_path=/does-not-exist"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Watch the health check.&lt;/strong&gt; Refresh the Health checks page. Allow 2-3 minutes (3 failures x 30 seconds). The primary changes from &lt;strong&gt;Healthy&lt;/strong&gt; to &lt;strong&gt;Unhealthy&lt;/strong&gt;.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Confirm which cloud is answering.&lt;/strong&gt; Your browser can't tell you, because both copies look the same. Ask DNS instead:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="w"&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;12&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ForEach-Object&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="n"&gt;Resolve-DnsName&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;www.&lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;domain&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;CNAME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-Server&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nx"&gt;one-awsdns-nameserver&lt;/span&gt;&lt;span class="err"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;NameHost&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Start-Sleep&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;20&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;Once the primary is Unhealthy, expect the answer to change from the CloudFront name to the Azure hostname.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Fail back.&lt;/strong&gt; Edit the health check path back to &lt;code&gt;/&lt;/code&gt; (or run &lt;code&gt;terraform apply&lt;/code&gt; without the &lt;code&gt;-var&lt;/code&gt;). After 1-3 minutes the primary is &lt;strong&gt;Healthy&lt;/strong&gt; again.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;What I observed:&lt;/strong&gt; after I changed the health check path in the Route 53 console, the primary check went from Healthy to Unhealthy, and the site stayed available. That’s what failover is meant to do: visitors don’t see an outage.&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%2Fuo5fnk1wv4lfla57zafx.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%2Fuo5fnk1wv4lfla57zafx.png" alt="Route 53 health checks, healthy and unhealthy" width="799" height="244"&gt;&lt;/a&gt;&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%2F8j5je53hgxcovl4n4c5e.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%2F8j5je53hgxcovl4n4c5e.png" alt="Route 53 health checks, healthy and unhealthy" width="800" height="329"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A limit:&lt;/strong&gt; DNS failover is &lt;strong&gt;recovery in minutes, not instant&lt;/strong&gt;. Detection takes a few health-check intervals, and clients can cache the old answer for up to the record's TTL (60 seconds here).&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Step 10: Clean up
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;destroy&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;CloudFront is slow to delete (10-15 minutes).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then verify:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;terraform&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;s3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;ls&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;cloudfront&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;list-distributions&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"DistributionList.Items[].Id"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="n"&gt;az&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;group&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;list&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;-o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;table&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Also check:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The ACM certificate in &lt;strong&gt;us-east-1&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;Route 53 hosted zones and health checks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Terraform does &lt;strong&gt;not&lt;/strong&gt; remove:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The domain itself. Turn off auto-renew at Namecheap if you don't want to keep it.&lt;/li&gt;
&lt;li&gt;The domain's nameservers. Point them back at your registrar's default DNS.&lt;/li&gt;
&lt;li&gt;The IAM user and its access keys. Deactivate or delete them.&lt;/li&gt;
&lt;li&gt;Local &lt;code&gt;terraform.tfstate&lt;/code&gt; (git-ignored)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Check Billing and Cost Management the next day.&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%2Fizyiiyz6g3xpztpw2cop.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%2Fizyiiyz6g3xpztpw2cop.png" alt="terraform destroy complete" width="800" height="317"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Troubleshooting: every error I hit
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;No configuration files&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Ran Terraform in the project root&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cd&lt;/code&gt; into &lt;code&gt;terraform/&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;403 on &lt;code&gt;s3:CreateBucket&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;IAM user has no S3 permission (authorization, not authentication)&lt;/td&gt;
&lt;td&gt;Attach &lt;code&gt;AmazonS3FullAccess&lt;/code&gt;, wait a minute&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Terraform acts as an unexpected user&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;aws configure&lt;/code&gt; overwrote &lt;code&gt;default&lt;/code&gt;, or stale &lt;code&gt;AWS_*&lt;/code&gt; variables&lt;/td&gt;
&lt;td&gt;Use named profiles and &lt;code&gt;$env:AWS_PROFILE&lt;/code&gt;; check &lt;code&gt;aws sts get-caller-identity&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure: subscription ID required&lt;/td&gt;
&lt;td&gt;azurerm v4+ needs it&lt;/td&gt;
&lt;td&gt;&lt;code&gt;$env:ARM_SUBSCRIPTION_ID = (az account show --query id -o tsv)&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure: &lt;code&gt;MissingSubscriptionRegistration&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Resource provider not registered on a new subscription&lt;/td&gt;
&lt;td&gt;&lt;code&gt;az provider register --namespace Microsoft.Storage --wait&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;static_website&lt;/code&gt; not expected / &lt;code&gt;storage_container_id&lt;/code&gt; required&lt;/td&gt;
&lt;td&gt;azurerm 5.x syntax changed; version not pinned&lt;/td&gt;
&lt;td&gt;Use &lt;code&gt;azurerm_storage_account_static_website&lt;/code&gt; and &lt;code&gt;storage_container_id&lt;/code&gt;; pin &lt;code&gt;~&amp;gt; 5.0&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure: connection reset after creating the account&lt;/td&gt;
&lt;td&gt;New account's DNS name not resolving yet&lt;/td&gt;
&lt;td&gt;Wait a minute, re-run apply&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudFront 403: account must be verified&lt;/td&gt;
&lt;td&gt;New-account restriction&lt;/td&gt;
&lt;td&gt;Support case (Account and billing), wait&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ACM &lt;code&gt;RequestCertificate&lt;/code&gt; AccessDenied&lt;/td&gt;
&lt;td&gt;IAM user lacks ACM permission&lt;/td&gt;
&lt;td&gt;Attach &lt;code&gt;AWSCertificateManagerFullAccess&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Certificate not offered for CloudFront&lt;/td&gt;
&lt;td&gt;Created outside &lt;code&gt;us-east-1&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Use the &lt;code&gt;aws.us_east_1&lt;/code&gt; provider alias for ACM&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Failover records do nothing&lt;/td&gt;
&lt;td&gt;Primary and secondary had different name or type&lt;/td&gt;
&lt;td&gt;Same name and type (CNAME on &lt;code&gt;www&lt;/code&gt;) for both&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser shows registrar parking page after nameserver change&lt;/td&gt;
&lt;td&gt;Cached DNS on my PC&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ipconfig /flushdns&lt;/code&gt;, private window, check with &lt;code&gt;-Server 8.8.8.8&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Azure &lt;code&gt;400 InvalidUri&lt;/code&gt; on the custom domain&lt;/td&gt;
&lt;td&gt;Azure rejects an unregistered Host header&lt;/td&gt;
&lt;td&gt;Step 8 (&lt;code&gt;asverify&lt;/code&gt; record plus &lt;code&gt;custom_domain&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Domain contact alert at the registrar&lt;/td&gt;
&lt;td&gt;ICANN contact email not verified&lt;/td&gt;
&lt;td&gt;Verify the email at the registrar&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Security checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;No keys, account IDs, subscription IDs or Support case IDs in code, docs or screenshots&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terraform.tfvars&lt;/code&gt;, &lt;code&gt;.terraform/&lt;/code&gt; and &lt;code&gt;*.tfstate*&lt;/code&gt; are git-ignored. &lt;code&gt;.terraform.lock.hcl&lt;/code&gt; is committed.&lt;/li&gt;
&lt;li&gt;Credentials come from CLI profiles and &lt;code&gt;az login&lt;/code&gt;, not from Terraform variables&lt;/li&gt;
&lt;li&gt;Check the website files for hardcoded API keys before committing, because everything in &lt;code&gt;website/&lt;/code&gt; is public&lt;/li&gt;
&lt;li&gt;WHOIS privacy and two-factor authentication on the registrar account&lt;/li&gt;
&lt;li&gt;Delete or deactivate unused IAM access keys after the project&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Known limits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Failover is not instant.&lt;/strong&gt; Detection takes a few health-check intervals, then clients can cache the old answer for up to the TTL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only &lt;code&gt;www&lt;/code&gt; fails over.&lt;/strong&gt; A CNAME can't sit at the bare domain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure custom domain is HTTP only&lt;/strong&gt; on a plain storage account.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State is local.&lt;/strong&gt; That's fine for a demo. Production uses a remote backend with locking.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Terraform is only as powerful as the identity behind it.&lt;/strong&gt; Know your credential chain before you apply.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the docs for the version you installed.&lt;/strong&gt; An unpinned provider changed its syntax under me.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;New cloud accounts have hidden walls&lt;/strong&gt; (verification, provider registration). Find them on day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A failover diagram isn't a failover.&lt;/strong&gt; I tested it by breaking the primary health check and watching what happened.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  What I'd do next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Azure Front Door in front of the Azure site, for HTTPS on a custom domain&lt;/li&gt;
&lt;li&gt;Remote state with locking&lt;/li&gt;
&lt;li&gt;OIDC federation from CI, so no long-lived keys exist&lt;/li&gt;
&lt;li&gt;Refactor into Terraform modules&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Let's connect
&lt;/h2&gt;

&lt;p&gt;I'm actively building cloud and DevOps projects and I'm open to opportunities. If you're hiring, or you're learning Terraform and got stuck where I did, reach out.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;GitHub: &lt;a href="https://github.com/samobeng14Dev" rel="noopener noreferrer"&gt;samobeng14Dev&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;LinkedIn: &lt;a href="https://www.linkedin.com/in/samobeng14dev/" rel="noopener noreferrer"&gt;samobeng14dev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Built from a module in Lucy Wang's advanced cloud project course, with my own changes and write-up.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aws</category>
      <category>azure</category>
      <category>terraform</category>
      <category>devops</category>
    </item>
    <item>
      <title>Building a Near Real-Time Stock Analytics Pipeline on AWS (and the Silent Bug That Hid My Data)</title>
      <dc:creator>Samuel Obeng</dc:creator>
      <pubDate>Thu, 05 Nov 2026 10:02:39 +0000</pubDate>
      <link>https://dev.to/samobeng14dev/building-a-near-real-time-stock-analytics-pipeline-on-aws-and-the-silent-bug-that-hid-my-data-4d11</link>
      <guid>https://dev.to/samobeng14dev/building-a-near-real-time-stock-analytics-pipeline-on-aws-and-the-silent-bug-that-hid-my-data-4d11</guid>
      <description>&lt;h1&gt;
  
  
  Building a Near Real-Time Stock Data Pipeline on AWS: What I Learned and Debugged
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Tech stack:&lt;/strong&gt; Python, boto3, Amazon Kinesis Data Streams, AWS Lambda, DynamoDB, Amazon S3, AWS Glue, Amazon Athena, Amazon SNS, IAM, CloudWatch&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Repository:&lt;/strong&gt; &lt;a href="https://github.com/samobeng14Dev/amazon-kinesis-stock-analyst-project" rel="noopener noreferrer"&gt;amazon-kinesis-stock-analyst-project&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;I wanted to understand how data moves through a cloud pipeline, from an external API to a streaming service, through serverless processing, into storage, and finally into analytics and notifications.&lt;/p&gt;

&lt;p&gt;So I built a stock market data pipeline using Python and AWS.&lt;/p&gt;

&lt;p&gt;The Python script sends stock price records to Amazon Kinesis Data Streams. AWS Lambda processes the incoming records, storing processed data in DynamoDB and raw data in Amazon S3. AWS Glue and Amazon Athena make the archived data available for SQL queries, while DynamoDB Streams, another Lambda function, and Amazon SNS provide the infrastructure for trend-based email alerts.&lt;/p&gt;

&lt;p&gt;This is a &lt;strong&gt;near-real-time pipeline, not a real-time trading system&lt;/strong&gt;. Records are sent every 30 seconds and processed in small batches.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Credit:&lt;/strong&gt; I built this project while taking a project course by Lucy Wang. The course provided the project brief and starter code, including the Lambda code. This post isn't a walkthrough. It's my field notes on the changes I made, what broke, and how I debugged it. If you want the guided version, I recommend the course.&lt;/p&gt;
&lt;/blockquote&gt;

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

&lt;p&gt;Here's how the data moves through 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;Stock API
    |
    v
Python Producer
    |
    v
Kinesis Data Streams
    |
    v
Processing Lambda
    |
    +------&amp;gt; DynamoDB (processed data)
    |
    +------&amp;gt; S3 (raw data)
                 |
                 v
              AWS Glue
                 |
                 v
             Amazon Athena
             (SQL analytics)

DynamoDB Streams
    |
    v
Alert Lambda
    |
    v
Amazon SNS
    |
    v
Email notification
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2Fok1mpmbaqvar529cxhae.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%2Fok1mpmbaqvar529cxhae.png" alt=" " width="700" height="447"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Each service has a specific responsibility:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Kinesis Data Streams&lt;/td&gt;
&lt;td&gt;Receives incoming stock price records.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Lambda&lt;/td&gt;
&lt;td&gt;Processes records, calculates metrics, and identifies potential anomalies.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB&lt;/td&gt;
&lt;td&gt;Stores processed records for fast lookups.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon S3&lt;/td&gt;
&lt;td&gt;Stores raw records for historical analysis.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS Glue&lt;/td&gt;
&lt;td&gt;Catalogs the data stored in S3.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon Athena&lt;/td&gt;
&lt;td&gt;Queries the archived data using SQL.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;DynamoDB Streams&lt;/td&gt;
&lt;td&gt;Captures changes to DynamoDB items.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lambda + SNS&lt;/td&gt;
&lt;td&gt;Supports trend detection and email notifications.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CloudWatch&lt;/td&gt;
&lt;td&gt;Provides logs for monitoring and debugging.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I built and tested the project on Windows using PowerShell and a Python virtual environment, with my AWS resources configured in &lt;code&gt;eu-north-1&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Changed Beyond the Starter Code
&lt;/h2&gt;

&lt;p&gt;Since I planned to publish the project publicly, I made several changes to the configuration and security setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Moved configuration out of the code
&lt;/h3&gt;

&lt;p&gt;The sample script contained hardcoded configuration values, including a placeholder stream name.&lt;/p&gt;

&lt;p&gt;I changed it to read settings from environment variables loaded from a local &lt;code&gt;.env&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;For example:&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;STREAM_NAME&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="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KINESIS_STREAM_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;stock-market-stream&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;AWS_REGION&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="nf"&gt;getenv&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_REGION&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;I also added a &lt;code&gt;.env.example&lt;/code&gt; file to document the expected configuration without exposing my local values.&lt;/p&gt;

&lt;p&gt;The actual &lt;code&gt;.env&lt;/code&gt; file is excluded from version control through &lt;code&gt;.gitignore&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Kept AWS credentials out of the repository
&lt;/h3&gt;

&lt;p&gt;The Python script contains no AWS access keys. Instead, boto3 retrieves credentials through the AWS CLI configuration.&lt;/p&gt;

&lt;p&gt;I also restricted the producer's IAM permissions to writing records to the intended Kinesis stream rather than granting unnecessary access to other services.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Reduced unnecessary logging
&lt;/h3&gt;

&lt;p&gt;The original script printed the full Kinesis response, including request metadata.&lt;/p&gt;

&lt;p&gt;I reduced the output to useful information, such as the stock symbol, price, and shard identifier. This made the logs easier to read and safer to share when troubleshooting.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Added secret-protection measures
&lt;/h3&gt;

&lt;p&gt;Before publishing the repository, I added:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;code&gt;.gitignore&lt;/code&gt; entry for &lt;code&gt;.env&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Secret scanning.&lt;/li&gt;
&lt;li&gt;GitHub push protection.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These safeguards help reduce the risk of accidentally committing credentials. They complement, rather than replace, appropriate IAM permissions and careful review of commits.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Bug That Cost Me an Hour
&lt;/h2&gt;

&lt;p&gt;This was the most valuable part of the project for me.&lt;/p&gt;

&lt;p&gt;My Python script printed a success message every 30 seconds:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;INFO Sent AAPL price=330.32 shard=shardId-000000000002
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But nothing appeared downstream.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;DynamoDB had no processed items.&lt;/li&gt;
&lt;li&gt;CloudWatch had no expected Lambda log streams.&lt;/li&gt;
&lt;li&gt;Kinesis Data Viewer showed no records in the intended stream.&lt;/li&gt;
&lt;li&gt;The Lambda event source mapping reported no records processed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything looked correct at first, so I started debugging from the middle of the pipeline.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Tested the processing Lambda independently
&lt;/h3&gt;

&lt;p&gt;I sent a manually constructed test event shaped like a Kinesis event to the processing Lambda.&lt;/p&gt;

&lt;p&gt;The function returned a successful response, but that alone wasn't enough. My handler caught some errors internally, meaning an invocation could appear successful even when processing had failed.&lt;/p&gt;

&lt;p&gt;I checked the logs and verified that the test record appeared in DynamoDB and that the expected file was written to S3.&lt;/p&gt;

&lt;p&gt;That gave me evidence that the processing and storage path could work independently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Checked the Lambda trigger
&lt;/h3&gt;

&lt;p&gt;Next, I checked the event source mapping connecting Kinesis to Lambda.&lt;/p&gt;

&lt;p&gt;It was enabled and attached to the intended stream. The status indicated that no records had been processed.&lt;/p&gt;

&lt;p&gt;Combined with the empty Kinesis Data Viewer, this pointed me toward the ingestion side rather than immediately blaming the Lambda function.&lt;/p&gt;

&lt;p&gt;My question changed from:&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Why isn't Lambda running?&lt;/em&gt;&lt;/p&gt;

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

&lt;p&gt;&lt;em&gt;Are my records reaching the Kinesis stream that Lambda is reading?&lt;/em&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Investigated the Python producer
&lt;/h3&gt;

&lt;p&gt;I went back to the producer and checked its environment.&lt;/p&gt;

&lt;p&gt;I found this error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ModuleNotFoundError: No module named 'dotenv'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;python-dotenv&lt;/code&gt; package wasn't installed in my virtual environment.&lt;/p&gt;

&lt;p&gt;My dependency list had been generated using &lt;code&gt;pip freeze&lt;/code&gt;, but it didn't include the package the script required.&lt;/p&gt;

&lt;p&gt;There was another problem: the import was wrapped in an &lt;code&gt;except ImportError&lt;/code&gt; block that silently ignored the failure.&lt;/p&gt;

&lt;p&gt;As a result, the script continued without loading the &lt;code&gt;.env&lt;/code&gt; file.&lt;/p&gt;

&lt;p&gt;That meant the intended AWS region wasn't being loaded from the project's configuration. Boto3 could instead use the region configured through the AWS CLI or another applicable AWS configuration source.&lt;/p&gt;

&lt;p&gt;The producer could therefore appear healthy while sending records somewhere other than the stream I was monitoring.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Fixed the configuration and verified the pipeline
&lt;/h3&gt;

&lt;p&gt;I installed the missing dependency, restarted the producer, and monitored the pipeline in sequence.&lt;/p&gt;

&lt;p&gt;My verification checklist was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Confirm records appeared in Kinesis.&lt;/li&gt;
&lt;li&gt;Confirm the processing Lambda received records.&lt;/li&gt;
&lt;li&gt;Confirm processed items appeared in DynamoDB.&lt;/li&gt;
&lt;li&gt;Confirm raw data was written to S3.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This helped me isolate the problem instead of changing multiple AWS resources unnecessarily.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The biggest lesson:&lt;/strong&gt; A successful message from the producer doesn't prove that the intended consumer received the data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Technical Decisions I Can Explain
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Raw data in S3, processed data in DynamoDB
&lt;/h3&gt;

&lt;p&gt;I used S3 for raw records and DynamoDB for processed records.&lt;/p&gt;

&lt;p&gt;S3 provides economical object storage for historical data, while DynamoDB supports fast item-level lookups. Keeping the raw data also provides a useful starting point for historical analysis.&lt;/p&gt;

&lt;h3&gt;
  
  
  Athena instead of maintaining a query database
&lt;/h3&gt;

&lt;p&gt;AWS Glue and Athena allowed me to query the data stored in S3 using SQL without maintaining a continuously running database.&lt;/p&gt;

&lt;p&gt;Athena charges according to the amount of data scanned, so query design and data layout still matter when controlling costs.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;Decimal&lt;/code&gt; instead of &lt;code&gt;float&lt;/code&gt; for DynamoDB
&lt;/h3&gt;

&lt;p&gt;Python's &lt;code&gt;float&lt;/code&gt; type isn't supported by boto3's DynamoDB serializer for numeric attributes.&lt;/p&gt;

&lt;p&gt;I used &lt;code&gt;Decimal&lt;/code&gt; for numeric values written to DynamoDB to avoid serialization errors and handle decimal values appropriately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Small batches for a low-volume stream
&lt;/h3&gt;

&lt;p&gt;The producer sends records every 30 seconds, and the Lambda event source mapping uses a small batch size.&lt;/p&gt;

&lt;p&gt;A batch size of two doesn't guarantee that Lambda runs exactly once every minute. Actual invocation timing also depends on polling, record arrival, and the event source mapping's configuration.&lt;/p&gt;

&lt;p&gt;The goal was to keep the pipeline simple while learning how batching affects processing latency and invocation overhead.&lt;/p&gt;

&lt;h3&gt;
  
  
  Kinesis on-demand mode
&lt;/h3&gt;

&lt;p&gt;I chose on-demand mode so I wouldn't need to determine a fixed shard capacity upfront.&lt;/p&gt;

&lt;p&gt;However, on-demand doesn't mean free. Costs depend on the pricing model and usage, so unused resources still need to be cleaned up.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned About the Data
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Repeated prices don't necessarily mean a broken pipeline
&lt;/h3&gt;

&lt;p&gt;During testing, I noticed the same stock price appearing repeatedly.&lt;/p&gt;

&lt;p&gt;The script fetched daily stock bars rather than continuously changing market prices. Sending the same daily value every 30 seconds didn't make the underlying data update every 30 seconds.&lt;/p&gt;

&lt;p&gt;The US market was also closed during my test, contributing to the repeated values.&lt;/p&gt;

&lt;p&gt;This taught me an important distinction: &lt;strong&gt;ingestion frequency is not the same as data freshness.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Trend alerts need meaningful price movement
&lt;/h3&gt;

&lt;p&gt;The alert logic compares a five-record moving average with a 20-record moving average.&lt;/p&gt;

&lt;p&gt;A crossover can indicate a potential trend change, but repeated or nearly identical prices provide little movement for testing this logic.&lt;/p&gt;

&lt;p&gt;A better test would use data containing meaningful price variation and known crossover scenarios, allowing the alert conditions to be validated deliberately.&lt;/p&gt;

&lt;h3&gt;
  
  
  Timestamp formats matter
&lt;/h3&gt;

&lt;p&gt;I also identified a potential issue with the alert function's time-window logic.&lt;/p&gt;

&lt;p&gt;The processing path writes timestamps in a format 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;2026-10-02T12:00:00Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The alert function constructs its cutoff using a space between the date and time instead of the &lt;code&gt;T&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;When timestamps are compared as strings, inconsistent formats can produce incorrect time-window results. The exact impact depends on the stored values and comparison logic.&lt;/p&gt;

&lt;p&gt;A safer implementation would use consistent UTC timestamps and compare normalized values or actual datetime objects.&lt;/p&gt;

&lt;p&gt;It's a reminder that a pipeline can successfully move data while still having problems with the logic applied to that data.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verifying the Pipeline
&lt;/h2&gt;

&lt;p&gt;The following screenshots provide evidence of the main stages of the project.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Kinesis Data Streams
&lt;/h3&gt;

&lt;p&gt;This is where I checked whether the producer's records were reaching the intended stream.&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%2Ff6bd8mqdlk4zyljjkdjg.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%2Ff6bd8mqdlk4zyljjkdjg.png" alt="Kinesis Data Streams showing incoming records" width="800" height="325"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  2. DynamoDB
&lt;/h3&gt;

&lt;p&gt;The processed records should appear in DynamoDB after the processing Lambda handles incoming events.&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%2Fgqnofzpe50thuxz187fx.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%2Fgqnofzpe50thuxz187fx.png" alt="DynamoDB table showing processed items" width="800" height="318"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Amazon S3
&lt;/h3&gt;

&lt;p&gt;The raw records are stored in S3 for historical analysis.&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%2Fgj1yt0fdnefmz2kd7b0k.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%2Fgj1yt0fdnefmz2kd7b0k.png" alt="S3 bucket containing raw stock data" width="800" height="326"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Amazon Athena
&lt;/h3&gt;

&lt;p&gt;Athena provides a way to query the archived data using SQL.&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%2Fep9ne2fuksqcai9i48e8.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%2Fep9ne2fuksqcai9i48e8.png" alt="Amazon Athena query results" width="800" height="477"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;These screenshots show different parts of the system. Together, they help readers follow the data from ingestion to storage and analytics.&lt;/p&gt;

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

&lt;p&gt;After completing the project, I removed the AWS resources to avoid unnecessary ongoing charges.&lt;/p&gt;

&lt;p&gt;My cleanup covered the producer and the AWS resources used by the pipeline, including Kinesis, Lambda, DynamoDB, SNS, S3, Glue, CloudWatch log groups, and associated IAM roles.&lt;/p&gt;

&lt;p&gt;The important lesson is that stopping an application doesn't automatically remove the cloud resources it uses.&lt;/p&gt;

&lt;p&gt;Before deleting resources, preserve any evidence or data you want to keep and check for dependencies. After cleanup, verify that the intended resources have actually been removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Improve Next
&lt;/h2&gt;

&lt;p&gt;If I extended the project, I'd focus on six areas:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Use fresher market data.&lt;/strong&gt; Introduce a suitable source of more frequent price updates so the trend logic can be tested with meaningful variation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fix and test the alert time window.&lt;/strong&gt; Normalize timestamps and create test cases for expected alert and non-alert scenarios.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply least-privilege IAM to both Lambda functions.&lt;/strong&gt; Limit each function to the actions and resources it actually needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Externalize remaining configuration.&lt;/strong&gt; Move bucket names and SNS topic ARNs into environment-specific configuration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Introduce Terraform.&lt;/strong&gt; Make the infrastructure reproducible and simplify setup and teardown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Improve failure handling.&lt;/strong&gt; Configure suitable retries, monitoring, and failure destinations or dead-letter queues where supported.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These improvements would make the project easier to reproduce, maintain, and extend.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;This project taught me more than how to connect AWS services. It taught me how to investigate a distributed system when one component appears healthy but the overall pipeline isn't behaving as expected.&lt;/p&gt;

&lt;p&gt;My biggest takeaways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Trace data end to end.&lt;/strong&gt; Verify the producer, stream, consumer, and storage independently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make configuration failures visible.&lt;/strong&gt; Missing dependencies and configuration should never fail silently.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test components in isolation.&lt;/strong&gt; A controlled Lambda test can help rule out entire sections of a pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Understand the data, not just the infrastructure.&lt;/strong&gt; Frequent ingestion doesn't automatically mean fresh data.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build with security and cost in mind.&lt;/strong&gt; Restrict permissions, protect credentials, and clean up unused resources.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I'm continuing to build my practical AWS skills, and this project gave me a clearer understanding of how streaming, serverless processing, storage, analytics, and event-driven notifications fit together.&lt;/p&gt;

&lt;h2&gt;
  
  
  Explore the Project
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub:&lt;/strong&gt; &lt;a href="https://github.com/samobeng14Dev/amazon-kinesis-stock-analyst-project" rel="noopener noreferrer"&gt;amazon-kinesis-stock-analyst-project&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/samobeng14dev/" rel="noopener noreferrer"&gt;samobeng14dev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Technologies used:&lt;/strong&gt; Python, boto3, Amazon Kinesis Data Streams, AWS Lambda, DynamoDB, DynamoDB Streams, Amazon S3, AWS Glue, Amazon Athena, Amazon SNS, IAM, CloudWatch, Git, and GitHub.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>serverless</category>
      <category>python</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>What Building My First AWS CI/CD Pipeline Taught Me (and Where It Broke)</title>
      <dc:creator>Samuel Obeng</dc:creator>
      <pubDate>Thu, 05 Nov 2026 10:01:07 +0000</pubDate>
      <link>https://dev.to/samobeng14dev/what-building-my-first-aws-cicd-pipeline-taught-me-and-where-it-broke-3jg1</link>
      <guid>https://dev.to/samobeng14dev/what-building-my-first-aws-cicd-pipeline-taught-me-and-where-it-broke-3jg1</guid>
      <description>&lt;h1&gt;
  
  
  From Git Push to AWS Deployment: My Docker CI/CD Pipeline for the 2048 Game
&lt;/h1&gt;

&lt;p&gt;&lt;strong&gt;Tech stack:&lt;/strong&gt; Docker, AWS CodePipeline, AWS CodeBuild, Amazon ECR, Amazon ECS Fargate, IAM, Amazon S3, GitHub&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Project repository:&lt;/strong&gt; &lt;a href="https://github.com/samobeng14Dev/aws-ecs-ci-cd-2048-game" rel="noopener noreferrer"&gt;aws-ecs-ci-cd-2048-game&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Introduction
&lt;/h2&gt;

&lt;p&gt;For a while, my Docker experience stopped at my laptop. I could build an image and run a container locally, but I wanted to understand what happens when a team needs to ship changes automatically.&lt;/p&gt;

&lt;p&gt;So I built a CI/CD pipeline on AWS where a GitHub push triggers a build, publishes a Docker image, and deploys the updated application to Amazon ECS Fargate.&lt;/p&gt;

&lt;p&gt;I used the open-source 2048 game because the application itself is relatively simple. That let me focus on the infrastructure, the deployment process, and the problems that came up along the way.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The goal:&lt;/strong&gt; push code, let the pipeline do the work, and verify that the change is live without manually rebuilding or deploying the application.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Credit:&lt;/strong&gt; I built this project while taking project-based cloud course by Lucy Wang. The course supplied the project brief and starter files. This post isn't a walkthrough. It's my field notes on what went wrong, what I changed, and what I learned. If you want the guided version, I recommend the course.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The Shape of the System
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GitHub Push
    |
    v
AWS CodePipeline
    |
    v
AWS CodeBuild
    |
    | Build and tag Docker image
    v
Amazon ECR
    |
    | Pull image and deploy
    v
Amazon ECS Fargate
    |
    v
Browser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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%2F8tk3sn8dw5gccjeht0j7.jpg" 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%2F8tk3sn8dw5gccjeht0j7.jpg" alt="AWS CI/CD pipeline architecture" width="800" height="503"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Here's what each service does:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;GitHub&lt;/td&gt;
&lt;td&gt;Stores the application source code and triggers the workflow when changes are pushed.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS CodePipeline&lt;/td&gt;
&lt;td&gt;Orchestrates the source, build, and deployment stages.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;AWS CodeBuild&lt;/td&gt;
&lt;td&gt;Builds the Docker image and prepares the deployment configuration.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon ECR&lt;/td&gt;
&lt;td&gt;Stores the versioned Docker images in a private container registry.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon ECS Fargate&lt;/td&gt;
&lt;td&gt;Runs the application container without requiring me to manage the underlying servers.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IAM&lt;/td&gt;
&lt;td&gt;Controls which AWS resources and actions the pipeline can access.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Amazon S3&lt;/td&gt;
&lt;td&gt;Supports storage of pipeline artifacts.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;I built and tested the project on Windows using PowerShell, with the AWS resources deployed in &lt;code&gt;eu-north-1&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The architecture is straightforward, but getting all the pieces to work together taught me quite a bit.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Was Given vs. What I Added
&lt;/h2&gt;

&lt;p&gt;The course provided the project brief, starter application, and sample build configuration. I made several changes to improve the configuration and understand how the deployment process worked.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Removed hardcoded AWS account details
&lt;/h3&gt;

&lt;p&gt;The original build configuration included my AWS account ID and region directly in the file.&lt;/p&gt;

&lt;p&gt;That wasn't something I wanted to leave in a public repository. I changed the configuration to retrieve the account ID during the build and construct the ECR registry URI dynamically.&lt;/p&gt;

&lt;p&gt;For example, these commands can be used in a CodeBuild &lt;code&gt;pre_build&lt;/code&gt; phase:&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;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;ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;REGISTRY=$ACCOUNT_ID.dkr.ecr.$AWS_DEFAULT_REGION.amazonaws.com&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The account ID now comes from the AWS identity used by the build environment rather than being hardcoded in the repository.&lt;/p&gt;

&lt;p&gt;This also made the configuration easier to reuse across environments.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Added commit-based Docker image tags
&lt;/h3&gt;

&lt;p&gt;Initially, the &lt;code&gt;latest&lt;/code&gt; tag seemed sufficient. But there's a problem with relying on it alone: the tag can point to a different image after every build.&lt;/p&gt;

&lt;p&gt;If something breaks after deployment, I want to know exactly which version of the source code produced the image.&lt;/p&gt;

&lt;p&gt;I added a tag derived from the Git commit hash:&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="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;IMAGE_TAG=$(echo ${CODEBUILD_RESOLVED_SOURCE_VERSION:-local} | cut -c 1-7)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Git sources where CodeBuild exposes the commit hash, this produces a short identifier that connects the image to a specific revision.&lt;/p&gt;

&lt;p&gt;For example, an image might be tagged with a value such as &lt;code&gt;a1b2c3d&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now, instead of relying only on a moving &lt;code&gt;latest&lt;/code&gt; tag, I can identify a build by its source commit. With the corresponding image retained in ECR and the deployment configuration referencing that tag, I can redeploy an earlier image if necessary.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. Simplified the image URI used for deployment
&lt;/h3&gt;

&lt;p&gt;I also changed how the deployment configuration was assembled.&lt;/p&gt;

&lt;p&gt;The sample configuration combined parts of the image address in multiple places, making it easier to accidentally duplicate the repository name.&lt;/p&gt;

&lt;p&gt;I used a single variable for the full image URI and reused it when generating the deployment file.&lt;/p&gt;

&lt;p&gt;This made the build configuration easier to follow and reduced the chance of deploying an incorrectly constructed image address.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. Kept a troubleshooting log
&lt;/h3&gt;

&lt;p&gt;I documented the errors I encountered instead of treating them as isolated annoyances.&lt;/p&gt;

&lt;p&gt;That turned out to be one of the most useful parts of the project. Several failures had nothing to do with the application itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Errors That Taught Me the Most
&lt;/h2&gt;

&lt;h3&gt;
  
  
  1. Docker couldn't reach its engine
&lt;/h3&gt;

&lt;p&gt;The first error looked 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;ERROR: failed to connect to the docker API
at npipe:////./pipe/dockerDesktopLinuxEngine
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first instinct was that something was wrong with Docker.&lt;/p&gt;

&lt;p&gt;It turned out that Docker Desktop wasn't running.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;docker&lt;/code&gt; command is a client that communicates with the Docker engine. Having the CLI available doesn't mean the engine is running.&lt;/p&gt;

&lt;p&gt;I started Docker Desktop and verified the connection:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;docker&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;info&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Once the engine was available, I could continue building and running containers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Check whether the underlying service is running before troubleshooting the commands that depend on it.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. PowerShell didn't recognize &lt;code&gt;aws&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;Next, PowerShell returned:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;aws : The term 'aws' is not recognized as the name of a cmdlet...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AWS CLI was either not installed correctly or wasn't available in the terminal's current environment.&lt;/p&gt;

&lt;p&gt;After installing AWS CLI v2, I reopened PowerShell and checked the installation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nt"&gt;--version&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Reopening the terminal ensured it picked up the updated environment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; A tool can be installed on your machine without being available in a terminal session that was already open.&lt;/p&gt;

&lt;h3&gt;
  
  
  3. AWS didn't know who I was
&lt;/h3&gt;

&lt;p&gt;The next error was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NoCredentials: Unable to locate credentials
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The AWS CLI was installed, but the local environment didn't have credentials configured for the commands I was running.&lt;/p&gt;

&lt;p&gt;For this learning project, I configured an IAM user's access key using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;configure&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then I verified which AWS identity the CLI was using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="n"&gt;aws&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;sts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nx"&gt;get-caller-identity&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That command became one of my first checks whenever I started working on a new AWS project.&lt;/p&gt;

&lt;p&gt;For a local learning environment, credentials need to be configured securely. Access keys should never be committed to Git or exposed in screenshots, and the identity should have only the permissions it needs. For longer-term development, temporary credentials through an appropriate role or IAM Identity Center are preferable where available.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Before troubleshooting an AWS permission error, confirm which identity is actually making the request.&lt;/p&gt;

&lt;h3&gt;
  
  
  4. The wall inside the pipeline
&lt;/h3&gt;

&lt;p&gt;This was the most frustrating error because it happened after I had connected the AWS services and configured the pipeline.&lt;/p&gt;

&lt;p&gt;When the pipeline reached the build stage, it failed before any of my build commands ran:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Error calling startBuild: Cannot have more than 0 builds in queue for the account
(Error Code: AccountLimitExceededException)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At first, I wondered whether my build configuration or IAM permissions were wrong.&lt;/p&gt;

&lt;p&gt;But the error pointed to something different: an account-level CodeBuild limit.&lt;/p&gt;

&lt;p&gt;My account's applicable build quota was zero, so CodeBuild couldn't accept the build request. This wasn't an application bug or a Dockerfile problem.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to approach this error:&lt;/strong&gt; Check the relevant AWS CodeBuild quotas in Service Quotas and determine whether the account needs an eligible quota increase. Once the account limit has been addressed, retry the pipeline and inspect the next failure, if any.&lt;/p&gt;

&lt;p&gt;This was a useful reminder that an AWS account can have service limits that prevent a technically correct configuration from running.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson:&lt;/strong&gt; Read the error type before changing your code. An &lt;code&gt;AccountLimitExceededException&lt;/code&gt; points you toward an account or service quota, not automatically toward your build file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decisions I Can Explain
&lt;/h2&gt;

&lt;p&gt;These are the choices that helped me understand the architecture rather than simply reproduce a set of instructions.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deploy manually before automating
&lt;/h3&gt;

&lt;p&gt;I deployed the application before relying on the pipeline.&lt;/p&gt;

&lt;p&gt;That established a working baseline: the ECS service, task definition, networking, and container configuration needed to be functional before automation could update them.&lt;/p&gt;

&lt;p&gt;When a pipeline fails, knowing that the underlying application already works makes debugging much easier.&lt;/p&gt;

&lt;h3&gt;
  
  
  Keep Nginx in the foreground
&lt;/h3&gt;

&lt;p&gt;The 2048 application is served as a web application through Nginx.&lt;/p&gt;

&lt;p&gt;A container needs its main process to remain running. If Nginx starts in daemon mode and exits from the container's perspective, the container can stop even though the application files are present.&lt;/p&gt;

&lt;p&gt;For this setup, the Nginx process needs to remain in the foreground, commonly using:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["nginx", "-g", "daemon off;"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This allows the container runtime to monitor the main process properly.&lt;/p&gt;

&lt;h3&gt;
  
  
  Enable privileged mode for Docker builds in CodeBuild
&lt;/h3&gt;

&lt;p&gt;My CodeBuild environment needed to build Docker images.&lt;/p&gt;

&lt;p&gt;Running Docker builds inside CodeBuild can require privileged mode so the build environment can access the Docker daemon, depending on the selected build environment and configuration.&lt;/p&gt;

&lt;p&gt;My initial symptom was another Docker daemon connection error.&lt;/p&gt;

&lt;p&gt;This taught me that a Dockerfile can be perfectly valid while the environment attempting to build it is not configured to support Docker.&lt;/p&gt;

&lt;p&gt;Privileged mode also has security implications, so it should be enabled only when needed and combined with appropriate IAM permissions and build isolation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Match the container names in the deployment configuration
&lt;/h3&gt;

&lt;p&gt;The container name referenced by the deployment configuration needs to match the container name in the ECS task definition.&lt;/p&gt;

&lt;p&gt;If the names differ, the deployment action may be unable to identify the container image that needs updating.&lt;/p&gt;

&lt;p&gt;It's a small detail, but it illustrates how deployment automation depends on consistent configuration across services.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choose Fargate over servers I manage
&lt;/h3&gt;

&lt;p&gt;I used ECS Fargate because I wanted to focus on container deployment without managing EC2 instances.&lt;/p&gt;

&lt;p&gt;The trade-off is cost: running tasks and associated infrastructure can continue to incur charges. Serverless container management doesn't mean the workload is free.&lt;/p&gt;

&lt;h2&gt;
  
  
  Proof It Worked
&lt;/h2&gt;

&lt;p&gt;The most satisfying moment was seeing the entire process work without manually intervening in the deployment.&lt;/p&gt;

&lt;p&gt;I changed the page title to include my name, committed the change, and pushed it to GitHub.&lt;/p&gt;

&lt;p&gt;CodePipeline picked up the change, CodeBuild built a new Docker image, and the deployment process updated the ECS service to run the new version.&lt;/p&gt;

&lt;p&gt;When I opened the address for the running task, the updated title was there.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pipeline execution
&lt;/h3&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%2Fy8hw4zqdy8szprr2x37p.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%2Fy8hw4zqdy8szprr2x37p.png" alt="AWS CodePipeline showing successful pipeline stages" width="800" height="409"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Updated application
&lt;/h3&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%2Fgnkrz8mwb0kq91cbkwfl.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%2Fgnkrz8mwb0kq91cbkwfl.png" alt="2048 game displaying the updated page title" width="800" height="722"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;That was the point where the architecture clicked for me. The change started in GitHub and made its way to the running application through the pipeline.&lt;/p&gt;

&lt;p&gt;The screenshots show the successful pipeline execution and the updated page. Together, they provide evidence that the automated deployment worked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cleanup: The Part You Shouldn't Skip
&lt;/h2&gt;

&lt;p&gt;After testing the pipeline, I removed the AWS resources to avoid unnecessary ongoing charges.&lt;/p&gt;

&lt;p&gt;My cleanup covered the pipeline, ECS service and cluster, CodeBuild project, ECR repository, artifact storage, log groups, IAM roles, GitHub connection, and the local IAM user's access key.&lt;/p&gt;

&lt;p&gt;The exact deletion order depends on resource dependencies. Generally, remove or disconnect resources that depend on other resources before deleting those dependencies. For example, stop the ECS service and its tasks before deleting the cluster, and ensure S3 buckets and ECR repositories are empty if deletion requires it.&lt;/p&gt;

&lt;p&gt;I also verified the resources I intended to remove rather than assuming that deleting the pipeline automatically removed everything it used.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A pipeline can be temporary. Its costs don't necessarily stop when you stop working on it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Cleanup is part of building responsibly in the cloud.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd Improve Next
&lt;/h2&gt;

&lt;p&gt;The project works as a learning exercise, but there are several ways I'd make it more production-ready.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Add an Application Load Balancer.&lt;/strong&gt; This would provide a stable entry point instead of relying on a task's public address.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Configure HTTPS and a custom domain.&lt;/strong&gt; This would provide a more appropriate endpoint for a publicly accessible application.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Introduce Terraform.&lt;/strong&gt; Infrastructure as code would make the AWS resources reproducible and simplify setup and teardown.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add an automated test stage.&lt;/strong&gt; Tests should run before an image is promoted to deployment.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apply least-privilege IAM.&lt;/strong&gt; The learning setup used broader managed policies than I'd want for production. I'd restrict each role to the actions and resources it actually needs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Improve deployment and rollback controls.&lt;/strong&gt; I'd make the deployed image version explicit and define a reliable way to return to a previously working version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These changes would make the project more secure, repeatable, and maintainable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Learned
&lt;/h2&gt;

&lt;p&gt;This project taught me that building a CI/CD pipeline is about more than connecting AWS services.&lt;/p&gt;

&lt;p&gt;My biggest takeaways were:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Verify the toolchain first.&lt;/strong&gt; My initial errors were setup problems, not application bugs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the error before changing the code.&lt;/strong&gt; Account limits, missing credentials, and configuration mismatches can look like application failures.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Avoid hardcoding account-specific details.&lt;/strong&gt; Keep configuration reusable and secrets out of public repositories.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test components before connecting them.&lt;/strong&gt; A working Docker build, a valid ECS deployment, and a functioning pipeline are separate things to verify.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Understand what happens after a Git push.&lt;/strong&gt; Knowing how images are built, stored, tagged, and deployed makes troubleshooting much easier.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat cleanup as part of the project.&lt;/strong&gt; AWS resources can continue to cost money after the application is no longer needed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The biggest shift for me was moving from running containers locally to understanding how a source-code change can become a deployed application through an automated workflow.&lt;/p&gt;

&lt;p&gt;That's the kind of practical cloud engineering experience I wanted to gain from this project.&lt;/p&gt;

&lt;h2&gt;
  
  
  Explore the Project
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GitHub repository:&lt;/strong&gt; &lt;a href="https://github.com/samobeng14Dev/aws-ecs-ci-cd-2048-game" rel="noopener noreferrer"&gt;aws-ecs-ci-cd-2048-game&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LinkedIn:&lt;/strong&gt; &lt;a href="https://www.linkedin.com/in/samobeng14dev/" rel="noopener noreferrer"&gt;samobeng14dev&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Skills demonstrated:&lt;/strong&gt; Docker, Amazon ECR, Amazon ECS Fargate, AWS CodeBuild, AWS CodePipeline, IAM, Amazon S3, VPCs, security groups, Git, and GitHub.&lt;/p&gt;

</description>
      <category>aws</category>
      <category>devops</category>
      <category>docker</category>
      <category>cicd</category>
    </item>
  </channel>
</rss>
