Multi-Cloud Network Foundation with Terraform

Deploy isolated VPCs across AWS, Azure, and GCP using reusable Terraform modules.

Introduction

30 Second Summary

Every major company runs infrastructure across multiple cloud providers, and the network is always the first thing you build before anything else can exist.

In this project, you will build a production-style network foundation across AWS, Azure, and GCP from a single Terraform configuration, complete with isolated networks, public and private subnets, proper routing, and reusable modules that follow a shared-interface pattern.

What You'll Build

A multi-cloud network foundation that provisions isolated VPCs and VNets on all three major cloud providers from one Terraform project, with reusable modules that expose the same inputs and outputs regardless of which cloud they target.

By the end of this project, you'll have:

  • A Terraform project with three provider-specific network modules for AWS VPC, Azure VNet, and GCP VPC.
  • Public and private subnet segmentation on each cloud with internet gateways, NAT gateways, and proper routing tables.
  • A validated deployment with drift detection and a Claude Code review session analyzing your modules for cross-cloud consistency.
  • A cross-cloud VPN tunnel connecting your AWS VPC and Azure VNet, managed entirely through Terraform.

Are there any prerequisites?

You need an active AWS account, an active Azure account with a subscription, and an active GCP account with a project and billing enabled. You should also be familiar with Terraform basics like resources, variables, and outputs. Basic command-line comfort is expected.

Install and Configure Your Tools

Before you can deploy networks across three clouds from a single project, you need the right tools installed and authenticated. This step gets Terraform, all three cloud CLIs, and the Claude Desktop App ready to go.

In This Step, Get Ready To:
  • Install Terraform CLI and the AWS, Azure, and GCP command-line tools.
  • Authenticate to all three cloud providers from your terminal.
  • Set up the Claude Desktop App Code tab for HCL review.
Install Terraform and Cloud CLIs

This project uses four CLI tools: Terraform to define and deploy infrastructure, AWS CLI to authenticate with Amazon Web Services, Azure CLI to authenticate with Microsoft Azure, and Google Cloud CLI to authenticate with Google Cloud Platform.

Install Terraform first. The latest version is 1.14.x.

macOS

  • Open your terminal and run the following command to install Terraform via Homebrew.
brew tap hashicorp/tap && brew install hashicorp/tap/terraform

What does this command do?

The first part (brew tap hashicorp/tap) adds HashiCorp's official package repository to Homebrew. The second part installs Terraform from that repository.

Windows

Linux

  • Install Terraform using the HashiCorp APT repository. Run the following commands.
wget -O- https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform

What does this command do?

These commands add HashiCorp's GPG key and APT repository to your system, then install Terraform from that repository.

Now install the three cloud CLIs if you don't already have them. Follow the official installation guides for each:

Once everything is installed, verify each tool by running these commands.

terraform -version
aws --version
az --version
gcloud --version

What should I see?

Each command should print a version number. For Terraform, you should see v1.14.x or higher. The exact versions of the cloud CLIs don't matter as long as they're reasonably recent.

I see version numbers

All four tools are installed. Move on to the next substep.

Command not found

If any command returns "command not found," the tool didn't install correctly or isn't in your system PATH.

  • Close and reopen your terminal, then try the command again.
  • On Windows, make sure the tool's install directory is added to your system PATH environment variable.
  • On macOS, run brew doctor to check for Homebrew issues.

Still having trouble?

If you're stuck, help me troubleshoot my CLI installation.

Authenticate to All Three Clouds

Terraform uses your local CLI credentials to communicate with each cloud provider. You need to authenticate to AWS, Azure, and GCP so that Terraform can create resources on your behalf.

Start with AWS. Run the following command and enter your Access Key ID, Secret Access Key, and default region when prompted.

aws configure
  • Enter your AWS Access Key ID.
  • Enter your AWS Secret Access Key.
  • Set the default region to us-east-1.
  • Press Enter to accept the default output format.

Next, authenticate to Azure.

az login

This opens a browser window where you sign in with your Azure account. After signing in, set your active subscription.

az account set --subscription [[AZURE_SUB_ID="your Azure subscription ID"]]

Pro tip: find your subscription ID

Run az account list --output table to see all your subscriptions and their IDs. Copy the one you want to use.

Finally, authenticate to GCP. This project uses Application Default Credentials, which Terraform picks up automatically.

gcloud auth application-default login

This opens a browser window for Google sign-in. After authenticating, set your active GCP project.

gcloud config set project [[GCP_PROJECT_ID="your GCP project ID"]]

Terraform needs the Compute Engine API enabled in your GCP project to create networking resources. Enable it now.

gcloud services enable compute.googleapis.com

What does this command do?

GCP requires you to explicitly enable APIs before you can use them. The Compute Engine API controls VPC networks, subnets, firewalls, and NAT gateways. Without it enabled, Terraform will fail when it tries to create any networking resources.

Verify that all three authentications are working by running a quick test command for each.

aws sts get-caller-identity
az account show
gcloud config get-value project

What should I see?

