TTR Networks

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

Build it, step by step

✓ 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

This is the whole process from a fresh Mac to the starter lab running, in order. Allow 15 minutes, most of it downloading the VyOS ISO and waiting for the first boot.

  1. Install Docker Desktop for Mac with Apple silicon. In its settings, give it at least 8 GB of memory (Resources), and turn on Rosetta for x86_64/amd64 emulation (General) if it isn't already.
  2. Check it with the commands from Check your Mac: the server should say arm64, and an --platform linux/amd64 container should print x86_64.
  3. Put the lab files in one directory: lab-compose.yaml, Dockerfile.vyos, start-vyos.sh, frr.conf, frr-daemons, vtysh.conf, get-vyos.sh and vyos-console.py, all from this lesson. Make the two scripts executable: chmod +x start-vyos.sh get-vyos.sh vyos-console.py.
  4. Fetch VyOS (about 630 MB): ./get-vyos.sh. It leaves vyos.iso, vmlinuz and initrd.img in ./vyos. macOS's tar can read the ISO directly.
  5. Start the lab: docker compose -f lab-compose.yaml up -d --build. The first build downloads Debian and QEMU; later starts take seconds.
  6. Wait for VyOS: docker compose -f lab-compose.yaml exec vyos vyos-console.py wait 900, then … vyos-console.py login. Expect about 4 minutes.
  7. Configure VyOS with the six set lines from the starter lab, through vyos-console.py conf '…' '…', or interactively on the console: docker compose -f lab-compose.yaml exec -it vyos socat -,raw,echo=0,escape=0x1d UNIX-CONNECT:/run/console.sock (Ctrl-] to leave).
  8. Check it: show ip bgp summary on VyOS, vtysh -c 'show bgp ipv4 unicast' on FRR, and a ping from the host to 198.51.100.1.
  9. Stop it when you're done: docker compose -f lab-compose.yaml down. VyOS boots from the ISO each time, so it starts fresh; keep your set commands in a file and re-apply them.

From here, every lab on this site is a variation: more FRR containers, more VyOS VMs (each with its own VM_ID), more Linux hosts, more layer-2-only networks.

get-vyos.sh
# Download the VyOS rolling ISO used in this lesson and extract its kernel and initrd.
mkdir -p vyos && cd vyos
curl -LO https://github.com/vyos/vyos-nightly-build/releases/download/2026.10.16-0024-rolling/vyos-2026.10.16-0024-rolling-generic-amd64.iso
mv vyos-*-amd64.iso vyos.iso
tar -xf vyos.iso live/vmlinuz live/initrd.img   # bsdtar (macOS) reads ISOs; on Linux use 7z or bsdtar
mv live/vmlinuz live/initrd.img . && rmdir live
frr-daemons
bgpd=yes
ospfd=no
ospf6d=no
ripd=no
ripngd=no
isisd=no
pimd=no
ldpd=no
nhrpd=no
eigrpd=no
babeld=no
sharpd=no
pbrd=no
bfdd=no
fabricd=no
vrrpd=no
pathd=no
staticd=yes
vtysh_enable=yes
zebra_options="  -A 127.0.0.1 -s 90000000"
bgpd_options="   -A 127.0.0.1"
staticd_options="-A 127.0.0.1"
vtysh.conf
service integrated-vtysh-config
vyos-console.py (the console driver)
#!/usr/bin/env python3
"""Drive the VyOS VM's serial console (QEMU unix socket) and print a clean transcript.

  vyos-console.py wait [secs]            wait for the login prompt
  vyos-console.py login                  log in as vyos/vyos
  vyos-console.py op 'show version' ...  run operational commands
  vyos-console.py conf 'set ...' ...     configure, run set/delete lines, commit, exit
  vyos-console.py capture SECS 'cmd'     run a command that needs Ctrl-C (monitor traffic), stop after SECS
"""
import os, re, socket, sys, time

SOCK = os.environ.get('VYOS_CONSOLE', '/run/console.sock')
ANSI = re.compile(r'\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b[=>()][0-9A-Za-z]?|\r|\x07|\x08')
OP_PROMPT = r'vyos@[\w-]+:~\$ $'
CONF_PROMPT = r'vyos@[\w-]+# $'

