Skip to content
7 min read

Automating MikroTik Bridge VLAN Filtering & Proxmox Trunks with Terraform

Disclosure: This post contains Amazon affiliate links (marked with *). If you buy through them, I earn a small commission at no extra cost to you. I only link gear I actually own and use daily.

If you have ever tried to configure VLANs on a MikroTik router, you know the pain. I run the RB5009* specifically for its hardware-offloaded VLAN switching, cheaper models like the hEX do VLAN filtering in software, which introduces latency under sustained trunk traffic. Transitioning from the legacy switch-chip VLAN methods to the modern Bridge VLAN Filtering often results in completely locking yourself out of the router.

When you add a virtualization host like Proxmox into the mix-which requires a trunk port passing multiple tagged VLANs-the complexity multiplies. Label your cables early, a Brother Labelmaker* saved me hours tracing connections during a VLAN migration. Doing this via the WinBox GUI is a recipe for configuration drift and late-night network outages.

In this deep dive, I will show you how to tame MikroTik VLANs using Infrastructure as Code (Terraform). We will build a dynamic, scalable L2 network architecture that includes a Proxmox trunk port, dedicated management access, and hardware-offloaded access ports for edge nodes like the Raspberry Pis.

View the complete MikroTik IaC source code on GitHub 🐙

The Architecture & Locals

Before writing any resources, we need to define our network’s topology. Hardcoding VLAN IDs across dozens of resources is a bad practice. Instead, we use Terraform locals to create a central source of truth.

locals {
  homelab_vlans = {
    "vlan20-srv"    = 20
    "vlan30-dmz"    = 30
    "vlan40-iot"    = 40
    "vlan100-admin" = 100
  }

  rpi_port_mapping = {
    "ether6" = 20 # RPi 4B #1 (Keepalived Node A)
    "ether7" = 20 # RPi 4B #2 (Keepalived Node B)
  }

  proxmox_port = "ether5"
}

This simple map dictates our entire Layer 2 strategy. If we ever need to add a “Guest” VLAN, we simply add it to the homelab_vlans map, and Terraform will automatically generate the interfaces, IP addresses, DHCP servers, and bridge matrix entries.

1. The Core Bridge & Ports

In modern RouterOS (v6.41+), the best practice is to use a single bridge for all ports and manage segmentation purely through VLAN filtering. This ensures maximum hardware offloading (if supported by your MikroTik switch chip).

resource "routeros_interface_bridge" "core_bridge" {
  name           = "bridge1"
  vlan_filtering = true
  comment        = "Core bridge managed by Terraform"
}

Assigning the Physical Ports

Next, we attach our physical ethernet ports to the bridge. This is where we define whether a port is an Access Port (untagged) or a Trunk Port (tagged).

The Proxmox Trunk: Our Proxmox server (ether5) needs to receive tagged traffic so the virtual machines can reside in different VLANs. We leave it on the default pvid = 1 - this port carries VLANs by explicit tag, so the pvid only matters for the rare untagged frame, and nothing on this trunk is meant to communicate over untagged VLAN 1 in the first place.

resource "routeros_interface_bridge_port" "proxmox_port" {
  bridge    = routeros_interface_bridge.core_bridge.name
  interface = local.proxmox_port
  pvid      = 1
  hw        = true
  comment   = "Proxmox Trunk (Tagged VLANs)"
}

The Edge Access Ports: For devices that don’t understand VLAN tags (like a standard PC or Raspberry Pi), we assign a specific pvid. The bridge will automatically strip the VLAN tag when sending traffic to these ports and add it when receiving.

resource "routeros_interface_bridge_port" "rpi_ports" {
  for_each  = local.rpi_port_mapping
  bridge    = routeros_interface_bridge.core_bridge.name
  interface = each.key
  pvid      = each.value
  hw        = true
  comment   = "Keepalived Node"
}

