Skip to content

Repository files navigation

Azure VM as Router

Deploy an Azure VM (Linux or Windows) with IP forwarding enabled, to be used as a router / Network Virtual Appliance (NVA).

Validate templatesBicepUbuntuWindows ServerLicense


Table of contents


Overview

These templates build a single-NIC virtual machine with IP forwarding enabled on both the Azure NIC and inside the guest OS, so it can route traffic on behalf of other subnets. A Custom Script Extension applies the in-guest configuration at provisioning time.

Linux routerWindows router
Operating systemUbuntu 24.04 LTS (default) or 22.04 LTSWindows Server 2025 / 2022 / 2019, Server Core, small disk, Gen 2
IPv4 + IPv6 forwardingYesYes
NAT / SNAT to the internetYes (iptables masquerade, persisted)No
ICMP echo reply enabledYesYes (Windows Firewall rule enabled by the script)
Trusted LaunchNoYes (Secure Boot + vTPM)
Default sizeStandard_B2sStandard_B2s

Note

The ARM JSON under infra/arm/ is generated from the Bicep sources under infra/bicep/. Edit the Bicep, then rebuild — never hand-edit the JSON. CI enforces this.


Quick start

Pick a template and deploy straight to the portal. Each one can join an existing subnet, add a subnet to an existing VNET, or build the VNET from scratch — see Network modes.

TemplateUse whenDeployVisualize
Linux routerYou want an Ubuntu router with forwarding and SNATDeploy To AzureVisualize
Windows routerYou want a Windows Server routerDeploy To AzureVisualize

Important

Set allowSshFromAddressPrefix (Linux) or allowRdpFromAddressPrefix (Windows) to your own public IP, e.g. 203.0.113.4/32. Standard SKU public IPs block all inbound traffic unless an NSG allows it. See Network security defaults.


Repository structure

.
├── .github/workflows/ CI: Bicep build + lint, ARM drift check, shell syntax / CRLF check
├── infra/
│ ├── bicep/ Template sources — edit these
│ └── arm/ Generated ARM JSON — deploy these, never hand-edit
├── scripts/
│ ├── linux/ Custom Script Extension payloads (.sh) and cloud-init
│ └── windows/ Custom Script Extension payload (.ps1)
├── labs/
│ ├── *.azcli End-to-end Azure CLI lab builds
│ └── conf/ Large BGP route lists used for scale testing
├── tools/ Helper scripts for publishing template specs
├── docs/ Supporting notes
└── README.md

Network modes

Both templates take a networkMode parameter that decides how the router attaches to the network, so a single template covers every starting point.

networkModeVirtual networkSubnetNSG placement
ExistingSubnet(default)Must already existMust already existOn the NIC, so an NSG already attached to that subnet is never overwritten
NewSubnetMust already existCreated using subnetAddressPrefixOn the new subnet
NewVnetCreated using virtualNetworkAddressPrefixCreated using subnetAddressPrefixOn the new subnet

The parameters that apply to each mode:

ParameterExistingSubnetNewSubnetNewVnet
virtualNetworkNameName of the existing VNETName of the existing VNETName of the VNET to create
virtualNetworkAddressPrefixIgnoredIgnoredAddress space of the new VNET
subnetNameName of the existing subnetName of the subnet to createName of the subnet to create
subnetAddressPrefixIgnoredCIDR of the new subnetCIDR of the new subnet

Note

In the two subnet-creating modes the NSG is always created, because the template owns the new subnet. In ExistingSubnet mode the NSG is only created when you set allowSshFromAddressPrefix / allowRdpFromAddressPrefix, so a deployment into an existing subnet never attaches an unexpected NSG.

Every deployment returns the values you need for a route table:

OutputDescription
privateIpAddressPrivate IP of the router — use this as the UDR next hop
publicIpAddressPublic IP, empty when deployPublicIpAddress is false
subnetIdResource ID of the subnet the router joined

Linux router

Deploys an Ubuntu router with a single NIC and IP forwarding enabled. The setup script enables IPv4 and IPv6 forwarding, disables ICMP redirects, configures iptables SNAT (masquerade) to the internet for private-range sources, and persists all of it across reboots with netfilter-persistent.

Parameters

