VLAN and 802.1Q trunk lab on Linux with Docker
Build a VLAN lab on Linux: a VLAN-aware bridge as the switch, an 802.1Q trunk, router-on-a-stick between two VLANs, all in Docker containers.
This tutorial builds a small VLAN lab on Linux with Docker: one Linux bridge acting as a VLAN-aware switch, three PCs on access ports, and a router on an 802.1Q trunk doing inter-VLAN routing. You will check that the VLANs are isolated, route between them, and see the tags on the wire with tcpdump. Every command and output below comes from a run on Docker 29 (Docker Desktop on macOS).
VLANs and trunks in two minutes
A VLAN splits one physical switch into several virtual ones. Each VLAN is its own broadcast domain: an ARP request or DHCP discover sent in VLAN 10 never reaches a port in VLAN 20. The IEEE 802.1Q standard adds a 4-byte tag to the Ethernet header that carries a 12-bit VLAN ID, so VLAN IDs run from 1 to 4094.
Switch ports come in two kinds:
- Access port: belongs to one VLAN. Frames go in and out without a tag, so the host behind it knows nothing about VLANs.
- Trunk port: carries several VLANs on one cable. Each frame keeps its tag so the far end knows which VLAN it belongs to.
To move traffic between VLANs you need a router. With router-on-a-stick, the router has one trunk to the switch and one subinterface per VLAN, each with the gateway address of that VLAN. On Linux, a bridge with vlan_filtering enabled behaves like a VLAN switch, and ip link ... type vlan creates the router's subinterfaces. That is all this lab uses.
What you'll build
VLAN 10 VLAN 20 VLAN 10
pc1 10.0.10.11 pc2 10.0.20.11 pc3 10.0.10.12
| eth0 | eth0 | eth0
| | |
eth1 eth2 eth3 access ports
+---------------------------------------------+
| sw: br0 (Linux bridge, vlan_filtering 1) |
+---------------------------------------------+
eth4 trunk: 10, 20 tagged
|
eth0
router: eth0.10 10.0.10.1/24
eth0.20 10.0.20.1/24
PC1 and PC3 share VLAN 10. PC2 is alone in VLAN 20. The router has one cable to the switch and two subinterfaces on it: the classic router-on-a-stick design.
What you need
- Docker Engine on Linux, or Docker Desktop on macOS or Windows.
- The
nicolaka/netshootimage, which shipsip,bridge,tcpdumpandtraceroute. - Basic comfort with IP addresses and subnets.
Step 1: start the containers
Every node runs with --network none. Docker gives it only a loopback interface, and you add the cables yourself. NET_ADMIN lets each container configure its own interfaces. The router also needs IP forwarding, which you set with --sysctl at start.
for n in sw pc1 pc2 pc3; do
docker run -d --name blogtest-vlan-$n --hostname $n --network none \
--cap-add NET_ADMIN nicolaka/netshoot sleep infinity
done
docker run -d --name blogtest-vlan-router --hostname router --network none \
--cap-add NET_ADMIN --sysctl net.ipv4.ip_forward=1 \
nicolaka/netshoot sleep infinity
Step 2: cable the lab with veth pairs
A veth pair is a virtual cable: what goes in one end comes out the other. This shell function creates a pair and puts each end straight into a container's network namespace. It runs ip in a short-lived privileged container that sees the host's process IDs, so it works the same on Linux and on Docker Desktop.
# link A IFA B IFB: a cable from container A (interface IFA) to B (IFB)
link() {
pa=$(docker inspect -f '{{.State.Pid}}' blogtest-vlan-$1)
pb=$(docker inspect -f '{{.State.Pid}}' blogtest-vlan-$3)
docker run --rm --privileged --pid host --network host nicolaka/netshoot \
ip link add "$2" netns "$pa" type veth peer name "$4" netns "$pb"
docker exec blogtest-vlan-$1 ip link set "$2" up
docker exec blogtest-vlan-$3 ip link set "$4" up
}
link sw eth1 pc1 eth0
link sw eth2 pc2 eth0
link sw eth3 pc3 eth0
link sw eth4 router eth0
docker exec blogtest-vlan-sw ip -br link show type veth
eth1@if11 UP 82:78:f7:00:24:2a <BROADCAST,MULTICAST,UP,LOWER_UP>
eth2@if11 UP f6:e6:81:85:1f:dd <BROADCAST,MULTICAST,UP,LOWER_UP>
eth3@if11 UP 72:e6:87:da:2c:36 <BROADCAST,MULTICAST,UP,LOWER_UP>
eth4@if11 UP 9e:17:c2:9f:ec:3a <BROADCAST,MULTICAST,UP,LOWER_UP>
Why not Docker networks? Each Docker network is a bridge of its own on the host, and Docker applies its filtering and NAT rules to bridged traffic. When I first built this lab that way on Docker Desktop, broadcasts were reflected back into the switch and caused a storm, and Docker's rules dropped the bridged IP traffic. Plain veth pairs pass frames, tags included, untouched. Lab tools such as containerlab wire containers the same way.
Step 3: turn the switch into a VLAN switch
Create a bridge with VLAN filtering, add the four ports, then set the VLANs per port. Every port starts as an untagged member of VLAN 1, so remove that first. In bridge vlan, pvid means untagged frames arriving on the port join this VLAN, and untagged means frames leave the port without a tag (bridge(8)).
docker exec -i blogtest-vlan-sw sh <<'EOF'
ip link add br0 type bridge vlan_filtering 1
for i in eth1 eth2 eth3 eth4; do ip link set $i master br0; done
ip link set br0 up
# access ports: untagged, one VLAN each
bridge vlan del dev eth1 vid 1
bridge vlan add dev eth1 vid 10 pvid untagged
bridge vlan del dev eth2 vid 1
bridge vlan add dev eth2 vid 20 pvid untagged
bridge vlan del dev eth3 vid 1
bridge vlan add dev eth3 vid 10 pvid untagged
# trunk port: VLANs 10 and 20, tagged
bridge vlan del dev eth4 vid 1
bridge vlan add dev eth4 vid 10
bridge vlan add dev eth4 vid 20
EOF
docker exec blogtest-vlan-sw bridge vlan show
port vlan-id
eth1 10 PVID Egress Untagged
eth2 20 PVID Egress Untagged
eth3 10 PVID Egress Untagged
eth4 10
20
br0 1 PVID Egress Untagged
The trunk eth4 carries 10 and 20 without the Egress Untagged flag, so frames leave it tagged.
Step 4: address the PCs and check isolation
docker exec blogtest-vlan-pc1 ip addr add 10.0.10.11/24 dev eth0
docker exec blogtest-vlan-pc3 ip addr add 10.0.10.12/24 dev eth0
docker exec blogtest-vlan-pc2 ip addr add 10.0.20.11/24 dev eth0
docker exec blogtest-vlan-pc1 ping -c 3 10.0.10.12
64 bytes from 10.0.10.12: icmp_seq=1 ttl=64 time=0.068 ms
64 bytes from 10.0.10.12: icmp_seq=2 ttl=64 time=0.049 ms
64 bytes from 10.0.10.12: icmp_seq=3 ttl=64 time=0.038 ms
3 packets transmitted, 3 received, 0% packet loss, time 2081ms
PC1 and PC3 share VLAN 10, so they talk directly. Now prove that the isolation happens at layer 2 and not only because the subnets differ. Give PC2 a second address in VLAN 10's subnet and try again:
docker exec blogtest-vlan-pc2 ip addr add 10.0.10.99/24 dev eth0
docker exec blogtest-vlan-pc1 ping -c 3 -W 1 10.0.10.99
docker exec blogtest-vlan-pc1 ip neigh show 10.0.10.99
docker exec blogtest-vlan-pc2 ip addr del 10.0.10.99/24 dev eth0
3 packets transmitted, 0 received, 100% packet loss, time 2075ms
10.0.10.99 dev eth0 FAILED
Same subnet, no reply. PC1's ARP request never leaves VLAN 10, so the neighbour entry stays FAILED.
Step 5: router-on-a-stick
On the router, create one 802.1Q subinterface per VLAN on top of eth0 (ip-link(8)) and give each the gateway address of its VLAN. Forwarding is already on from step 1.
docker exec -i blogtest-vlan-router sh <<'EOF'
ip link add link eth0 name eth0.10 type vlan id 10
ip link add link eth0 name eth0.20 type vlan id 20
ip addr add 10.0.10.1/24 dev eth0.10
ip addr add 10.0.20.1/24 dev eth0.20
ip link set eth0.10 up
ip link set eth0.20 up
EOF
docker exec blogtest-vlan-pc1 ip route add default via 10.0.10.1
docker exec blogtest-vlan-pc3 ip route add default via 10.0.10.1
docker exec blogtest-vlan-pc2 ip route add default via 10.0.20.1
docker exec blogtest-vlan-pc1 traceroute -n 10.0.20.11
traceroute to 10.0.20.11 (10.0.20.11), 30 hops max, 46 byte packets
1 10.0.10.1 0.005 ms 0.003 ms 0.005 ms
2 10.0.20.11 0.009 ms 0.004 ms 0.004 ms
PC1 reaches PC2 through the router, one hop away.
Step 6: see the tags on the trunk
Capture on the router's eth0 with -e to print link-layer headers, then ping once from PC1 to PC2 in another terminal:
docker exec blogtest-vlan-router tcpdump -eni eth0 -c 4 icmp
ca:ef:33:fa:9b:75 > 52:1f:b1:23:6c:fa, ethertype 802.1Q (0x8100), length 102: vlan 10, p 0, ethertype IPv4 (0x0800), 10.0.10.11 > 10.0.20.11: ICMP echo request, id 29707, seq 1, length 64
52:1f:b1:23:6c:fa > f6:83:7a:58:6c:f7, ethertype 802.1Q (0x8100), length 102: vlan 20, p 0, ethertype IPv4 (0x0800), 10.0.10.11 > 10.0.20.11: ICMP echo request, id 29707, seq 1, length 64
f6:83:7a:58:6c:f7 > 52:1f:b1:23:6c:fa, ethertype 802.1Q (0x8100), length 102: vlan 20, p 0, ethertype IPv4 (0x0800), 10.0.20.11 > 10.0.10.11: ICMP echo reply, id 29707, seq 1, length 64
52:1f:b1:23:6c:fa > ca:ef:33:fa:9b:75, ethertype 802.1Q (0x8100), length 102: vlan 10, p 0, ethertype IPv4 (0x0800), 10.0.20.11 > 10.0.10.11: ICMP echo reply, id 29707, seq 1, length 64
The same echo request crosses the trunk twice: in on VLAN 10, out on VLAN 20. On PC1's access port the same packet is untagged and 4 bytes shorter:
docker exec blogtest-vlan-pc1 tcpdump -eni eth0 -c 2 icmp
ca:ef:33:fa:9b:75 > 52:1f:b1:23:6c:fa, ethertype IPv4 (0x0800), length 98: 10.0.10.11 > 10.0.20.11: ICMP echo request, id 29708, seq 1, length 64
52:1f:b1:23:6c:fa > ca:ef:33:fa:9b:75, ethertype IPv4 (0x0800), length 98: 10.0.20.11 > 10.0.10.11: ICMP echo reply, id 29708, seq 1, length 64
Those 4 bytes are the 802.1Q tag. The switch's MAC table shows each address learned per VLAN, with the router's MAC on the trunk in both:
docker exec blogtest-vlan-sw bridge fdb show br br0 | grep -v permanent
ca:ef:33:fa:9b:75 dev eth1 vlan 10 master br0
f6:83:7a:58:6c:f7 dev eth2 vlan 20 master br0
be:2c:72:44:a8:94 dev eth3 vlan 10 master br0
52:1f:b1:23:6c:fa dev eth4 vlan 20 master br0
52:1f:b1:23:6c:fa dev eth4 vlan 10 master br0
One more experiment: remove VLAN 20 from the trunk and ping PC2 again.
docker exec blogtest-vlan-sw bridge vlan del dev eth4 vid 20
docker exec blogtest-vlan-pc1 ping -c 2 -W 1 10.0.20.11
2 packets transmitted, 0 received, 100% packet loss, time 1046ms
That is the classic "VLAN not allowed on the trunk" fault. Put it back with bridge vlan add dev eth4 vid 20 and the ping works again.
If something doesn't work
- Same-VLAN ping fails: check
bridge vlan show. A port left in VLAN 1, or a missingpvid, drops untagged frames from the PC. - Inter-VLAN ping fails: check the PC's default route, that both VLANs are on the trunk, and
sysctl net.ipv4.ip_forwardon the router (it must be 1). RTNETLINK answers: Operation not permitted: the container was started without--cap-add NET_ADMIN.- Pings work, then a storm of traffic appears: two cables form a loop. This bridge runs no spanning tree by default, so keep the topology a tree.
Clean up
Removing the containers deletes the veth pairs with them.
docker rm -f blogtest-vlan-sw blogtest-vlan-router \
blogtest-vlan-pc1 blogtest-vlan-pc2 blogtest-vlan-pc3
Going further
- Add a second switch container, cable the two with a trunk, and stretch VLAN 10 across both.
- Replace the router with a firewall. The DMZ lab with nftables uses the same veth cabling.
- Open the trunk in Wireshark to read the 802.1Q header field by field. Capturing a container's traffic shows how to pipe a capture into it.
- Add a native VLAN on the trunk with
bridge vlan add dev eth4 vid 30 pvid untagged, then see what breaks when the two ends disagree.
Draw it in Isothrum
Isothrum's editor is a free browser editor for labs like this one, with no account; its engine runs locally as WebAssembly. It has switches with VLANs and trunks, containers from any Docker image, and FRR or Cisco routers from your own images. You draw the lab, export isoloom.yml plus Docker Compose, Vagrant and other files, and run it with those tools. A desktop app that runs labs locally with consoles and packet capture into Wireshark is coming soon.