I built this while working through a project module from Lucy Wang's 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.

Repo: samobeng14Dev/aws_azure_multicloud_deployment_terraforms

Why I built this

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

What this project shows

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

What you will build

A weather app served from two clouds at once:

  • Primary: AWS, with S3 static hosting behind CloudFront (HTTPS)
  • Secondary: Azure Blob Storage static website
  • Failover: Route 53 health checks check each site every 30 seconds. After 3 failed checks, DNS answers with the other cloud
  • Infrastructure: all of it in Terraform

Architecture: Route 53 failover between AWS (CloudFront + S3) and Azure Blob Storage

You will learn:

  • Terraform with two providers
  • S3 static hosting and CloudFront with an ACM certificate
  • Azure Blob static websites
  • Route 53 failover, and how to test it

Before you start

Prerequisites

  • An AWS account and an Azure subscription
  • A registered domain (I used Namecheap)
  • Terraform, AWS CLI and Azure CLI
  • Basic command-line comfort. I was new to Terraform when I started.

Setup used in this post

AWS region eu-north-1 (the CloudFront certificate must be in us-east-1)
Azure region North Europe
Shell Windows PowerShell
Terraform >= 1.5
Cost 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.

Warning: this creates billable resources. Finish the cleanup in Step 10.

Names used (replace with your own)

Thing Name
S3 bucket weather-tracker-<name>-<4 digits> (globally unique)
Azure storage account weather<name><4 digits> (3-24 characters, lowercase letters and digits only)
Azure resource group rg-weather-static-website
Domain <your-domain>
Failover hostname www.<your-domain>
AWS CLI profile weather

Project structure

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) 

.gitignore

.terraform/ *.tfstate *.tfstate.* terraform.tfvars secrets/ .env 

Always run Terraform from the terraform/ folder. In the project root it fails with "No configuration files".

Step 0: Prerequisites

0.1 Install the tools

terraform version aws --version az version 
  • Run the three commands above. Each should print a version.
  • Download and install any tool that is missing, then run it again.

0.2 AWS identity (no keys in code)

  1. Create an IAM user for this project.
  2. Attach these managed policies:
    • AmazonS3FullAccess
    • CloudFrontFullAccess
    • AmazonRoute53FullAccess
    • AWSCertificateManagerFullAccess
  3. Create an access key:
    • Sign in to the AWS Management Console and go to IAM → Users.
    • Select the user and open Security credentials.
    • Under Access keys, click Create access key and choose Command Line Interface (CLI).
    • Copy the Access Key ID and Secret Access Key. AWS shows the secret only once.
  4. Configure a named profile. When prompted, enter the key pair, your default region and an output format:
aws configure --profile weather $env:AWS_PROFILE = "weather" aws sts get-caller-identity 
  1. Check the output of aws sts get-caller-identity. The user name must be the IAM user you just created.

These broad managed policies are fine for a throwaway learning account. For anything real, write a least-privilege policy.

Important: aws configure without --profile overwrites your default credentials, which can silently affect your other projects. A named profile keeps each project's credentials separate.

My experience: 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 aws sts get-caller-identity before every terraform apply.

0.3 Azure identity

Terraform needs two things for Azure: a signed-in account and a subscription to deploy into.

az login $env:ARM_SUBSCRIPTION_ID = (az account show --query id -o tsv) az provider register --namespace Microsoft.Storage --wait az provider show --namespace Microsoft.Storage --query registrationState -o tsv 

What each command does:

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

Important: environment variables last only for the current PowerShell window. In every new window, set the subscription ID again before running Terraform:
$env:ARM_SUBSCRIPTION_ID = (az account show --query id -o tsv)

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

My experience: 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 ARM_SUBSCRIPTION_ID as soon as I open a new terminal.

0.4 How does Terraform know who I am?

AWS Azure
Identity IAM user My Entra ID login
Credential Long-lived access key Short-lived token from az login
Stored in ~/.aws/credentials The Azure CLI's token cache
In the repo? Never Never
  • AWS: Terraform uses the credentials of the selected profile. AWS tools search in this order: environment variables, named profiles in ~/.aws/credentials, ~/.aws/config, then SSO.
  • Azure: Terraform reuses the session created by az login.
  • Result: no access keys, passwords or tokens in any Terraform file.

The routine:

  1. Authenticate once with each CLI.
  2. Let Terraform use those credentials.
  3. Check your identity before every apply.

My experience: 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.

0.5 Domain

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

0.6 Account limits to expect

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

My experience: 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.

Step 1: Provider setup

Four files set up Terraform itself. Create them in terraform/.

versions.tf pins the Terraform and provider versions.