The AWS command should return your account ID and IAM user ARN. The Azure command should show your subscription details. The GCP command should print your project ID.

Getting authentication errors?

Make sure you have an active AWS account with an IAM user that has programmatic access, an Azure subscription with billing enabled, and a GCP project with billing enabled. If any of these are missing, the authentication commands will fail. Help me debug my cloud authentication.

Set Up the Claude Desktop App for HCL Review

Later in this project, you'll use Claude Code to review your Terraform modules for cross-cloud consistency. The Claude Desktop App's Code tab can read your project files directly, so you won't need to copy and paste code.

Subscription required

The Code tab in the Claude Desktop App requires a Pro, Max, Team, or Enterprise subscription. If you don't have one, you can still complete this project. You'll just copy and paste your Terraform files into a regular Claude chat for review in the final step.

Linux is not supported

The Claude Desktop App is available on macOS (Universal) and Windows (x64, ARM64) only. If you're on Linux, use the Claude web interface at claude.ai and paste your code manually when you reach the review step.

  • Download and install the Claude Desktop App if you don't already have it.
  • Open the app and sign in with your Anthropic account.
  • Click the Code tab at the top of the window.
  • Start a new session and choose Local as the environment.

You'll select your project folder in the next step after you create the directory structure. For now, just confirm that the Code tab opens and you can start a session.

Why use Claude Code for Terraform review?

When you manage infrastructure across three clouds, it's easy for module interfaces to drift apart. One module might use cidr_block while another uses address_space. Claude Code can read all your modules at once and flag inconsistencies, overlapping CIDR ranges, and security concerns across providers.

Your tools are installed and all three clouds are authenticated. Next up, you'll scaffold the Terraform project structure with separate modules for AWS, Azure, and GCP networking.

Scaffold the Multi-Cloud Terraform Project

With all three cloud CLIs authenticated and Terraform installed, you need a project structure that can manage AWS, Azure, and GCP resources from a single codebase. The key design decision is using cloud-specific modules with a shared variable interface. Each cloud's networking primitives are different enough that a single abstraction would hide important details. You'll create three modules that accept the same inputs and expose the same outputs.

In This Step, Get Ready To:
  • Create a project directory with three cloud-specific module folders.
  • Configure all three Terraform providers (AWS, Azure, GCP) in a single root configuration.
  • Define shared variables and initialize the project with terraform init.
Create the Project Directory Structure

Each cloud provider gets its own module directory with its own main.tf, variables.tf, and outputs.tf. The root directory also has these three files for the top-level configuration that wires the modules together.

  • Open your terminal and create the project folder with all module directories.
mkdir -p multicloud-network/modules/aws-network
mkdir -p multicloud-network/modules/azure-network
mkdir -p multicloud-network/modules/gcp-network
cd multicloud-network
  • Create the root-level Terraform files.
touch main.tf variables.tf outputs.tf
  • Create the module files for each cloud provider.
touch modules/aws-network/main.tf modules/aws-network/variables.tf modules/aws-network/outputs.tf
touch modules/azure-network/main.tf modules/azure-network/variables.tf modules/azure-network/outputs.tf
touch modules/gcp-network/main.tf modules/gcp-network/variables.tf modules/gcp-network/outputs.tf
  • Verify your directory structure by running the following command.

macOS

find . -type f | sort

Windows

Get-ChildItem -Recurse -File | Select-Object -ExpandProperty FullName | Sort-Object

You should see 12 files total: 3 root files and 3 files in each of the 3 module directories.

Why Cloud-Specific Modules Instead of One Abstraction?

Cloud networking primitives don't map 1:1 across providers. AWS uses an Internet Gateway as a separate resource. Azure gives public subnets internet access by default. GCP uses Cloud Router plus Cloud NAT instead of a standalone NAT gateway.

The industry best practice is cloud-specific modules with a shared variable and output contract. You learn each cloud's actual networking model while keeping a consistent interface for the code that calls these modules.

Configure All Three Providers in the Root main.tf

The root main.tf tells Terraform which provider plugins to download and how to authenticate to each cloud. You'll define version constraints to ensure reproducible builds.

  • Open main.tf in your editor and add the following configuration.
terraform {
  required_version = ">= 1.14.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
    google = {
      source  = "hashicorp/google"
      version = "~> 7.0"
    }
  }
}

# AWS provider - uses region from variable
provider "aws" {
  region = var.aws_region
}

# Azure provider - requires features block and subscription ID
provider "azurerm" {
  features {}
  subscription_id = var.azure_subscription_id
}

# GCP provider - uses project ID and region from variables
provider "google" {
  project = var.gcp_project_id
  region  = var.gcp_region
}

What Does This Configuration Do?

The required_providers block pins each provider to a major version range. The ~> 6.0 constraint means "any version 6.x but not 7.0". This prevents breaking changes from new major versions while still allowing patch updates.

The Azure provider requires an empty features {} block even if you don't configure any feature flags. Without it, Terraform will throw an error during initialization.

Define Shared Variables and Run terraform init

