Create an AWS to Azure site-to-site VPN

  • https://techcommunity.microsoft.com/blog/startupsatmicrosoftblog/how-to-easily-set-up-a-vpn-between-azure-and-aws-using-managed-services-updated-/4278966

AWS

AWS portal

todo

AWS command line

VPC

vpc_id=$( aws ec2 create-vpc --cidr-block 10.0.0.0/16 --query 'Vpc.VpcId' --output text )
aws ec2 create-tags --resource "${vpc_id}" --tags Key=Name,Value=vpc-AzureAWSVPN

Subnet

subnet_id=$( aws ec2 create-subnet --vpc-id "${vpc_id}" --cidr-block 10.0.1.0/24 --availability-zone ca-central-1 --query 'Subnet.SubnetId' --output text )
aws ec2 create-tags --resource "${subnet_id}" --tags Key=Name,Value=subnet-AzureAWSVPN

Virtual private gateway (VGW)

vgw_id=$( aws ec2 create-vpn-gateway --type ipsec.1 --amazon-side-asn 64512 )
aws ec2 create-tags --resource "${vgw_id}" --tags Key=Name,Value=vgw-AzureAWSVPN
Attach vgw to vpc
aws ec2 attach-vpn-gatway --vpn-gateway-id $vgw_id --vpc-id $vpc_id

Customer gateway (CGW)

cgw_id=$( aws ec2 create-customer-gateway --type ipsec.1 --public-ip [ azure_gw_ip ] --bgp-asn 65000)
aws ec2 create-tags --resource "${cgw_id}" --tags Key=Name,Value=cgw-AzureAWSVPN

Site-to-site VPN connection

vpn_id=$( aws ec2 create-vpn-connection --type ipsec.1 --customer-gateway-id ="${cgw_id}" --vpn-gateway-id "${vgw_id}" --options StaticRoutesOnly=true
aws ec2 create-tags --resource "${vpn_id}" --tags Key=Name,Value=vpn-AzureAWSVPN
Enable route propagation

todo

Add routes for the vpn

todo

Create an Internet Gateway

todo

Attach the igw to the vpc

todo

Create a route to the igw

todo

Create a security group

To allow SSH & ICMP

todo

Create a VM in the VPC

todo

Azure

Azure portal

Create the VPN gateway

  • Hybrid connectivity => VPN gateways => Create
    • Project details:
      • Subscription: auto fills
    • Instance details: Name: aws Region: Canada Central Gateway type: VPN SKU: VpnGw2AZ Generation: Generation2 Virtual network => Create virtual network Name: vnet-aws Resource group => Create new Name: rg-aws-vpn-gw Address space: leave as-is Subnets: default: leave as-is Add a new one: Name: aws Address range: 10.0.200.0/24 => Ok
    • Public IP address: Public IP address: Create new Name: pubip-aws-vpn-gw Enable active-active mode: Disabled Configure BGP: Disabled
    • Review + create => Create

Create the vnet

  • Project details:

    • Subscription: auto fills
      • Resource group => Create new => Name: rg-aws-vpn-gw
  • Instance details:

    • Virtual network name: vnet-aws-vpn-gw
    • Region: Canada Central (ca-central-1)
  • Leave everything else as-is => Review + create => Create

Create the Local network gateway

Create the VPN connection

Azure command line

Resource group

rg_name="RG-AzureAWSVPN"
vnet_name="VNet-AzureAWSVPN"
ip_name="IP-AzureAWSVPN"
gw_name="GW-AzureAWSVPN"
az group create --name $rg_name --location canadacentral

VNet

az network vnet create --name $vnet_name --resource-group $rg_name --address-prefix 172.16.0.0/16 --subnet-name Subnet-AzureAWSVPN --subnet-prefix 172.16.1.0/24

Gateway subnet

az network vnet subnet create --resource-group $rg_name --vnet-name $vnet_name --name GatewaySubnet --address-prefixes 172.16.254.0/27

Public IP

az network public-ip create --name $ip_name --resource-group $rg_name --allocation-method Static

VPN Gateway

az network vnet-gateway create --name $gw_name --resource-group $rg_name --public-ip-address $ip_name --vnet $vnet_name --gateway-type Vpn --vpn-type RouteBased --sku VpnGw1

this may take awhile, start the AWS stuff while the wheels are turning here

Create the local network gateway

todo

Terraform to do it all

GitLab Terraform repository incoming in the near future

But for now:

terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.110"
    }
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  required_version = ">= 1.6.0"
}

