Why Terraform?

While Microsoft recently (and quietly) brought the Basic SKU back to the Azure Portal dropdown, the GUI remains incredibly unreliable. The option was missing for over a year due to backend UI conflicts and could vanish again without warning.

Rely on the platform, not the blade. Terraform makes the most sense here because you aren't dependent on a shifting UI state. You are targeting the API directly. Everything is defined in code, version-controlled, and easy to reuse.

If you need to rebuild it or tweak something later, you’re not starting from scratch, you just update the config and redeploy.

And if your current infrastructure is already managed in Terraform, the VPN Gateway just fits straight into the same setup.


What You Already Have

This post assumes your foundational infrastructure is already in place. Before adding the VPN gateway, confirm you have:

✅ Providers set up and initialised within your main.tf or providers.tf

✅ A Resource Group (e.g. rg-ppcloud-prod-uksouth-001)

✅ A Virtual Network (e.g. vnet-ppcloud-prod-uksouth-001) with an address space like 10.1.0.0/16

✅ At least one existing subnet for your workloads

✅ A GatewaySubnet - or room in the address space to create one (we'll handle this)

We'll use Terraform Data Blocks to reference your existing resources rather than recreating them, then add only the VPN-specific pieces on top.

Terraform Data Blocks are basically read-only lookups. Instead of creating something, you’re telling Terraform “go find this thing that already exists in Azure and give me its details.”

note

Data Blocks = Read only.
Resource Blocks = Terraform manages them.

So for something like a VNet or subnet, you’re not defining it again, you’re just pulling in things like the ID, name, or address space so you can use them in other resources.

The key point is Terraform doesn’t manage those resources. It won’t try to change or delete them, it’s just referencing them.

Then when you create something new, like a VPN gateway, you plug those values in so everything connects properly without duplicating anything.

note

Azure retired Basic SKU public IPs on 30 September 2025, so new Basic gateways require a Standard SKU public IP with zones configured.

caution

Basic SKU is for dev/test only. It has limited throughput, no IPv6, and no RADIUS. For production, use VpnGw1 or higher.


Referencing Existing Resources

Instead of managing your resource group and VNet in this Terraform config, we pull them in as read-only Data Blocks. This means Terraform won't try to modify or destroy them.

Existing main.tf

# --- Reference your existing infrastructure ---
# These data sources read your existing RG and VNet without managing them
# Terraform will NOT modify or destroy these resources

data "azurerm_resource_group" "rg" {
  name = var.resource_group_name # Set in variables.tf
}

data "azurerm_virtual_network" "vnet" {
  name                = var.vnet_name # Set in variables.tf
  resource_group_name = data.azurerm_resource_group.rg.name
}

Existing variables.tf

variable "resource_group_name" {
  description = "Name of your existing resource group"
  type        = string
  default     = "rg-ppcloud-prod-uksouth-001" # <-- Change this to your RG name
}

variable "vnet_name" {
  description = "Name of your existing virtual network"
  type        = string
  default     = "vnet-ppcloud-prod-uksouth-001" # <-- Change this to your VNet name
}

variable "gateway_subnet_prefix" {
  description = "CIDR for the GatewaySubnet (min /27)"
  type        = list(string)
  default     = ["10.1.255.0/27"] # <-- Use a spare range from your VNet *Must be a minimum /27*
}

variable "vpn_type" {
  description = "RouteBased or PolicyBased"
  type        = string
  default     = "RouteBased" # RouteBased supports more connection types
}

# --- Local naming components for new resources ---

variable "workload" {
  description = "Workload or application name"
  type        = string
  default     = "ppcloud" # <-- Change this to your Workload/Application name
}

variable "environment" {
  description = "Environment (prod, dev, qa, stage, test)"
  type        = string
  default     = "prod" # <-- Change this to your environment name
}

variable "location_short" {
  description = "Short region name for resource naming (e.g. uksouth, ukwest, eastus2)"
  type        = string
  default     = "uksouth" # <-- Change this to the region you require
}

variable "instance" {
  description = "Instance number"
  type        = string
  default     = "001" 
}

New locals.tf

Instead of hardcoding names in every resource block, we'll build them dynamically from variables. A locals block lets us define the suffix once and reuse it everywhere. Change a variable, and every resource name updates automatically.

Add this to your main.tf (or a new locals.tf file):

# --- Build a reusable name suffix ---
locals {
  name_suffix = "${var.workload}-${var.environment}-${var.location_short}-${var.instance}"
}

With the defaults, local.name_suffix resolves to ppcloud-prod-uksouth-001. We then prefix it in each resource block with pip-, vgw-, etc.


The GatewaySubnet

Azure requires a dedicated subnet named exactly GatewaySubnet for VPN gateways. If you already have one, you can reference it as a data source instead. If not, we create it here - this is the one subnet we do need to manage. Add this to your main.tf.

resource "azurerm_subnet" "gateway" {
  name                 = "GatewaySubnet" # Must be exactly this
  resource_group_name  = data.azurerm_resource_group.rg.name
  virtual_network_name = data.azurerm_virtual_network.vnet.name
  address_prefixes     = var.gateway_subnet_prefix # Minimum /27
}
important

The name must be exactly GatewaySubnet. This is an Azure requirement.

If your GatewaySubnet already exists, replace the resource block above with:

# Use this instead if the GatewaySubnet already exists
# Remember to also change subnet_id in the gateway to: data.azurerm_subnet.gateway.id
data "azurerm_subnet" "gateway" {
  name                 = "GatewaySubnet"
  resource_group_name  = data.azurerm_resource_group.rg.name
  virtual_network_name = data.azurerm_virtual_network.vnet.name
}

Then update the gateway's subnet_id reference from azurerm_subnet.gateway.id to data.azurerm_subnet.gateway.id.


Public IP Address

Azure retired Basic SKU public IPs on 30 September 2025, so new Basic VPN gateways now require a Standard SKU public IP with Static allocation and availability zones configured where supported (some regions like UK West don't have availability zones).

This is actually an improvement. You get your IP address immediately, and it's zone-redundant. Add this to your main.tf.

# Basic SKU PIPs retired 30 Sept 2025 - must use Standard + Static + Zones
resource "azurerm_public_ip" "gw_pip" {
  name                = "pip-${local.name_suffix}"
  location            = data.azurerm_resource_group.rg.location
  resource_group_name = data.azurerm_resource_group.rg.name
  allocation_method   = "Static"   # Required for Standard SKU
  sku                 = "Standard" # Basic SKU no longer available
  zones               = ["1", "2", "3"] # Zone-redundant - Required
}
warning

Zones matter here

If you use a Standard SKU without proper zone configuration, Azure will reject the gateway with a ZRStandardIpNeeded error.

If you use a Basic SKU public IP, you'll hit a BasicSkuPublicIpCountLimitReached error because Basic public IPs can no longer be created.


The VPN Gateway

This is the main event. The azurerm_virtual_network_gateway resource creates the Basic SKU VPN gateway and attaches it to your existing VNet via the GatewaySubnet and the Standard public IP. Finally, add this to your main.tf.

resource "azurerm_virtual_network_gateway" "vpn_gw" {
  name                = "vgw-${local.name_suffix}"
  location            = data.azurerm_resource_group.rg.location
  resource_group_name = data.azurerm_resource_group.rg.name

  type     = "Vpn"
  vpn_type = var.vpn_type   # "RouteBased" or "PolicyBased"
  sku      = "Basic"

  ip_configuration {
    name                          = "vgwipc-${local.name_suffix}"
    public_ip_address_id          = azurerm_public_ip.gw_pip.id
    private_ip_address_allocation = "Dynamic"
    subnet_id                     = azurerm_subnet.gateway.id # Use data.azurerm_subnet if existing
  }
}
note

This will take 30–45 minutes. Azure VPN gateway provisioning is slow. This is entirely normal. Terraform will wait for it to complete. Don't cancel the apply; just let it run.


Outputs.tf

# Run "terraform output" in terminal to see these values
output "vpn_gateway_id" {
  description = "Resource ID of the VPN gateway"
  value       = azurerm_virtual_network_gateway.vpn_gw.id
}

output "gateway_public_ip" {
  description = "Public IP assigned to the VPN gateway"
  value       = azurerm_public_ip.gw_pip.ip_address
}

Deploy & Verify

# Authenticate
az login

# Initialise (If you haven't already) then plan & apply
terraform init

terraform plan
terraform apply

Grab the Gateway Public IP with:

terraform output gateway_public_ip

Clean-up

To tear it down without touching your existing RG and VNet:

terraform destroy
note

Because the resource group and VNet are referenced via Data Blocks (not managed by Terraform), terraform destroy will only remove resources this config created, such as the Gateway, Public IP, and GatewaySubnet.


Next Steps

The gateway is the foundation. Here's what you can build on top of it:

Site-to-Site connection - Add an azurerm_local_network_gateway for your on-prem device and an azurerm_virtual_network_gateway_connection with a shared key.

Point-to-Site - Configure the gateway's vpn_client_configuration block with a root certificate and client address pool.

VNet-to-VNet - Spin up a second VPN gateway in another VNet and link them.

Upgrade later - When you outgrow Basic, change the SKU to VpnGw1 or higher for better throughput, BGP support, and active-active redundancy.

All posts