Skip to content

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.

Published on 6 min read

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/netshoot image, which ships ip, bridge, tcpdump and traceroute.
  • 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 missing pvid, 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_forward on 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.

Related articles

Build an eBGP lab with three FRR routers in Docker, one per AS. Bring up sessions, exchange prefixes, read the AS path and filter a route.
Set up an isolated security lab with DVWA as a target, an attacker box and a packet-capture sensor in Docker. Tested, with legal and isolation rules.
A three-zone DMZ lab in Docker: an nftables firewall between LAN, DMZ and outside, with DNAT to a web server, tested with real connection attempts.