Skip to main content
Multi-Cloud · lesson 8 of 8
Multi-Cloud advanced Lesson 8 of 8

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

cloud aws azure gcp terraform

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 destroy or a state-corrupting error touches all three clouds at once, and terraform plan output 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 apply three 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.”

Frequently Asked Questions

Do I need to learn Terraform basics before this lesson?
Yes — this lesson assumes you already know providers, resources, variables, and state, covered in this site's terraform track. What's new here is not Terraform syntax; it's the pattern for provisioning the same conceptual resource across three provider blocks in one configuration without three copy-pasted modules that drift apart.
Should one Terraform root module really manage AWS, Azure, and GCP resources at once?
Only if all three actually need to exist for the same environment — a genuine multi-cloud deployment, a migration in flight, or a disaster-recovery target on a second cloud. If you're only ever deploying to one cloud per environment, separate root modules (or workspaces) per cloud are simpler to reason about and safer to apply; don't adopt this pattern just because it's possible.
Does Terraform state get mixed together across three clouds in one file?
Yes, by default — a single root module's plan and apply operate on one state file covering every resource it declares, regardless of provider. That is the main operational risk of this pattern: a `terraform destroy` or a bad apply against that one state file can touch all three clouds at once. Splitting into per-cloud state (via separate root modules or workspaces) trades that risk for the coordination overhead of applying three times instead of one.
Can the same resource block work for all three clouds?
No — each cloud's storage primitive is a genuinely different resource type (`aws_s3_bucket`, `azurerm_storage_account` plus `azurerm_storage_container`, `google_storage_bucket`) with different required arguments, so there is no single resource block that spans providers. What can be shared is the naming convention, the tagging scheme, and the variable inputs feeding all three — the pattern is convergence at the module boundary, not inside a single resource.