resource "routeros_interface_bridge_port" "mgmt_port" {
  bridge    = routeros_interface_bridge.core_bridge.name
  interface = "ether2"
  pvid      = 100
  hw        = true
  comment   = "Admin Workstation Access Port"
}

2. Creating the VLAN Interfaces

A bridge connects physical ports, but the router itself needs an IP address in each VLAN to act as the default gateway. We create virtual VLAN interfaces attached to the bridge1 interface.

Using Terraform’s for_each loop, we dynamically generate these based on our locals block:

resource "routeros_interface_vlan" "vlans" {
  for_each  = local.homelab_vlans
  interface = routeros_interface_bridge.core_bridge.name
  name      = each.key
  vlan_id   = each.value
}

# Assign Gateway IPs to the Router
resource "routeros_ip_address" "vlan_ips" {
  for_each  = local.homelab_vlans
  address   = "10.0.${each.value}.1/24"
  interface = routeros_interface_vlan.vlans[each.key].name
}

3. The Magic: Dynamic Bridge VLAN Matrix

This is the hardest part of MikroTik configuration, and where Terraform truly shines. We must explicitly tell the bridge which ports are allowed to carry which VLANs.

If we forget to add bridge1 to the tagged list, the router’s CPU won’t be able to process the traffic, and DHCP/Routing will fail entirely.

The Management Override

First, we explicitly define the Management VLAN (vlan100). We tag the CPU (core_bridge) and the Proxmox trunk.

resource "routeros_interface_bridge_vlan" "vlan100_mgmt" {
  bridge   = routeros_interface_bridge.core_bridge.name
  vlan_ids = [100]
  tagged = [
    routeros_interface_bridge.core_bridge.name,
    local.proxmox_port
  ]
  # Note: ether2 is dynamically added as untagged by its PVID
}

The Dynamic Matrix Loop

For the rest of the VLANs, we use an advanced Terraform loop. This block dynamically calculates the untagged ports by reading the local.rpi_port_mapping and checking if the required VLAN matches the port’s assigned PVID.

resource "routeros_interface_bridge_vlan" "vlan_matrix" {
  # Loop through all VLANs EXCEPT 100 (since we handled it above)
  for_each = { for k, v in local.homelab_vlans : k => v if v != 100 }

  bridge   = routeros_interface_bridge.core_bridge.name
  vlan_ids = [each.value]

  tagged = [
    routeros_interface_bridge.core_bridge.name,
    local.proxmox_port
  ]

  untagged = [
    for port, vlan in local.rpi_port_mapping : port if vlan == each.value
  ]
}

Why This Scales

Look at the untagged logic. If you decide to move a Raspberry Pi from the Server VLAN (20) to the DMZ VLAN (30), you don’t have to touch the bridge configuration, the matrix, or the interface settings.

You simply change the 20 to 30 in the local.rpi_port_mapping at the top of the file. Terraform will calculate the diff, modify the port’s PVID, and seamlessly update the Bridge VLAN table. This is true Infrastructure as Code.

Conclusion

By treating network infrastructure as code, we transform a fragile, click-heavy configuration into a robust, auditable state. Proxmox gets its tagged trunks, hardware edge devices get their native access ports, and the router maintains secure L2 isolation. With the VLAN layer in place, the next step is building L3 firewall rules on top — default-deny forward chains, micro-segmented allow rules, and NAT for public-facing services.

Define the desired state, let the provider compute the diff — that declarative loop is exactly what makes enterprise Azure resource deployments auditable, and it’s the same loop running here in your edge router.

Enjoying this? Get the next deep dive in your inbox.

Subscribe →
Share
DW

David Woitzik

Hybrid Cloud Engineer

Specializing in Azure, Terraform, and Zero-Trust network architecture. I publish the hardened templates and deep dives I wish existed when I needed them.

Was this helpful?

More like this in your inbox

New enterprise modules and deep dives — straight to your inbox. No spam.