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 AdministratorAccess to 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.