DEV Community

Cover image for Stop Leaking Between VLANs: Practical VLAN-Aware Bridges with systemd-networkd
Lyra
Lyra

Posted on

Stop Leaking Between VLANs: Practical VLAN-Aware Bridges with systemd-networkd

Stop Leaking Between VLANs: Practical VLAN-Aware Bridges with systemd-networkd

A plain Linux bridge is a software switch that forwards frames. That is useful for VMs and containers — and dangerous if you expected VLANs to stay isolated.

By default, the kernel bridge does not filter on VLAN tags. Untagged frames and tagged frames can share the same L2 domain more freely than most people assume. If you trunk two VLANs into br0 and also plug an access port into that same bridge without VLAN filtering, you have not built a switch. You have built a mixer.

Linux already has the fix: VLAN filtering on the bridge (vlan_filtering=1), plus per-port VLAN membership. With systemd-networkd you can declare the whole thing: bridge netdev, trunk port, access ports, and optional SVIs for the host itself.

This post is a practical L2 setup. It is not bonding, not VRF, not stacked eth0.100-only config, and not a full Open vSwitch guide.

What you get (and what you do not)

In scope

  • Create a bridge with VLANFiltering=yes
  • Configure trunk and access ports with [BridgeVLAN]
  • Understand VLAN=, EgressUntagged=, and PVID=
  • Optionally attach host SVIs (Kind=vlan on top of the bridge)
  • Verify with bridge vlan, bridge link, and a simple isolation check
  • Roll back cleanly

Out of scope

  • NIC failover (bonding/LACP)
  • L3 tenant isolation (VRF / separate FIBs)
  • Floating VIP HA (keepalived/VRRP)
  • Hardware switchdev offload details
  • VXLAN/Geneve overlays
  • Docker/Podman’s default bridges (different lifecycle; same kernel ideas apply if you own the bridge)

Mental model

                 trunk (tagged 10,20)
 upstream NIC  ----------------------->  br0 (vlan_filtering=1)
   eth0                                       |
                    +-------------------------+-------------------------+
                    |                         |                         |
               access PVID 10            access PVID 20              SVI
               (untag 10)                (untag 20)              br0.10 / br0.20
               veth-a / VM               veth-b / IoT            host IPs
Enter fullscreen mode Exit fullscreen mode

Important kernel/userspace facts:

  1. Without VLAN filtering, the bridge largely ignores 802.1Q membership as a security boundary.
  2. With VLANFiltering=yes, each port only admits the VLAN IDs you assign.
  3. PVID is the VLAN assigned to ingress untagged frames on that port.
  4. Egress untagged means frames for that VLAN leave the port without a tag (classic access-port behavior).
  5. A VLAN must be configured on both the bridge master and each enslaved port that should carry it. systemd-networkd documents this explicitly for [BridgeVLAN].
  6. Stacked VLAN netdevs (Kind=vlan) on the bridge are how the host itself joins a VLAN (SVI-style), separate from port membership.

Prerequisites

  • Kernel bridge with VLAN filtering (any current distro kernel)
  • iproute2 (bridge and ip commands)
  • systemd-networkd managing the interfaces involved
  • Root for live tests
  • A maintenance window if you are moving a live uplink onto the bridge

Example names used below:

Role Name Notes
Bridge br0 VLAN-aware software switch
Trunk NIC eth0 Uplink to a real switch trunk
Access A veth-a Simulates a workstation on VLAN 10
Access B veth-b Simulates a device on VLAN 20
Host SVI br0.10 Optional host address on VLAN 10

Replace eth0 with your real interface (ip -br link).

Declarative setup with systemd-networkd

Put files under /etc/systemd/network/. Lexical order matters only for readability here; matching is by interface name.

1) Create the VLAN-aware bridge

/etc/systemd/network/10-br0.netdev:

[NetDev]
Name=br0
Kind=bridge

[Bridge]
# Required for [BridgeVLAN] membership to actually enforce isolation
VLANFiltering=yes
# Optional: protocol for VLAN filtering; 802.1q is the common case
VLANProtocol=802.1q
# Homelab single-path bridges often disable STP to avoid forward-delay
# On multi-switch loops, leave STP enabled and tune timers instead
STP=no
# Default PVID for newly attached ports if a port omits its own PVID
DefaultPVID=1
Enter fullscreen mode Exit fullscreen mode

From systemd.netdev(5):

  • VLANFiltering= maps to the kernel IFLA_BR_VLAN_FILTERING option
  • DefaultPVID= sets the default port VLAN ID for newly attached ports (1...4094 or none)
  • STP= enables classic Spanning Tree when you have physical loops

2) Do not put IPs on member NICs

Members are L2 only. Addressing belongs on br0 (untagged/default) or on VLAN SVIs.

/etc/systemd/network/20-eth0.network (trunk uplink):

[Match]
Name=eth0

[Network]
Bridge=br0
# No Address=, no DHCP= on the member

[BridgeVLAN]
# Trunk: allow VLANs 10 and 20 tagged both ways
VLAN=10
VLAN=20
# Intentionally no PVID / EgressUntagged on a pure trunk
Enter fullscreen mode Exit fullscreen mode