# -------------------------------
# Providers
# -------------------------------
provider "azurerm" {
  features {}
  subscription_id = var.azure_subscription_id
}

provider "aws" {
  region = var.aws_region
}

# -------------------------------
# Azure side
# -------------------------------
# https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/resource_group
resource "azurerm_resource_group" "azure" {
  name     = "RG-AzureAWSVPN"
  location = var.azure_location
}

# https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network
resource "azurerm_virtual_network" "azure" {
  name                = "VNet-AzureAWSVPN"
  address_space       = ["172.16.0.0/16"]
  location            = azurerm_resource_group.azure.location
  resource_group_name = azurerm_resource_group.azure.name
}

# https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/subnet
resource "azurerm_subnet" "internal" {
  name                 = "Subnet-AzureAWSVPN"
  resource_group_name  = azurerm_resource_group.azure.name
  virtual_network_name = azurerm_virtual_network.azure.name
  address_prefixes     = ["172.16.1.0/24"]
}

# https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/subnet
resource "azurerm_subnet" "gateway" {
  name                 = "GatewaySubnet"
  resource_group_name  = azurerm_resource_group.azure.name
  virtual_network_name = azurerm_virtual_network.azure.name
  address_prefixes     = ["172.16.254.0/27"]
}

# https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/public_ip
resource "azurerm_public_ip" "vpn" {
  name                = "IP-AzureAWSVPN"
  location            = azurerm_resource_group.azure.location
  resource_group_name = azurerm_resource_group.azure.name
  allocation_method   = "Static"
  sku                 = "Standard"
}

# https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network_gateway
resource "azurerm_virtual_network_gateway" "vpn" {
  name                = "GW-AzureAWSVPN"
  location            = azurerm_resource_group.azure.location
  resource_group_name = azurerm_resource_group.azure.name
  type                = "Vpn"
  vpn_type            = "RouteBased"
  active_active       = false
  enable_bgp          = false
  sku                 = "VpnGw2"
  ip_configuration {
    name                          = "gw-ipconfig"
    public_ip_address_id          = azurerm_public_ip.vpn.id
    subnet_id                     = azurerm_subnet.gateway.id
  }
}

# AWS will create its gateway IP; Azure needs that for the LNG
# https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/local_network_gateway
resource "azurerm_local_network_gateway" "aws" {
  name                = "LNG-AzureAWSVPN"
  location            = azurerm_resource_group.azure.location
  resource_group_name = azurerm_resource_group.azure.name
  #gateway_address     = aws_vpn_gateway.aws_vgw.id != "" ? aws_vpn_gateway.aws_vgw.tags["PublicIp"] : null
  # We'll override this below properly
  #gateway_address     = aws_vpn_connection.aws.connection_static_ip
  gateway_address     = aws_vpn_connection.aws.tunnel1_address
  #address_space       = ["10.0.1.0/24"]
  address_space       = ["10.0.0.0/16"]
}

# VPN connection (Azure → AWS)
# https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs/resources/virtual_network_gateway_connection
resource "azurerm_virtual_network_gateway_connection" "vpn" {
  name                = "CON-AzureAWSVPN"
  location            = azurerm_resource_group.azure.location
  resource_group_name = azurerm_resource_group.azure.name
  type                = "IPsec"
  virtual_network_gateway_id = azurerm_virtual_network_gateway.vpn.id
  local_network_gateway_id   = azurerm_local_network_gateway.aws.id
  shared_key          = var.shared_key
}

