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=, andPVID= - Optionally attach host SVIs (
Kind=vlanon 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
Important kernel/userspace facts:
- Without VLAN filtering, the bridge largely ignores 802.1Q membership as a security boundary.
- With
VLANFiltering=yes, each port only admits the VLAN IDs you assign. - PVID is the VLAN assigned to ingress untagged frames on that port.
- Egress untagged means frames for that VLAN leave the port without a tag (classic access-port behavior).
- 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]. - 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(bridgeandipcommands) -
systemd-networkdmanaging 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
From systemd.netdev(5):
-
VLANFiltering=maps to the kernelIFLA_BR_VLAN_FILTERINGoption -
DefaultPVID=sets the default port VLAN ID for newly attached ports (1...4094ornone) -
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
/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
/etc/systemd/network/20-veth-b.network (access VLAN 20):
[Match]
Name=veth-b
[Network]
Bridge=br0
[BridgeVLAN]
VLAN=20
EgressUntagged=20
PVID=20
From systemd.network(5) [BridgeVLAN]:
-
VLAN=— allowed VLAN ID or rangeM-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
.networkare 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
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
/etc/systemd/network/40-br0.20.netdev:
[NetDev]
Name=br0.20
Kind=vlan
[VLAN]
Id=20
/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=
/etc/systemd/network/50-br0.20.network:
[Match]
Name=br0.20
[Network]
Address=10.10.20.1/24
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
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
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
Confirm members list master br0 and the bridge shows VLAN filtering enabled in the detailed link output.
Per-port VLAN table
bridge vlan show
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
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
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
From the VLAN 20 endpoint, reverse the expectations.
Optional packet view on the trunk:
sudo tcpdump -ni eth0 -e vlan
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
Forgetting VLAN filtering
[BridgeVLAN]withoutVLANFiltering=yeson the bridge does not give you a VLAN-aware switch.Configuring VLANs only on ports, not on
br0
systemd-networkd requires the bridge master and enslaved devices to share the relevant VLAN IDs.Putting DHCP/IP on member NICs
Members should be pure L2. Addresses go on SVIs or, for a single untagged LAN, onbr0itself.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.STP forward delay surprises
With STP enabled, new ports can sit in listening/learning before forwarding. Labs often setSTP=no; multi-switch fabrics should keep STP (or move to a proper control plane) and accept the delay.MAC address / hosting filters
Some providers filter by the physical NIC MAC. Cloning the uplink MAC ontobr0(or usingMACAddress=none+MACAddressPolicy=nonepatterns from the ArchWiki bridge notes) may be required when bridging the primary NIC on a hosted server.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.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
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
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=bridgewithVLANFiltering=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
br0for a single LAN) - [ ]
bridge vlan showmatches the design - [ ] Cross-VLAN ping fails without intentional L3 forwarding
- [ ] Firewall policy written if SVIs route
References
-
systemd.netdev(5) —
Kind=bridge,[Bridge](VLANFiltering=,DefaultPVID=,STP=,VLANProtocol=) -
systemd.network(5) —
Bridge=,[BridgeVLAN](VLAN=,EgressUntagged=,PVID=) -
bridge(8) —
bridge vlan,bridge link,bridge fdb - ArchWiki: systemd-networkd — Bridge interface
- ArchWiki: Network bridge
- ArchWiki: VLAN — stacked VLAN netdevs vs bridge membership
-
Kernel switchdev overview — notes enabling VLAN filtering on bridges (
vlan_filtering)
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)