TTR Networks

Beginner · Building a network lab on an Apple Silicon Mac · section 5 of 8

x86 virtual machines with QEMU

✓ Lab-tested 18 October 2026 on macOS 26.5 on Apple M4 Max, Docker Desktop (Linux 7.0.14-linuxkit, arm64), VyOS 2026.10.16-0024-rolling, FRR 10.2.1

A full x86 virtual machine is the only reliable way to run an x86-only network OS like VyOS on an ARM Mac. These labs run each VM with QEMU inside a Docker container, so the VM joins the lab's Docker networks like any other node. QEMU itself is a native arm64 program; it emulates an entire x86 PC in software using its Tiny Code Generator (TCG), translating the guest's instructions as they run.

The pieces

  • The ISO: get-vyos.sh downloads a VyOS rolling ISO and extracts the kernel and initrd, so QEMU can boot it directly as a live system (nothing to install, nothing to save).
  • The container image: an arm64 Debian with qemu-system-x86, iproute2, python3 and socat.
  • start-vyos.sh: the container's start-up script. For every Docker network interface (eth0, eth1, …) it creates a Linux bridge and a tap device, joins them, then boots VyOS with one virtual NIC per tap. VyOS's eth0 ends up on the container's eth0's network, eth1 on eth1's, and so on.
  • The console driver: VyOS's serial console is a Unix socket in the container. vyos-console.py logs in and runs commands through it, so the VM can be scripted with docker compose exec.

What to expect

Emulation is slow, so plan for it. On this M4 Max, the VM took about 4 minutes from docker compose up to the login prompt, and a configuration session of six set lines and a commit took 30 seconds. Native FRR does the same in well under a second.

Once it's up, it's a completely real x86 VyOS: show version reports x86_64, System type: QEMU guest, and the hardware UUID the start script gave it.

Three settings in start-vyos.sh came from problems in these labs:

  • One vCPU (VYOS_SMP=1). Multi-vCPU TCG guests occasionally locked up; one emulated CPU is slower but reliable.
  • A unique VM_ID per VM. Each VM's NIC n gets MAC address 52:54:00:<VM_ID>:0n:01. Without it, two VMs on the same network get identical MACs and their links break in confusing ways.
  • A fixed SMBIOS UUID (-uuid, also derived from VM_ID). VyOS derives its DHCPv6 client identifier (DUID) from it; without one, DHCPv6 fails.