# Security group allowing SSH + ICMP
resource "azurerm_network_security_group" "nsg" {
  name                = "NSG-AzureAWSVPN"
  location            = azurerm_resource_group.azure.location
  resource_group_name = azurerm_resource_group.azure.name

  security_rule {
    name                       = "AllowSSHFromAnywhere"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range           = "*"
    destination_port_range      = "22"
    source_address_prefix       = "*"
    destination_address_prefix  = "*"
  }

  security_rule {
    name                       = "AllowICMPInternal"
    priority                   = 200
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Icmp"
    source_port_range           = "*"
    destination_port_range      = "*"
    source_address_prefix       = "172.16.1.0/24"
    destination_address_prefix  = "172.16.1.0/24"
  }
}

# -------------------------------
# AWS side
# -------------------------------
# https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc
resource "aws_vpc" "aws" {
  cidr_block = "10.0.0.0/16"
  tags = { Name = "VPC-AzureAWSVPN" }
}

# https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/subnet
resource "aws_subnet" "aws" {
  vpc_id            = aws_vpc.aws.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "${var.aws_region}a"
  tags = { Name = "Subnet-AzureAWSVPN" }
}

# https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpn_gateway
resource "aws_vpn_gateway" "aws_vgw" {
  vpc_id          = aws_vpc.aws.id
  amazon_side_asn = 64512
  tags = { Name = "VGW-AzureAWSVPN" }
}

# https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpn_gateway_attachment
resource "aws_vpn_gateway_attachment" "attach" {
  vpc_id         = aws_vpc.aws.id
  vpn_gateway_id = aws_vpn_gateway.aws_vgw.id
}

# Customer gateway (uses Azure VPN gateway public IP)
# https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/customer_gateway
resource "aws_customer_gateway" "azure_cgw" {
  bgp_asn    = 65000
  ip_address = azurerm_public_ip.vpn.ip_address
  type       = "ipsec.1"
  tags = { Name = "CGW-AzureAWSVPN" }
}

# VPN connection (AWS → Azure)
# https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpn_connection
resource "aws_vpn_connection" "aws" {
  customer_gateway_id = aws_customer_gateway.azure_cgw.id
  #customer_gateway_id = aws_customer_gateway.aws_cgw.id
  vpn_gateway_id      = aws_vpn_gateway.aws_vgw.id
  type                = "ipsec.1"
  tunnel1_preshared_key = var.shared_key
  tunnel2_preshared_key = var.shared_key

  static_routes_only = true

  # Deprecated apparently
  #Provider Version	Inline routes block	aws_vpn_connection_route resource
  #≤ v4.0		❌ Not supported	✅ Required (only method)
  #4.1 – 4.63		✅ Supported		✅ Still supported (both worked)
  #≥ v5.0 (current)	❌ Removed (read-only)	✅ Only supported method
  #routes = [
  #  { 
  #    destination_cidr_block = "172.16.1.0/24"
  #    source = "static"
  #    state = "active"
  #  }
  #]

  tags = { Name = "AWS-VPN-To-Azure" }
}

# https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpn_connection_route
# Use this instead of the inline routes parameter for vpn_connection apparently
resource "aws_vpn_connection_route" "azure_subnet" {
  vpn_connection_id      = aws_vpn_connection.aws.id
  destination_cidr_block = "172.16.1.0/24"
}

# Enable route propagation
# https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpn_gateway_route_propagation
resource "aws_vpn_gateway_route_propagation" "prop" {
  route_table_id     = aws_vpc.aws.main_route_table_id
  vpn_gateway_id     = aws_vpn_gateway.aws_vgw.id
}

# -------------------------------
# Example Azure VM (depends implicitly on network resources)
# -------------------------------
resource "azurerm_public_ip" "vm_ip" {
  name = "vm-public-ip"
  location            = azurerm_resource_group.azure.location
  resource_group_name = azurerm_resource_group.azure.name
  allocation_method = "Static"
  sku = "Standard"
}