ParameterTypeDefaultDescription
virtualMachineNamestring(required)Name of the router VM.
adminUsernamestring(required)Local admin user name.
adminPasswordsecure string(required)Local admin password.
networkModestringExistingSubnetExistingSubnet, NewSubnet or NewVnet — see Network modes.
virtualNetworkNamestring(required)VNET to join, or to create in NewVnet mode.
virtualNetworkAddressPrefixstring10.100.0.0/16Address space for the new VNET. NewVnet only.
subnetNamestring(required)Subnet to join, or to create in NewSubnet / NewVnet mode.
subnetAddressPrefixstring10.100.0.0/24CIDR for the new subnet, can be as small as /29. NewSubnet / NewVnet only.
osVersionstring24.04Ubuntu LTS version — 24.04 or 22.04.
virtualMachineSizestringStandard_B2sVM size.
osDiskTypestringStandard_LRSPremium_LRS, StandardSSD_LRS or Standard_LRS.
deployPublicIpAddressbooltrueCreate a Standard SKU static public IP.
allowSshFromAddressPrefixstring''Source prefix allowed inbound on TCP 22. Empty means no SSH rule.
scriptUristringresolved from the template's own URLSetup script to run.
scriptCmdstringsh linuxrouter.shCommand used to run the script.
locationstringresource group locationAzure region.

Windows router

Deploys a Windows Server Core, small disk, Generation 2 router with Trusted Launch (Secure Boot + vTPM) enabled. The setup script enables IPv4 and IPv6 forwarding on all interfaces and enables the inbound ICMPv4/ICMPv6 echo request firewall rules, which Windows blocks by default.

Note

The Windows router forwards traffic but does not perform NAT. If you need SNAT to the internet, use the Linux router or add Routing and Remote Access / NAT separately.

Parameters

ParameterTypeDefaultDescription
virtualMachineNamestring(required)Name of the router VM.
adminUsernamestring(required)Local admin user name.
adminPasswordsecure string(required)Local admin password.
networkModestringExistingSubnetExistingSubnet, NewSubnet or NewVnet — see Network modes.
virtualNetworkNamestring(required)VNET to join, or to create in NewVnet mode.
virtualNetworkAddressPrefixstring10.100.0.0/16Address space for the new VNET. NewVnet only.
subnetNamestring(required)Subnet to join, or to create in NewSubnet / NewVnet mode.
subnetAddressPrefixstring10.100.0.0/24CIDR for the new subnet, can be as small as /29. NewSubnet / NewVnet only.
osVersionstring2025Windows Server version — 2025, 2022 or 2019.
virtualMachineSizestringStandard_B2sVM size.
osDiskTypestringStandard_LRSPremium_LRS, StandardSSD_LRS or Standard_LRS.
deployPublicIpAddressbooltrueCreate a Standard SKU static public IP.
allowRdpFromAddressPrefixstring''Source prefix allowed inbound on TCP 3389. Empty means no RDP rule.
scriptUristringresolved from the template's own URLSetup script to run.
scriptCmdstringpowershell.exe -ExecutionPolicy Unrestricted -File winrouter.ps1Command used to run the script.
locationstringresource group locationAzure region.

Network security defaults

The templates deploy a Standard SKU public IP, because the Basic SKU was retired by Azure in September 2025. Standard public IPs are secure by default, which changes the out-of-the-box behaviour compared to older versions of these templates:

  • Inbound internet traffic is blocked unless an NSG explicitly allows it. Set allowSshFromAddressPrefix / allowRdpFromAddressPrefix to a trusted source — 203.0.113.4/32 for a single address, or a CIDR range for an office network. The template then creates an NSG with the matching management rule (priority 200) plus an RFC 1918 allow rule (priority 300) so forwarded traffic keeps flowing under the new default-deny. Leaving the parameter empty keeps the previous behaviour and means you manage the VM from inside your network or through Azure Bastion. Setting it to Internet or * allows access from anywhere and is not recommended.
  • Outbound internet traffic is allowed. NSGs permit outbound by default, and the attached public IP provides an explicit outbound method (SNAT), so the setup script can install packages during provisioning. This also keeps the templates working after Azure's retirement of default outbound access for new deployments.
  • If you set deployPublicIpAddress=false, make sure the subnet has another explicit outbound method — a NAT Gateway, a route through Azure Firewall or another NVA, or Load Balancer outbound rules. Without one, the VM has no internet access on a modern VNET and the setup script cannot install its packages.

Using the router

Deploying the VM does not by itself send any traffic through it. To route traffic:

  1. Create a route table and add a user-defined route, for example 0.0.0.0/0 with next hop type Virtual appliance and the router's private IP as the next hop address.
  2. Associate the route table with the source subnets whose traffic should traverse the router.
  3. Make sure the router's NSG allows the traffic you intend to forward — the templates add an RFC 1918 allow rule for exactly this reason.

Tip

Do not associate the route table with the router's own subnet using a default route pointing at itself; that creates a routing loop.


Deploying from the command line

Deploy the generated ARM template directly from GitHub. Joining an existing subnet:

