After Day 13, the production setup is solid: FastAPI on EC2 behind an ALB, RDS for persistence, credentials in Secrets Manager, zero-downtime rolling updates. Everything runs in us-east-1.
The next ask: deploy the same stack in eu-west-1 for European users, with Route 53 routing traffic to whichever region is closest. One Terraform config, two regions, same module.
To do that, you need to understand providers — what they are, how versioning works, and how to run multiple copies of the same provider in one config.
What Is a Provider
A provider is a plugin that knows how to talk to a specific API. The AWS provider translates HCL resource definitions into AWS API calls. The same is true for Azure, GCP, Cloudflare, Datadog, GitHub — there are thousands of providers in the Terraform Registry.
When you run terraform init, Terraform downloads the providers declared in your config into .terraform/providers/. From that point on, every resource block is handled by its provider — aws_lb goes to the AWS provider, cloudflare_record goes to Cloudflare, and so on.
The provider block sets the configuration for each provider — at minimum, which region to operate in:
provider "aws" {
region = "us-east-1"
}
Every aws_* resource in your config uses this provider by default.
Provider Versioning and the Lock File
Providers are versioned independently of Terraform itself. You pin provider versions in the terraform block:
terraform {
required_version = ">= 1.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # any 5.x version, but not 6.0
}
}
}
The ~> constraint means "allow patch and minor updates within the major version." ~> 5.0 accepts 5.1, 5.47, but not 6.0. This protects you from breaking changes in new major versions while still getting bug fixes.
When you run terraform init, Terraform writes the exact version it resolved to .terraform.lock.hcl:
# .terraform.lock.hcl — commit this file
provider "registry.terraform.io/hashicorp/aws" {
version = "5.47.0"
constraints = "~> 5.0"
hashes = [
"h1:...",
]
}
Commit .terraform.lock.hcl to git. This is not an auto-generated throwaway file — it is the exact version pin that makes your builds reproducible. Without it, two engineers running terraform init on the same day might get different provider versions if a new patch release dropped between runs.
To upgrade a pinned provider:
terraform init -upgrade # resolves a new version within the constraint
git add .terraform.lock.hcl
git commit -m "chore: upgrade aws provider to 5.50.0"
Provider Aliases — Two Copies of the Same Provider
A provider alias lets you use the same provider with different configuration in one Terraform config. The most common case: the same AWS provider, two different regions.
# Primary region — no alias needed, this is the default
provider "aws" {
region = "us-east-1"
}
# Secondary region — alias required so Terraform can tell them apart
provider "aws" {
alias = "eu"
region = "eu-west-1"
}
Resources that don't specify a provider use the default (no alias). Resources that need the aliased provider declare it explicitly:
# Uses the default provider — deploys in us-east-1
resource "aws_s3_bucket" "primary_logs" {
bucket = "fastapi-logs-us-east-1"
}
# Uses the aliased provider — deploys in eu-west-1
resource "aws_s3_bucket" "secondary_logs" {
provider = aws.eu
bucket = "fastapi-logs-eu-west-1"
}
The provider = aws.eu argument is how you direct a resource to a specific provider instance.
Multi-Region FastAPI Deployment
With aliases in place, you can deploy the same module to two regions by passing the provider into each module call.
Provider configuration
# root/main.tf
provider "aws" {
region = "us-east-1"
}
provider "aws" {
alias = "eu"
region = "eu-west-1"
}
Module calls — one per region
# Primary deployment — us-east-1
module "web_app_us" {
source = "git::https://github.com/mohamednourdine/terraform-modules.git//modules/web-app?ref=v1.4.0"
providers = {
aws = aws # default provider — us-east-1
}
environment = var.environment
instance_type = var.instance_type
min_size = var.min_size
max_size = var.max_size
server_port = 8000
health_check_path = "/health"
health_check_grace_period = 360
user_data = local.fastapi_user_data
}
# Secondary deployment — eu-west-1
module "web_app_eu" {
source = "git::https://github.com/mohamednourdine/terraform-modules.git//modules/web-app?ref=v1.4.0"
providers = {
aws = aws.eu # aliased provider — eu-west-1
}
environment = "${var.environment}-eu"
instance_type = var.instance_type
min_size = 1 # start smaller in the secondary region
max_size = var.max_size
server_port = 8000
health_check_path = "/health"
health_check_grace_period = 360
user_data = local.fastapi_user_data
}
The providers argument in a module block is how you pass a specific provider instance into a module. Inside the module, aws still refers to "the AWS provider" — the alias is resolved at the calling level, invisible to the module itself.
Outputs from both regions
output "primary_url" {
value = "http://${module.web_app_us.alb_dns_name}"
}
output "secondary_url" {
value = "http://${module.web_app_eu.alb_dns_name}"
}
Apply once, two full stacks deployed in two regions.
Passing Providers into Modules — What the Module Needs
For a module to accept an aliased provider, it must declare which providers it uses in a required_providers block. Without this, Terraform assumes the module uses the default provider and ignores the providers argument in the calling config.
# modules/web-app/versions.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
This is already in most modules. If you see an error like:
Error: Module does not support count
or providers not being passed correctly, adding required_providers to the module is usually the fix.
The ACM + CloudFront Gotcha
This is the most common reason engineers discover provider aliases — not multi-region deployments, but a specific AWS constraint: ACM certificates used by CloudFront must be created in us-east-1, regardless of where everything else lives.
If your infrastructure is in eu-west-1 and you want to add CloudFront with a custom domain, your provider config needs a us-east-1 alias specifically for the certificate:
# Primary infra in eu-west-1
provider "aws" {
region = "eu-west-1"
}
# ACM for CloudFront must be in us-east-1 — no exceptions
provider "aws" {
alias = "us_east_1"
region = "us-east-1"
}
# Certificate in us-east-1
resource "aws_acm_certificate" "api" {
provider = aws.us_east_1
domain_name = "api..com"
validation_method = "DNS"
lifecycle {
create_before_destroy = true
}
}
# CloudFront distribution — in any region, but references the us-east-1 cert
resource "aws_cloudfront_distribution" "api" {
# provider not specified — uses default (eu-west-1 here)
# but the certificate ARN comes from the us-east-1 resource above
viewer_certificate {
acm_certificate_arn = aws_acm_certificate.api.arn
ssl_support_method = "sni-only"
}
# ... rest of distribution config
}
Without the alias, you'd create the certificate in eu-west-1 and CloudFront would reject it. The error message from AWS is not always obvious about why — knowing this constraint upfront saves real debugging time.
Route 53 Failover Between Regions
With two ALBs running, Route 53 can route users to the closest healthy region. This uses health checks and failover routing — both using the default provider since Route 53 is global.
# Route 53 is a global service — no region alias needed
resource "aws_route53_zone" "api" {
name = "api..com"
}
# Health check for the primary (us-east-1) ALB
resource "aws_route53_health_check" "primary" {
fqdn = module.web_app_us.alb_dns_name
port = 80
type = "HTTP"
resource_path = "/health"
failure_threshold = 3
request_interval = 30
}
# Primary record — us-east-1
resource "aws_route53_record" "primary" {
zone_id = aws_route53_zone.api.zone_id
name = "api..com"
type = "A"
alias {
name = module.web_app_us.alb_dns_name
zone_id = module.web_app_us.alb_zone_id
evaluate_target_health = true
}
failover_routing_policy {
type = "PRIMARY"
}
health_check_id = aws_route53_health_check.primary.id
set_identifier = "primary"
}
# Secondary record — eu-west-1 (used when primary is unhealthy)
resource "aws_route53_record" "secondary" {
zone_id = aws_route53_zone.api.zone_id
name = "api..com"
type = "A"
alias {
name = module.web_app_eu.alb_dns_name
zone_id = module.web_app_eu.alb_zone_id
evaluate_target_health = true
}
failover_routing_policy {
type = "SECONDARY"
}
set_identifier = "secondary"
}
For this to work, the module needs to expose alb_zone_id as an output — add it to modules/web-app/outputs.tf:
output "alb_zone_id" {
value = aws_lb.web.zone_id
description = "Hosted zone ID of the ALB — required for Route 53 alias records"
}
Multi-Account Deployments with assume_role
Provider aliases also handle multi-account setups. Instead of two regions, you have two AWS accounts — a common pattern where prod lives in a separate account from dev for isolation.
# Default provider — your tooling account (where Terraform runs)
provider "aws" {
region = "us-east-1"
}
# Prod account — Terraform assumes a role in that account
provider "aws" {
alias = "prod"
region = "us-east-1"
assume_role {
role_arn = "arn:aws:iam::PROD_ACCOUNT_ID:role/TerraformDeployRole"
}
}
# Dev account
provider "aws" {
alias = "dev"
region = "us-east-1"
assume_role {
role_arn = "arn:aws:iam::DEV_ACCOUNT_ID:role/TerraformDeployRole"
}
}
The TerraformDeployRole in each account must trust the tooling account to assume it, and have permissions to create the resources Terraform manages. This is the standard multi-account Terraform pattern used by most teams with separate prod and non-prod accounts.
Common Gotchas
Forgetting providers in the module call. If you pass providers = { aws = aws.eu } in the module call but the module has no required_providers, Terraform silently ignores it and uses the default provider. Resources end up in the wrong region with no error.
Data sources inherit the calling module's provider. A data "aws_ami" block inside a module uses the provider passed into that module. If you call the same module twice with different providers, each module call fetches the AMI from its own region — which is correct, but means the AMI IDs will differ between regions.
The lock file and CI. In CI pipelines, always run terraform init with the lock file committed. If .terraform.lock.hcl is in .gitignore, every CI run resolves provider versions fresh — meaning a new provider release can silently change your pipeline's behaviour between runs.
Provider versions and module compatibility. If your root config pins aws ~> 5.0 but a module you're using was written for aws ~> 4.0, you may hit resource attribute changes or deprecated arguments. Always check the module's required_providers constraint when upgrading.
Quick Reference
| Concept | Syntax |
|---|---|
| Default provider | provider "aws" { region = "us-east-1" } |
| Aliased provider | provider "aws" { alias = "eu"; region = "eu-west-1" } |
| Resource using alias | resource "aws_s3_bucket" "x" { provider = aws.eu } |
| Module using alias | module "m" { providers = { aws = aws.eu } } |
| Version constraint | version = "~> 5.0" (any 5.x, not 6.0) |
| Upgrade provider | terraform init -upgrade |
| Multi-account | assume_role { role_arn = "arn:aws:iam::ACCOUNT:role/Role" } |
Where I Am At
The FastAPI stack now has a blueprint for multi-region deployment: one config, two module calls, two provider instances, Route 53 routing traffic between them. The ACM/CloudFront constraint is documented so it does not become a surprise when CloudFront gets added.
The key mental model shift from today: providers are not just "which cloud" — they are configuration contexts. Two copies of the same provider with different regions or accounts are treated as completely independent by Terraform, and resources are pinned to their provider at creation time.
Tomorrow: creating modules that are designed from the start to work with multiple providers — rather than adapting existing ones after the fact.
This post is part of a 30-day Terraform learning journey.
💬 Comments
No comments yet. Be the first to share your thoughts!
Leave a Comment