These root-level variables control where resources get deployed on each cloud. The CIDR ranges are intentionally non-overlapping across providers (10.0.0.0/16 for AWS, 10.1.0.0/16 for Azure, 10.2.0.0/16 for GCP). This is critical for future cross-cloud VPN or peering connections.

  • Open variables.tf and add the following variable definitions.
variable "project_name" {
  description = "Name prefix for all resources"
  type        = string
  default     = "multicloud-net"
}

variable "aws_region" {
  description = "AWS region for the VPC"
  type        = string
  default     = "us-east-1"
}

variable "azure_location" {
  description = "Azure region for the VNet"
  type        = string
  default     = "eastus"
}

variable "azure_subscription_id" {
  description = "Azure subscription ID"
  type        = string
}

variable "gcp_project_id" {
  description = "GCP project ID"
  type        = string
}

variable "gcp_region" {
  description = "GCP region for the VPC"
  type        = string
  default     = "us-central1"
}

Why Don't azure_subscription_id and gcp_project_id Have Defaults?

These values are unique to your accounts and have no sensible default. Terraform will prompt you to enter them when you run terraform plan or terraform apply. You can also pass them with -var flags or create a terraform.tfvars file to avoid retyping them.

  • Create a terraform.tfvars file so you don't have to enter these values every time.
azure_subscription_id = "[[AZURE_SUB_ID=\"your Azure subscription ID\"]]"
gcp_project_id        = "[[GCP_PROJECT_ID=\"your GCP project ID\"]]"

Pro tip: finding your IDs

Run az account show --query id -o tsv to get your Azure subscription ID. Run gcloud config get-value project to get your GCP project ID.

  • Run terraform init to download all three provider plugins.
terraform init

I see "Terraform has been successfully initialized!"

All three provider plugins downloaded successfully. You should see lines confirming the installation of hashicorp/aws, hashicorp/azurerm, and hashicorp/google. A .terraform directory and a .terraform.lock.hcl file have been created in your project folder.

I see an error

Common issues during initialization:

  • If you see "Failed to query available provider packages", check your internet connection. Terraform needs to download plugins from the registry.
  • If you see a version constraint error, make sure your required_version matches your installed Terraform version. Run terraform -version to check.
  • If you see "Error: Missing required argument" for the Azure provider, make sure the features {} block is present in your azurerm provider block.

Still Having Trouble?

Double-check that your main.tf matches the configuration above exactly. If the error persists, help me debug my terraform init error.

Your project scaffold is ready with all three providers initialized. Next up, you'll build the AWS VPC module with public and private subnets, an internet gateway, and a NAT gateway.

Build the AWS VPC Module

Your project scaffold is in place with three empty module directories waiting for real infrastructure code. The AWS VPC module is the first one you will build, and it establishes the interface pattern that Azure and GCP will follow. Every cloud has different networking primitives, but by giving each module the same variable names and output names, you create a consistent contract that makes your root configuration clean and predictable.

In This Step, Get Ready To:
  • Define a shared module interface with variables and outputs that Azure and GCP modules will mirror.
  • Create an AWS VPC with public and private subnets, an internet gateway, and a NAT gateway.
  • Wire the module into your root configuration and verify the plan.
Define the Module Interface

A module interface is the set of inputs (variables) and outputs that a Terraform module exposes. By designing the interface first, you lock in the contract before writing any resource code. This is the pattern that makes your three cloud modules interchangeable.

  • Open modules/aws-network/variables.tf and add the following variable definitions.
variable "name" {
  description = "Name prefix for all resources"
  type        = string
}

variable "cidr_block" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "availability_zones" {
  description = "List of availability zones for subnet placement"
  type        = list(string)
}

variable "tags" {
  description = "Tags to apply to all resources"
  type        = map(string)
  default     = {}
}

Why These Four Variables?

These four variables (name, cidr_block, a zone/region list, and tags) form the shared contract that all three cloud modules will use. Azure and GCP will have the same variable names with cloud-appropriate defaults. This consistency means your root configuration looks nearly identical for each module call.

  • Open modules/aws-network/outputs.tf and define the outputs.
output "network_id" {
  description = "The ID of the VPC"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "List of public subnet IDs"
  value       = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  description = "List of private subnet IDs"
  value       = aws_subnet.private[*].id
}

output "cidr_block" {
  description = "The CIDR block of the VPC"
  value       = aws_vpc.main.cidr_block
}

What Is the Shared-Interface Pattern?

Every module outputs the same four values: network_id, public_subnet_ids, private_subnet_ids, and cidr_block. This means any downstream module or resource that needs a network ID can accept it from any cloud module without caring which provider created it.

Create the VPC, Subnets, and Gateways

An AWS VPC is your isolated network inside AWS. Within it, you carve out subnets for different purposes. Public subnets route traffic through an internet gateway so resources can be reached from the internet. Private subnets route outbound traffic through a NAT gateway so resources can reach the internet without being directly exposed.

  • Open modules/aws-network/main.tf and add the VPC resource with DNS support enabled.