az group create -n rg-nva -l eastus
az deployment group create \
-g rg-nva \
--template-uri https://raw.githubusercontent.com/dmauser/AzureVM-Router/master/infra/arm/linux-router.json \
--parameters \
virtualMachineName=nva1 \
adminUsername=azureuser \
adminPassword='<your-password>' \
networkMode=ExistingSubnet \
virtualNetworkName=vnet1 \
subnetName=nva-subnet \
allowSshFromAddressPrefix="$(curl -s ifconfig.me)/32"

Building the VNET and subnet from scratch:

az deployment group create \
-g rg-nva \
--template-uri https://raw.githubusercontent.com/dmauser/AzureVM-Router/master/infra/arm/linux-router.json \
--parameters \
virtualMachineName=nva1 \
adminUsername=azureuser \
adminPassword='<your-password>' \
networkMode=NewVnet \
virtualNetworkName=vnet-nva \
virtualNetworkAddressPrefix=10.100.0.0/16 \
subnetName=lxnva-subnet \
subnetAddressPrefix=10.100.0.0/24 \
allowSshFromAddressPrefix="$(curl -s ifconfig.me)/32"

Use networkMode=NewSubnet to add the subnet to a VNET that already exists.

Warning

scriptUri defaults to a path resolved relative to the template's own URL, so it automatically follows the branch or fork you deploy from. That resolution relies on deployment().properties.templateLink, which is not populated when you deploy a local file with --template-file or from a template spec. In those cases the default falls back to the master branch on GitHub. Pass the script location explicitly to pin it elsewhere:

--parameters scriptUri=https://raw.githubusercontent.com/dmauser/AzureVM-Router/master/scripts/linux/linuxrouter.sh

Publishing as a template spec

Template specs let you store a versioned template in your own subscription and share it through Azure RBAC, so consumers deploy it without needing access to this repository. tools/publish-templatespecs.sh publishes both routers:

./tools/publish-templatespecs.sh rg-templatespecs 1.0.0 eastus

Then deploy from the spec:

az deployment group create \
-g rg-nva \
--template-spec "$(az ts show -g rg-templatespecs -n linux-router --version 1.0.0 --query id -o tsv)" \
--parameters \
virtualMachineName=nva1 \
adminUsername=azureuser \
adminPassword='<your-password>' \
networkMode=NewVnet \
virtualNetworkName=vnet-nva \
subnetName=lxnva-subnet \
allowSshFromAddressPrefix="$(curl -s ifconfig.me)/32"

Note

A template spec deployment does not expose the original template URL, so scriptUri falls back to the master branch of this repository. Pass scriptUri explicitly if you host the setup scripts somewhere else.


Setup scripts

Custom Script Extension payloads under scripts/.

ScriptPurpose
linux/linuxrouter.shDefault. IPv4/IPv6 forwarding, no ICMP redirects, iptables SNAT to the internet, persisted with netfilter-persistent.
linux/linuxrouterv2.shSame as above, using /etc/sysctl.d/ drop-ins instead of editing /etc/sysctl.conf.
linux/linuxrouteronly.shMinimal — enables forwarding only, no NAT and no packages installed.
linux/linuxrouterbgp.shRouter plus Quagga BGP, peering with two route server / peer IPs.
linux/linuxrouterbgpnh.shQuagga BGP with an explicit next-hop override for advertised routes.
linux/linuxrouterbgpfrr.shRouter plus FRRouting BGP.
linux/linuxrouterbgpfrr2.shFRRouting variant used for the second NVA in dual-NVA labs.
linux/linuxrouterbgpfrr2nh.shFRRouting second-NVA variant with a next-hop override.
linux/cloud-init.txtcloud-init alternative to the Custom Script Extension.
windows/winrouter.ps1Enables forwarding on all interfaces and allows inbound ICMP echo.

Note

Every script that installs packages first runs cloud-init status --wait. Without it the extension can race cloud-init while it is still switching the VM to the regional Azure apt mirror, which leaves the on-disk package indexes pointing at the superseded mirror and makes installs fail with Unable to locate package.


Lab deployment scripts

End-to-end environment builds under labs/, intended to be run interactively line by line.

