Terraform’s provider model is what makes multi-cloud tractable: the same language, the same workflow, and one dependency graph spanning systems that otherwise have nothing in common.
Topic 1: Why Multi-Cloud
- Vendor independence — no single provider’s pricing or outage defines your availability.
- Resilience — distributing across providers is the strongest form of redundancy.
- Cost arbitrage — different providers price different workloads differently.
- Best-of-breed — using each provider’s genuinely strongest services.
The cost is complexity, and that is exactly what Terraform’s provider abstraction absorbs.
Topic 2: Several Providers, One Configuration
provider "aws" {
region = "us-west-2"
alias = "primary"
}
provider "azurerm" {
features {}
alias = "primary"
}
resource "aws_instance" "web" {
provider = aws.primary
ami = data.aws_ami.base.id
instance_type = "t3.micro"
tags = local.common_tags
}
resource "azurerm_linux_virtual_machine" "web" {
provider = azurerm.primary
name = "web-vm"
resource_group_name = azurerm_resource_group.main.name
location = azurerm_resource_group.main.location
size = "Standard_DS1_v2"
network_interface_ids = [azurerm_network_interface.web.id]
admin_username = var.admin_username
admin_password = data.azurerm_key_vault_secret.admin.value
os_disk {
caching = "ReadWrite"
storage_account_type = "Standard_LRS"
}
tags = local.common_tags
}
Modules can receive an explicit provider mapping:
module "vm" {
source = "./modules/vm"
providers = {
aws = aws.primary
}
}
Topic 3: State Separation vs. Consolidation
A real decision, not a preference.
Separate state per cloud gives isolation and independent blast radius — a mistake in the Azure configuration cannot touch AWS resources, and each can be applied by a different team with different credentials.
A single state file is possible and couples the clouds together. Every apply touches both; every lock blocks both.
Decide on team boundaries, security domains and SLAs — not on convenience. The question to ask is: should one team’s mistake be able to break the other’s infrastructure? The answer decides the layout.
Topic 4: Consistency Across Providers
Naming and tagging must be identical across providers or none of the downstream tooling works — cost attribution, monitoring, compliance reporting all key on tags.
locals {
common_tags = {
Environment = var.environment
Project = var.project
ManagedBy = "terraform"
Owner = var.owning_team
}
}
Apply the same map in every provider block. Where a provider calls them “labels” rather than “tags”, map the same source:
labels = local.common_tags # same values, different attribute name
Cross-cloud networking patterns: VPN or transit gateways between provider networks; a service mesh for unified service discovery; DNS-based failover for multi-region traffic management. Centralised logging and monitoring spanning both is not optional — debugging a request that crosses providers with two separate log systems is close to impossible.
Topic 5: Hybrid — Connecting On-Premises
resource "aws_vpn_gateway" "main" {
vpc_id = aws_vpc.main.id
}
resource "aws_customer_gateway" "on_prem" {
bgp_asn = 65000
ip_address = var.on_prem_gateway_ip
type = "ipsec.1"
}
resource "aws_vpn_connection" "main" {
customer_gateway_id = aws_customer_gateway.on_prem.id
type = "ipsec.1"
static_routes_only = true
tags = local.common_tags
}
resource "aws_vpn_connection_route" "on_prem" {
vpn_connection_id = aws_vpn_connection.main.id
destination_cidr_block = var.on_prem_cidr
}
A private-circuit equivalent — dedicated connectivity rather than VPN over the internet — declares a circuit with a connectivity provider, a peering location and a bandwidth tier, then a gateway of the matching type.
On-premises resources have their own providers. Hypervisor platforms, private clouds and container orchestrators are all manageable:
provider "vsphere" {
vsphere_server = var.vsphere_server
user = var.vsphere_user
password = var.vsphere_password # from a secret store
allow_unverified_ssl = true
}
resource "vsphere_virtual_machine" "app" {
name = "app-vm"
resource_pool_id = data.vsphere_resource_pool.pool.id
datastore_id = data.vsphere_datastore.datastore.id
num_cpus = 2
memory = 4096
guest_id = "rhel8_64Guest"
network_interface {
network_id = data.vsphere_network.network.id
adapter_type = "vmxnet3"
}
disk {
label = "disk0"
size = 40
thin_provisioned = true
}
}
Topic 6: Provisioning Kubernetes
Terraform manages the cluster and, through a second provider, the workloads inside it.
module "eks" {
source = "terraform-aws-modules/eks/aws"
cluster_name = "platform-cluster"
cluster_version = "1.29"
subnet_ids = module.network.private_subnets
vpc_id = module.network.vpc_id
eks_managed_node_groups = {
default = {
desired_size = 2
max_size = 5
min_size = 1
instance_types = ["t3.medium"]
}
}
}
provider "kubernetes" {
host = module.eks.cluster_endpoint
cluster_ca_certificate = base64decode(module.eks.cluster_certificate_authority_data)
exec {
api_version = "client.authentication.k8s.io/v1beta1"
command = "aws"
args = ["eks", "get-token", "--cluster-name", module.eks.cluster_name]
}
}
resource "kubernetes_namespace" "app" {
metadata { name = "app" }
}
Where to draw the line:
Terraform is excellent at provisioning the cluster and platform-level add-ons — ingress controllers, cert management, monitoring stacks. Managing application workloads through Terraform puts every application deploy behind an infrastructure state lock, which means a deploy waits on a network change and a rollback needs a Terraform run.
Most mature teams stop at the cluster and platform layer and let a purpose-built delivery tool own application manifests. Knowing where Terraform stops is as valuable as knowing what it does.
Topic 7: Serverless
resource "aws_lambda_function" "api" {
function_name = "api-handler"
handler = "handler.main"
runtime = "python3.12"
role = aws_iam_role.lambda.arn
filename = "function.zip"
source_code_hash = filebase64sha256("function.zip")
}
resource "aws_lambda_permission" "api_gateway" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.api.function_name
principal = "apigateway.amazonaws.com"
}
source_code_hash is the line that makes redeployment work. Without it Terraform compares only the filename, sees no change, and never updates the function — so your new code sits in a zip file that nothing deploys.
The same shape applies elsewhere: a resource group, storage account, hosting plan and function app. The secret-management rules carry over unchanged — function configuration comes from a secret store, never from literals.
Try it yourself: Declare two aliased instances of one provider pointing at different regions, create a resource in each, and run terraform graph. Both branches appear in one graph, applied in parallel — which is the multi-provider model in a single picture.
Common mistake: One state file spanning every cloud and every environment because “it is all one system”. It is not one system — it is several with different failure modes, different credentials and different owners, and a single lock file makes every team wait on every other.