# The VPC is the top-level network container in AWS
resource "aws_vpc" "main" {
  cidr_block           = var.cidr_block
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = merge(var.tags, {
    Name = "${var.name}-vpc"
  })
}
  • Add the public and private subnets below the VPC resource. The cidrsubnet() function carves smaller ranges from the VPC CIDR.
# Public subnets - one per availability zone
# cidrsubnet("10.0.0.0/16", 8, 0) gives "10.0.0.0/24"
# cidrsubnet("10.0.0.0/16", 8, 1) gives "10.0.1.0/24"
resource "aws_subnet" "public" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.cidr_block, 8, count.index)
  availability_zone = var.availability_zones[count.index]

  map_public_ip_on_launch = true

  tags = merge(var.tags, {
    Name = "${var.name}-public-${var.availability_zones[count.index]}"
    Tier = "public"
  })
}

# Private subnets - one per availability zone, offset by 100
# cidrsubnet("10.0.0.0/16", 8, 100) gives "10.0.100.0/24"
resource "aws_subnet" "private" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.cidr_block, 8, count.index + 100)
  availability_zone = var.availability_zones[count.index]

  tags = merge(var.tags, {
    Name = "${var.name}-private-${var.availability_zones[count.index]}"
    Tier = "private"
  })
}

What Does cidrsubnet() Do?

The cidrsubnet() function takes a CIDR block and carves it into smaller ranges. The second argument (8) adds 8 bits to the prefix, turning a /16 into a /24 (256 addresses). The third argument picks which subnet number to use. Public subnets start at 0 and private subnets start at 100, keeping them well separated.

  • Add the internet gateway and NAT gateway resources below the subnets.
# Internet gateway for public subnet traffic
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = merge(var.tags, {
    Name = "${var.name}-igw"
  })
}

# Elastic IP for the NAT gateway
resource "aws_eip" "nat" {
  domain = "vpc"

  tags = merge(var.tags, {
    Name = "${var.name}-nat-eip"
  })
}

# NAT gateway sits in a public subnet and provides outbound
# internet access for resources in private subnets
resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id

  tags = merge(var.tags, {
    Name = "${var.name}-nat"
  })

  depends_on = [aws_internet_gateway.main]
}

Why Does the NAT Gateway Live in a Public Subnet?

The NAT gateway needs its own internet access to forward traffic on behalf of private resources. It sits in a public subnet (which has a route to the internet gateway) and gets an Elastic IP so it can send and receive traffic from the internet. Private subnet resources send their outbound traffic to the NAT gateway, which forwards it out and routes responses back.

  • Add the route tables and subnet associations to complete the networking setup.
# Public route table - sends internet traffic to the internet gateway
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.name}-public-rt"
  })
}

# Private route table - sends internet traffic to the NAT gateway
resource "aws_route_table" "private" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main.id
  }

  tags = merge(var.tags, {
    Name = "${var.name}-private-rt"
  })
}

