Quick take — Provision Azure Route Server with Terraform and azurerm ~> 4.0: provider-pinned, a validated RouteServerSubnet, Standard public IP, optional BGP peerings to your NVAs, branch-to-branch control, and peer IP outputs for 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 "route_server" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-route-server?ref=v1.0.0"
name = "..." # Route Server name; validated 2–80 chars, alphanumeric s…
resource_group_name = "..." # Resource group holding the Route Server, public IP, and…
location = "..." # Azure region (e.g. `westeurope`).
}
Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.
What this module is
Azure Route Server is a fully managed, Microsoft-operated BGP service that lives inside a virtual network and lets a Network Virtual Appliance (NVA) — a Cisco/Palo Alto/Fortinet/Aviatrix firewall, an SD-WAN concentrator, a Linux router running FRR — exchange routes dynamically with the Azure SDN fabric. Without it, an NVA that wants VNet traffic to flow through it has to be advertised via User Defined Routes that you write and maintain by hand. With Route Server, the NVA simply peers over BGP, advertises the prefixes it owns, and Azure programs those routes into the VNet’s effective route table automatically — no UDR churn, and the routes update the moment the NVA’s topology changes.
Route Server is not a router you forward packets through; it is purely a control-plane component. It has a fixed ASN of 65515, always deploys as the Standard SKU into a dedicated subnet that must be named exactly RouteServerSubnet (minimum /27), and requires a Standard-tier public IP for its management/peering endpoints. It hands back two BGP peer IPs (one per redundant instance) that you configure on the NVA side.
Wrapping this in a reusable module matters because the surrounding wiring is fiddly and easy to get subtly wrong: the subnet name is load-bearing (Azure rejects anything else), the public IP must be Standard/static, the NVA peer_asn must not be 65515 (that GUID is reserved by Route Server itself), and branch_to_branch_enabled quietly governs whether ExpressRoute and VPN gateways exchange routes with the NVA. A module turns “create a subnet, allocate a Standard IP, deploy Route Server, wire up two BGP sessions, then copy the peer IPs into the firewall” into one versioned module block with validated inputs.
This module provisions the Route Server (azurerm_route_server) plus, optionally, one or more BGP connections (azurerm_route_server_bgp_connection) to your NVAs, so the common production path — Route Server with its firewall peerings ready — is a single block.
When to use it
- You run an NVA in your VNet (firewall, SD-WAN, custom router) and want it to inject routes into Azure dynamically over BGP instead of maintaining brittle User Defined Routes by hand.
- You need transit routing between an NVA and the Azure gateways — letting an SD-WAN appliance learn ExpressRoute/VPN routes (and vice versa) via
branch_to_branch_enabled. - You are building a hub-and-spoke or virtual WAN-adjacent topology where on-prem prefixes, NVA prefixes, and spoke prefixes must converge without UDR sprawl across dozens of subnets.
- You want active-active NVA designs where two appliance instances each peer with Route Server and Azure load-balances/ECMPs across the advertised next hops.
- You need an auditable, peer-reviewed record of which NVA ASN peers with which Route Server, in which VNet, instead of portal click-ops on a control-plane component nobody remembers configuring.
Reach for plain UDRs instead if you have a handful of static routes that never change, or use Azure Virtual WAN if you want Microsoft to manage the entire hub including routing — Route Server is the right tool specifically when you own the NVA and want it to speak BGP to a VNet you control.
Module structure
terraform-module-azure-route-server/
├── versions.tf # provider + Terraform version pins
├── main.tf # subnet + public IP + route server + optional BGP connections
├── variables.tf # var-driven inputs with validation
└── outputs.tf # id, virtual_router_asn, peer IPs, bgp connection ids
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
# main.tf
locals {
tags = merge(
{
module = "terraform-module-azure-route-server"
environment = var.environment
},
var.tags,
)
}
# Route Server requires a dedicated subnet named EXACTLY "RouteServerSubnet",
# minimum /27. When create_subnet = true the module owns it; otherwise the
# caller passes an existing subnet id via route_server_subnet_id.
resource "azurerm_subnet" "route_server" {
count = var.create_subnet ? 1 : 0
name = "RouteServerSubnet"
resource_group_name = var.resource_group_name
virtual_network_name = var.virtual_network_name
address_prefixes = [var.route_server_subnet_prefix]
}
# Route Server's endpoints need a Standard-tier, statically-allocated public IP.
resource "azurerm_public_ip" "this" {
name = "${var.name}-pip"
resource_group_name = var.resource_group_name
location = var.location
allocation_method = "Static"
sku = "Standard"
zones = var.zones
tags = local.tags
}
resource "azurerm_route_server" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = var.location
sku = "Standard" # the only SKU Route Server supports
public_ip_address_id = azurerm_public_ip.this.id
subnet_id = var.create_subnet ? azurerm_subnet.route_server[0].id : var.route_server_subnet_id
branch_to_branch_enabled = var.branch_to_branch_enabled
hub_routing_preference = var.hub_routing_preference
tags = local.tags
}
# Optional BGP peerings to your NVAs. The peer_asn MUST NOT be 65515 (reserved
# by Route Server itself) and peer_ip must sit inside the VNet address space.
resource "azurerm_route_server_bgp_connection" "this" {
for_each = var.bgp_connections
name = each.key
route_server_id = azurerm_route_server.this.id
peer_asn = each.value.peer_asn
peer_ip = each.value.peer_ip
}
# variables.tf
variable "name" {
description = "Name of the Route Server. Use your org naming convention, e.g. rtsvr-hub-weu-prod."
type = string
validation {
condition = can(regex("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,78}[a-zA-Z0-9_]$", var.name))
error_message = "name must be 2-80 chars, start alphanumeric, and contain only letters, numbers, '.', '_' or '-'."
}
}
variable "resource_group_name" {
description = "Resource group that will hold the Route Server, public IP, and (optionally) the subnet."
type = string
}
variable "location" {
description = "Azure region for the Route Server and its public IP (e.g. westeurope)."
type = string
}
variable "virtual_network_name" {
description = "Name of the VNet that will host RouteServerSubnet. Required only when create_subnet = true."
type = string
default = null
}
variable "create_subnet" {
description = "If true, the module creates the dedicated RouteServerSubnet in virtual_network_name. If false, supply route_server_subnet_id."
type = bool
default = true
}
variable "route_server_subnet_prefix" {
description = "Address prefix for the RouteServerSubnet. Must be at least a /27. Only used when create_subnet = true."
type = string
default = null
validation {
condition = (
var.route_server_subnet_prefix == null ? true :
tonumber(split("/", var.route_server_subnet_prefix)[1]) <= 27
)
error_message = "route_server_subnet_prefix must be a /27 or larger (prefix length <= 27), e.g. 10.0.250.0/27."
}
}
variable "route_server_subnet_id" {
description = "Resource ID of an existing subnet named RouteServerSubnet. Required when create_subnet = false."
type = string
default = null
}
variable "zones" {
description = "Availability zones for the public IP (e.g. [\"1\",\"2\",\"3\"]). Empty list = no zonal pinning."
type = list(string)
default = ["1", "2", "3"]
}
variable "branch_to_branch_enabled" {
description = "Allow routes learned from one branch (NVA/ExpressRoute/VPN) to be advertised to another. Enable for NVA<->gateway transit; disable to isolate."
type = bool
default = false
}
variable "hub_routing_preference" {
description = "Route selection preference when multiple paths exist: ExpressRoute, VpnGateway, or ASPath."
type = string
default = "ExpressRoute"
validation {
condition = contains(["ExpressRoute", "VpnGateway", "ASPath"], var.hub_routing_preference)
error_message = "hub_routing_preference must be one of ExpressRoute, VpnGateway, ASPath."
}
}
variable "bgp_connections" {
description = <<-EOT
Map of BGP peerings to your NVAs, keyed by connection name. peer_ip must be an
address inside the VNet (typically the NVA's NIC). peer_asn is the NVA's ASN and
MUST NOT be 65515, which Route Server reserves for itself. Use a map so adding or
removing a peer does not re-index the others.
EOT
type = map(object({
peer_asn = number
peer_ip = string
}))
default = {}
validation {
condition = alltrue([
for c in values(var.bgp_connections) :
c.peer_asn != 65515 && c.peer_asn >= 1 && c.peer_asn <= 4294967295
])
error_message = "Each bgp_connections peer_asn must be a valid ASN (1-4294967295) and must not be 65515 (reserved by Route Server)."
}
}
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 Route Server and public IP."
type = map(string)
default = {}
}
# outputs.tf
output "id" {
description = "Resource ID of the Route Server."
value = azurerm_route_server.this.id
}
output "name" {
description = "Name of the Route Server."
value = azurerm_route_server.this.name
}
output "virtual_router_asn" {
description = "ASN of the Route Server (fixed at 65515). Configure your NVA's BGP neighbor to this remote ASN."
value = azurerm_route_server.this.virtual_router_asn
}
output "virtual_router_ips" {
description = "The two BGP peer IPs (one per redundant instance) to configure as neighbors on your NVA."
value = azurerm_route_server.this.virtual_router_ips
}
output "public_ip_id" {
description = "Resource ID of the Standard public IP attached to the Route Server."
value = azurerm_public_ip.this.id
}
output "subnet_id" {
description = "Resource ID of the RouteServerSubnet in use (created or supplied)."
value = var.create_subnet ? azurerm_subnet.route_server[0].id : var.route_server_subnet_id
}
output "bgp_connection_ids" {
description = "Map of BGP connection name => resource ID for each NVA peering created."
value = { for k, c in azurerm_route_server_bgp_connection.this : k => c.id }
}
How to use it
module "route_server" {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-route-server?ref=v1.0.0"
name = "rtsvr-hub-weu-prod"
resource_group_name = azurerm_resource_group.connectivity.name
location = "westeurope"
# Let the module own the dedicated RouteServerSubnet (/27) in the hub VNet.
create_subnet = true
virtual_network_name = azurerm_virtual_network.hub.name
route_server_subnet_prefix = "10.0.250.0/27"
# Enable NVA <-> ExpressRoute/VPN transit so the firewall learns on-prem routes.
branch_to_branch_enabled = true
hub_routing_preference = "ExpressRoute"
# Peer both active-active firewall instances over BGP.
bgp_connections = {
"fw-instance-0" = {
peer_asn = 65010
peer_ip = "10.0.1.4"
}
"fw-instance-1" = {
peer_asn = 65010
peer_ip = "10.0.1.5"
}
}
environment = "prod"
tags = {
cost_center = "networking"
owner = "platform-team"
}
}
# Downstream: surface the peer IPs the NVA must use as BGP neighbours.
# Feed these into your firewall automation (e.g. an ARM/Bicep, Ansible, or
# vendor API step that configures the appliance's BGP neighbours).
output "nva_bgp_neighbours" {
description = "Configure the firewall's BGP neighbours to these IPs with remote-as 65515."
value = {
remote_asn = module.route_server.virtual_router_asn
neighbours = module.route_server.virtual_router_ips
}
}
After apply, the two virtual_router_ips are the addresses your NVA must configure as BGP neighbours, with remote-as 65515 (the virtual_router_asn output). The Azure side of each session is created by the azurerm_route_server_bgp_connection resources, but the NVA side is not managed by this module — you still configure the firewall’s neighbours (out-of-band or via vendor automation) before routes will flow.
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/route_server/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
terraform {
source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-route-server?ref=v1.0.0"
}
inputs = {
name = "..."
resource_group_name = "..."
location = "..."
}
3. Deploy one environment, or roll out all modules together:
cd live/prod/route_server && 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 | Route Server name; validated 2–80 chars, alphanumeric start. |
resource_group_name |
string |
— | Yes | Resource group holding the Route Server, public IP, and optional subnet. |
location |
string |
— | Yes | Azure region (e.g. westeurope). |
virtual_network_name |
string |
null |
Conditional | VNet to create RouteServerSubnet in; required when create_subnet = true. |
create_subnet |
bool |
true |
No | Whether the module creates the dedicated RouteServerSubnet. |
route_server_subnet_prefix |
string |
null |
Conditional | Address prefix for the subnet; validated to /27 or larger. Used when create_subnet = true. |
route_server_subnet_id |
string |
null |
Conditional | Existing RouteServerSubnet id; required when create_subnet = false. |
zones |
list(string) |
["1","2","3"] |
No | Availability zones for the Standard public IP. |
branch_to_branch_enabled |
bool |
false |
No | Allow NVA ↔ ExpressRoute/VPN route exchange (transit routing). |
hub_routing_preference |
string |
"ExpressRoute" |
No | Path preference: ExpressRoute, VpnGateway, or ASPath. |
bgp_connections |
map(object) |
{} |
No | NVA BGP peerings (peer_asn, peer_ip); peer_asn must not be 65515. |
environment |
string |
"prod" |
No | Environment label applied as a tag. |
tags |
map(string) |
{} |
No | Extra tags merged onto Route Server and public IP. |
Outputs
| Name | Description |
|---|---|
id |
Resource ID of the Route Server. |
name |
Name of the Route Server. |
virtual_router_asn |
Route Server ASN (fixed 65515); use as the NVA’s remote AS. |
virtual_router_ips |
The two BGP peer IPs to configure as neighbours on the NVA. |
public_ip_id |
Resource ID of the Standard public IP attached to the Route Server. |
subnet_id |
Resource ID of the RouteServerSubnet in use (created or supplied). |
bgp_connection_ids |
Map of BGP connection name → resource ID for each NVA peering. |
Enterprise scenario
A retail platform team runs an active-active pair of Palo Alto firewalls in the hub VNet of an Enterprise-Scale landing zone, fronting all spoke-to-internet and spoke-to-on-prem traffic. They deploy this module as rtsvr-hub-weu-prod with branch_to_branch_enabled = true and two BGP connections — one per firewall instance (peer_asn = 65010). The firewalls advertise the default route and a summarized spoke supernet to Route Server, which programs them across every spoke without a single hand-written UDR, while the firewalls learn on-prem prefixes back from the hub’s ExpressRoute gateway. When the team scales the spoke estate, no routing change is needed — BGP converges automatically — and the module’s virtual_router_ips output feeds straight into the Ansible job that configures the firewalls’ neighbours.
Best practices
- Never reuse ASN 65515 on the NVA. Route Server owns
65515; configuring the same ASN on the appliance breaks the session. The module validatespeer_asn != 65515, but also pick a private ASN (64512–65534 or the 32-bit private range) for the NVA and keep it consistent across instances for clean active-active ECMP. - Treat the subnet name as immutable infrastructure. Azure only accepts a subnet named exactly
RouteServerSubnetat a minimum of/27. Don’t co-locate other resources there, don’t shrink it, and size the VNet plan to reserve a dedicated/27up front — moving it later means tearing down the Route Server. - Set
branch_to_branch_enableddeliberately. Leave itfalseto keep an NVA’s routes isolated from your ExpressRoute/VPN gateways; turn ittrueonly when you actually want transit routing between branches and the appliance. Flipping it changes which prefixes propagate VNet-wide, so review the blast radius before changing it in prod. - Mind cost and zonal resilience. Route Server bills hourly per the managed service plus the inter-VNet/processed-route data it handles — it is always-on, so don’t leave orphaned instances in dev. Pin the public IP across
["1","2","3"](the default) so the redundant instances survive a zone failure. - Watch the route limits. Each BGP peer has a ceiling on advertised and learned routes (in the low thousands per the service limits). Summarize aggressively on the NVA and avoid advertising the full internet table or per-host /32s, or sessions will start dropping routes silently.
- Standardize naming and tags. Bake region, role, and environment into
name(e.g.rtsvr-hub-weu-prod) and always setcost_center/ownertags so this easily-forgotten control-plane resource stays attributable in cost and inventory reports.