ScriptBuilds
deploylinuxnva.azcliA VNET with a Linux NVA plus spoke/test VMs and UDRs to validate routing through it.
deploylinuxnvabgp.azcliA Linux NVA running BGP, peered with an Azure Route Server.
deploylinuxnvabgpnp.azcliThe BGP lab with a custom next-hop, plus network test tooling on the test VMs.
conf/*-bgproutes.txtPre-generated route lists (999 to 10240 prefixes) for BGP scale testing.

Working with the templates

Rebuild the ARM JSON after changing any Bicep file:

az bicep build --file infra/bicep/linux-router.bicep --outfile infra/arm/linux-router.json
az bicep build --file infra/bicep/windows-router.bicep --outfile infra/arm/windows-router.json

CI runs bicep lint, rebuilds every template and fails if infra/arm/ differs from the committed output. It also checks the shell scripts for syntax errors and rejects CRLF line endings, which break the shebang when the Custom Script Extension runs a script on Linux.


Recent improvements

The templates and scripts were modernised after several Azure platform retirements broke the original versions.

AreaWhat changed
Infrastructure as codeTemplates converted to Bicep; the ARM JSON is now generated output, kept in sync by CI.
Operating systemsUbuntu 18.04 and the retired UbuntuLTS / ubuntults CLI aliases replaced with Ubuntu 24.04 LTS (default) and 22.04 LTS. Windows moved from Server 2019 to Server 2025 Core / small disk / Gen 2, with Trusted Launch.
Public IPBasic SKU (retired September 2025) replaced with Standard SKU, static allocation across all templates and lab scripts.
Network securityAdded allowSshFromAddressPrefix / allowRdpFromAddressPrefix so the templates can create the NSG that Standard SKU public IPs now require, together with an RFC 1918 rule so forwarded traffic still flows. Lab scripts that previously created no NSG now create one.
Provisioning reliabilityFixed a latent cloud-init race that intermittently failed package installation with Unable to locate package netfilter-persistent. All package-installing scripts now wait for cloud-init to finish first.
Repository layoutReorganised into infra/, scripts/, labs/, tools/ and docs/, with consistent file naming.
Consolidated network modesThe separate "existing subnet" and "new subnet" templates were merged into one template per OS. A networkMode parameter now selects ExistingSubnet, NewSubnet or NewVnet, and the capability was extended to Windows, which previously only supported an existing subnet. Templates now also emit privateIpAddress, publicIpAddress and subnetId outputs.
Template specsAdded tools/publish-templatespecs.sh and made scriptUri resolve safely when deployment().properties.templateLink is unavailable, so the templates work identically from a URL, a local file or a template spec.
Quality gatesAdded GitHub Actions validation and a .gitattributes that pins shell scripts to LF.
DocumentationRewrote this README with parameter references, network mode guidance, security guidance and coverage of every script in the repository.

All templates and the affected lab scripts were verified by deploying them to Azure and confirming NSG placement, inbound reachability, extension success, in-guest forwarding and NAT state, end-to-end egress through the NVA, and persistence across a reboot.


Roadmap

Add a VMSS option for both Linux and Windows deployments

Replace the single-VM deployment with a Virtual Machine Scale Set in Flexible orchestration mode so the router tier can scale out and survive the loss of an instance.

  • Place the scale set behind an internal Standard Load Balancer with an HA Ports rule, so all protocols and ports are distributed, and a health probe that removes unhealthy instances from rotation.
  • Point user-defined routes at the load balancer's frontend IP instead of a single VM's private IP, so the next hop stays valid as instances come and go.
  • Spread instances across availability zones for zone resilience, and apply the existing setup scripts through the scale set's extension profile so every new instance is configured identically.
  • Design consideration: stateful features such as iptables SNAT require flow symmetry, so return traffic must reach the same instance that handled the outbound flow. The NAT-to-internet scenario therefore needs per-instance outbound addressing or a NAT Gateway on the subnet rather than per-instance masquerade. Pure forwarding and BGP scenarios do not have this constraint.

Add an Accelerated Networking option

Expose an acceleratedNetworking parameter that sets enableAcceleratedNetworking on the NIC. Accelerated Networking gives the VM SR-IOV, bypassing the host virtual switch to deliver substantially lower latency and jitter, far higher packets-per-second, and lower CPU utilisation per gigabit — all of which are the main throughput limits for a software NVA.

  • Requires a supported VM size. The current Standard_B2s default is a burstable size and does not support Accelerated Networking, so enabling it also means moving to a size such as Standard_D2s_v5 or larger.
  • Should ship with clear guidance mapping expected throughput to VM size, since the NIC setting alone does not lift the size's own bandwidth cap.
  • Plan to validate the flag against every supported OS image, since enabling it on an unsupported size or image causes the deployment to fail rather than silently degrade.

Contributing

Issues and pull requests are welcome. When changing a template, edit the Bicep under infra/bicep/, rebuild the ARM JSON, and commit both — CI will fail if they drift apart.

License

Released under the MIT License.

About

Deploy Azure VM (Linux or Windows) with IP forwarding enabled to be used as Router

Resources

Stars

17 stars

Watchers

2 watching

Forks

Releases

Packages

Contributors

Languages