# Associate each public subnet with the public route table
resource "aws_route_table_association" "public" {
  count          = length(var.availability_zones)
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

# Associate each private subnet with the private route table
resource "aws_route_table_association" "private" {
  count          = length(var.availability_zones)
  subnet_id      = aws_subnet.private[count.index].id
  route_table_id = aws_route_table.private.id
}

What Are Route Tables?

A route table is a set of rules that determines where network traffic goes. The 0.0.0.0/0 route is the default route, meaning "all traffic not destined for the local VPC." Public subnets send this traffic to the internet gateway. Private subnets send it to the NAT gateway. Without these associations, subnets would have no outbound connectivity.

Wire the Module Into the Root Configuration and Plan

The module code is complete. Now you need to call it from your root configuration so Terraform knows to create these resources.

  • Open your root main.tf and add the AWS network module block below your provider configurations.
module "aws_network" {
  source = "./modules/aws-network"

  name               = var.project_name
  cidr_block         = "10.0.0.0/16"
  availability_zones = ["us-east-1a", "us-east-1b"]

  tags = {
    Project     = var.project_name
    Environment = "dev"
    ManagedBy   = "terraform"
  }
}

Why Two Availability Zones?

Spreading subnets across two availability zones is the minimum for high availability. If one data center has an outage, resources in the other AZ keep running. Production workloads typically use three AZs, but two is sufficient for this project.

  • Run terraform plan to preview the resources Terraform will create.
terraform plan

What Should I See?

The plan output should show approximately 13 resources to add: 1 VPC, 2 public subnets, 2 private subnets, 1 internet gateway, 1 Elastic IP, 1 NAT gateway, 2 route tables, and 4 route table associations. You should see Plan: 13 to add, 0 to change, 0 to destroy (or a similar count) at the bottom.

Seeing an Error About Missing Modules?

If Terraform says it cannot find the module, run terraform init again. Terraform needs to register new module sources before it can plan them. If you see variable-related errors, double-check that your root variables.tf has the project_name variable defined from Step 2.

Still stuck? Help me debug this Terraform plan error.

Your AWS VPC module is built and verified. It has a clean interface that the Azure and GCP modules will mirror exactly. Next up, you will build both of those modules and run a full multi-cloud plan from a single configuration.

Build the Azure VNet and GCP VPC Modules

Your AWS VPC module is planned and ready. Now you need to bring Azure and GCP up to the same standard. Each cloud has its own networking primitives, but your modules will follow the same variable interface pattern you established with AWS. This means any future project can swap in one of these modules and get a consistent experience regardless of which cloud it targets.

In This Step, Get Ready To:
  • Build the Azure VNet module with a resource group, subnets, and NAT gateway.
  • Build the GCP VPC module with custom subnets, Cloud Router, and Cloud NAT.
  • Wire both modules into the root configuration and run a full multi-cloud plan.
Build the Azure VNet Module

Azure networking differs from AWS in a few important ways. Azure requires a resource group before you can create any resources. Public subnets get internet access by default when associated with a public IP or load balancer, so there is no separate internet gateway resource like AWS has. The NAT gateway is a standalone resource that you explicitly associate with subnets.

Start with the module's variable interface. This mirrors the pattern you used for AWS.

  • Open modules/azure-network/variables.tf and add the following variables.
variable "name" {
  description = "Name prefix for all Azure network resources"
  type        = string
}

variable "cidr_block" {
  description = "CIDR block for the Azure VNet"
  type        = string
  default     = "10.1.0.0/16"
}

variable "location" {
  description = "Azure region for the VNet"
  type        = string
}

variable "tags" {
  description = "Tags to apply to all resources"
  type        = map(string)
  default     = {}
}

Why 10.1.0.0/16 for Azure?

Each cloud gets a non-overlapping CIDR range: AWS uses 10.0.0.0/16, Azure uses 10.1.0.0/16, and GCP uses 10.2.0.0/16. Non-overlapping ranges are critical for future cross-cloud VPN or peering. If CIDRs overlap, routing breaks because the network cannot determine which cloud a packet should go to.

Now build the main resources for the Azure module.

  • Open modules/azure-network/main.tf and add the following configuration.
# Azure requires a resource group before any resources can be created
resource "azurerm_resource_group" "this" {
  name     = "${var.name}-rg"
  location = var.location
  tags     = var.tags
}

# The VNet is Azure's equivalent of an AWS VPC
resource "azurerm_virtual_network" "this" {
  name                = "${var.name}-vnet"
  location            = azurerm_resource_group.this.location
  resource_group_name = azurerm_resource_group.this.name
  address_space       = [var.cidr_block]
  tags                = var.tags
}

# Public subnet - gets internet access via public IPs by default
resource "azurerm_subnet" "public" {
  name                 = "${var.name}-public"
  resource_group_name  = azurerm_resource_group.this.name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefixes     = [cidrsubnet(var.cidr_block, 8, 1)]
}

# Private subnet - outbound internet goes through the NAT gateway
resource "azurerm_subnet" "private" {
  name                 = "${var.name}-private"
  resource_group_name  = azurerm_resource_group.this.name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefixes     = [cidrsubnet(var.cidr_block, 8, 2)]
}

# Public IP for the NAT gateway
resource "azurerm_public_ip" "nat" {
  name                = "${var.name}-nat-pip"
  location            = azurerm_resource_group.this.location
  resource_group_name = azurerm_resource_group.this.name
  allocation_method   = "Static"
  sku                 = "Standard"
  tags                = var.tags
}

# NAT gateway for outbound private subnet traffic
resource "azurerm_nat_gateway" "this" {
  name                = "${var.name}-natgw"
  location            = azurerm_resource_group.this.location
  resource_group_name = azurerm_resource_group.this.name
  sku_name            = "Standard"
  tags                = var.tags
}

# Associate the public IP with the NAT gateway
resource "azurerm_nat_gateway_public_ip_association" "this" {
  nat_gateway_id       = azurerm_nat_gateway.this.id
  public_ip_address_id = azurerm_public_ip.nat.id
}

# Link the NAT gateway to the private subnet
resource "azurerm_subnet_nat_gateway_association" "private" {
  subnet_id      = azurerm_subnet.private.id
  nat_gateway_id = azurerm_nat_gateway.this.id
}

Why This Approach Instead of Mirroring AWS Exactly?

You might expect Azure to have an internet gateway resource like AWS. It does not. Azure VNets provide internet access to subnets by default when resources have public IPs. The NAT gateway handles outbound traffic from private subnets, but there is no separate internet gateway to create. This is why cloud-specific modules with a shared interface work better than a single abstraction that tries to unify all three clouds.

Finally, define the outputs so this module exposes the same interface as the AWS module.

  • Open modules/azure-network/outputs.tf and add the following outputs.
output "network_id" {
  description = "The ID of the Azure VNet"
  value       = azurerm_virtual_network.this.id
}

output "public_subnet_ids" {
  description = "List of public subnet IDs"
  value       = [azurerm_subnet.public.id]
}

output "private_subnet_ids" {
  description = "List of private subnet IDs"
  value       = [azurerm_subnet.private.id]
}

output "cidr_block" {
  description = "The CIDR block of the VNet"
  value       = var.cidr_block
}

What is the shared interface pattern?

All three modules output the same four values: network_id, public_subnet_ids, private_subnet_ids, and cidr_block. This means any downstream module or configuration that needs a network ID can accept it from any of the three clouds without caring which provider created it.

Build the GCP VPC Module

GCP networking has its own distinct model. A GCP VPC is a global resource, not regional. Subnets are regional. Instead of a standalone NAT gateway, GCP uses a Cloud Router paired with Cloud NAT for outbound traffic from private subnets. GCP also uses "labels" for metadata instead of "tags."

  • Open modules/gcp-network/variables.tf and add the following variables.
variable "name" {
  description = "Name prefix for all GCP network resources"
  type        = string
}

variable "cidr_block" {
  description = "CIDR block for the GCP VPC (used for subnet calculations)"
  type        = string
  default     = "10.2.0.0/16"
}

variable "region" {
  description = "GCP region for the subnets"
  type        = string
}

variable "tags" {
  description = "Labels to apply to all resources"
  type        = map(string)
  default     = {}
}

Now build the main GCP network resources.

  • Open modules/gcp-network/main.tf and add the following configuration.
# Custom mode VPC - no auto-created subnets
resource "google_compute_network" "this" {
  name                    = "${var.name}-vpc"
  auto_create_subnetworks = false
}

# Public subnet - regional, within the VPC
resource "google_compute_subnetwork" "public" {
  name          = "${var.name}-public"
  ip_cidr_range = cidrsubnet(var.cidr_block, 8, 1)
  region        = var.region
  network       = google_compute_network.this.id
}

# Private subnet - regional, outbound traffic routed through Cloud NAT
resource "google_compute_subnetwork" "private" {
  name          = "${var.name}-private"
  ip_cidr_range = cidrsubnet(var.cidr_block, 8, 2)
  region        = var.region
  network       = google_compute_network.this.id
  purpose       = "PRIVATE"
}

# Cloud Router - required for Cloud NAT
resource "google_compute_router" "this" {
  name    = "${var.name}-router"
  region  = var.region
  network = google_compute_network.this.id
}

# Cloud NAT - provides outbound internet for private subnet
resource "google_compute_router_nat" "this" {
  name                               = "${var.name}-nat"
  router                             = google_compute_router.this.name
  region                             = var.region
  nat_ip_allocate_option             = "AUTO_ONLY"
  source_subnetwork_ip_ranges_to_nat = "LIST_OF_SUBNETWORKS"

  subnetwork {
    name                    = google_compute_subnetwork.private.id
    source_ip_ranges_to_nat = ["ALL_IP_RANGES"]
  }
}

# Allow internal traffic within the VPC CIDR
resource "google_compute_firewall" "internal" {
  name    = "${var.name}-allow-internal"
  network = google_compute_network.this.name

  allow {
    protocol = "tcp"
    ports    = ["0-65535"]
  }

  allow {
    protocol = "udp"
    ports    = ["0-65535"]
  }

  allow {
    protocol = "icmp"
  }

  source_ranges = [var.cidr_block]
}

What does auto_create_subnetworks = false do?

By default, GCP creates a subnet in every region automatically. Setting auto_create_subnetworks = false creates a "custom mode" VPC where you define exactly which subnets exist and where. This is the recommended setting for production because it gives you full control over your IP address ranges and avoids unexpected subnets consuming your CIDR space.

Define the outputs to complete the shared interface.

  • Open modules/gcp-network/outputs.tf and add the following outputs.
output "network_id" {
  description = "The ID of the GCP VPC network"
  value       = google_compute_network.this.id
}

output "public_subnet_ids" {
  description = "List of public subnet IDs"
  value       = [google_compute_subnetwork.public.id]
}

output "private_subnet_ids" {
  description = "List of private subnet IDs"
  value       = [google_compute_subnetwork.private.id]
}

output "cidr_block" {
  description = "The CIDR block of the VPC"
  value       = var.cidr_block
}
Wire Both Modules Into Root and Run a Full Plan

With all three modules built, you can now call them from the root configuration and see the full multi-cloud plan.

  • Open the root main.tf and add the Azure and GCP module blocks below your existing AWS module block.
module "azure_network" {
  source   = "./modules/azure-network"
  name     = var.project_name
  location = var.azure_location

  tags = {
    project = var.project_name
  }
}

module "gcp_network" {
  source = "./modules/gcp-network"
  name   = var.project_name
  region = var.gcp_region

  tags = {
    project = var.project_name
  }
}

Now add root-level outputs that expose all three networks' IDs and CIDRs.

  • Open the root outputs.tf and add the following.
output "aws_network_id" {
  value = module.aws_network.network_id
}

output "aws_cidr" {
  value = module.aws_network.cidr_block
}

output "azure_network_id" {
  value = module.azure_network.network_id
}

output "azure_cidr" {
  value = module.azure_network.cidr_block
}

output "gcp_network_id" {
  value = module.gcp_network.network_id
}

output "gcp_cidr" {
  value = module.gcp_network.cidr_block
}

Before you can plan the new modules, Terraform needs to download any new provider plugins referenced by the modules.

  • Run terraform init to register the new modules.
terraform init
  • Run terraform plan to see the full multi-cloud plan.
terraform plan

What should I see?

The plan output should now show resources across all three clouds in a single output. You should see your AWS resources from the previous step plus the new Azure resources (resource group, VNet, subnets, public IP, NAT gateway, associations) and GCP resources (VPC network, subnets, router, Cloud NAT, firewall rule). The total resource count should be roughly 25-30 resources.

Seeing a GCP API Error?

If Terraform reports that the Compute Engine API is not enabled, you need to enable it in your GCP project. Run the following command:

gcloud services enable compute.googleapis.com

Wait a minute or two for the API to activate, then run terraform plan again. If you are still seeing errors, help me debug my Terraform GCP error.

Seeing an Azure Subscription Error?

If Terraform reports a subscription or authorization error for Azure, verify that you have set the correct subscription. Run az account show to check your active subscription. If it is wrong, run az account set --subscription <your-subscription-id> and try the plan again. If you are still stuck, help me debug my Azure Terraform error.

All three cloud modules are built and planned. Next up, you will deploy the entire multi-cloud network with a single terraform apply and use Claude Code to review your modules for cross-cloud consistency.

Deploy, Validate, and Review with Claude Code

Your Terraform configuration is complete across all three clouds. Now it is time to deploy everything, confirm the infrastructure matches your code, and use Claude Code to review your modules for cross-cloud consistency. This step is where your code becomes real infrastructure.

In This Step, Get Ready To:
  • Deploy your multi-cloud network foundation across AWS, Azure, and GCP with a single command.
  • Validate the deployment and perform a drift detection check.
  • Use Claude Code to review your Terraform modules for consistency and security.
Apply the Configuration Across All Three Clouds

Terraform can provision resources on multiple clouds in parallel from a single terraform apply command. It reads your provider configurations and creates resources on AWS, Azure, and GCP simultaneously.

  • Run the apply command from your project root directory.
terraform apply
  • Review the plan output that Terraform displays before applying.
  • Type yes and press Enter to confirm.

Terraform provisions resources on all three clouds in parallel. This typically takes 2-4 minutes.

Seeing errors during apply?

The most common issues are:

  • GCP Compute Engine API not enabled. Run gcloud services enable compute.googleapis.com and try again.
  • Azure subscription permissions. Verify your subscription is active with az account show.
  • AWS credential expiration. Re-run aws configure if your session has timed out.

If you are hitting a different error, ask for help debugging your terraform apply output.

What should I see?

When the apply finishes, Terraform prints a summary showing all the resources it created. You should see a message like Apply complete! Resources: X added, 0 changed, 0 destroyed. followed by your output values.

Validate the Deployment and Check for Drift

After deploying infrastructure, you need to confirm two things. First, that your outputs contain the expected values. Second, that the actual cloud state matches your Terraform state file. This second check is called drift detection.

  • Run terraform output to view all three network IDs and CIDRs.
terraform output

You should see output values for your AWS VPC ID, Azure VNet ID, and GCP network name, along with their respective CIDR ranges (10.0.0.0/16, 10.1.0.0/16, 10.2.0.0/16).

  • Run terraform plan again immediately after the apply.
terraform plan

What is drift detection?

When you run terraform plan after a successful apply, Terraform compares the actual state of your cloud resources against what it expects from the state file. If someone (or some automation) modified a resource outside of Terraform, the plan would show unexpected changes. This is called infrastructure drift.

A clean plan that shows "No changes" confirms your infrastructure is exactly as your code defines it.

The plan should show No changes. Your infrastructure matches the configuration. This confirms zero drift across all three clouds.

I see "No changes"

Your infrastructure is in sync with your Terraform code. No drift detected across any of the three clouds.

I see unexpected changes

If the plan shows changes you did not expect, something modified your cloud resources outside of Terraform. Read the plan output carefully to identify which resource drifted and on which cloud.

Common causes include cloud provider defaults being applied after creation, or a console-based change. You can run terraform apply again to bring the infrastructure back in line with your code.

If you are unsure what caused the drift, ask for help understanding your terraform plan drift output.

Use Claude Code to Review Your Modules for Cross-Cloud Consistency

Your infrastructure is deployed and validated. Now you will use Claude Code to perform an AI-assisted review of your Terraform modules. Claude Code can read your project files directly and analyze them for consistency, security issues, and best practice violations across all three cloud providers.

  • Open the Claude Desktop App and click the Code tab.
  • Confirm your project folder is loaded. If not, start a new session and select your multicloud-network directory.
  • Enter the following prompt to kick off the cross-cloud review.
Review my three network modules in modules/aws-network, modules/azure-network, and modules/gcp-network. Check that the variable interfaces are consistent, the CIDR ranges don't overlap, the routing is correct, and flag any security concerns.

What does this prompt do?

Claude Code reads all the .tf files in your module directories and analyzes them together. It checks whether your variable names and types match across modules, whether the CIDR ranges (10.0.0.0/16, 10.1.0.0/16, 10.2.0.0/16) are non-overlapping, and whether your routing and gateway configurations follow security best practices.

  • Read through Claude's response and note any suggestions or issues it flags.
  • If Claude suggests improvements, review each one and decide whether to apply it.

Pro tip: dig deeper with follow-up prompts

After the initial review, try follow-up prompts like "Are there any firewall rules or security groups I should add?" or "How would I prepare these modules for a production environment?" Claude Code maintains context from your previous messages, so each follow-up builds on the last.

Why use AI for infrastructure review?

Multi-cloud configurations are hard to review manually because each provider has different naming conventions, resource structures, and security defaults. An AI reviewer can compare patterns across all three modules simultaneously. It catches inconsistencies that are easy to miss when reviewing one module at a time.

Your multi-cloud network foundation is deployed, validated, and reviewed. You have proven that your Terraform code produces real infrastructure that matches your state file with zero drift. Next, you will clean up your resources to avoid ongoing costs.

Secret mission

Add Cross-Cloud VPN Between AWS and Azure

Your three cloud networks are isolated from each other. In production, workloads often need to communicate across clouds securely. Can you connect your AWS VPC and Azure VNet with an IPsec VPN tunnel, managed entirely through Terraform?

Before you go

Clean Up Your Resources

This project deploys resources across three cloud providers, and several of them charge by the hour. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Cost warning

NAT gateways on all three clouds charge approximately $0.045/hour each. Leaving the base infrastructure running costs roughly $3-4/day. If you completed the Secret Mission, the Azure VPN Gateway (VpnGw1 SKU at ~$0.19/hour) and AWS VPN connection ($0.05/hour) push costs to $7-8/day.

Destroy your resources as soon as you finish the project to avoid unnecessary charges.

Resources you used:

  • AWS: VPC, public and private subnets, internet gateway, NAT gateway, Elastic IP, route tables, and route table associations.
  • Azure: Resource group, Virtual Network (VNet), public and private subnets, NAT gateway, and public IP.
  • GCP: VPC network, public and private subnetworks, Cloud Router, Cloud NAT, and firewall rule.
  • Secret Mission only: AWS VPN gateway, AWS customer gateway, AWS VPN connection, Azure VPN gateway (VpnGw1), Azure local network gateway, and Azure VPN gateway connection.
  • Local: Terraform project directory, Terraform state file, and provider plugin cache.

Keep everything running

No action needed. Choose this if you are still actively building on top of this network foundation.

  • Be aware that NAT gateways on all three clouds will continue charging approximately $0.045/hour each.
  • If you completed the Secret Mission, the Azure VPN Gateway charges approximately $0.19/hour.
  • Check each cloud console periodically to confirm no unexpected resources are running.

Pause - I'll come back to this later

Destroy the cloud infrastructure to stop all charges, but keep your Terraform module code for redeployment later.

  • Run terraform destroy in your project directory and type yes to confirm.
  • This removes all resources across AWS, Azure, and GCP in a single command.
  • Keep your project folder with all module code intact. You can redeploy anytime with terraform apply.
  • Verify in each cloud console (AWS VPC dashboard, Azure Virtual Networks, GCP VPC Networks) that no resources remain.

Delete - I don't want to use this again

Remove all cloud resources and delete your local project files for a complete cleanup.

  • Run terraform destroy in your project directory and type yes to confirm.
  • Verify in the AWS VPC dashboard that the VPC, subnets, NAT gateway, and internet gateway have been deleted.
  • Verify in the Azure portal that the resource group and all its contents have been removed.
  • Verify in the GCP console that the VPC network, subnets, Cloud Router, and Cloud NAT have been deleted.
  • Delete your local project folder: rm -rf multicloud-network (macOS/Linux) or remove it from File Explorer (Windows).
  • Optionally clear the Terraform plugin cache: rm -rf ~/.terraform.d/plugin-cache.

Mission accomplished

That's a Wrap!

Nice work! You just built a production-style multi-cloud network foundation across AWS, Azure, and GCP from a single Terraform project. That's three clouds, one codebase, and a shared module pattern you can carry into any future infrastructure project.

Here's what you learned:

  • Designed and deployed isolated VPCs and VNets on three clouds with public and private subnet segmentation, internet gateways, NAT gateways, and proper route tables.
  • Built reusable Terraform modules with a shared-interface pattern so every module exposes the same inputs and outputs regardless of which cloud it targets.
  • Ran drift detection and used Claude Code to review your HCL modules for cross-cloud consistency, CIDR overlap, and security concerns.
  • Connected your AWS VPC and Azure VNet with an IPsec VPN tunnel managed entirely through Terraform.

Ready to quiz yourself?