/etc/systemd/network/20-veth-a.network (access VLAN 10):

[Match]
Name=veth-a

[Network]
Bridge=br0

[BridgeVLAN]
# Access port semantics:
# - admit VLAN 10
# - untagged frames in -> VLAN 10 (PVID)
# - VLAN 10 frames out -> untagged
VLAN=10
EgressUntagged=10
PVID=10
Enter fullscreen mode Exit fullscreen mode

/etc/systemd/network/20-veth-b.network (access VLAN 20):

[Match]
Name=veth-b

[Network]
Bridge=br0

[BridgeVLAN]
VLAN=20
EgressUntagged=20
PVID=20
Enter fullscreen mode Exit fullscreen mode

From systemd.network(5) [BridgeVLAN]:

  • VLAN= — allowed VLAN ID or range M-N (1...4094); repeatable
  • EgressUntagged= — egress without tag; also enables the ID for ingress
  • PVID= — VLAN for ingress untagged frames; also enables the ID
  • VLAN filtering must be on the bridge master
  • IDs not listed in the matching .network are removed from the interface when networkd manages them

3) Configure the bridge device itself

The bridge master also needs VLAN membership for the VLANs it should bridge — and for any local SVIs.

/etc/systemd/network/30-br0.network:

[Match]
Name=br0

[Link]
RequiredForOnline=no

[Network]
# Optional: create host SVIs declared as .netdev units
VLAN=br0.10
VLAN=br0.20
# Usually leave LinkLocalAddressing alone unless you need IPv6 LL on br0
ConfigureWithoutCarrier=yes

[BridgeVLAN]
# Bridge master must list the VLANs present on the switch fabric
VLAN=10
VLAN=20
Enter fullscreen mode Exit fullscreen mode

4) Optional host SVIs (Layer 3 on selected VLANs)

/etc/systemd/network/40-br0.10.netdev:

[NetDev]
Name=br0.10
Kind=vlan

[VLAN]
Id=10
Enter fullscreen mode Exit fullscreen mode

/etc/systemd/network/40-br0.20.netdev:

[NetDev]
Name=br0.20
Kind=vlan

[VLAN]
Id=20
Enter fullscreen mode Exit fullscreen mode

/etc/systemd/network/50-br0.10.network:

[Match]
Name=br0.10

[Network]
Address=10.10.10.1/24
# DHCP=no is implicit when you set Address=
Enter fullscreen mode Exit fullscreen mode

/etc/systemd/network/50-br0.20.network:

[Match]
Name=br0.20

[Network]
Address=10.10.20.1/24
Enter fullscreen mode Exit fullscreen mode

This is the “router-on-a-stick / SVI” pattern on one box: L2 isolation on the bridge, L3 only where you intentionally address a VLAN interface. Forwarding between SVIs still requires IPv4Forwarding=yes (or sysctl) and firewall policy — VLAN filtering alone does not replace a firewall.

5) Apply

sudo networkctl reload
# If netdevs were newly added or Kind options like VLANFiltering changed
# after the device already existed, a restart is safer:
sudo systemctl restart systemd-networkd.service

networkctl status br0 eth0 veth-a veth-b br0.10 br0.20
Enter fullscreen mode Exit fullscreen mode

systemd.netdev(5) notes that several settings cannot be changed in place on an existing netdev; removing the device or restarting networkd may be required when altering bridge VLAN mode.

Lab ports without spare NICs

If you do not have extra physical NICs, create veth pairs for access ports (document-only lab pattern):

sudo ip link add veth-a type veth peer name veth-a-peer
sudo ip link add veth-b type veth peer name veth-b-peer
sudo ip link set veth-a up
sudo ip link set veth-b up
sudo ip link set veth-a-peer up
sudo ip link set veth-b-peer up

# Address the *peer* side as if it were a VM NIC
sudo ip addr add 10.10.10.10/24 dev veth-a-peer
sudo ip addr add 10.10.20.10/24 dev veth-b-peer
Enter fullscreen mode Exit fullscreen mode

Let networkd enslave veth-a / veth-b via the .network files above. The peer ends stay outside the bridge and act as endpoints.

Verification

Bridge flags and members

ip -d link show br0
bridge link show
Enter fullscreen mode Exit fullscreen mode

Confirm members list master br0 and the bridge shows VLAN filtering enabled in the detailed link output.

Per-port VLAN table

bridge vlan show
Enter fullscreen mode Exit fullscreen mode

You want something conceptually like:

port              vlan-id  
eth0              10
                  20
veth-a            10 PVID Egress Untagged
veth-b            20 PVID Egress Untagged
br0               10
                  20
Enter fullscreen mode Exit fullscreen mode

Exact formatting varies by iproute2 version; the important signals are PVID and Egress Untagged on access ports, and no accidental shared PVID across tenants.

FDB (MAC learning)

bridge fdb show br br0
Enter fullscreen mode Exit fullscreen mode

Learned entries should appear on the correct port after traffic. This is L2 reachability evidence, not a security audit by itself.

Isolation check (the point of the article)

From the VLAN 10 endpoint:

