In this post I’ll create a site-to-site (S2S) tunnel between AWS and Azure. It’s an HA tunnel so it’s regionally resilient. I’ll use Terraform for the deployment using a set of modules (download here). The Terraform files will be separated in two different folders. One for Azure and one for AWS.

The architectural diagram looks like this.

Table of Contents
Azure
For Azure, we will provision a public subnet (10.1.0.0/24) and a Gateway subnet (10.1.1.0/24). Then, we’ll provision one VNET Gateway with active-active BGP tunnel. The Azure setup requires that we provision 4 local network gateways and S2S connections, but the output will return only 2 public IPs. These are the files.
provider.tf
terraform {
required_version = "~>1.9.5"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~>3.110.0"
}
}
}
provider "azurerm" {
features {
resource_group {
prevent_deletion_if_contains_resources = false
}
}
}
azure.tf
# Create a resource group
module "rg" {
source = "../../modules/azure/rg-0.0.4"
az_rg_name = var.rg_ResourceGroupName
az_rg_location = var.rgn_RegionLocation
}
# Create a VNET
module "vnet" {
source = "../../modules/azure/vnet-4.0.0"
vnet_name = var.vnet_Name
resource_group_name = var.rg_ResourceGroupName
vnet_location = var.rgn_RegionLocation
use_for_each = false
address_space = [var.vnet_CIDR]
subnet_prefixes = [var.sub_PublicCIDR, var.sub_GatewayCIDR]
subnet_names = [var.sub_PublicName, var.sub_GatewayName]
depends_on = [module.rg]
}
# Create a virtual network gateway
module "vnetgw" {
source = "../../modules/azure/vnetgw-0.5.1"
location = var.rgn_RegionLocation
name = var.vgw_Name
subnet_address_prefix = var.sub_GatewayCIDR
sku = var.vgw_SKU
type = var.vgw_Type
virtual_network_id = module.vnet.vnet_id
vpn_generation = var.vgw_Generation
vpn_type = var.vpn_Type
subnet_creation_enabled = false
vpn_active_active_enabled = true
vpn_bgp_enabled = true
vpn_bgp_settings = {
asn = var.asn_Azure
}
# Create virtual network gateway configurations
ip_configurations = {
"ip_config_01" = {
name = var.gwc_BGPName1
apipa_addresses = var.ip_APIPAAddresses1
public_ip = {
allocation_method = var.ip_allocation_method
}
},
"ip_config_02" = {
name = var.gwc_BGPName2
apipa_addresses = var.ip_APIPAAddresses2
public_ip = {
allocation_method = var.ip_allocation_method
}
}
}
# Create local network gateways and connections
local_network_gateways = {
# AWS Tunnel 1 to Azure Instance 0
"lngw_01" = {
name = var.lng_AWST1A0
gateway_address = var.ip_AWST1A0
bgp_settings = {
asn = var.asn_AWS
bgp_peering_address = var.ip_peering_AWST1A0
}
connection = {
name = var.conn_AWST1A0
type = var.conn_Type
enable_bgp = true
shared_key = var.key_SharedKey
custom_bgp_addresses = {
primary = var.ip_primary_AWST1A0
secondary = var.ip_secondary_AWST1A0
}
}
},
# AWS Tunnel 2 to Azure Instance 0
"lngw_02" = {
name = var.lng_AWST2A0
gateway_address = var.ip_AWST2A0
bgp_settings = {
asn = var.asn_AWS
bgp_peering_address = var.ip_peering_AWST2A0
}
connection = {
name = var.conn_AWST2A0
type = var.conn_Type
enable_bgp = true
shared_key = var.key_SharedKey
custom_bgp_addresses = {
primary = var.ip_primary_AWST2A0
secondary = var.ip_secondary_AWST2A0 }
}
},
# AWS Tunnel 1 to Azure Instance 1
"lngw_03" = {
name = var.lng_AWST1A1
gateway_address = var.ip_AWST1A1
bgp_settings = {
asn = var.asn_AWS
bgp_peering_address = var.ip_peering_AWST1A1
}
connection = {
name = var.conn_AWST1A1
type = var.conn_Type
enable_bgp = true
shared_key = var.key_SharedKey
custom_bgp_addresses = {
primary = var.ip_primary_AWST1A1
secondary = var.ip_secondary_AWST1A1 }
}
},
# AWS Tunnel 2 to Azure Instance 1
"lngw_04" = {
name = var.lng_AWST2A1
gateway_address = var.ip_AWST2A1
bgp_settings = {
asn = var.asn_AWS
bgp_peering_address = var.ip_peering_AWST2A1
}
connection = {
name = var.conn_AWST2A1
type = var.conn_Type
enable_bgp = true
shared_key = var.key_SharedKey
custom_bgp_addresses = {
primary = var.ip_primary_AWST2A1
secondary = var.ip_secondary_AWST2A1 }
}
}
}
depends_on = [module.vnet]
}
variables.tf
# Resource group
variable "rg_ResourceGroupName" {
type = string
default = "rgVPN"
description = "Resource group name"
}
variable "rgn_RegionLocation" {
type = string
default = "eastus2"
description = "Azure region"
}
# VNET
variable "vnet_Name" {
type = string
default = "vnetVPN"
description = "VNET name"
}
variable "vnet_CIDR" {
type = string
default = "10.1.0.0/16"
description = "VNET CIDR"
}
variable "sub_PublicCIDR" {
type = string
default = "10.1.0.0/24"
description = "CIDR of the public subnet"
}
variable "sub_GatewayCIDR" {
type = string
default = "10.1.1.0/24"
description = "CIDR of the Gateway subnet"
}
variable "sub_PublicName" {
type = string
default = "subPublic"
description = "Name of the public subnet"
}
variable "sub_GatewayName" {
type = string
default = "GatewaySubnet"
description = "Name of the Gateway subnet - DO NOT CHANGE. It has to stay as GatewaySubnet"
}
# Virtual network gateway
variable "vgw_Name" {
type = string
default = "vgwVPN"
description = "Name of the virtual gateway"
}
variable "vgw_SKU" {
type = string
default = "VpnGw2AZ"
description = "Virtual Gateway SKU"
}
variable "vgw_Type" {
type = string
default = "Vpn"
description = "Virtual Gateway Type (VPN or ExpressRoute)"
}
variable "vgw_Generation" {
type = string
default = "Generation2"
description = "Virtual Gateway Generation"
}
variable "vpn_Type" {
type = string
default = "RouteBased"
description = "VPN type - RouteBased or PolicyBased"
}
variable "asn_Azure" {
type = string
default = "65000"
description = "Azure ASN"
}
variable "asn_AWS" {
type = string
default = "64512"
description = "AWS ASN"
}
# Virtual network gateway config 1
variable "gwc_BGPName1" {
type = string
default = "vnetGatewayConfig01"
description = "Name of the virtual network gateway configuration for the first tunnel"
}
variable "ip_APIPAAddresses1" {
type = list(string)
default = ["169.254.21.2", "169.254.22.2"]
description = "APIPA address for the first tunnel"
}
# Virtual network gateway config 2
variable "gwc_BGPName2" {
type = string
default = "vnetGatewayConfig02"
description = "Name of the virtual network gateway configuration for the second tunnel"
}
variable "ip_APIPAAddresses2" {
type = list(string)
default = ["169.254.21.6", "169.254.22.6"]
description = "APIPA address for the second tunnel"
}
variable "ip_allocation_method" {
type = string
default = "Static"
description = "Static or Dynamic public IP"
}
# Local network gateway common settings
variable "conn_Type" {
type = string
default = "IPsec"
description = "Connection type: IPsec or Vnet2Vnet"
}
variable "key_SharedKey" {
type = string
default = "KlimentAndreev1970_"
description = "Make sure it's the same as 'key_SharedKey' variable in variables.tf under the AWS folder"
# Don't go crazy with complexity. AWS side can support only
# between 8 and 64 characters in length and cannot start with zero (0).
# Allowed characters are alphanumeric characters, periods (.), and underscores (_).
}
# Local network gateway AWS Tunnel 1 to Azure Instance 0
# lngw_01
variable "lng_AWST1A0" {
type = string
default = "lng_AWSTunnel1ToAzureInstance0"
description = "From AWS Tunnel 1 to Azure Instance 0"
}
###
### REPLACE FROM THE OUTPUT OF AWS IN STEP 2
###
variable "ip_AWST1A0" {
type = string
default = "13.58.80.164"
description = "Enter the output of ip_AWST1IP1 from the AWS output.tf script"
}
variable "ip_peering_AWST1A0" {
type = string
default = "169.254.21.1"
description = "BGP Peering IP for AWS Tunnel1 to Azure Instance 0"
}
variable "ip_primary_AWST1A0" {
type = string
default = "169.254.21.2"
description = "Primary Custom BGP Address for AWS Tunnel1 to Azure Instance 0"
}
variable "ip_secondary_AWST1A0" {
type = string
default = "169.254.21.6"
description = "Secondary Custom BGP Address for AWS Tunnel1 to Azure Instance 0"
}
variable "conn_AWST1A0" {
type = string
default = "connAWSTunnel1toAzureInstance0"
description = "Connection name for AWS Tunnel1 to Azure Instance 0"
}
# Local network gateway AWS Tunnel 2 to Azure Instance 0
# lngw_02
variable "lng_AWST2A0" {
type = string
default = "lng_AWSTunnel2ToAzureInstance0"
description = "From AWS Tunnel 2 to Azure Instance 0"
}
###
### REPLACE FROM THE OUTPUT OF AWS IN STEP 2
###
variable "ip_AWST2A0" {
type = string
default = "18.119.84.239"
description = "Enter the output of ip_AWST2A0 from the AWS output.tf script"
}
variable "ip_peering_AWST2A0" {
type = string
default = "169.254.22.1"
description = "BGP Peering IP for AWS Tunnel2 to Azure Instance 0"
}
variable "ip_primary_AWST2A0" {
type = string
default = "169.254.22.2"
description = "Primary Custom BGP Address for AWS Tunnel2 to Azure Instance 0"
}
variable "ip_secondary_AWST2A0" {
type = string
default = "169.254.21.6"
description = "Secondary Custom BGP Address for AWS Tunnel1 to Azure Instance 1"
}
variable "conn_AWST2A0" {
type = string
default = "connAWSTunnel2toAzureInstance0"
description = "Connection name for AWS Tunnel2 to Azure Instance 0"
}
# Local network gateway AWS Tunnel 1 to Azure Instance 1
# lngw_03
variable "lng_AWST1A1" {
type = string
default = "lng_AWSTunnel1ToAzureInstance1"
description = "From AWS Tunnel 1 to Azure Instance 1"
}
###
### REPLACE FROM THE OUTPUT OF AWS IN STEP 2
###
variable "ip_AWST1A1" {
type = string
default = "18.119.2.80"
description = "Enter the output of ip_AWST1A1 from the AWS output.tf script"
}
variable "ip_peering_AWST1A1" {
type = string
default = "169.254.21.5"
description = "BGP Peering IP for AWS Tunnel1 to Azure Instance 1"
}
variable "ip_primary_AWST1A1" {
type = string
default = "169.254.21.2"
description = "Primary Custom BGP Address for AWS Tunnel1 to Azure Instance 1"
}
variable "ip_secondary_AWST1A1" {
type = string
default = "169.254.21.6"
description = "Secondary Custom BGP Address for AWS Tunnel1 to Azure Instance 1"
}
variable "conn_AWST1A1" {
type = string
default = "connAWSTunnel1toAzureInstance1"
description = "Connection name for AWS Tunnel1 to Azure Instance 1"
}
# Local network gateway AWS Tunnel 2 to Azure Instance 1
# lngw_04
variable "lng_AWST2A1" {
type = string
default = "lng_AWSTunnel2ToAzureInstance1"
description = "From AWS Tunnel 2 to Azure Instance 1"
}
###
### REPLACE FROM THE OUTPUT OF AWS IN STEP 2
###
variable "ip_AWST2A1" {
type = string
default = "18.220.73.188"
description = "Enter the output of ip_AWST2A1 from the AWS ouput.tf script"
}
variable "ip_peering_AWST2A1" {
type = string
default = "169.254.22.5"
description = "BGP Peering IP for AWS Tunnel2 to Azure Instance 1"
}
variable "ip_primary_AWST2A1" {
type = string
default = "169.254.21.2"
description = "Primary Custom BGP Address for AWS Tunnel2 to Azure Instance 1"
}
variable "ip_secondary_AWST2A1" {
type = string
default = "169.254.22.6"
description = "Secondary Custom BGP Address for AWS Tunnel2 to Azure Instance 1"
}
variable "conn_AWST2A1" {
type = string
default = "connAWSTunnel2toAzureInstance1"
description = "Connection name for AWS Tunnel2 to Azure Instance 1"
}
outputs.tf
output "vgw_publicip_1" {
value = module.vnetgw.public_ip_addresses.ip_config_01.ip_address
description = "Use this output to populate the same variable in AWS variables.tf file."
}
output "vgw_publicip_2" {
value = module.vnetgw.public_ip_addresses.ip_config_02.ip_address
description = "Use this output to populate the same variable in AWS variables.tf file."
}
Once you run the initial terraform apply it will take about 35-40 mins and you’ll get two IPs as output. You’ll need these momentarily in the file variables.tf under 02.AWS folder.
vgw_publicip_1 = "48.211.240.129" vgw_publicip_2 = "48.211.240.186"
AWS
In AWS we’ll provision a private and a public subnet as well as two VPN tunnels. This is the Terraform code for that.
provider.tf
terraform {
required_version = "~>1.9.5"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~>5.57.0"
}
}
}
provider "aws" {
region = var.rgn_RegionLocation
}
aws.tf
# Create VPC
module "vpc" {
source = "../../modules/aws/vpc-5.5.1"
name = var.vpc_Name
cidr = var.vpc_CIDR
azs = var.vpc_AZs
private_subnets = var.sub_pvtCIDR
public_subnets = var.sub_pubCIDR
enable_nat_gateway = true
enable_vpn_gateway = true
amazon_side_asn = var.asn_AWS
}
# Change the route to enable propagation to public route table
# resource "aws_vpn_gateway_route_propagation" "rtPropg" {
# vpn_gateway_id = module.vpc.vgw_id
# route_table_id = module.vpc.public_route_table_ids[0]
# }
# Change the route to enable propagation to private route table
resource "aws_vpn_gateway_route_propagation" "rtPvtPropg" {
vpn_gateway_id = module.vpc.vgw_id
route_table_id = module.vpc.private_route_table_ids[0]
}
# Add the NAT gateway as a route to Internet to the private route table
# resource "aws_route" "r" {
# route_table_id = module.vpc.private_route_table_ids[0]
# destination_cidr_block = "0.0.0.0/0"
# nat_gateway_id = module.vpc.natgw_ids[0]
# }
# Create 2 customer gateways
module "cgw" {
source = "../../modules/aws/cgw-2.0.1/"
name = var.cgw_Name
customer_gateways = {
IP1 = {
bgp_asn = var.asn_Azure
ip_address = var.vgw_publicip_1
},
IP2 = {
bgp_asn = var.asn_Azure
ip_address = var.vgw_publicip_2
}
}
}
module "vpngw1" {
source = "../../modules/aws/vpngw-3.7.2/"
customer_gateway_id = module.cgw.ids[0]
vpc_id = module.vpc.vpc_id
vpn_gateway_id = module.vpc.vgw_id
vpn_connection_static_routes_only = false
local_ipv4_network_cidr = "0.0.0.0/0"
tunnel1_inside_cidr = var.ip_AWST1A0
tunnel2_inside_cidr = var.ip_AWST2A0
tunnel1_preshared_key = var.key_SharedKey
tunnel2_preshared_key = var.key_SharedKey
tags = {
Name = var.vpn_AWSA0Name
}
depends_on = [module.cgw]
}
module "vpngw2" {
source = "../../modules/aws/vpngw-3.7.2/"
customer_gateway_id = module.cgw.ids[1]
vpc_id = module.vpc.vpc_id
vpn_gateway_id = module.vpc.vgw_id
vpn_connection_static_routes_only = false
local_ipv4_network_cidr = "0.0.0.0/0"
tunnel1_inside_cidr = var.ip_AWST1A1
tunnel2_inside_cidr = var.ip_AWST2A1
tunnel1_preshared_key = var.key_SharedKey
tunnel2_preshared_key = var.key_SharedKey
tags = {
Name = var.vpn_AWSA1Name
}
depends_on = [module.cgw]
}
variables.tf – replace the IPs in line 50 and 59 from the previous Azure output
# VPC
variable "rgn_RegionLocation" {
type = string
default = "us-east-2"
description = "AWS region"
}
variable "vpc_Name" {
type = string
default = "vpcVPN"
description = "Name of the VPC in AWS"
}
variable "vpc_CIDR" {
type = string
default = "10.2.0.0/16"
description = "CIDR of the VPC in AWS"
}
variable "vpc_AZs" {
type = list(string)
default = ["us-east-2a"]
description = "Location of available zones"
}
variable "sub_pubCIDR" {
type = list(string)
default = ["10.2.0.0/24"]
description = "Public subnet CIDR"
}
variable "sub_pvtCIDR" {
type = list(string)
default = ["10.2.1.0/24"]
description = "Private subnet CIDR"
}
# Customer gateways
variable "cgw_Name" {
type = string
default = "cgwVPN"
description = "Name of the Customer Gateway"
}
####
#### REPLACE WITH OUTPUT FROM AZURE
####
variable "vgw_publicip_1" {
type = string
default = "20.10.220.96"
description = "Use the output from previous Azure script. First public IP of the Virtual Network Gateway in Azure"
}
####
#### REPLACE WITH OUTPUT FROM AZURE
####
variable "vgw_publicip_2" {
type = string
default = "4.152.124.252"
description = "Use the output from previous Azure script. Second public IP of the Virtual Network Gateway in Azure"
}
variable "asn_AWS" {
type = string
default = "64512"
description = "AWS ASN"
}
variable "asn_Azure" {
type = string
default = "65000"
description = "Azure ASN"
}
# VPN site to site 1
variable "key_SharedKey" {
type = string
default = "KlimentAndreev1970_"
description = "Make sure it's the same as vartiables.tf in Azure folder"
# Don't go crazy with complexity. AWS side can support only
# between 8 and 64 characters in length and cannot start with zero (0).
# Allowed characters are alphanumeric characters, periods (.), and underscores (_).
}
variable "vpn_AWSA0Name" {
type = string
default = "vpnToAzureInstance0"
description = "AWS Tunnel to Azure Instance 0 name"
}
variable "ip_AWST1A0" {
type = string
default = "169.254.21.0/30"
}
variable "ip_AWST2A0" {
type = string
default = "169.254.22.0/30"
}
# VPN site to site 2
variable "vpn_AWSA1Name" {
type = string
default = "vpnToAzureInstance1"
description = "AWS Tunnel to Azure Instance 1 name"
}
variable "ip_AWST1A1" {
type = string
default = "169.254.21.4/30"
}
variable "ip_AWST2A1" {
type = string
default = "169.254.22.4/30"
}
outputs.tf
output "ip_AWST1A0" {
value = module.vpngw1.vpn_connection_tunnel1_address
}
output "ip_AWST2A0" {
value = module.vpngw1.vpn_connection_tunnel2_address
}
output "ip_AWST1A1" {
value = module.vpngw2.vpn_connection_tunnel1_address
}
output "ip_AWST2A1" {
value = module.vpngw2.vpn_connection_tunnel2_address
}
Once you run terraform apply you’ll get 4 IPs as an output.
ip_AWST1A0 = "3.137.10.4" ip_AWST1A1 = "3.20.47.74" ip_AWST2A0 = "18.220.233.198" ip_AWST2A1 = "18.190.37.23"
You have to go back and replace these 4 IPs in variables.tf in the previous Azure folder. So, you run terraform apply in 01.Azure folder first, then 02.AWS second and then again terraform apply in 01.Azure.
Azure
Edit variables.tf file and add the 4 IPs. Make sure that the variables match, do not just assign IPs randomly. Replace the IPs in the lines 155, 196, 237 and 278. The second time you run terraform apply it will take about a minute or two.
Verification
Now, go to AWS and go to VPC | VPN | Site-to-Site VPN connections. You’ll see two tunnels there. For each one select the tunnel and then click on the Tunnel details tab. Both should be up for each tunnel.

In Azure, go to Virtual Network Gateways, click on the vgw, then Monitoring and BGP peers.
You should see something like this.


