IaC Azure

Terraform Module: Azure Route Server — NVA-to-VNet dynamic routing as code

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

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 configlive/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 configlive/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

TerraformAzureRoute ServerModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading