Overview
The promise of cloud computing — agility, scalability, and cost-efficiency — has driven enterprises to adopt cloud-native strategies at an unprecedented pace. However, as organizations mature, many find themselves navigating a multi-cloud landscape, either by design to avoid vendor lock-in, for compliance reasons, or through mergers and acquisitions. Managing infrastructure across disparate cloud providers like Amazon Web Services (AWS), Microsoft Azure, and Google Cloud Platform (GCP) introduces significant operational complexity, inconsistency, and potential for misconfiguration.
This is where Infrastructure as Code (IaC) solutions, particularly Terraform by HashiCorp, become indispensable. Terraform allows engineers to define and provision infrastructure using a declarative configuration language (HCL). While powerful for single-cloud deployments, extending Terraform to a multi-cloud environment requires a thoughtful approach to module design. The goal is not just to provision resources, but to do so consistently, securely, and efficiently across different cloud ecosystems.
Multi-cloud module design patterns aim to encapsulate cloud-specific configurations into reusable, standardized building blocks. This approach abstracts away the underlying cloud provider's nuances, allowing for a more unified and streamlined deployment process. By adopting robust module design patterns, enterprises can achieve:
- **Consistency:** Ensure identical environments (e.g., development, staging, production) across different clouds or regions.
- **Reusability:** Develop modules once and use them across multiple projects, teams, or environments.
- **Maintainability:** Simplify updates and changes by modifying a central module rather than scattered configurations.
- **Scalability:** Rapidly provision new environments or expand existing ones with minimal effort.
- **Improved Governance:** Enforce organizational standards, security policies, and compliance requirements through standardized modules.
- **Reduced Operational Overhead:** Automate infrastructure provisioning, minimizing manual errors and accelerating deployment cycles.
This article will delve into practical, publication-ready design patterns for building effective Terraform multi-cloud modules, complete with real-world examples for AWS and Azure.
Prerequisites
Before diving into the implementation details, ensure you have the following prerequisites in place:
- **Terraform CLI:** Install Terraform version 1.0 or newer. You can download it from the HashiCorp releases page.
terraform -vExpected output similar to:
Terraform v1.5.7 on linux_amd64 - **Cloud Provider Accounts:** Active accounts with AWS and Azure. For demonstration purposes, free tier accounts or trial subscriptions are sufficient.
- **Cloud Provider CLIs:**
- **AWS CLI:** Configured with appropriate programmatic access keys (IAM user with necessary permissions).
aws configureProvide your AWS Access Key ID, Secret Access Key, default region (e.g., `us-east-1`), and default output format (e.g., `json`).
- **Azure CLI:** Authenticated to your Azure subscription.
az loginThis will open a browser window for authentication.
az account showVerify your active subscription.
- **AWS CLI:** Configured with appropriate programmatic access keys (IAM user with necessary permissions).
- **Basic Terraform Knowledge:** Familiarity with Terraform HCL syntax, resources, data sources, variables, and outputs.
- **Version Control System:** Git installed and configured for managing your Terraform configurations.
- **Conceptual Understanding:** Basic knowledge of cloud networking (VPCs, subnets, security groups, virtual networks, resource groups) in both AWS and Azure.
Step-by-step Implementation: Designing Multi-Cloud Modules
The core challenge in multi-cloud module design is to balance abstraction with cloud-specific requirements. A common strategy involves creating cloud-agnostic "root" modules that orchestrate cloud-specific "child" modules. These child modules encapsulate the intricacies of each provider, offering a standardized interface to the root module.
Design Principles
- **Abstraction:** Cloud-specific details (e.g., resource types, attribute names) should be hidden within child modules.
- **Reusability:** Modules should be generic enough to be used across different environments (dev, stage, prod) and projects.
- **Composition:** Build complex infrastructure by combining smaller, focused modules.
- **Cloud-Agnostic Interfaces:** Define inputs and outputs for child modules that are as consistent as possible across providers, even if the underlying implementation differs significantly.
- **Single Responsibility:** Each module should do one thing well (e.g., a network module, a compute module, a database module).
Example Scenario: Deploying a Multi-Cloud Network and Compute Environment
Let's design modules to provision a basic network and a compute instance in both AWS and Azure.
Project Structure
A typical multi-cloud Terraform repository structure might look like this:
├── environments/
│ ├── dev/
│ │ ├── aws/
│ │ │ └── main.tf
│ │ ├── azure/
│ │ │ └── main.tf
│ │ └── common.tfvars
│ ├── prod/
│ │ ├── aws/
│ │ │ └── main.tf
│ │ ├── azure/
│ │ │ └── main.tf
│ │ └── common.tfvars
├── modules/
│ ├── aws-network/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── aws-compute/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── azure-network/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── azure-compute/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
└── README.md
For this example, we'll focus on the `modules/` directory and how an environment-specific `main.tf` might call them.
1. AWS Network Module (`modules/aws-network`)
This module will create a VPC, subnets, and an Internet Gateway in AWS.
`modules/aws-network/variables.tf`
variable "vpc_cidr_block" {
description = "The CIDR block for the VPC."
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidrs" {
description = "A list of CIDR blocks for public subnets."
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "private_subnet_cidrs" {
description = "A list of CIDR blocks for private subnets."
type = list(string)
default = ["10.0.101.0/24", "10.0.102.0/24"]
}
variable "environment" {
description = "The environment name (e.g., dev, prod)."
type = string
}
variable "region" {
description = "AWS region."
type = string
default = "us-east-1"
}
variable "tags" {
description = "A map of tags to assign to resources."
type = map(string)
default = {}
}
`modules/aws-network/main.tf`
provider "aws" {
region = var.region
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(var.tags, {
Name = "${var.environment}-vpc"
Environment = var.environment
})
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = merge(var.tags, {
Name = "${var.environment}-igw"
Environment = var.environment
})
}
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = "${var.region}${element(["a", "b", "c"], count.index)}"
map_public_ip_on_launch = true
tags = merge(var.tags, {
Name = "${var.environment}-public-subnet-${count.index + 1}"
Environment = var.environment
})
}
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = "${var.region}${element(["a", "b", "c"], count.index)}"
tags = merge(var.tags, {
Name = "${var.environment}-private-subnet-${count.index + 1}"
Environment = var.environment
})
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = merge(var.tags, {
Name = "${var.environment}-public-rtb"
Environment = var.environment
})
}
resource "aws_route_table_association" "public" {
count = length(aws_subnet.public)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
`modules/aws-network/outputs.tf`
output "vpc_id" {
description = "The ID of the created VPC."
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "A list of public subnet IDs."
value = [for s in aws_subnet.public : s.id]
}
output "private_subnet_ids" {
description = "A list of private subnet IDs."
value = [for s in aws_subnet.private : s.id]
}
2. Azure Network Module (`modules/azure-network`)
This module will create a Resource Group, Virtual Network (VNet), and subnets in Azure.
`modules/azure-network/variables.tf`
variable "resource_group_name" {
description = "The name of the resource group to create."
type = string
}
variable "location" {
description = "The Azure region where resources will be deployed."
type = string
default = "eastus"
}
variable "vnet_address_space" {
description = "The address space for the Virtual Network."
type = list(string)
default = ["10.1.0.0/16"]
}
variable "public_subnet_prefixes" {
description = "A list of address prefixes for public subnets."
type = list(string)
default = ["10.1.1.0/24", "10.1.2.0/24"]
}
variable "private_subnet_prefixes" {
description = "A list of address prefixes for private subnets."
type = list(string)
default = ["10.1.101.0/24", "10.1.102.0/24"]
}
variable "environment" {
description = "The environment name (e.g., dev, prod)."
type = string
}
variable "tags" {
description = "A map of tags to assign to resources."
type = map(string)
default = {}
}
`modules/azure-network/main.tf`
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "main" {
name = var.resource_group_name
location = var.location
tags = merge(var.tags, {
Environment = var.environment
})
}
resource "azurerm_virtual_network" "main" {
name = "${var.environment}-vnet"
address_space = var.vnet_address_space
location = azurerm_resource_group.main.location
resource_group_name = azurerm_resource_group.main.name
tags = merge(var.tags, {
Environment = var.environment
})
}
resource "azurerm_subnet" "public" {
count = length(var.public_subnet_prefixes)
name = "${var.environment}-public-subnet-${count.index + 1}"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = [var.public_subnet_prefixes[count.index]]
tags = merge(var.tags, {
Environment = var.environment
})
}
resource "azurerm_subnet" "private" {
count = length(var.private_subnet_prefixes)
name = "${var.environment}-private-subnet-${count.index + 1}"
resource_group_name = azurerm_resource_group.main.name
virtual_network_name = azurerm_virtual_network.main.name
address_prefixes = [var.private_subnet_prefixes[count.index]]
tags = merge(var.tags, {
Environment = var.environment
})
}
`modules/azure-network/outputs.tf`
output "resource_group_name" {
description = "The name of the created Azure Resource Group."
value = azurerm_resource_group.main.name
}
output "vnet_id" {
description = "The ID of the created Virtual Network."
value = azurerm_virtual_network.main.id
}
output "public_subnet_ids" {
description = "A list of public subnet IDs."
value = [for s in azurerm_subnet.public : s.id]
}
output "private_subnet_ids" {
description = "A list of private subnet IDs."
value = [for s in azurerm_subnet.private : s.id]
}
3. Root Module Orchestration (e.g., `environments/dev/main.tf`)
This root module demonstrates how to call the cloud-specific network modules for a 'dev' environment.
`environments/dev/main.tf`
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.0"
}
}
}
provider "aws" {
region = var.aws_region
}
provider "azurerm" {
features {}
location = var.azure_location # Can also be set in module or resource
}
variable "environment" {
description = "The environment name."
type = string
default = "dev"
}
variable "aws_region" {
description = "AWS region for deployment."
type = string
default = "us-east-1"
}
variable "azure_location" {
description = "Azure location for deployment."
type = string
default = "eastus"
}
variable "aws_vpc_cidr" {
description = "CIDR block for AWS VPC."
type = string
default = "10.0.0.0/16"
}
variable "azure_vnet_cidr" {
description = "CIDR block for Azure VNet."
type = list(string)
default = ["10.1.0.0/16"]
}
module "aws_network" {
source = "../../modules/aws-network"
vpc_cidr_block = var.aws_vpc_cidr
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnet_cidrs = ["10.0.101.0/24", "10.0.102.0/24"]
environment = var.environment
region = var.aws_region
tags = {
Project = "MultiCloudApp"
}
}
module "azure_network" {
source = "../../modules/azure-network"
resource_group_name = "${var.environment}-rg-network"
location = var.azure_location
vnet_address_space = var.azure_vnet_cidr
public_subnet_prefixes = ["10.1.1.0/24", "10.1.2.0/24"]
private_subnet_prefixes = ["10.1.101.0/24", "10.1.102.0/24"]
environment = var.environment
tags = {
Project = "MultiCloudApp"
}
}
output "aws_vpc_id" {
value = module.aws_network.vpc_id
}
output "azure_vnet_id" {
value = module.azure_network.vnet_id
}
4. AWS Compute Module (`modules/aws-compute`)
This module will create an EC2 instance and a Security Group.
`modules/aws-compute/variables.tf`
variable "instance_name" {
description = "Name for the EC2 instance."
type = string
}
variable "ami_id" {
description = "AMI ID for the EC2 instance."
type = string
default = "ami-053b0d53c27927914" # Amazon Linux 2 AMI (HVM), SSD Volume Type, us-east-1
}
variable "instance_type" {
description = "EC2 instance type."
type = string
default = "t3.micro"
}
variable "subnet_id" {
description = "ID of the subnet to launch the instance into."
type = string
}
variable "vpc_id" {
description = "ID of the VPC where the security group will be created."
type = string
}
variable "environment" {
description = "The environment name."
type = string
}
variable "region" {
description = "AWS region."
type = string
default = "us-east-1"
}
variable "tags" {
description = "A map of tags to assign to resources."
type = map(string)
default = {}
}
`modules/aws-compute/main.tf`
provider "aws" {
region = var.region
}
resource "aws_security_group" "web_sg" {
name = "${var.environment}-${var.instance_name}-sg"
description = "Allow HTTP and SSH access"
vpc_id = var.vpc_id
ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = merge(var.tags, {
Name = "${var.environment}-${var.instance_name}-sg"
Environment = var.environment
})
}
resource "aws_instance" "web" {
ami = var.ami_id
instance_type = var.instance_type
subnet_id = var.subnet_id
vpc_security_group_ids = [aws_security_group.web_sg.id]
associate_public_ip_address = true # Only for public subnets
tags = merge(var.tags, {
Name = "${var.environment}-${var.instance_name}"
Environment = var.environment
})
}
`modules/aws-compute/outputs.tf`
output "instance_id" {
description = "The ID of the EC2 instance."
value = aws_instance.web.id
}
output "public_ip" {
description = "The public IP address of the EC2 instance."
value = aws_instance.web.public_ip
}
5. Azure Compute Module (`modules/azure-compute`)
This module will create an Azure Virtual Machine, Network Interface, and Network Security Group.
`modules/azure-compute/variables.tf`
variable "vm_name" {
description = "Name for the Azure Virtual Machine."
type = string
}
variable "resource_group_name" {
description = "The name of the resource group where the VM will be deployed."
type = string
}
variable "location" {
description = "The Azure region for the VM."
type = string
}
variable "subnet_id" {
description = "ID of the subnet to launch the VM into."
type = string
}
variable "admin_username" {
description = "Administrator username for the VM."
type = string
default = "azureadmin"
}
variable "admin_password" {
description = "Administrator password for the VM. (Use secrets manager in production)"
type = string
sensitive = true
}
variable "vm_size" {
description = "Size of the Azure VM."
type = string
default = "Standard_B1s"
}
variable "image_publisher" {
description = "Publisher of the VM image."
type = string
default = "Canonical"
}
variable "image_offer" {
description = "Offer of the VM image."
type = string
default = "0001-com-ubuntu-server-focal" # Ubuntu Server 20.04 LTS
}
variable "image_sku" {
description = "SKU of the VM image."
type = string
default = "20_04-lts-gen2"
}
variable "image_version" {
description = "Version of the VM image."
type = string
default = "latest"
}
variable "environment" {
description = "The environment name."
type = string
}
variable "tags" {
description = "A map of tags to assign to resources."
type = map(string)
default = {}
}
`modules/azure-compute/main.tf`
provider "azurerm" {
features {}
}
resource "azurerm_network_security_group" "web_nsg" {
name = "${var.environment}-${var.vm_name}-nsg"
location = var.location
resource_group_name = var.resource_group_name
security_rule {
name = "SSH"
priority = 100
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "22"
source_address_prefix = "*"
destination_address_prefix = "*"
}
security_rule {
name = "HTTP"
priority = 110
direction = "Inbound"
access = "Allow"
protocol = "Tcp"
source_port_range = "*"
destination_port_range = "80"
source_address_prefix = "*"
destination_address_prefix = "*"
}
tags = merge(var.tags, {
Name = "${var.environment}-${var.vm_name}-nsg"
Environment = var.environment
})
}
resource "azurerm_public_ip" "web_ip" {
name = "${var.environment}-${var.vm_name}-public-ip"
location = var.location
resource_group_name = var.resource_group_name
allocation_method = "Dynamic" # Use Static for production
tags = merge(var.tags, {
Name = "${var.environment}-${var.vm_name}-public-ip"
Environment = var.environment
})
}
resource "azurerm_network_interface" "web_nic" {
name