def connect():
    s = socket.socket(socket.AF_UNIX)
    s.connect(SOCK)
    s.settimeout(0.3)
    return s

def read_until(s, pattern, timeout):
    buf, end = '', time.time() + timeout
    while time.time() < end:
        try:
            data = s.recv(65536)
        except socket.timeout:
            data = b''
        if data:
            buf += data.decode('utf-8', 'replace')
            if re.search(pattern, ANSI.sub('', buf)):
                return ANSI.sub('', buf), True
    return ANSI.sub('', buf), False

def send(s, line):
    s.sendall(line.encode() + b'\n')

def run(s, cmd, prompt, timeout=300):
    send(s, cmd)
    out, ok = read_until(s, prompt, timeout)
    if not ok:
        sys.stderr.write(f'!! timeout waiting for prompt after: {cmd}\n')
    return out

def main():
    mode, args = sys.argv[1], sys.argv[2:]
    s = connect()
    if mode == 'wait':
        end = time.time() + float(args[0] if args else 900)
        while time.time() < end:
            send(s, '')
            out, ok = read_until(s, r'login: $|\$ $|# $', 10)
            if ok:
                print('ready:', out.strip().splitlines()[-1])
                return
        sys.exit('timeout waiting for login prompt')
    if mode == 'login':
        send(s, '')
        out, _ = read_until(s, r'login: $|\$ $', 30)
        if out.rstrip().endswith('$'):
            print('already logged in'); return
        send(s, 'vyos'); read_until(s, r'Password: $', 60)
        send(s, 'vyos'); out, ok = read_until(s, OP_PROMPT, 300)
        print('logged in' if ok else 'login failed:\n' + out[-500:])
        for c in ('set terminal length 0', 'set terminal width 200'):
            run(s, c, OP_PROMPT, 60)
        return
    if mode == 'op':
        send(s, ''); read_until(s, OP_PROMPT, 30)
        for cmd in args:
            sys.stdout.write(run(s, cmd, OP_PROMPT))
        print()
        return
    if mode == 'conf':
        send(s, ''); read_until(s, OP_PROMPT, 30)
        transcript = run(s, 'configure', CONF_PROMPT, 120)
        for cmd in args:
            transcript += run(s, cmd, CONF_PROMPT, 120)
        transcript += run(s, 'commit', CONF_PROMPT, 900)
        transcript += run(s, 'exit', OP_PROMPT, 120)
        sys.stdout.write(transcript)
        print()
        return
    if mode == 'capture':
        secs, cmd = float(args[0]), args[1]
        send(s, ''); read_until(s, OP_PROMPT, 30)
        send(s, cmd)
        out, _ = read_until(s, r'(?!)', secs)  # collect output for SECS
        s.sendall(b'\x03')
        rest, _ = read_until(s, OP_PROMPT, 60)
        sys.stdout.write(out + rest)
        print()
        return
    sys.exit(__doc__)

main()

Where next

  • Switching basics: layer 2, VLANs and IP addressingAlso in Foundations · Beginner · 40 min

    How a switch learns MAC addresses and floods broadcasts, how IP subnets and default gateways decide where packets go, how VLANs split a switch into separate networks, and how trunks and a router connect them. Built on a real switch, three PCs and a VyOS router.

  • Why not just keep it simple?Also in Foundations · Beginner · 25 min

    Why networks use routing protocols, BGP and redundant links instead of a few static routes, when they shouldn't, and what the extra complexity buys. With a real experiment: one quiet link failure, three designs, measured.

  • Linux Network ConnectivityAlso in Foundations · Beginner · 80 min

    Find out why a Linux machine can't reach something, layer by layer: interfaces and addresses, routes and ip route get, ARP and the neighbour table, ping and traceroute, DNS, and listening sockets — with real output from a three-host lab.

  • Setting up VyOS with NATAlso on VyOS · Beginner · 45 min

    A VyOS router with a NAT interface for a private LAN and a public interface for a routed public subnet: plain routing, source NAT, port forwarding and the forward firewall that NAT isn't. Every output comes from a real VyOS lab.