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
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)
- Create an IAM user for this project.
- Attach these managed policies:
AmazonS3FullAccessCloudFrontFullAccessAmazonRoute53FullAccessAWSCertificateManagerFullAccess
- 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.
- 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
- 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 configurewithout--profileoverwrites yourdefaultcredentials, 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-identitybefore everyterraform 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 loginopens 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.Storageresource provider. New subscriptions don't always have it enabled, and Azure can't create storage accounts without it. The final command should printRegistered.
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,
initcan 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_IDas 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:
- Authenticate once with each CLI.
- Let Terraform use those credentials.
- 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
azurermthat 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_nameandazure_storage_account_namemust be globally unique. The Azure name is validated: 3-24 lowercase letters and digits. -
website_config_filenamechooses which config file is uploaded:config.js.example(default) or your localconfig.js. Everything uploaded to S3 and Azure is publicly readable, so never put a real secret in either. -
enable_custom_domainswitches between the stage 1 and stage 2 setups (Steps 5 and 7). -
primary_health_check_pathis 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.
What went wrong for me:
- "No configuration files." I ran
terraform initin the project root, twice. Terraform only reads.tffiles from the current folder, so I now open my terminal directly interraform/.- 403 on
s3:CreateBucket. My IAM user existed (authentication worked) but nothing allowed S3 (authorization failed). AttachingAmazonS3FullAccessand 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
$webcontainer - Uploads the same files as S3
- The
custom_domainblock lets Azure acceptwww.<your-domain>. It is explained in Step 8.
Heads-up: this file references the
azure_asverifyrecord fromdns_failover.tf(Step 5), so Terraform can't plan it alone. Either create the Step 5 file first, or comment out thecustom_domainblock and thedepends_onuntil 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.
What went wrong for me:
MissingSubscriptionRegistrationfor Microsoft.Storage. A fresh subscription doesn't have every provider enabled. Theaz provider registercommand in Step 0.3 fixes it.- My code stopped validating. I hadn't pinned
azurerm, so I got the newest major release (5.x). The inlinestatic_websiteblock was removed and blobs now take astorage_container_id. I read the docs for the version I actually had, rewrote the file (the version above), and now pin~> 5.0.- "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_origin_config {
http_port = 80
https_port = 443
origin_protocol_policy = "http-only"
origin_ssl_protocols = ["TLSv1.2"]
}
}
# Serve the site with a simple, cache-friendly policy for static content.
default_cache_behavior {
target_origin_id = "s3-website"
viewer_protocol_policy = "redirect-to-https"
allowed_methods = ["GET", "HEAD"]
cached_methods = ["GET", "HEAD"]
cache_policy_id = "658327ea-f89d-4fab-a63d-7e88639e58f6" # Managed-CachingOptimized
}
# No geo-blocking restrictions are applied for this deployment.
restrictions {
geo_restriction {
restriction_type = "none"
}
}
# Use the default CloudFront certificate when no custom domain is configured.
dynamic "viewer_certificate" {
for_each = var.enable_custom_domain ? [] : [1]
content {
cloudfront_default_certificate = true
}
}
# Use the ACM certificate and custom TLS settings when a custom domain is configured.
dynamic "viewer_certificate" {
for_each = var.enable_custom_domain ? [1] : []
content {
acm_certificate_arn = aws_acm_certificate_validation.site[0].certificate_arn
ssl_support_method = "sni-only"
minimum_protocol_version = "TLSv1.2_2021"
}
}
}
What it does:
- Uses the S3 website endpoint as the origin. That endpoint speaks only HTTP, so CloudFront connects over HTTP.
- Redirects viewers from HTTP to HTTPS
- Uses the default CloudFront certificate until you enable the custom domain (Step 7)
- Takes 5-15 minutes to deploy
What went wrong for me:
applyreturned 403 "Your account must be verified before you can add new CloudFront resources". 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.
Step 5: Route 53 zone, health checks and failover records
dns_failover.tf
# DNS and failover configuration for the global website.
# This file creates the hosted zone, requests the public certificate, validates it,
# and routes traffic between the primary AWS endpoint and the secondary Azure endpoint.
locals {
site_host = "www.${var.domain_name}"
}
# Hosted zone for the custom domain used by the site.
resource "aws_route53_zone" "main" {
name = var.domain_name
}
# ACM certificate for the CloudFront custom domain. This certificate must be issued in us-east-1
# because CloudFront is a global AWS service and expects certificates in that region.
resource "aws_acm_certificate" "site" {
count = var.enable_custom_domain ? 1 : 0
provider = aws.us_east_1
domain_name = local.site_host
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
# Create the DNS validation records required to verify the ACM certificate.
resource "aws_route53_record" "cert_validation" {
for_each = var.enable_custom_domain ? {
for dvo in aws_acm_certificate.site[0].domain_validation_options : dvo.domain_name => {
name = dvo.resource_record_name
type = dvo.resource_record_type
record = dvo.resource_record_value
}
} : {}
zone_id = aws_route53_zone.main.zone_id
name = each.value.name
type = each.value.type
records = [each.value.record]
ttl = 60
allow_overwrite = true
}
# Wait for the ACM certificate to be valid before using it in CloudFront.
resource "aws_acm_certificate_validation" "site" {
count = var.enable_custom_domain ? 1 : 0
provider = aws.us_east_1
certificate_arn = aws_acm_certificate.site[0].arn
validation_record_fqdns = [for r in aws_route53_record.cert_validation : r.fqdn]
}
# Health checks allow Route53 to fail over automatically when the primary endpoint becomes unavailable.
resource "aws_route53_health_check" "primary" {
type = "HTTPS"
fqdn = aws_cloudfront_distribution.site.domain_name
port = 443
resource_path = var.primary_health_check_path
request_interval = 30
failure_threshold = 3
tags = { Name = "weather-primary-aws" }
}
resource "aws_route53_health_check" "secondary" {
type = "HTTPS"
fqdn = azurerm_storage_account.storage.primary_web_host
port = 443
resource_path = "/"
request_interval = 30
failure_threshold = 3
tags = { Name = "weather-secondary-azure" }
}
# Route53 failover records share the same host name and type, but are distinguished by their set identifier.
# The primary record points to the AWS CloudFront distribution and the secondary record points to Azure.
resource "aws_route53_record" "primary" {
zone_id = aws_route53_zone.main.zone_id
name = local.site_host
type = "CNAME"
ttl = 60
records = [aws_cloudfront_distribution.site.domain_name]
set_identifier = "primary"
health_check_id = aws_route53_health_check.primary.id
failover_routing_policy {
type = "PRIMARY"
}
}
resource "aws_route53_record" "secondary" {
zone_id = aws_route53_zone.main.zone_id
name = local.site_host
type = "CNAME"
ttl = 60
records = [azurerm_storage_account.storage.primary_web_host]
set_identifier = "secondary"
health_check_id = aws_route53_health_check.secondary.id
failover_routing_policy {
type = "SECONDARY"
}
}
# Lets Azure verify that we own www.<domain> before it accepts requests for that name.
resource "aws_route53_record" "azure_asverify" {
zone_id = aws_route53_zone.main.zone_id
name = "asverify.www.${var.domain_name}"
type = "CNAME"
ttl = 60
records = ["asverify.${var.azure_storage_account_name}.blob.core.windows.net"]
}
What it does:
- Creates the hosted zone for your domain
- Requests the ACM certificate in
us-east-1and validates it by DNS (used in stage 2) - Creates one health check per cloud: HTTPS, every 30 seconds, 3 failures
- Creates two
wwwCNAME records, PRIMARY (CloudFront) and SECONDARY (Azure) - Creates the
asverifyrecord Azure uses to verify domain ownership (Step 8)
A design lesson: failover records must have the same name and type. 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
www. The trade-off: the bare domain can't take part, because a CNAME can't sit at the root.
outputs.tf
# Deployment endpoints and DNS values exported for use after Terraform apply.
# These outputs provide the direct URLs for each platform and the DNS details needed
# to complete the custom domain setup.
output "s3_website_url" {
description = "Direct S3 static website endpoint over HTTP."
value = "http://${aws_s3_bucket_website_configuration.site.website_endpoint}"
}
output "azure_website_url" {
description = "Azure static website endpoint served over HTTPS."
value = azurerm_storage_account.storage.primary_web_endpoint
}
output "azure_website_host" {
description = "Azure website hostname used as the secondary failover target."
value = azurerm_storage_account.storage.primary_web_host
}
output "cloudfront_url" {
description = "Primary site URL served through CloudFront over HTTPS."
value = "https://${aws_cloudfront_distribution.site.domain_name}"
}
output "route53_name_servers" {
description = "Name servers to configure at the registrar for the custom domain."
value = aws_route53_zone.main.name_servers
}
output "site_url" {
description = "Custom domain URL for the production website after DNS is configured."
value = "https://www.${var.domain_name}"
}
Run stage 1 (enable_custom_domain = false):
terraform validate
terraform plan
terraform apply
terraform output cloudfront_url
terraform output route53_name_servers
Verify:
-
cloudfront_urlopens the app over HTTPS. -
route53_name_serversprints four nameservers. You need them in Step 6. - In Route 53, both health checks show Healthy.
Step 6: Point the domain at Route 53
- In Namecheap: Domain List → Manage → Nameservers → Custom DNS.
- Enter the four
awsdnsnameservers. No trailing dot, no spaces. - Save, then check:
Resolve-DnsName <your-domain> -Type NS -Server 8.8.8.8
- Wait until it lists the
awsdnsnames. This can take up to about 30 minutes. - Do not start stage 2 before this works.
- Every
destroyand re-applycreates a new zone with new nameservers, so repeat this step each time.
What went wrong for me:
- After switching nameservers I kept seeing my registrar's parking page. Google's resolver (
-Server 8.8.8.8) already had the right answer, so it was cached DNS on my own PC.ipconfig /flushdnsand a private window fixed it.- I also had to verify my domain contact email (ICANN) at the registrar, or the domain could be suspended.
Step 7: Stage 2, custom domain with HTTPS
- Make sure the IAM user has
AWSCertificateManagerFullAccess. - Set
enable_custom_domain = trueinterraform.tfvars. - Run:
terraform apply
What happens:
- Terraform requests the certificate in
us-east-1 - It creates the DNS validation record and waits for validation (2-10 minutes)
- It updates CloudFront with the alias and certificate (5-15 minutes)
Verify:
-
https://www.<your-domain>loads with a padlock. - If you still see an old page, run
ipconfig /flushdns.
What went wrong for me:
RequestCertificatefailed with AccessDenied. I had given my IAM user S3, CloudFront and Route 53 permissions but forgot ACM. I attachedAWSCertificateManagerFullAccessand re-ran.- CloudFront only accepts certificates from us-east-1, even though my infrastructure is in
eu-north-1. That is whyproviders.tfhas a second, aliased AWS provider.
Step 8: Make Azure accept the custom domain
Without this, http://www.<your-domain> returns 400 InvalidUri when DNS points at Azure. Azure rejects requests whose Host header it doesn't know.
In the code above, two things fix it:
- The
azure_asverifyCNAME indns_failover.tfproves you own the name. - The
custom_domainblock anddepends_oninazure_storage.tfregisterwww.<your-domain>on the storage account.
If you commented them out in Step 3, uncomment them now, then:
terraform plan
terraform apply
- Azure checks the
asverifyrecord when it applies the custom domain, so complete Step 6 first. - Before applying,
planshould show the storage account updated in place, not replaced. - 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.
What went wrong for me: opening the failover hostname while it pointed at Azure first returned
400 InvalidUri. After the custom domain was registered with theasverifyrecord and thecustom_domainblock, the Azure site loaded onwww.<your-domain>.
Step 9: Test the failover
- Before. In Route 53 → Health checks, both are Healthy. Then query one of your four nameservers, which avoids local caching:
(Resolve-DnsName www.<domain> -Type CNAME -Server <one-awsdns-nameserver>).NameHost
Expect the CloudFront name.
-
Break the primary to fake an outage, in the AWS console:
- Go to Route 53 → Health checks and select
weather-primary-aws. - Click Edit and change Path from
/to/does-not-exist. - Save. That path doesn't exist, so the check starts failing.
- Go to Route 53 → Health checks and select
The Terraform alternative does the same thing from code:
terraform apply -var="primary_health_check_path=/does-not-exist"
Watch the health check. Refresh the Health checks page. Allow 2-3 minutes (3 failures x 30 seconds). The primary changes from Healthy to Unhealthy.
Confirm which cloud is answering. Your browser can't tell you, because both copies look the same. Ask DNS instead:
1..12 | ForEach-Object { (Resolve-DnsName www.<domain> -Type CNAME -Server <one-awsdns-nameserver>).NameHost; Start-Sleep 20 }
Once the primary is Unhealthy, expect the answer to change from the CloudFront name to the Azure hostname.
-
Fail back. Edit the health check path back to
/(or runterraform applywithout the-var). After 1-3 minutes the primary is Healthy again.
What I observed: 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.
A limit: DNS failover is recovery in minutes, not instant. Detection takes a few health-check intervals, and clients can cache the old answer for up to the record's TTL (60 seconds here).
Step 10: Clean up
terraform destroy
- CloudFront is slow to delete (10-15 minutes).
Then verify:
terraform state list
aws s3 ls
aws cloudfront list-distributions --query "DistributionList.Items[].Id"
az group list -o table
Also check:
- The ACM certificate in us-east-1
- Route 53 hosted zones and health checks
Terraform does not remove:
- The domain itself. Turn off auto-renew at Namecheap if you don't want to keep it.
- The domain's nameservers. Point them back at your registrar's default DNS.
- The IAM user and its access keys. Deactivate or delete them.
- Local
terraform.tfstate(git-ignored)
Check Billing and Cost Management the next day.
Troubleshooting: every error I hit
| Symptom | Cause | Fix |
|---|---|---|
No configuration files |
Ran Terraform in the project root |
cd into terraform/
|
403 on s3:CreateBucket
|
IAM user has no S3 permission (authorization, not authentication) | Attach AmazonS3FullAccess, wait a minute |
| Terraform acts as an unexpected user |
aws configure overwrote default, or stale AWS_* variables |
Use named profiles and $env:AWS_PROFILE; check aws sts get-caller-identity
|
| Azure: subscription ID required | azurerm v4+ needs it | $env:ARM_SUBSCRIPTION_ID = (az account show --query id -o tsv) |
Azure: MissingSubscriptionRegistration
|
Resource provider not registered on a new subscription | az provider register --namespace Microsoft.Storage --wait |
static_website not expected / storage_container_id required |
azurerm 5.x syntax changed; version not pinned | Use azurerm_storage_account_static_website and storage_container_id; pin ~> 5.0
|
| Azure: connection reset after creating the account | New account's DNS name not resolving yet | Wait a minute, re-run apply |
| CloudFront 403: account must be verified | New-account restriction | Support case (Account and billing), wait |
ACM RequestCertificate AccessDenied |
IAM user lacks ACM permission | Attach AWSCertificateManagerFullAccess
|
| Certificate not offered for CloudFront | Created outside us-east-1
|
Use the aws.us_east_1 provider alias for ACM |
| Failover records do nothing | Primary and secondary had different name or type | Same name and type (CNAME on www) for both |
| Browser shows registrar parking page after nameserver change | Cached DNS on my PC |
ipconfig /flushdns, private window, check with -Server 8.8.8.8
|
Azure 400 InvalidUri on the custom domain |
Azure rejects an unregistered Host header | Step 8 (asverify record plus custom_domain) |
| Domain contact alert at the registrar | ICANN contact email not verified | Verify the email at the registrar |
Security checklist
- No keys, account IDs, subscription IDs or Support case IDs in code, docs or screenshots
-
terraform.tfvars,.terraform/and*.tfstate*are git-ignored..terraform.lock.hclis committed. - Credentials come from CLI profiles and
az login, not from Terraform variables - Check the website files for hardcoded API keys before committing, because everything in
website/is public - WHOIS privacy and two-factor authentication on the registrar account
- Delete or deactivate unused IAM access keys after the project
Known limits
- Failover is not instant. Detection takes a few health-check intervals, then clients can cache the old answer for up to the TTL.
-
Only
wwwfails over. A CNAME can't sit at the bare domain. - Azure custom domain is HTTP only on a plain storage account.
- State is local. That's fine for a demo. Production uses a remote backend with locking.
What I learned
- Terraform is only as powerful as the identity behind it. Know your credential chain before you apply.
- Read the docs for the version you installed. An unpinned provider changed its syntax under me.
- New cloud accounts have hidden walls (verification, provider registration). Find them on day one.
- A failover diagram isn't a failover. I tested it by breaking the primary health check and watching what happened.
What I'd do next
- Azure Front Door in front of the Azure site, for HTTPS on a custom domain
- Remote state with locking
- OIDC federation from CI, so no long-lived keys exist
- Refactor into Terraform modules
Let's connect
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.
- GitHub: samobeng14Dev
- LinkedIn: samobeng14dev
Built from a module in Lucy Wang's advanced cloud project course, with my own changes and write-up.











Top comments (0)