resource "azurerm_network_interface" "vmnic" {
  name                = "nic-azureaws"
  location            = azurerm_resource_group.azure.location
  resource_group_name = azurerm_resource_group.azure.name
  ip_configuration {
    name                          = "internal"
    subnet_id                     = azurerm_subnet.internal.id
    private_ip_address_allocation = "Dynamic"
    public_ip_address_id          = azurerm_public_ip.vm_ip.id
  }
}

resource "azurerm_linux_virtual_machine" "vm" {
  name                = "VM-AzureAWSVPN"
  resource_group_name = azurerm_resource_group.azure.name
  location            = azurerm_resource_group.azure.location
  size                = "Standard_B1s"
  admin_username      = "azureuser"
  network_interface_ids = [azurerm_network_interface.vmnic.id]

  admin_ssh_key {
    username   = "azureuser"
    public_key = file("~/.ssh/id_rsa.pub")
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Standard_LRS"
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "0001-com-ubuntu-server-jammy"
    sku       = "22_04-lts"
    version   = "latest"
  }
}

# Associate NSG with subnet
resource "azurerm_subnet_network_security_group_association" "assoc" {
  subnet_id                 = azurerm_subnet.internal.id
  network_security_group_id = azurerm_network_security_group.nsg.id
}

# ---------------------------------------
#  AWS Internet Gateway + Route Table
# ---------------------------------------

resource "aws_internet_gateway" "igw" {
  vpc_id = aws_vpc.aws.id
  tags = { Name = "IGW-AzureAWSVPN" }
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.aws.id
  tags   = { Name = "RT-Public-AzureAWSVPN" }
}

# Default route through Internet Gateway
resource "aws_route" "default_internet_access" {
  route_table_id         = aws_route_table.public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.igw.id
}

# Associate route table with subnet
resource "aws_route_table_association" "public_assoc" {
  subnet_id      = aws_subnet.aws.id
  route_table_id = aws_route_table.public.id
}

# Enable VPN route propagation as well
resource "aws_vpn_gateway_route_propagation" "prop_public" {
  route_table_id = aws_route_table.public.id
  vpn_gateway_id = aws_vpn_gateway.aws_vgw.id
}

# ---------------------------------------
#  AWS Security Group for VM
# ---------------------------------------

resource "aws_security_group" "vm_sg" {
  name        = "SG-AzureAWSVPN"
  description = "Allow SSH from anywhere, ICMP from Azure internal subnet"
  vpc_id      = aws_vpc.aws.id

  ingress {
    description = "Allow SSH from anywhere"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  ingress {
    description = "Allow ICMP from Azure subnet"
    from_port   = -1
    to_port     = -1
    protocol    = "icmp"
    cidr_blocks = ["172.16.1.0/24"]
  }

  egress {
    description = "Allow all outbound"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "SG-AzureAWSVPN" }
}

# ---------------------------------------
#  AWS EC2 Instance
# ---------------------------------------
# https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/instance
resource "aws_instance" "vm" {
  ami           = data.aws_ami.amazon_linux.id
  instance_type = "t2.micro"
  subnet_id     = aws_subnet.aws.id
  vpc_security_group_ids = [aws_security_group.vm_sg.id]
  associate_public_ip_address = true
  key_name      = var.aws_key_pair

  tags = { Name = "VM-AzureAWSVPN" }
}

# Fetch latest Amazon Linux 2 AMI automatically
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]

  filter {
    name   = "name"
    values = ["amzn2-ami-hvm-*-x86_64-gp2"]
  }
}

variable "aws_region" {
  type = string
  default = "ca-central-1"
}

variable "azure_subscription_id" {
  type = string
  default = "[ subscription_id ]"
}

variable "azure_location" {
  type = string
  default = "canadacentral"
}

variable "aws_key_pair" {
  type = string
  default = "[ keypair_name ]"
}

variable "shared_key" {
  type = string
  sensitive = true
  default = "[ shared_key ]"
}
# ---------------------------------------
#  Output: Azure & AWS VPN Connection Info
# ---------------------------------------

