Day 14: Getting Started with Multiple Providers in Terraform

Day 14: Getting Started with Multiple Providers in Terraform

The FastAPI app is getting users in Europe. Latency from `us-east-1` is noticeable. Time to deploy a second region — and that means two providers, two module calls, and a few things that only become obvious once you try it.

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.

Share This Article

Did you find this helpful?

💬 Comments

No comments yet. Be the first to share your thoughts!

Leave a Comment

Get In Touch

I'm always open to discussing new projects and opportunities.

Location Yassa/Douala, Cameroon
Availability Open for opportunities

Connect With Me

Send a Message

Have a project in mind? Let's talk about it.