Quick take — Provision Azure ExpressRoute circuits with Terraform and azurerm ~> 4.0: provider-pinned, var-driven SKU and bandwidth, optional private peering, validated inputs, and service-key outputs for cross-team handoff. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.
Quickstart (copy-paste)
Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):
provider "azurerm" {
features {}
}
module "expressroute" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-expressroute?ref=v1.0.0"
name = "..." # Circuit name; validated to 3–80 chars, alphanumeric sta…
resource_group_name = "..." # Resource group holding the circuit (usually the connect…
location = "..." # ARM region for the circuit (e.g. `westeurope`).
service_provider_name = "..." # Connectivity provider name exactly as Azure lists it (c…
peering_location = "..." # Physical meet-me / peering facility name as Azure lists…
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
An Azure ExpressRoute circuit is a logical representation of a private, dedicated layer-2/layer-3 connection between your on-premises network (or a colocation facility) and Microsoft’s edge, provisioned through a connectivity provider such as Equinix, Megaport, Colt, or a telco. Unlike a site-to-site VPN that rides the public internet, an ExpressRoute circuit gives you a private path with a financially-backed availability SLA, predictable latency, and the ability to reach Azure private services (and optionally Microsoft 365 / public PaaS over the Microsoft peering) without egress traversing the internet.
The circuit itself (azurerm_express_route_circuit) is only the provider-facing half of the story. When Terraform creates it, Azure returns a service key — a GUID you hand to your connectivity provider so they can light up their side of the cross-connect. Until the provider provisions, the circuit sits in a NotProvisioned state; once they finish, it flips to Provisioned. Wrapping this in a reusable module matters because: the service-key handoff is a repeatable cross-team ritual, the sku tier/family pricing model is easy to get wrong (and expensive), and you almost always want private peering plus consistent tagging applied the same way for every circuit (prod, DR, per-region). A module turns “raise a ticket, click through the portal, email a GUID” into a versioned, reviewable artifact.
This module provisions the circuit and, optionally, its private peering (azurerm_express_route_circuit_peering) so the most common production path — circuit plus Azure Private peering ready for a gateway connection — is one module block.
When to use it
- You need private, SLA-backed connectivity from on-premises or a colo into Azure VNets and don’t want production traffic on the public internet.
- You are running hybrid workloads (Active Directory, large data replication, latency-sensitive line-of-business apps) where a VPN’s variable internet path is unacceptable.
- You want predictable, high bandwidth (50 Mbps up to 10 Gbps on a standard metered/unlimited circuit, or higher on ExpressRoute Direct) with consistent latency.
- You operate a landing zone / hub-and-spoke topology and want the circuit defined alongside the connectivity subscription’s hub, peered and tagged identically across environments.
- You need an auditable, peer-reviewed record of who provisioned which circuit, at what SKU, with which service key — instead of portal click-ops.
Reach for a plain VPN gateway instead if you only need occasional, low-bandwidth, internet-tolerant connectivity, or for short-lived spikes — ExpressRoute is a monthly billed commitment with a provider contract behind it.
Module structure
terraform-module-azure-expressroute/
├── versions.tf # provider + Terraform version pins
├── main.tf # express route circuit + optional private peering
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id, name, service_key, peering attributes
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
# main.tf
locals {
# Azure expresses the commercial tier as "<Tier>_<Family>" e.g. "Standard_MeteredData".
sku_name = format("%s_%s", var.sku_tier, var.sku_family)
tags = merge(
{
module = "terraform-module-azure-expressroute"
environment = var.environment
},
var.tags,
)
}
resource "azurerm_express_route_circuit" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
service_provider_name = var.service_provider_name
peering_location = var.peering_location
bandwidth_in_mbps = var.bandwidth_in_mbps
sku {
tier = var.sku_tier
family = var.sku_family
}
# Defaults to true; allowing classic operations is legacy and rarely wanted.
allow_classic_operations = var.allow_classic_operations
tags = local.tags
}
# Azure Private peering: the path your ExpressRoute gateway connection rides on.
# Created only when private_peering is supplied. Provider must have provisioned
# the circuit before peering will succeed end to end.
resource "azurerm_express_route_circuit_peering" "private" {
count = var.private_peering == null ? 0 : 1
peering_type = "AzurePrivatePeering"
express_route_circuit_name = azurerm_express_route_circuit.this.name
resource_group_name = var.resource_group_name
peer_asn = var.private_peering.peer_asn
primary_peer_address_prefix = var.private_peering.primary_peer_address_prefix
secondary_peer_address_prefix = var.private_peering.secondary_peer_address_prefix
vlan_id = var.private_peering.vlan_id
shared_key = var.private_peering.shared_key
}
# variables.tf
variable "name" {
description = "Name of the ExpressRoute circuit. Use your org naming convention, e.g. erc-hub-weu-prod."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{1,78}[a-zA-Z0-9_]$", var.name))
error_message = "name must be 3-80 chars, start alphanumeric, and contain only letters, numbers, '.', '_' or '-'."
}
}
variable "resource_group_name" {
description = "Resource group that will hold the circuit. Typically the connectivity subscription hub RG."
type = string
}
variable "location" {
description = "Azure region for the circuit resource (e.g. westeurope). This is the ARM region, not the peering location."
type = string
}
variable "service_provider_name" {
description = "Connectivity provider exactly as Azure lists it (e.g. Equinix, Megaport, Colt). Case sensitive."
type = string
}
variable "peering_location" {
description = "Physical peering location / meet-me facility, exactly as Azure lists it (e.g. London2, Amsterdam)."
type = string
}
variable "bandwidth_in_mbps" {
description = "Provisioned circuit bandwidth in Mbps. Must be a value the chosen provider/SKU supports."
type = number
default = 1000
validation {
condition = contains(
[50, 100, 200, 500, 1000, 2000, 5000, 10000],
var.bandwidth_in_mbps,
)
error_message = "bandwidth_in_mbps must be one of 50, 100, 200, 500, 1000, 2000, 5000, 10000."
}
}
variable "sku_tier" {
description = "Circuit tier: Local, Standard, or Premium. Premium unlocks more routes and global reach."
type = string
default = "Standard"
validation {
condition = contains(["Local", "Standard", "Premium"], var.sku_tier)
error_message = "sku_tier must be one of Local, Standard, Premium."
}
}
variable "sku_family" {
description = "Billing model: MeteredData (pay per egress GB) or UnlimitedData (flat). UnlimitedData cannot be downgraded to MeteredData in place."
type = string
default = "MeteredData"
validation {
condition = contains(["MeteredData", "UnlimitedData"], var.sku_family)
error_message = "sku_family must be MeteredData or UnlimitedData."
}
}
variable "allow_classic_operations" {
description = "Allow management of the circuit via the classic (ASM) deployment model. Leave false for ARM-only environments."
type = bool
default = false
}
variable "private_peering" {
description = <<-EOT
Optional Azure Private peering configuration. Set to null to create the circuit only
(e.g. when the provider has not yet provisioned). All address prefixes must be a /30 (v1)
or /126 (v2) reserved exclusively for peering and not overlapping any VNet space.
EOT
type = object({
peer_asn = number
primary_peer_address_prefix = string
secondary_peer_address_prefix = string
vlan_id = number
shared_key = optional(string)
})
default = null
validation {
condition = (
var.private_peering == null ? true :
var.private_peering.vlan_id >= 1 && var.private_peering.vlan_id <= 4094
)
error_message = "private_peering.vlan_id must be between 1 and 4094."
}
}
variable "environment" {
description = "Environment label applied as a tag (e.g. prod, dr, nonprod)."
type = string
default = "prod"
}
variable "tags" {
description = "Additional tags merged onto the circuit and peering."
type = map(string)
default = {}
}
# outputs.tf
output "id" {
description = "Resource ID of the ExpressRoute circuit."
value = azurerm_express_route_circuit.this.id
}
output "name" {
description = "Name of the ExpressRoute circuit."
value = azurerm_express_route_circuit.this.name
}
output "service_key" {
description = "Service key (GUID) to hand to the connectivity provider so they can provision their side. Treat as sensitive."
value = azurerm_express_route_circuit.this.service_key
sensitive = true
}
output "service_provider_provisioning_state" {
description = "Provider provisioning state of the circuit (e.g. NotProvisioned, Provisioning, Provisioned)."
value = azurerm_express_route_circuit.this.service_provider_provisioning_state
}
output "sku_name" {
description = "Resolved SKU name as <Tier>_<Family>, e.g. Standard_MeteredData."
value = local.sku_name
}
output "private_peering_id" {
description = "Resource ID of the Azure Private peering, or null when private_peering was not supplied."
value = try(azurerm_express_route_circuit_peering.private[0].id, null)
}
How to use it
module "expressroute_circuit" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-expressroute?ref=v1.0.0"
name = "erc-hub-weu-prod"
resource_group_name = azurerm_resource_group.connectivity.name
location = "westeurope"
service_provider_name = "Equinix"
peering_location = "Amsterdam"
bandwidth_in_mbps = 1000
sku_tier = "Premium"
sku_family = "MeteredData"
private_peering = {
peer_asn = 65010
primary_peer_address_prefix = "10.250.0.0/30"
secondary_peer_address_prefix = "10.250.0.4/30"
vlan_id = 100
shared_key = var.expressroute_bgp_shared_key
}
environment = "prod"
tags = {
cost_center = "networking"
owner = "platform-team"
}
}
# Downstream: connect the hub's ExpressRoute gateway to this circuit using the module's output id.
resource "azurerm_virtual_network_gateway_connection" "er" {
name = "cn-er-hub-weu-prod"
location = "westeurope"
resource_group_name = azurerm_resource_group.connectivity.name
type = "ExpressRoute"
virtual_network_gateway_id = azurerm_virtual_network_gateway.hub_er.id
express_route_circuit_id = module.expressroute_circuit.id
routing_weight = 100
}
After apply, retrieve the service key (terraform output -raw service_key since it is marked sensitive) and submit it to your provider’s portal. The gateway connection above will only carry traffic once the provider flips the circuit to Provisioned and the private peering is established.
With Terragrunt
Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.
1. Root config — live/terragrunt.hcl (inherited by every module):
remote_state {
backend = "azurerm"
generate = { path = "backend.tf", if_exists = "overwrite" }
config = {
# ...azurerm state bucket/container + key per path...
}
}
2. Module config — live/prod/expressroute/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-expressroute?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
service_provider_name = "..."
peering_location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/expressroute && terragrunt apply # this module
terragrunt run-all apply # every module under live/prod
Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.
Inputs
| Name | Type | Default | Required | Description |
|---|---|---|---|---|
name |
string |
— | Yes | Circuit name; validated to 3–80 chars, alphanumeric start. |
resource_group_name |
string |
— | Yes | Resource group holding the circuit (usually the connectivity hub RG). |
location |
string |
— | Yes | ARM region for the circuit (e.g. westeurope). |
service_provider_name |
string |
— | Yes | Connectivity provider name exactly as Azure lists it (case sensitive). |
peering_location |
string |
— | Yes | Physical meet-me / peering facility name as Azure lists it. |
bandwidth_in_mbps |
number |
1000 |
No | Provisioned bandwidth; validated against supported tiers (50–10000). |
sku_tier |
string |
"Standard" |
No | Local, Standard, or Premium. |
sku_family |
string |
"MeteredData" |
No | MeteredData or UnlimitedData billing model. |
allow_classic_operations |
bool |
false |
No | Permit classic (ASM) management of the circuit. |
private_peering |
object |
null |
No | Azure Private peering config (ASN, /30 prefixes, VLAN, shared key); null to skip. |
environment |
string |
"prod" |
No | Environment label applied as a tag. |
tags |
map(string) |
{} |
No | Extra tags merged onto circuit and peering. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the ExpressRoute circuit. |
name |
Name of the ExpressRoute circuit. |
service_key |
Sensitive service-key GUID to hand to the connectivity provider. |
service_provider_provisioning_state |
Provider provisioning state (NotProvisioned / Provisioning / Provisioned). |
sku_name |
Resolved SKU as <Tier>_<Family>, e.g. Standard_MeteredData. |
private_peering_id |
Resource ID of the Azure Private peering, or null when not configured. |
Enterprise scenario
A global insurer runs an Enterprise-Scale landing zone with a dedicated connectivity subscription per geography. The platform team uses this module to declare one Premium ExpressRoute circuit per region (erc-hub-weu-prod, erc-hub-eus-prod) at an Equinix meet-me facility, each with private peering pre-wired into the regional hub’s ExpressRoute gateway. Premium tier is chosen so the circuits support ExpressRoute Global Reach between the European and US hubs, letting on-premises data centers in both regions reach each other across the Microsoft backbone. Because the service key is a tagged, versioned output, the network operations team has a clean audit trail of which circuit was handed to which Equinix order — replacing the spreadsheet-and-email process they used before.
Best practices
- Treat the service key as a secret. It authorizes provider-side provisioning of your circuit. Keep the
service_keyoutputsensitive, never echo it in CI logs, and pass it to providers over a trusted channel — not Slack or email. - Choose the SKU family deliberately for cost.
MeteredDatabills per egress GB and suits low/variable egress;UnlimitedDatais a flat rate that wins above the crossover point. You cannot downgradeUnlimitedDataback toMeteredDataon the same circuit, so model your egress before committing — a wrong choice means re-provisioning. - Use
Premiumonly when you need it. Premium raises route limits, enables Global Reach and global connectivity, but costs more per circuit. Stick toStandard(orLocal, which is cheapest and meters nothing for in-region traffic) unless a concrete requirement justifies the upgrade. - Reserve peering address space carefully. Private-peering
/30prefixes must be dedicated to the peering and must not overlap any VNet or on-premises range; collisions cause silent routing failures that are painful to diagnose after the provider has provisioned. - Deploy circuits in pairs for reliability. A single circuit is a single facility dependency. For production, provision a second circuit at a different
peering_location(and ideally a different provider) and let BGP handle failover — instantiate the module twice rather than relying on one circuit’s redundant primary/secondary ports alone. - Standardize naming and tags. Bake region, role, and environment into
name(e.g.erc-hub-weu-prod) and always setcost_center/ownertags so circuits — among the most expensive line items in a landing zone — are unambiguously attributable in cost reports.