output "azure_vpn_gateway_public_ip" {
  description = "Public IP address of the Azure VPN Gateway (connect here from AWS)"
  value       = azurerm_public_ip.vpn.ip_address
}

output "aws_customer_gateway_public_ip" {
  description = "Public IP address of the AWS Customer Gateway (connect here from Azure)"
  value       = aws_vpn_connection.aws.tunnel1_address
}

output "aws_vpn_tunnel1_external_ip" {
  description = "AWS tunnel 1 external IP (connects to Azure VPN Gateway)"
  value       = aws_vpn_connection.aws.tunnel1_address
}

output "aws_vpn_tunnel2_external_ip" {
  description = "AWS tunnel 2 external IP (secondary tunnel)"
  value       = aws_vpn_connection.aws.tunnel2_address
}

output "aws_vpn_tunnel1_inside_cidr" {
  description = "AWS tunnel 1 inside CIDR"
  value       = aws_vpn_connection.aws.tunnel1_inside_cidr
}

output "aws_vpn_tunnel2_inside_cidr" {
  description = "AWS tunnel 2 inside CIDR"
  value       = aws_vpn_connection.aws.tunnel2_inside_cidr
}

output "aws_vpn_connection_shared_key_tunnel1" {
  description = "Preshared key for tunnel 1"
  value       = aws_vpn_connection.aws.tunnel1_preshared_key
  sensitive   = true
}

output "aws_vpn_connection_shared_key_tunnel2" {
  description = "Preshared key for tunnel 2"
  value       = aws_vpn_connection.aws.tunnel2_preshared_key
  sensitive   = true
}


output "aws_vpn_static_routes" {
  description = "Static routes configured on the AWS VPN Connection"
  value       = aws_vpn_connection.aws.routes
}

output "azure_to_aws_static_routes" {
  description = "Azure local network gateway address space(s)"
  value       = azurerm_local_network_gateway.aws.address_space
}

# ---------------------------------------
#  Optional: Render a simple configuration template for each side
# ---------------------------------------

output "azure_vpn_device_config_template" {
  description = "Configuration summary for Azure side (for manual validation)"
  value = <<EOT
Azure VPN Gateway: ${azurerm_virtual_network_gateway.vpn.name}
Public IP: ${azurerm_public_ip.vpn.ip_address}

Connects to AWS VPN Gateway:
  - AWS Tunnel 1 IP: ${aws_vpn_connection.aws.tunnel1_address}
  - AWS Tunnel 2 IP: ${aws_vpn_connection.aws.tunnel2_address}

Shared Key Tunnel 1: ${aws_vpn_connection.aws.tunnel1_preshared_key}
Shared Key Tunnel 2: ${aws_vpn_connection.aws.tunnel2_preshared_key}

Address Spaces:
  Azure VNet: 172.16.1.0/24
  AWS Subnet: 10.0.1.0/24
EOT

  sensitive = true
}

output "aws_vpn_device_config_template" {
  description = "Configuration summary for AWS side (for manual validation)"
  value = <<EOT
AWS VPN Connection: ${aws_vpn_connection.aws.id}
Tunnels:
  - Tunnel 1: ${aws_vpn_connection.aws.tunnel1_address}
  - Tunnel 2: ${aws_vpn_connection.aws.tunnel2_address}

Tunnel Inside CIDRs:
  - ${aws_vpn_connection.aws.tunnel1_inside_cidr}
  - ${aws_vpn_connection.aws.tunnel2_inside_cidr}

Connects to Azure Gateway:
  - IP: ${azurerm_public_ip.vpn.ip_address}

Static Route(s): ${join(", ", [for r in aws_vpn_connection.aws.routes : r.destination_cidr_block])}
Shared Key Tunnel 1: ${aws_vpn_connection.aws.tunnel1_preshared_key}
Shared Key Tunnel 2: ${aws_vpn_connection.aws.tunnel2_preshared_key}

EOT

  sensitive = true
}

Bicep to do the Azure stuff

CloudFormation to do the AWS stuff

todo