Multi-Cloud · lesson 8 of 8
Intermediate
5Advanced
3Also part of Multi-Cloud
8- + CloudWatch Monitoring Basics from AWS
- + Azure Monitor Basics from Azure
- + Cloud Monitoring Basics from Google Cloud
- + AWS Cost and Well-Architected Basics from AWS
- + Azure Cost Management Basics from Azure
- + GCP Cost and Billing Basics from Google Cloud
- + Availability, Regions, and the Arithmetic of Nines from Cloud Interviews
- + Scaling, Queues, and Backpressure from Cloud Interviews
Also part of: Terraform
Multi-Cloud Terraform: One Pattern, Three Providers
One root module, three provider blocks — AWS, Azure, GCP — provisioning the same storage bucket, plus the state and naming decisions behind it.
By SkillByExample · Published
This builds on the Terraform fundamentals track — provider blocks, resource syntax, and variables are assumed knowledge here, not repeated. What’s new is the pattern for provisioning the same conceptual resource across three clouds in one configuration, and the state decision that comes with it.
The shape: three provider blocks, one naming input
# providers.tf
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
azurerm = { source = "hashicorp/azurerm", version = "~> 3.0" }
google = { source = "hashicorp/google", version = "~> 5.0" }
}
}
provider "aws" {
region = "us-east-1"
}
provider "azurerm" {
features {}
}
provider "google" {
project = var.gcp_project
region = "us-central1"
}
# variables.tf
variable "bucket_prefix" {
description = "Shared naming prefix across all three clouds"
type = string
default = "reports-2026"
}
variable "gcp_project" {
type = string
}
One bucket_prefix variable feeding three resource blocks is the actual pattern — not a
shared resource type, which doesn’t exist across providers, but a shared naming and
tagging convention that keeps three independently-typed resources recognizably part of the
same deployment.
The same conceptual resource, three provider-specific blocks
# storage.tf
resource "aws_s3_bucket" "reports" {
bucket = "${var.bucket_prefix}-aws"
tags = { Environment = "prod", ManagedBy = "terraform" }
}
resource "azurerm_resource_group" "reports" {
name = "${var.bucket_prefix}-rg"
location = "eastus"
}
resource "azurerm_storage_account" "reports" {
name = replace("${var.bucket_prefix}azure", "-", "")
resource_group_name = azurerm_resource_group.reports.name
location = azurerm_resource_group.reports.location
account_tier = "Standard"
account_replication_type = "LRS"
tags = { Environment = "prod", ManagedBy = "terraform" }
}
resource "azurerm_storage_container" "reports" {
name = "reports"
storage_account_name = azurerm_storage_account.reports.name
container_access_type = "private"
}
resource "google_storage_bucket" "reports" {
name = "${var.bucket_prefix}-gcp"
location = "US"
labels = { environment = "prod", managed-by = "terraform" }
}
Note what doesn’t unify: Azure alone needs an explicit resource group as a parent, and its storage account plus container is two resource blocks where AWS and GCP each need one. Forcing a single shared “bucket module” interface across all three would mean padding the AWS and GCP calls with an unused resource-group concept just to keep the module signature symmetric — usually not worth the abstraction. A thin per-cloud resource block, unified only by naming and tags, is the pattern that survives contact with each provider’s actual shape.
Plan and apply
terraform init
$ terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Finding hashicorp/azurerm versions matching "~> 3.0"...
- Finding hashicorp/google versions matching "~> 5.0"...
- Installing hashicorp/aws v5.64.0...
- Installing hashicorp/azurerm v3.116.0...
- Installing hashicorp/google v5.42.0...
Terraform has been successfully initialized!
terraform plan
$ terraform plan
# aws_s3_bucket.reports will be created
# azurerm_resource_group.reports will be created
# azurerm_storage_account.reports will be created
# azurerm_storage_container.reports will be created
# google_storage_bucket.reports will be created
Plan: 5 to add, 0 to change, 0 to destroy.
One plan, five resources, three clouds — and this is exactly the operational tradeoff to
weigh before adopting the pattern: terraform apply here is a single all-or-nothing
operation across every provider. If the GCP provider’s credentials are stale, the AWS and
Azure resources in the same plan can still apply successfully while the GCP resource
fails partway through, leaving state with two clouds provisioned and one half-applied —
Terraform does not roll back the resources that already succeeded.
The state decision
This root module’s terraform.tfstate (see
remote state and safety for the backend
mechanics) holds all five resources across three clouds in one file. Two real options
from here:
- One state, three providers (what’s shown above) — simplest to apply, but a
terraform destroyor a state-corrupting error touches all three clouds at once, andterraform planoutput gets noisy as the deployment grows. - One root module per cloud, sharing input variables — apply AWS, Azure, and GCP
independently, each with its own state file, coordinated by a wrapper script or CI
pipeline that runs all three. This is the safer default for anything beyond a small,
tightly-coupled deployment, at the cost of running
terraform applythree times instead of once. The workspace and environment and modules lessons cover the reuse patterns that make three separate root modules for AWS/Azure/GCP no more repetitive than one combined module.
There’s no universally correct choice here — it’s the same kind of tradeoff as the blast-radius decisions covered generally in the state-safety lesson, applied specifically to the case where the blast radius spans vendors, not just environments.
Practice
1. Split the combined root module into three separate root modules (one per cloud), each reading `bucket_prefix` from a shared `terraform.tfvars`.
infra/
aws/ main.tf (aws_s3_bucket only)
azure/ main.tf (azurerm_* resources only)
gcp/ main.tf (google_storage_bucket only)
shared.auto.tfvars # bucket_prefix = "reports-2026"
Each directory gets its own terraform init/plan/apply and its own state file — three
applies instead of one, but a broken GCP credential no longer risks the AWS and Azure
resources.
2. Add an `output` block to the combined module that prints all three bucket identifiers together.
output "bucket_ids" {
value = {
aws = aws_s3_bucket.reports.id
azure = azurerm_storage_account.reports.name
gcp = google_storage_bucket.reports.name
}
}
Outputs:
bucket_ids = {
"aws" = "reports-2026-aws"
"azure" = "reports2026azure"
"gcp" = "reports-2026-gcp"
}
One terraform output call surfaces every cloud’s identifier at once — useful for feeding
a downstream pipeline that needs all three without three separate state reads.
3. Simulate the partial-failure scenario: make the GCP provider block reference an invalid project, then run `terraform apply`.
$ terraform apply
aws_s3_bucket.reports: Creation complete
azurerm_storage_account.reports: Creation complete
google_storage_bucket.reports: Creating...
Error: googleapi: Error 403: The caller does not have permission
Apply failed. Terraform has recorded 2 successful creations in state.
Exactly the partial-application risk described above — re-run terraform apply after
fixing the GCP project, and Terraform’s plan will show only the missing GCP resource,
skipping the two that already exist.
4. Add a fourth provider-agnostic variable, `retention_days`, and use it to configure lifecycle rules on all three buckets.
variable "retention_days" {
type = number
default = 90
}
resource "aws_s3_bucket_lifecycle_configuration" "reports" {
bucket = aws_s3_bucket.reports.id
rule {
id = "expire"
status = "Enabled"
expiration { days = var.retention_days }
}
}
AWS needs a separate aws_s3_bucket_lifecycle_configuration resource; Azure configures
lifecycle via azurerm_storage_management_policy; GCP sets it inline with a
lifecycle_rule block on google_storage_bucket itself — three more different shapes
behind the one shared retention_days input, reinforcing that convergence happens at the
variable, not the resource type.
This closes the multi-cloud track. The pattern throughout has been the same one: name and tag consistently, share inputs, but let each provider’s resource shape be what it actually is rather than forcing a false symmetry — the four-factor framework from the decision framework lesson at the start of this track applies just as much to “should this be one Terraform root module or three.”