Terraform Multi-Cloud: AWS + Azure + GCP with Shared Modules
Terraform architecture for multi-cloud environments: abstraction layer with uniform interfaces for computing, networking and databases, provider management multiples and separation of configuration by environment.
The Case for Multi-Cloud
92% of large companies use more than one cloud provider (Flexera State of the Cloud 2025). The reasons are various: vendor lock-in reduction, cost optimization (use the cheapest provider for each workload), compliance requirements (data in EU regions only on Azure, AI/ML on GCP, enterprise workload on AWS), and acquisitions that bring heterogeneous infrastructure.
Terraform is the ideal tool for managing multi-cloud: it supports natively provider for all major clouds with the same HCL syntax. The challenge is not it's technical, it's architectural: how to structure the modules for maximize reuse e minimize complexity when each cloud has different APIs for similar concepts.
What You Will Learn
- Multi-provider configuration: alias, provider per workspace, provider per module
- Abstraction layer: uniform interface module for computing, networking, databases
- Pattern to manage semantic differences between clouds (VPC/VNet, Instance/VM)
- Repository structure for multi-cloud teams: monorepo vs polyrepo
- Multi-cloud secret management: Vault as a single source of truth
- Cost optimization: spot instances AWS, preemptible GCP, spot Azure
Multi-Provider Configuration with Alias
Terraform allows you to use multiple instances of the same provider (or different providers) in the same form via the alias. This is useful for deploy resources in multiple regions or accounts of the same cloud, as well as for configure different providers.
# providers.tf - Configurazione centralizzata di tutti i provider
terraform {
required_version = "~> 1.7"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.90"
}
google = {
source = "hashicorp/google"
version = "~> 5.0"
}
vault = {
source = "hashicorp/vault"
version = "~> 3.0"
}
}
}
# AWS: provider principale (EU) e secondario (US) con alias
provider "aws" {
region = "eu-west-1" # Provider default
}
provider "aws" {
alias = "us_east"
region = "us-east-1" # Alias per risorse in US
}
provider "aws" {
alias = "disaster_recovery"
region = "eu-central-1" # Alias per DR
}
# Azure: richiede features{} minimo
provider "azurerm" {
features {}
subscription_id = var.azure_subscription_id
# Autenticazione tramite Service Principal o Managed Identity
}
# GCP: configurazione base
provider "google" {
project = var.gcp_project_id
region = "europe-west1"
}
# Vault: per gestione centralizzata dei segreti multi-cloud
provider "vault" {
address = "https://vault.mycompany.com"
# Token da variabile d'ambiente VAULT_TOKEN o via AppRole
}
Abstraction Layer: The Fundamental Design Pattern
The main challenge of multi-cloud is that AWS calls its service EC2, Azure calls it Virtual Machine and GCP Compute Engine, but conceptually they are the same thing. THE'abstraction layer create forms with interfaces uniforms that hide these differences.
# Struttura del repository con abstraction layer
terraform-multicloud/
modules/
# Layer 1: Moduli cloud-specific (implementazione)
aws/
compute/ # EC2, Auto Scaling Groups
networking/ # VPC, Subnets, Security Groups
database/ # RDS, Aurora
azure/
compute/ # Virtual Machine Scale Sets
networking/ # VNet, NSG, Subnets
database/ # Azure Database for PostgreSQL
gcp/
compute/ # Instance Groups, MIGs
networking/ # VPC, Firewall Rules
database/ # Cloud SQL
# Layer 2: Moduli di interfaccia (abstraction)
compute/ # Interfaccia uniforme, delega a aws/ o azure/ o gcp/
networking/
database/
# Layer 3: Composite modules (pattern applicativi)
three-tier-app/ # Frontend + Backend + Database su cloud specificato
kubernetes-cluster/
environments/
dev/
main.tf # Usa composite modules
providers.tf
production-aws/
production-azure/
Implement the Abstraction Layer
# modules/compute/variables.tf
# Interfaccia uniforme per il modulo compute (cloud-agnostic)
variable "cloud" {
type = string
description = "Cloud provider: aws, azure, gcp"
validation {
condition = contains(["aws", "azure", "gcp"], var.cloud)
error_message = "cloud deve essere aws, azure o gcp"
}
}
variable "name" {
type = string
description = "Nome del gruppo di compute (snake_case)"
}
variable "environment" {
type = string
description = "Ambiente: dev, staging, production"
}
variable "instance_type" {
type = string
description = "Tipo istanza nel formato normalizzato: small, medium, large, xlarge"
validation {
condition = contains(["small", "medium", "large", "xlarge"], var.instance_type)
error_message = "instance_type deve essere small|medium|large|xlarge"
}
}
variable "min_size" {
type = number
default = 1
}
variable "max_size" {
type = number
default = 10
}
variable "subnet_ids" {
type = list(string)
description = "Lista di subnet/subnetwork IDs dove deployare le istanze"
}
variable "ami_or_image_id" {
type = string
description = "AMI ID (AWS), Image ID (Azure/GCP)"
}
variable "user_data" {
type = string
description = "Script di inizializzazione (cloud-init compatible)"
default = ""
}
variable "tags" {
type = map(string)
default = {}
}
# modules/compute/main.tf
# Abstraction layer: delega all'implementazione cloud-specifica
locals {
# Mapping instance_type -> tipo istanza per ogni cloud
instance_type_map = {
aws = {
small = "t3.small"
medium = "t3.medium"
large = "t3.large"
xlarge = "t3.xlarge"
}
azure = {
small = "Standard_B2s"
medium = "Standard_B4ms"
large = "Standard_D4s_v3"
xlarge = "Standard_D8s_v3"
}
gcp = {
small = "e2-small"
medium = "e2-medium"
large = "e2-standard-4"
xlarge = "e2-standard-8"
}
}
resolved_instance_type = local.instance_type_map[var.cloud][var.instance_type]
}
# Delegazione condizionale all'implementazione cloud-specifica
module "aws_compute" {
source = "../aws/compute"
count = var.cloud == "aws" ? 1 : 0
name = var.name
environment = var.environment
instance_type = local.resolved_instance_type
min_size = var.min_size
max_size = var.max_size
subnet_ids = var.subnet_ids
ami_id = var.ami_or_image_id
user_data = var.user_data
tags = var.tags
}
module "azure_compute" {
source = "../azure/compute"
count = var.cloud == "azure" ? 1 : 0
name = var.name
environment = var.environment
vm_size = local.resolved_instance_type
min_instances = var.min_size
max_instances = var.max_size
subnet_ids = var.subnet_ids
source_image = var.ami_or_image_id
custom_data = var.user_data
tags = var.tags
}
module "gcp_compute" {
source = "../gcp/compute"
count = var.cloud == "gcp" ? 1 : 0
name = var.name
environment = var.environment
machine_type = local.resolved_instance_type
min_replicas = var.min_size
max_replicas = var.max_size
subnetwork_ids = var.subnet_ids
source_image = var.ami_or_image_id
metadata = var.user_data != "" ? { "user-data" = var.user_data } : {}
labels = var.tags
}
# modules/compute/outputs.tf
# Output uniformi indipendentemente dal cloud
output "instance_group_id" {
value = var.cloud == "aws" ? module.aws_compute[0].autoscaling_group_id :
var.cloud == "azure" ? module.azure_compute[0].scale_set_id :
module.gcp_compute[0].instance_group_id
description = "ID del gruppo di compute (ASG ID, Scale Set ID, MIG ID)"
}
output "load_balancer_dns" {
value = var.cloud == "aws" ? module.aws_compute[0].alb_dns_name :
var.cloud == "azure" ? module.azure_compute[0].load_balancer_fqdn :
module.gcp_compute[0].load_balancer_ip
description = "DNS o IP del load balancer frontale"
}
Multi-Cloud Database Module
# modules/database/main.tf
# Astrazione per PostgreSQL su AWS (RDS), Azure (Flexible Server) e GCP (Cloud SQL)
variable "cloud" {
type = string
}
variable "engine_version" {
type = string
default = "15" # PostgreSQL major version
}
variable "size" {
type = string
default = "small" # small, medium, large
}
variable "storage_gb" {
type = number
default = 50
}
variable "backup_retention_days" {
type = number
default = 7
}
variable "multi_az" {
type = bool
default = false
description = "Alta disponibilità: Multi-AZ (AWS), Zone-Redundant (Azure), HA (GCP)"
}
locals {
db_size_map = {
aws = {
small = "db.t3.medium"
medium = "db.t3.large"
large = "db.r6g.xlarge"
}
azure = {
small = "Standard_D2ds_v4"
medium = "Standard_D4ds_v4"
large = "Standard_D8ds_v4"
}
gcp = {
small = "db-custom-2-7680"
medium = "db-custom-4-15360"
large = "db-custom-8-30720"
}
}
}
# AWS: RDS PostgreSQL
resource "aws_db_instance" "main" {
count = var.cloud == "aws" ? 1 : 0
engine = "postgres"
engine_version = var.engine_version
instance_class = local.db_size_map.aws[var.size]
allocated_storage = var.storage_gb
storage_encrypted = true # Sempre: CKV_AWS_17
deletion_protection = true # Sempre in non-dev
backup_retention_period = var.backup_retention_days
multi_az = var.multi_az
# Performance Insights
performance_insights_enabled = true
performance_insights_retention_period = 7
tags = {
ManagedBy = "terraform"
Cloud = "aws"
}
}
# Azure: PostgreSQL Flexible Server
resource "azurerm_postgresql_flexible_server" "main" {
count = var.cloud == "azure" ? 1 : 0
name = var.name
resource_group_name = var.resource_group_name
location = var.location
sku_name = local.db_size_map.azure[var.size]
version = var.engine_version
storage_mb = var.storage_gb * 1024
backup_retention_days = var.backup_retention_days
geo_redundant_backup_enabled = var.multi_az
high_availability {
mode = var.multi_az ? "ZoneRedundant" : "Disabled"
}
}
# GCP: Cloud SQL PostgreSQL
resource "google_sql_database_instance" "main" {
count = var.cloud == "gcp" ? 1 : 0
name = var.name
database_version = "POSTGRES_${var.engine_version}"
settings {
tier = local.db_size_map.gcp[var.size]
disk_size = var.storage_gb
disk_autoresize = true
backup_configuration {
enabled = true
point_in_time_recovery_enabled = true
transaction_log_retention_days = var.backup_retention_days
}
availability_type = var.multi_az ? "REGIONAL" : "ZONAL"
insights_config {
query_insights_enabled = true
}
}
deletion_protection = true
}
Multi-Cloud Secret Management with Vault
# modules/secrets/main.tf
# HashiCorp Vault come source of truth unica per segreti multi-cloud
variable "cloud" {
type = string
}
variable "environment" {
type = string
}
variable "application" {
type = string
}
# Leggi i segreti da Vault
data "vault_generic_secret" "app_secrets" {
path = "secret/${var.environment}/${var.application}"
}
# Distribuisci i segreti al cloud appropriato
# AWS: crea Secrets Manager entry dal segreto Vault
resource "aws_secretsmanager_secret" "app" {
count = var.cloud == "aws" ? 1 : 0
name = "${var.environment}/${var.application}"
tags = {
ManagedBy = "terraform"
Source = "vault"
}
}
resource "aws_secretsmanager_secret_version" "app" {
count = var.cloud == "aws" ? 1 : 0
secret_id = aws_secretsmanager_secret.app[0].id
secret_string = jsonencode(data.vault_generic_secret.app_secrets.data)
}
# Azure: crea Key Vault secrets dal segreto Vault
resource "azurerm_key_vault_secret" "app" {
for_each = var.cloud == "azure" ? data.vault_generic_secret.app_secrets.data : {}
name = replace(each.key, "_", "-") # Azure Key Vault: no underscore
value = each.value
key_vault_id = var.azure_key_vault_id
}
# GCP: crea Secret Manager entries
resource "google_secret_manager_secret" "app" {
for_each = var.cloud == "gcp" ? data.vault_generic_secret.app_secrets.data : {}
secret_id = "${var.environment}-${var.application}-${each.key}"
replication {
auto {}
}
}
resource "google_secret_manager_secret_version" "app" {
for_each = var.cloud == "gcp" ? data.vault_generic_secret.app_secrets.data : {}
secret = google_secret_manager_secret.app[each.key].id
secret_data = each.value
}
Multi-Cloud Deployment of an Application
# environments/production-multicloud/main.tf
# Deploy della stessa applicazione su AWS (primary) e Azure (DR)
locals {
app_name = "catalog-api"
environment = "production"
common_tags = {
Application = local.app_name
Environment = local.environment
ManagedBy = "terraform"
CostCenter = "product-team"
}
}
# Networking AWS (Primary)
module "aws_networking" {
source = "../../modules/aws/networking"
name = "${local.app_name}-${local.environment}"
cidr_block = "10.0.0.0/16"
az_count = 3
tags = local.common_tags
}
# Networking Azure (DR)
module "azure_networking" {
source = "../../modules/azure/networking"
name = "${local.app_name}-${local.environment}"
resource_group_name = azurerm_resource_group.dr.name
location = "West Europe"
address_space = ["10.1.0.0/16"]
tags = local.common_tags
}
# Compute AWS (Primary) - Usa il modulo uniforme
module "compute_primary" {
source = "../../modules/compute"
cloud = "aws"
name = "${local.app_name}-primary"
environment = local.environment
instance_type = "large"
min_size = 3
max_size = 20
subnet_ids = module.aws_networking.private_subnet_ids
ami_or_image_id = data.aws_ami.app.id
tags = local.common_tags
}
# Compute Azure (DR) - Stessa interfaccia, cloud diverso
module "compute_dr" {
source = "../../modules/compute"
cloud = "azure"
name = "${local.app_name}-dr"
environment = local.environment
instance_type = "large"
min_size = 1 # DR: capacità ridotta finché non necessaria
max_size = 20
subnet_ids = module.azure_networking.subnet_ids
ami_or_image_id = var.azure_vm_image_id
tags = local.common_tags
}
# Database AWS (Primary)
module "database_primary" {
source = "../../modules/database"
cloud = "aws"
name = "${local.app_name}-primary"
size = "large"
storage_gb = 200
backup_retention_days = 30
multi_az = true # HA in production
}
# Database Azure (DR)
module "database_dr" {
source = "../../modules/database"
cloud = "azure"
name = "${local.app_name}-dr"
resource_group_name = azurerm_resource_group.dr.name
location = "West Europe"
size = "medium"
storage_gb = 200
backup_retention_days = 7
multi_az = false # DR: single zone per costi
}
# Output per entrambi gli ambienti
output "primary_endpoint" {
value = module.compute_primary.load_balancer_dns
}
output "dr_endpoint" {
value = module.compute_dr.load_balancer_dns
}
Cost Optimization Multi-Cloud: Spot/Preemptible
# modules/compute-spot/main.tf
# Modulo unificato per spot/preemptible instances (70-90% risparmio vs on-demand)
variable "cloud" {
type = string
}
variable "spot_percentage" {
type = number
default = 70
description = "Percentuale di istanze spot (0-100). Il resto è on-demand."
}
# AWS: Mixed Instance Policy con Spot
resource "aws_autoscaling_group" "mixed" {
count = var.cloud == "aws" ? 1 : 0
mixed_instances_policy {
instances_distribution {
on_demand_base_capacity = 2 # Minimo garantito on-demand
on_demand_percentage_above_base_capacity = 100 - var.spot_percentage
spot_allocation_strategy = "price-capacity-optimized"
}
launch_template {
launch_template_specification {
launch_template_id = aws_launch_template.app[0].id
version = "$Latest"
}
# Tipi istanza diversi per aumentare disponibilità spot
override {
instance_type = "t3.large"
}
override {
instance_type = "t3a.large"
}
override {
instance_type = "m5.large"
}
}
}
min_size = var.min_size
max_size = var.max_size
}
# GCP: Preemptible instances nel MIG
resource "google_compute_instance_template" "preemptible" {
count = var.cloud == "gcp" ? 1 : 0
scheduling {
preemptible = var.spot_percentage > 0
automatic_restart = false # Obbligatorio per preemptible
on_host_maintenance = "TERMINATE"
}
}
# Azure: Spot VMs con eviction policy
resource "azurerm_orchestrated_virtual_machine_scale_set" "spot" {
count = var.cloud == "azure" ? 1 : 0
priority = "Spot"
eviction_policy = "Deallocate" # o "Delete" per risparmio storage
max_bid_price = -1 # -1 = paga fino al prezzo on-demand
}
Multi-Cloud Anti-Pattern to Avoid
Common Mistakes in Multi-Cloud IaC Architecture
- Abstraction too forced: Not all services have equivalents direct between clouds. AWS SQS, Azure Service Bus, and GCP Pub/Sub are similar but not identical. A form that is too generic hides important cloud-specific features.
- A single state file for all clouds: Separate state file per cloud and by environment. Putting everything in a single state file increases the risk of corruption and slows down operations.
-
Providers that are too permissive: Don't give
AdministratorAccessto the Terraform provider. Use IAM least-privilege roles specific to each environment. - Hard-coded credentials in .tfs: Always use environment variables, OIDC or Vault. Never AWS keys in Terraform files.
Conclusions and Next Steps
The abstraction layer pattern in Terraform allows you to build infrastructure maintainable multi-cloud: differences between providers are encapsulated in modules cloud-specific, consumers use a uniform interface, and cloud switching it's a change to a variable, not a rewrite of the infrastructure.
The key is finding the right level of abstraction: too many hidden differences make the form unusable, while a form that exposes too many details cloud-specific loses the benefit of uniformity.
Next Articles in the Series
- Article 8: GitOps for Terraform — Flux TF Controller, Spacelift and Drift Detection: Brings Terraform into the GitOps paradigm with continuous reconciliation from the repository environment.
- Article 9: Terraform vs Pulumi vs OpenTofu — Comparison Final 2026: When to choose which IaC tool based on context.