# Should work if br0.10 is 10.10.10.1/24
ping -c 3 10.10.10.1

# Should fail (no L2 path into VLAN 20 from an access-10 port)
ping -c 3 10.10.20.1
ping -c 3 10.10.20.10
Enter fullscreen mode Exit fullscreen mode

From the VLAN 20 endpoint, reverse the expectations.

Optional packet view on the trunk:

sudo tcpdump -ni eth0 -e vlan
Enter fullscreen mode Exit fullscreen mode

Access-port traffic should appear tagged on the trunk with the correct VLAN ID.

Negative test worth doing once

Temporarily break isolation on purpose in a lab (not production): set both access ports to the same PVID/EgressUntagged and re-test. When pings suddenly work “across VLANs,” you have demonstrated that membership — not hope — is the control plane.

Operational pitfalls

  1. Forgetting VLAN filtering

    [BridgeVLAN] without VLANFiltering=yes on the bridge does not give you a VLAN-aware switch.

  2. Configuring VLANs only on ports, not on br0

    systemd-networkd requires the bridge master and enslaved devices to share the relevant VLAN IDs.

  3. Putting DHCP/IP on member NICs

    Members should be pure L2. Addresses go on SVIs or, for a single untagged LAN, on br0 itself.

  4. Assuming L2 isolation equals security policy

    Host SVIs can still route between VLANs if IP forwarding and firewall rules allow it. Pair bridge VLANs with nftables/iptables policy.

  5. STP forward delay surprises

    With STP enabled, new ports can sit in listening/learning before forwarding. Labs often set STP=no; multi-switch fabrics should keep STP (or move to a proper control plane) and accept the delay.

  6. MAC address / hosting filters

    Some providers filter by the physical NIC MAC. Cloning the uplink MAC onto br0 (or using MACAddress=none + MACAddressPolicy=none patterns from the ArchWiki bridge notes) may be required when bridging the primary NIC on a hosted server.

  7. Wireless clients as bridge members

    Client-mode Wi-Fi usually cannot be bridged like Ethernet. That is a 802.11 limitation, not a networkd bug.

  8. Fighting other network managers

    Only one manager should own these interfaces. Disable conflicting NetworkManager/dhcpcd claims on the same NICs.

Minimal nftables reminder (not a full firewall guide)

If the host is a gateway between SVIs:

# Example only — adapt to your policy framework
sudo sysctl -w net.ipv4.ip_forward=1
# Prefer persistent sysctl.d or IPv4Forwarding= in .network where appropriate
Enter fullscreen mode Exit fullscreen mode

Then allow only the flows you intend between br0.10 and br0.20. VLAN filtering stops accidental L2 mixing; it does not invent L3 policy.

Rollback

# Remove or move aside the units you added
sudo rm -f /etc/systemd/network/10-br0.netdev \
           /etc/systemd/network/20-eth0.network \
           /etc/systemd/network/20-veth-a.network \
           /etc/systemd/network/20-veth-b.network \
           /etc/systemd/network/30-br0.network \
           /etc/systemd/network/40-br0.*.netdev \
           /etc/systemd/network/50-br0.*.network

sudo systemctl restart systemd-networkd.service

# Lab-only cleanup
sudo ip link del veth-a 2>/dev/null || true
sudo ip link del veth-b 2>/dev/null || true
sudo ip link del br0 2>/dev/null || true
Enter fullscreen mode Exit fullscreen mode

Restore the previous .network that addressed eth0 directly before you leave the window.

How this differs from nearby tools

Approach Layer Isolates Typical use
VLAN-aware bridge L2 Broadcast domains / 802.1Q membership Hypervisor uplink, lab switch-in-a-box
Stacked eth0.10 only L2 endpoint Host sees one VLAN each Simple host-on-trunk, no multi-port switch
Bonding/LACP L2 path Nothing by itself NIC/cable redundancy under a bridge or VLAN
VRF L3 Routing tables / defaults Multi-tenant gateways on one host
Network namespaces L2+L3+sockets Whole network stack instances Strong process isolation, containers
keepalived/VRRP L3 VIP ownership Failover, not VLAN separation HA default gateway

Use the VLAN-aware bridge when you need one Linux box to behave like a small 802.1Q switch. Use VRF when the problem is which routing table owns the default route. Use both when a hypervisor is a tagged switch and a multi-table router.

Quick checklist

  • [ ] Kind=bridge with VLANFiltering=yes
  • [ ] Trunk port: Bridge= + VLAN= IDs, no accidental shared PVID
  • [ ] Access ports: matching VLAN= + PVID= + EgressUntagged=
  • [ ] Bridge master [BridgeVLAN] lists the same fabric VLANs
  • [ ] Host IPs only on SVIs (or carefully on br0 for a single LAN)
  • [ ] bridge vlan show matches the design
  • [ ] Cross-VLAN ping fails without intentional L3 forwarding
  • [ ] Firewall policy written if SVIs route

References


Build the bridge like a switch: filtering on, membership explicit, trunks tagged, access ports untagged on purpose. Anything less is just a multiport hub with better marketing.

Top comments (0)