terraform { required_version = ">= 1.5" required_providers { aws = { source = "hashicorp/aws" version = ">= 5.0" } azurerm = { source = "hashicorp/azurerm" # Pin the MAJOR version you actually installed (check: terraform version). # Unpinned, a new major release changed resource syntax under me. version = "~> 5.0" } } } 
  • Pin the major version of azurerm that you actually installed. An unpinned provider changed its syntax under me (see Step 3).

providers.tf configures both clouds.

# Providers for the multi-cloud deployment. # AWS hosts the S3 and CloudFront primary site, while Azure hosts the failover website. provider "aws" { region = var.aws_region } provider "azurerm" { features {} } # CloudFront certificates must be requested in us-east-1 because CloudFront is a global service. provider "aws" { alias = "us_east_1" region = "us-east-1" } 
  • Neither provider has credentials in it (see 0.4).
  • The second AWS provider, with the alias us_east_1, exists only for the CloudFront certificate.

variables.tf holds the values you can change.

# Shared configuration values used across the AWS and Azure deployment resources. # These values control the target regions, regional naming constraints, custom-domain setup, # and the Route53 health check used for failover. variable "aws_region" { description = "AWS region for the S3 bucket." type = string default = "eu-north-1" } variable "bucket_name" { description = "Globally unique S3 bucket name for the website." type = string } variable "azure_location" { description = "Azure region for the resource group and storage account." type = string default = "North Europe" } variable "azure_storage_account_name" { description = "Globally unique Azure storage account name (3-24 chars, lowercase letters and digits only)." type = string validation { condition = can(regex("^[a-z0-9]{3,24}$", var.azure_storage_account_name)) error_message = "Use 3-24 characters: lowercase letters and digits only, no hyphens." } } variable "domain_name" { description = "Your registered domain, without www." type = string } variable "website_config_filename" { description = "Website config file to upload: config.js.example or your local config.js." type = string default = "config.js.example" validation { condition = contains(["config.js", "config.js.example"], var.website_config_filename) error_message = "Set website_config_filename to either config.js or config.js.example." } } variable "enable_custom_domain" { description = "Stage switch: false = default CloudFront certificate, true = www custom domain with ACM certificate." type = bool default = false } variable "primary_health_check_path" { description = "Path the primary health check requests. Use a missing path to simulate an AWS outage." type = string default = "/" } 
  • bucket_name and azure_storage_account_name must be globally unique. The Azure name is validated: 3-24 lowercase letters and digits.
  • website_config_filename chooses which config file is uploaded: config.js.example (default) or your local config.js. Everything uploaded to S3 and Azure is publicly readable, so never put a real secret in either.
  • enable_custom_domain switches between the stage 1 and stage 2 setups (Steps 5 and 7).
  • primary_health_check_path is how I simulate an outage in Step 9.

terraform.tfvars is git-ignored. Keep a placeholder copy named terraform.tfvars.example.

aws_region = "eu-north-1" bucket_name = "weather-tracker-<name>-<4digits>" azure_storage_account_name = "weather<name><4digits>" domain_name = "<your-domain>" enable_custom_domain = false 

Step 2: S3 static website (primary origin)

aws_s3.tf creates the bucket and uploads the site.

# AWS S3 hosts the primary static website used by CloudFront. # This file creates the public website bucket, enables website hosting, and uploads the # static frontend files with the correct content types for browsers to render them properly. locals { # Keep the website root files explicit; assets are discovered recursively below. site_dir = "${path.module}/../website" config_js_path = "${local.site_dir}/${var.website_config_filename}" site_files = { "index.html" = "text/html" "styles.css" = "text/css" "script.js" = "application/javascript" "config.js" = "application/javascript" } site_file_sources = { for name in keys(local.site_files) : name => name == "config.js" ? local.config_js_path : "${local.site_dir}/${name}" } # Common static asset types; unknown extensions fall back to binary content. mime_types = { png = "image/png" jpg = "image/jpeg" jpeg = "image/jpeg" gif = "image/gif" svg = "image/svg+xml" webp = "image/webp" ico = "image/x-icon" } } # Create the S3 bucket that hosts the static website content. resource "aws_s3_bucket" "weather_app" { bucket = var.bucket_name } # Configure the bucket as a static website so it exposes the index document at the public endpoint. resource "aws_s3_bucket_website_configuration" "site" { bucket = aws_s3_bucket.weather_app.id index_document { suffix = "index.html" } } # Allow public reads required for a static website served from S3. resource "aws_s3_bucket_public_access_block" "public_access" { bucket = aws_s3_bucket.weather_app.id block_public_acls = false block_public_policy = false ignore_public_acls = false restrict_public_buckets = false } # Grant anonymous visitors read access to website objects without requiring AWS credentials. resource "aws_s3_bucket_policy" "public_read" { bucket = aws_s3_bucket.weather_app.id depends_on = [aws_s3_bucket_public_access_block.public_access] policy = jsonencode({ Version = "2012-10-17" Statement = [{ Sid = "PublicReadGetObject" Effect = "Allow" Principal = "*" Action = "s3:GetObject" Resource = "${aws_s3_bucket.weather_app.arn}/*" }] }) } # Upload the main HTML, CSS, and JavaScript files with the correct MIME types. resource "aws_s3_object" "site_files" { for_each = local.site_files bucket = aws_s3_bucket.weather_app.id key = each.key source = local.site_file_sources[each.key] etag = filemd5(local.site_file_sources[each.key]) content_type = each.value } # Upload static assets under website/assets while keeping the same relative path structure. resource "aws_s3_object" "site_assets" { for_each = fileset("${local.site_dir}/assets", "**") bucket = aws_s3_bucket.weather_app.id key = "assets/${each.value}" source = "${local.site_dir}/assets/${each.value}" etag = filemd5("${local.site_dir}/assets/${each.value}") # Derive the file extension from each path so nested assets also receive a correct MIME type. content_type = lookup(local.mime_types, lower(reverse(split(".", each.value))[0]), "application/octet-stream") } 

What it does:

  • Creates the bucket and turns on static website hosting
  • Allows public reads, because the bucket is a public website
  • Uploads the site files and assets with the correct content types

Before you apply:

  • Check the website JavaScript for hardcoded API keys. Everything in website/ becomes public.

Run:

cd <project>\terraform terraform init terraform validate terraform plan terraform apply aws s3 ls s3://<bucket>/ --recursive 

Verify:

  • The site opens at http://<bucket>.s3-website.eu-north-1.amazonaws.com. S3 website endpoints are HTTP only.
  • The bucket lists your site files.

terraform apply complete for the S3 part

S3 bucket showing the site files

What went wrong for me:

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

Step 3: Azure static website (secondary)

azure_storage.tf creates the resource group, the storage account, and uploads the same site to the $web container.

# Azure Blob Storage hosts the secondary site used during Route53 failover. # This configuration enables static website hosting and uploads the built site files into the # Azure-managed "$web" container that serves the application. resource "azurerm_resource_group" "rg" { name = "rg-weather-static-website" location = var.azure_location } resource "azurerm_storage_account" "storage" { name = var.azure_storage_account_name resource_group_name = azurerm_resource_group.rg.name location = azurerm_resource_group.rg.location account_tier = "Standard" account_replication_type = "LRS" account_kind = "StorageV2" # Static website hosting and public traffic are enabled for the failover endpoint. public_network_access = "Enabled" custom_domain { name = "www.${var.domain_name}" use_subdomain = true # indirect (asverify) validation } depends_on = [aws_route53_record.azure_asverify] } # Enabling static website hosting creates the special "$web" container that serves the site. resource "azurerm_storage_account_static_website" "site" { storage_account_id = azurerm_storage_account.storage.id index_document = "index.html" } locals { # Resource Manager ID of the Azure-managed "$web" container used for static hosting. web_container_id = "${azurerm_storage_account.storage.id}/blobServices/default/containers/$web" } resource "azurerm_storage_blob" "site_files" { for_each = local.site_files name = each.key storage_container_id = local.web_container_id type = "Block" content_type = each.value source = local.site_file_sources[each.key] content_md5 = filemd5(local.site_file_sources[each.key]) depends_on = [azurerm_storage_account_static_website.site] } resource "azurerm_storage_blob" "site_assets" { for_each = fileset("${local.site_dir}/assets", "**") name = "assets/${each.value}" storage_container_id = local.web_container_id type = "Block" content_type = lookup(local.mime_types, lower(reverse(split(".", each.value))[0]), "application/octet-stream") source = "${local.site_dir}/assets/${each.value}" content_md5 = filemd5("${local.site_dir}/assets/${each.value}") depends_on = [azurerm_storage_account_static_website.site] } 

What it does:

  • Creates the resource group and storage account
  • Turns on static website hosting, which creates the special $web container
  • Uploads the same files as S3
  • The custom_domain block lets Azure accept www.<your-domain>. It is explained in Step 8.

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

Run (with ARM_SUBSCRIPTION_ID set and az login done):

terraform init -upgrade terraform validate terraform plan terraform apply terraform output azure_website_url 

Verify:

  • Open the URL. HTTPS works on the Azure endpoint.
  • In the Azure portal: Storage accounts → your account → Data storage → Containers → $web.
  • The URL is also under Data management → Static website.

Azure $web container and the Azure site

What went wrong for me:

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

Step 4: CloudFront (primary, stage 1)

aws_cloudfront.tf puts HTTPS and caching in front of S3.

# CloudFront distribution for the static website. # This layer serves the site globally over HTTPS, caches static content at the edge, # and optionally uses a custom domain certificate when the domain is enabled. resource "aws_cloudfront_distribution" "site" { enabled = true default_root_object = "index.html" price_class = "PriceClass_100" # Attach the custom domain only when the user has enabled domain hosting. aliases = var.enable_custom_domain ? ["www.${var.domain_name}"] : [] # The S3 website endpoint is configured for website hosting and only supports HTTP. # CloudFront therefore uses an HTTP origin connection while still serving HTTPS to viewers. origin { domain_name = aws_s3_bucket_website_configuration.site.website_endpoint origin_id = "s3-website" custom_orig...
Source:https://dev.to/samobeng14dev/i-built-a-website-that-fails-over-from-aws-to-azure-a-terraform-walkthrough-and-every-error-i-hit-2ooa