Dockerfile.vyos
# A Debian (arm64) container whose only job is to run one VyOS VM.
# qemu-system-x86_64 runs natively on the Mac's CPU and emulates an x86 PC (TCG).
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
      qemu-system-x86 iproute2 python3 socat \
 && rm -rf /var/lib/apt/lists/*
COPY start-vyos.sh /usr/local/bin/start-vyos.sh
CMD ["/usr/local/bin/start-vyos.sh"]
  • Line 3: A native arm64 Debian, so QEMU itself runs at full speed; only the guest is emulated.
  • Lines 4–6: QEMU's x86 system emulator, plus the tools the start script and console driver need.
start-vyos.sh
#!/bin/sh
# Bridge every container interface (eth0, eth1, …) to a tap, then boot VyOS with one virtio NIC per tap.
# Each VM gets a fixed SMBIOS UUID: VyOS derives its DHCPv6 DUID from it, and without one DHCPv6 clients fail.
# VM NIC n gets MAC 52:54:00:<VM_ID>:0n:01, so VyOS's ethN matches the container's ethN. Give each VM its own VM_ID.
set -e
NETDEVS=""
n=0
for ifc in $(ls /sys/class/net | grep -E '^eth[0-9]+$' | sort -V); do
  ip link add "br$n" type bridge
  ip link set "$ifc" master "br$n"
  ip tuntap add "tap$n" mode tap
  ip link set "tap$n" master "br$n"
  ip link set "tap$n" up
  ip link set "br$n" up
  # Pin the VM's MAC to its tap and stop learning on the Docker side: Docker reflects the VM's
  # broadcasts back, which would otherwise teach the bridge the VM's MAC on the wrong port.
  bridge link set dev "$ifc" learning off
  bridge fdb replace "52:54:00:${VM_ID:-00}:0$n:01" dev "tap$n" master static
  NETDEVS="$NETDEVS -netdev tap,id=n$n,ifname=tap$n,script=no,downscript=no -device virtio-net-pci,netdev=n$n,mac=52:54:00:${VM_ID:-00}:0$n:01"
  n=$((n + 1))
done
exec qemu-system-x86_64 -m "${VYOS_MEM:-2048}" -smp "${VYOS_SMP:-2}" -cpu max -accel tcg,thread=multi \
  -uuid "00000000-0000-4000-8000-0000000000${VM_ID:-00}" \
  -kernel /vyos/vmlinuz -initrd /vyos/initrd.img \
  -append "boot=live components hostname=vyos username=live nopersistence noautologin nonetworking union=overlay console=ttyS0,115200 net.ifnames=0 biosdevname=0" \
  -cdrom /vyos/vyos.iso $NETDEVS \
  -display none -serial unix:/run/console.sock,server=on,wait=off -monitor none
  • Lines 8–14: For each Docker interface: a bridge, a tap, and both joined. The VM's NIC n will sit on the container's eth n network.
  • Lines 15–18: Stop the bridge learning MAC addresses from Docker's side, and pin the VM's MAC to its tap: Docker reflects the VM's broadcasts back and would otherwise confuse the bridge.
  • Line 19: One virtio NIC per tap, with a MAC that includes the VM_ID, so every VM in a lab is unique.
  • Line 22: -accel tcg: software emulation of an x86 CPU. VYOS_SMP defaults to 2 here, but the labs set it to 1 for stability.
  • Line 23: A fixed SMBIOS UUID per VM: VyOS derives its DHCPv6 DUID from it.
  • Lines 24–26: Boot the ISO's kernel directly, as a live system: nothing installed, nothing saved.
  • Line 27: The serial console becomes a Unix socket, which is what vyos-console.py and socat connect to.
Inside the VM's container: the plumbing (real output)
mac$ docker compose -f lab-compose.yaml exec -T vyos ip -br link
lo               UNKNOWN        00:00:00:00:00:00 <LOOPBACK,UP,LOWER_UP> 
tunl0@NONE       DOWN           0.0.0.0 <NOARP> 
gre0@NONE        DOWN           0.0.0.0 <NOARP> 
gretap0@NONE     DOWN           00:00:00:00:00:00 <BROADCAST,MULTICAST> 
erspan0@NONE     DOWN           00:00:00:00:00:00 <BROADCAST,MULTICAST> 
ip_vti0@NONE     DOWN           0.0.0.0 <NOARP> 
ip6_vti0@NONE    DOWN           :: <NOARP> 
sit0@NONE        DOWN           0.0.0.0 <NOARP> 
ip6tnl0@NONE     DOWN           :: <NOARP> 
ip6gre0@NONE     DOWN           :: <NOARP> 
eth1@if1335      UP             a2:39:40:f9:ef:00 <BROADCAST,MULTICAST,UP,LOWER_UP> 
eth0@if1336      UP             b6:2a:57:65:ac:a9 <BROADCAST,MULTICAST,UP,LOWER_UP> 
br0              UP             46:70:03:c8:97:38 <BROADCAST,MULTICAST,UP,LOWER_UP> 
tap0             UP             46:70:03:c8:97:38 <BROADCAST,MULTICAST,UP,LOWER_UP> 
br1              UP             a2:39:40:f9:ef:00 <BROADCAST,MULTICAST,UP,LOWER_UP> 
tap1             UP             de:4a:55:14:97:e0 <BROADCAST,MULTICAST,UP,LOWER_UP> 

mac$ docker compose -f lab-compose.yaml exec -T vyos bridge link
11: eth1@if1335: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br1 state forwarding priority 32 cost 2 
12: eth0@if1336: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br0 state forwarding priority 32 cost 2 
14: tap0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br0 state forwarding priority 32 cost 2 
16: tap1: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 master br1 state forwarding priority 32 cost 2 
  • Lines 12–13: The container's two Docker interfaces: eth0 (core) and eth1 (lan).
  • Lines 14–17: A bridge and a tap for each.
  • Lines 20–23: eth0 and tap0 share br0, eth1 and tap1 share br1: each bridge joins one Docker network to one VM NIC, never two networks together.
Waiting for the first boot (real output)
mac$ time docker compose -f lab-compose.yaml exec -T vyos vyos-console.py wait 900
ready: vyos login:

real	4m2.594s
user	0m0.045s
sys	0m0.026s
  • Line 2: The console driver saw the login prompt…
  • Line 4: …about 4 minutes after starting.
A real x86 VyOS on an ARM Mac (real output)
mac$ docker compose -f lab-compose.yaml exec -T vyos vyos-console.py op 'show version'
show version
Version:          VyOS 2026.10.16-0024-rolling
Release train:    rolling
Release flavor:   generic

Built by:         autobuild@vyos.net
Built on:         Fri 16 Oct 2026 00:25 UTC
Build UUID:       ae7b6104-8d03-4b05-952b-abd3c725e52b
Build commit ID:  

Architecture:     x86_64
Boot via:         livecd
System type:      QEMU guest
Secure Boot:      n/a (BIOS)

Hardware vendor:  QEMU
Hardware model:   Standard PC (i440FX + PIIX, 1996)
Hardware S/N:     
Hardware UUID:    00000000-0000-4000-8000-000000000001

Copyright:        VyOS maintainers and contributors
vyos@vyos:~$ 
  • Line 12: The guest is x86_64…
  • Line 14: …running as a QEMU guest…
  • Line 20: …with the UUID that start-vyos.sh derived from VM_ID=01.

Check your understanding

You add a second VyOS VM to a lab by copying the first one's compose service and changing only its networks. Both boot, but on the network they share, neither can reach the other. What did you forget?

  • A different VM_ID, so the VMs got identical MAC addresses.
  • More vCPUs for the second VM.
  • Rosetta.
  • A layer-2-only network.