Deploy an Azure VM (Linux or Windows) with IP forwarding enabled, to be used as a router / Network Virtual Appliance (NVA).
- Overview
- Quick start
- Repository structure
- Network modes
- Linux router
- Windows router
- Network security defaults
- Using the router
- Deploying from the command line
- Publishing as a template spec
- Setup scripts
- Lab deployment scripts
- Working with the templates
- Recent improvements
- Roadmap
- Contributing
- License
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 router | Windows router | |
|---|---|---|
| Operating system | Ubuntu 24.04 LTS (default) or 22.04 LTS | Windows Server 2025 / 2022 / 2019, Server Core, small disk, Gen 2 |
| IPv4 + IPv6 forwarding | Yes | Yes |
| NAT / SNAT to the internet | Yes (iptables masquerade, persisted) |
No |
| ICMP echo reply enabled | Yes | Yes (Windows Firewall rule enabled by the script) |
| Trusted Launch | No | Yes (Secure Boot + vTPM) |
| Default size | Standard_B2s |
Standard_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.
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.
| Template | Use when | Deploy | Visualize |
|---|---|---|---|
| Linux router | You want an Ubuntu router with forwarding and SNAT | ||
| Windows router | You want a Windows Server router |
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.
.
├── .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
Both templates take a networkMode parameter that decides how the router attaches to the network, so a single template covers every starting point.
networkMode |
Virtual network | Subnet | NSG placement |
|---|---|---|---|
ExistingSubnet (default) |
Must already exist | Must already exist | On the NIC, so an NSG already attached to that subnet is never overwritten |
NewSubnet |
Must already exist | Created using subnetAddressPrefix |
On the new subnet |
NewVnet |
Created using virtualNetworkAddressPrefix |
Created using subnetAddressPrefix |
On the new subnet |
The parameters that apply to each mode:
| Parameter | ExistingSubnet |
NewSubnet |
NewVnet |
|---|---|---|---|
virtualNetworkName |
Name of the existing VNET | Name of the existing VNET | Name of the VNET to create |
virtualNetworkAddressPrefix |
Ignored | Ignored | Address space of the new VNET |
subnetName |
Name of the existing subnet | Name of the subnet to create | Name of the subnet to create |
subnetAddressPrefix |
Ignored | CIDR of the new subnet | CIDR 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:
| Output | Description |
|---|---|
privateIpAddress |
Private IP of the router — use this as the UDR next hop |
publicIpAddress |
Public IP, empty when deployPublicIpAddress is false |
subnetId |
Resource ID of the subnet the router joined |
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.
| Parameter | Type | Default | Description |
|---|---|---|---|
virtualMachineName |
string | (required) | Name of the router VM. |
adminUsername |
string | (required) | Local admin user name. |
adminPassword |
secure string | (required) | Local admin password. |
networkMode |
string | ExistingSubnet |
ExistingSubnet, NewSubnet or NewVnet — see Network modes. |
virtualNetworkName |
string | (required) | VNET to join, or to create in NewVnet mode. |
virtualNetworkAddressPrefix |
string | 10.100.0.0/16 |
Address space for the new VNET. NewVnet only. |
subnetName |
string | (required) | Subnet to join, or to create in NewSubnet / NewVnet mode. |
subnetAddressPrefix |
string | 10.100.0.0/24 |
CIDR for the new subnet, can be as small as /29. NewSubnet / NewVnet only. |
osVersion |
string | 24.04 |
Ubuntu LTS version — 24.04 or 22.04. |
virtualMachineSize |
string | Standard_B2s |
VM size. |
osDiskType |
string | Standard_LRS |
Premium_LRS, StandardSSD_LRS or Standard_LRS. |
deployPublicIpAddress |
bool | true |
Create a Standard SKU static public IP. |
allowSshFromAddressPrefix |
string | '' |
Source prefix allowed inbound on TCP 22. Empty means no SSH rule. |
scriptUri |
string | resolved from the template's own URL | Setup script to run. |
scriptCmd |
string | sh linuxrouter.sh |
Command used to run the script. |
location |
string | resource group location | Azure region. |
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.
| Parameter | Type | Default | Description |
|---|---|---|---|
virtualMachineName |
string | (required) | Name of the router VM. |
adminUsername |
string | (required) | Local admin user name. |
adminPassword |
secure string | (required) | Local admin password. |
networkMode |
string | ExistingSubnet |
ExistingSubnet, NewSubnet or NewVnet — see Network modes. |
virtualNetworkName |
string | (required) | VNET to join, or to create in NewVnet mode. |
virtualNetworkAddressPrefix |
string | 10.100.0.0/16 |
Address space for the new VNET. NewVnet only. |
subnetName |
string | (required) | Subnet to join, or to create in NewSubnet / NewVnet mode. |
subnetAddressPrefix |
string | 10.100.0.0/24 |
CIDR for the new subnet, can be as small as /29. NewSubnet / NewVnet only. |
osVersion |
string | 2025 |
Windows Server version — 2025, 2022 or 2019. |
virtualMachineSize |
string | Standard_B2s |
VM size. |
osDiskType |
string | Standard_LRS |
Premium_LRS, StandardSSD_LRS or Standard_LRS. |
deployPublicIpAddress |
bool | true |
Create a Standard SKU static public IP. |
allowRdpFromAddressPrefix |
string | '' |
Source prefix allowed inbound on TCP 3389. Empty means no RDP rule. |
scriptUri |
string | resolved from the template's own URL | Setup script to run. |
scriptCmd |
string | powershell.exe -ExecutionPolicy Unrestricted -File winrouter.ps1 |
Command used to run the script. |
location |
string | resource group location | Azure region. |
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/allowRdpFromAddressPrefixto a trusted source —203.0.113.4/32for 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 toInternetor*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.
Deploying the VM does not by itself send any traffic through it. To route traffic:
- Create a route table and add a user-defined route, for example
0.0.0.0/0with next hop type Virtual appliance and the router's private IP as the next hop address. - Associate the route table with the source subnets whose traffic should traverse the router.
- 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.
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.shTemplate 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 eastusThen 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.
Custom Script Extension payloads under scripts/.
| Script | Purpose |
|---|---|
linux/linuxrouter.sh |
Default. IPv4/IPv6 forwarding, no ICMP redirects, iptables SNAT to the internet, persisted with netfilter-persistent. |
linux/linuxrouterv2.sh |
Same as above, using /etc/sysctl.d/ drop-ins instead of editing /etc/sysctl.conf. |
linux/linuxrouteronly.sh |
Minimal — enables forwarding only, no NAT and no packages installed. |
linux/linuxrouterbgp.sh |
Router plus Quagga BGP, peering with two route server / peer IPs. |
linux/linuxrouterbgpnh.sh |
Quagga BGP with an explicit next-hop override for advertised routes. |
linux/linuxrouterbgpfrr.sh |
Router plus FRRouting BGP. |
linux/linuxrouterbgpfrr2.sh |
FRRouting variant used for the second NVA in dual-NVA labs. |
linux/linuxrouterbgpfrr2nh.sh |
FRRouting second-NVA variant with a next-hop override. |
linux/cloud-init.txt |
cloud-init alternative to the Custom Script Extension. |
windows/winrouter.ps1 |
Enables 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.
End-to-end environment builds under labs/, intended to be run interactively line by line.
| Script | Builds |
|---|---|
deploylinuxnva.azcli |
A VNET with a Linux NVA plus spoke/test VMs and UDRs to validate routing through it. |
deploylinuxnvabgp.azcli |
A Linux NVA running BGP, peered with an Azure Route Server. |
deploylinuxnvabgpnp.azcli |
The BGP lab with a custom next-hop, plus network test tooling on the test VMs. |
conf/*-bgproutes.txt |
Pre-generated route lists (999 to 10240 prefixes) for BGP scale testing. |
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.jsonCI 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.
The templates and scripts were modernised after several Azure platform retirements broke the original versions.
| Area | What changed |
|---|---|
| Infrastructure as code | Templates converted to Bicep; the ARM JSON is now generated output, kept in sync by CI. |
| Operating systems | Ubuntu 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 IP | Basic SKU (retired September 2025) replaced with Standard SKU, static allocation across all templates and lab scripts. |
| Network security | Added 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 reliability | Fixed 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 layout | Reorganised into infra/, scripts/, labs/, tools/ and docs/, with consistent file naming. |
| Consolidated network modes | The 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 specs | Added 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 gates | Added GitHub Actions validation and a .gitattributes that pins shell scripts to LF. |
| Documentation | Rewrote 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.
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
iptablesSNAT 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.
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_B2sdefault is a burstable size and does not support Accelerated Networking, so enabling it also means moving to a size such asStandard_D2s_v5or 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.
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.
Released under the MIT License.