A 100-node enterprise network lab running on a single MacBook M-series, with no hypervisor and no dedicated server.
- 40+ Cisco IOL devices (L2/L3, real IOS 17.x): routers, distribution switches, access switches across 10 sites.
- 60+ Linux endpoints (alpine containers): workstations, servers, NAS, printers, cameras, IP phones, video conf units, DNS, monitoring.
- Real routing: OSPF area 0 inside each site, eBGP between sites, iBGP inside hubs, single NAT'd internet egress.
- One YAML file (
sites.yaml) drives the whole topology: add a site, change an ASN, scale endpoints — re-run the generator, redeploy.
Built and tested on a MacBook M4 inside an OrbStack Ubuntu machine. Should work on any Apple Silicon Mac with enough RAM; resource overhead is comparable to running ~100 small Docker containers (the IOL ones are the heavyweights at ~150 MB RSS each).
EVE-NG and GNS3 both struggle on Apple Silicon. Hypervisor mode chews RAM/CPU, bare-metal mode has its own driver headaches, and either way you hit a wall around 10–15 nodes before the VM becomes unusable. The combination that scales well past 100 nodes on a laptop:
| Component | Role |
|---|---|
| OrbStack | Lightweight Linux VM on macOS. Replaces Docker Desktop; runs an Ubuntu machine with native-feeling overhead on M-series. |
| Containerlab | Topology orchestrator. Wires nodes into a Docker bridge management network, brings them up in parallel. |
| vrnetlab cisco_iol | Wraps the Cisco IOL binary in a container so containerlab can drive it. |
| Cisco IOL | Real IOS 17.x as a userland binary. No KVM, no nested virtualization. |
| alpine-ssh | Tiny endpoint image (~17 MB) with sshd + dnsmasq + bind-tools. One container per endpoint. |
A Mac with Apple Silicon (M1+), 16 GB RAM minimum (32 GB recommended for the full topology), and a few hours the first time you set things up.
Install OrbStack: https://orbstack.dev/download.
Create a Linux machine inside it (from your Mac terminal):
orb create ubuntu labDrop into it whenever you need a shell:
orb shell labEverything below runs inside that Ubuntu machine, not on your Mac.
orb shell is enough for most things, but having real SSH makes the dashboard
and NetBox easier to reach from your Mac browser.
# Inside the Ubuntu machine
sudo apt-get update
sudo apt-get install -y openssh-server
sudo systemctl enable --now ssh
sudo passwd $USER
# Find the machine's IP:
ip -4 addr show eth0 | awk '/inet /{print $2}' | cut -d/ -f1From your Mac: ssh <user>@<ip>, or use the OrbStack alias
ssh <user>@lab.orb.local.
Python 3 is preinstalled but python3-venv and python3-pip aren't:
sudo apt-get install -y make git curl ca-certificates python3-venv python3-pipsudo apt-get install -y docker.io docker-buildx docker-compose-v2
sudo usermod -aG docker $USER
# log out and back in (or `newgrp docker`) so the group membership takes effect
docker run --rm hello-world # smoke test
docker compose version # should print "Docker Compose v2.x"
docker-buildxis needed for the vrnetlab cross-arch build in step 7.docker-compose-v2is thedocker compose(with a space) plugin; NetBox uses it.
bash -c "$(curl -sL https://get.containerlab.dev)"
containerlab versionObtain these yourself — they're Cisco proprietary and not redistributed by this repo:
x86_64_crb_linux-adventerprisek9-ms.iol(L3 / router)x86_64_crb_linux_l2-adventerprisek9-ms.iol(L2 / switch)
If you have a Cisco Modeling Labs license, the binaries are in the CML refplat
under iol-xe-x.y.z/ and ioll2-xe-x.y.z/.
git clone https://github.com/hellt/vrnetlab.git
cd vrnetlab/cisco/iol
# Drop your .bin files in with the naming the makefile expects:
cp /path/to/x86_64_crb_linux-adventerprisek9-ms.iol cisco_iol-17.16.01.bin
cp /path/to/x86_64_crb_linux_l2-adventerprisek9-ms.iol cisco_iol-L2-17.16.01.bin
# Build (the platform override is critical on Apple Silicon):
DOCKER_PLATFORM=linux/amd64 make docker-imageAfter ~3 minutes:
$ docker images | grep cisco_iol
vrnetlab/cisco_iol 17.16.01 ...
vrnetlab/cisco_iol L2-17.16.01 ...
Why
DOCKER_PLATFORM=linux/amd64: the vrnetlab Dockerfile installsiouyapfrom an apt repo that only publishes amd64. A plainmake docker-imageon Apple Silicon runs the build as arm64 and apt fails withE: Unable to locate package iouyap. The override routes the build throughdocker buildx build --platform=linux/amd64(hence step 4'sdocker-buildx). The IOL binary itself is x86_64, so the resulting container runs amd64 via OrbStack's Rosetta translation.
Used for every Linux endpoint and the two DNS containers (they need
dnsmasq in the same image):
cat > /tmp/Dockerfile <<'EOF'
FROM alpine:latest
RUN apk add --no-cache openssh dnsmasq bind-tools tcpdump iputils \
&& ssh-keygen -A \
&& echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config \
&& echo 'PasswordAuthentication yes' >> /etc/ssh/sshd_config \
&& adduser -D admin && echo 'admin:admin' | chpasswd
EOF
docker build -t alpine-ssh:latest -f /tmp/Dockerfile /tmpbind-tools gives you dig/nslookup/host; tcpdump and iputils round
out the troubleshooting kit on every endpoint.
All commands run inside the Ubuntu machine from Prerequisites step 1.
# 1. Clone
git clone https://github.com/xsen84/orion.git
cd orion
# 2. Generate the host-specific IOL license file
python3 scripts/gen-iourc.py
# -> writes ./iourc (gitignored)
# 3. Bring up the lab (3–5 min for IOS to boot and BGP to converge)
sudo containerlab deploy --topo topology.clab.yml
# 4. Smoke-test reachability
./tests/connectivity.sh
# 5. Start the dashboard
cd dashboard && ./run.sh
# Then open http://<ubuntu-vm-ip>:5001 in your Mac browser.
# (or http://lab.orb.local:5001 via the OrbStack hostname alias)Tear it down:
sudo containerlab destroy --topo topology.clab.yml --cleanupAfter the deploy finishes, the connectivity script should report 0 failures:
$ ./tests/connectivity.sh | tail -3
DUB-WS-1 172.20.20.138 | OK | OK
DUB-WS-2 172.20.20.139 | OK | OK
DUB-SRV-1 172.20.20.140 | OK | OK
OK: 100 FAIL: 0
A few one-liners that exercise the real routing/DNS stack:
# BGP summary on the egress router — 4 eBGP peers (one per spoke) + 1 iBGP
$ ssh admin@172.20.20.11 'show ip bgp summary' | tail -6
Neighbor V AS MsgRcvd MsgSent TblVer InQ OutQ Up/Down State/PfxRcd
10.0.0.2 4 65011 14 13 26 0 0 00:01:48 11
10.0.1.2 4 65012 9 13 26 0 0 00:01:46 3
10.0.1.6 4 65013 8 13 26 0 0 00:01:42 3
10.0.1.10 4 65014 8 13 26 0 0 00:01:15 3
10.0.3.2 4 65010 12 12 26 0 0 00:01:46 10
# Cross-site path — Tokyo workstation reaches Dubai server through 5 hops
$ docker exec -u root clab-orion-enterprise-tyo-ws-1 traceroute -n 10.110.10.151
1 10.70.0.1 ← tyo-r1 (Tokyo LAN gateway)
2 10.0.1.17 ← buc-r2 (Tokyo's BGP peer at the hub)
3 10.0.3.1 ← buc-r1 (iBGP within Bucharest)
4 10.0.0.2 ← fra-r1 (BUC↔FRA hub interconnect)
5 10.0.2.10 ← dub-r1 (Frankfurt's spoke to Dubai)
6 10.110.10.151 ← dub-srv-1 (destination)
# DNS resolution works across sites
$ docker exec clab-orion-enterprise-tyo-ws-1 dig +short @10.10.100.53 buc-srv-1.orion.lab
10.10.10.151
# NAT on the egress router — workstation IP rewritten to the mgmt IP
$ docker exec clab-orion-enterprise-tyo-ws-1 ping -c1 1.1.1.1 > /dev/null
$ ssh admin@172.20.20.11 'show ip nat translations'
Pro Inside global Inside local Outside local Outside global
icmp 172.20.20.11:1024 10.70.10.101:11552 1.1.1.1:11552 1.1.1.1:1024If you see those, the lab is fully operational. If not, jump to Troubleshooting.
10 sites, 100 containers:
| Site | Code | Role | AS | LAN | Routers | Dist | Access | Endpoints |
|---|---|---|---|---|---|---|---|---|
| Bucharest | BUC | Hub (primary) | 65010 | 10.10.0.0/16 | 2 | 2 | 3 | 9 + DNS + monitor |
| Frankfurt | FRA | Hub (secondary) | 65011 | 10.20.0.0/16 | 2 | 2 | 3 | 9 + DNS |
| Pune | PNQ | Regional | 65012 | 10.30.0.0/16 | 1 | 1 | 2 | 5 |
| Denver | DEN | Regional | 65013 | 10.40.0.0/16 | 1 | 1 | 2 | 5 |
| Paris | PAR | Regional | 65014 | 10.50.0.0/16 | 1 | 1 | 2 | 5 |
| Singapore | SIN | Regional | 65015 | 10.60.0.0/16 | 1 | 1 | 2 | 5 |
| Tokyo | TYO | Regional | 65016 | 10.70.0.0/16 | 1 | 1 | 2 | 5 |
| Stockholm | STO | Branch | 65020 | 10.90.0.0/16 | 1 | 1 | 1 | 3 |
| Toronto | TOR | Branch | 65021 | 10.100.0.0/16 | 1 | 1 | 1 | 3 |
| Dubai | DUB | Branch | 65022 | 10.110.0.0/16 | 1 | 1 | 1 | 3 |
┌─────────────────────────────────────────┐
│ WAN BACKBONE (eBGP) │
└─────────────────────────────────────────┘
│ │
┌───────────┴────────────┐ ┌────┴────────────────┐
│ BUCHAREST (Hub) │ │ FRANKFURT (Hub) │
│ AS 65010 │ │ AS 65011 │
│ R1 ◀── Internet egress│ │ R1, R2 (iBGP) │
│ R2 (iBGP) │ │ DSW1, DSW2 │
│ DSW1, DSW2 │ │ ASW1, ASW2, ASW3 │
│ ASW1, ASW2, ASW3 │ │ DNS Secondary │
│ DNS Primary, Monitor │ │ │
└──┬──────┬──────┬───────┘ └──┬──────┬──────┬───┘
│ │ │ │ │ │
┌───┘ ┌───┘ ┌──┘ ┌──┘ ┌──┘ ┌──┘
┌──────┴─┐┌───┴──┐┌───┴───┐┌───────┴┐┌────┴──┐┌──┴───┐
│ Pune ││Denver││ Paris ││Stockh. ││Toronto││ Dubai│
│ AS65012││65013 ││ 65014 ││ 65020 ││ 65021 ││65022 │
└────────┘└──────┘└───────┘└────────┘└───────┘└──────┘
+ Singapore (AS 65015) via BUC-R2
+ Tokyo (AS 65016) via BUC-R2
Two planes, kept deliberately separate.
Management plane — 172.20.20.0/24.
Every node has an eth0 here. It's a flat L2 segment provided by
containerlab. You use it for SSH into routers/switches and for the dashboard
to ping devices. It is never used for transit by lab traffic. Every
router does have 172.20.20.0/24 as a directly-connected route on its own
mgmt interface (clab attaches that interface, you can't hide it), but the
interface is passive-interface in OSPF so no hellos leak there, and the
only static default that exists anywhere is on the single egress router. A
spoke router has no path to the mgmt bridge from the data plane — only the
egress can NAT out via eth0.
Data plane — 10.0.0.0/8.
- Endpoints' default route is
via <site-router-LAN-IP> dev eth1. Everything except management traffic enters the lab fabric. - Inside a site: OSPF area 0 between site router(s), distribution switches, and (via SVI) access switches.
- Between sites: eBGP on /30 WAN links.
- Inside a hub: iBGP between the two routers.
- The data plane has no internet route of its own. Internet egress is single-pointed (see below).
defaults.internet_egress_router in sites.yaml names the one
router that NATs lab traffic to the outside world. buc-r1 by default.
The generator emits on the egress router only:
ip access-list standard LAB-NAT
permit 10.0.0.0 0.255.255.255
!
ip nat inside source list LAB-NAT interface Ethernet0/0 overload
!
interface Ethernet0/0 ! management bridge — outside
ip nat outside
interface Ethernet0/1 ! site LAN — inside
ip nat inside
interface Ethernet0/2..N ! every WAN link — inside
ip nat inside
!
router bgp 65010
address-family ipv4 unicast
neighbor <each-eBGP-peer> default-originate
!
ip route 0.0.0.0 0.0.0.0 172.20.20.1
Every other router has no static default and learns 0.0.0.0/0 via eBGP from
the egress router. No site can shortcut to the mgmt bridge — all internet
traffic transits Bucharest.
A trace from a Tokyo workstation:
$ docker exec -u root clab-orion-enterprise-tyo-ws-1 traceroute -n 1.1.1.1
1 10.70.0.1 ← tyo-r1 LAN gateway (eth1)
2 10.0.1.17 ← buc-r2 WAN end of the TYO↔BUC link
3 10.0.3.1 ← buc-r1 iBGP end of the BUC R1↔R2 link
4 172.20.20.1 ← clab mgmt bridge (post-NAT egress)
5+ ... ← VM, host, internet
To shift the egress (failover testing, or to land traffic in Europe): set
internet_egress_router: fra-r1 in sites.yaml, regenerate, redeploy.
dashboard/ is a small Flask + vanilla JS app that auto-parses
topology.clab.yml, pings every node every few seconds via the mgmt bridge,
opens a TCP connection to port 22 to check SSH, and reads the sibling
sites.yaml for site names, roles, and ASNs.
cd dashboard
./run.sh # creates .venv, installs deps, runs app.py
# -> http://<ubuntu-vm-ip>:5001It needs network access to the clab mgmt bridge (172.20.20.0/24), so run it
from the same Ubuntu machine that hosts the lab.
CLAB_FILE overrides which topology the dashboard reads:
CLAB_FILE=$PWD/../examples/triangle/topology.clab.yml ./run.shThe dashboard picks up the topology name and per-site metadata automatically:
NetBox runs as a separate Docker stack — it's not part of the clab topology. Useful for browsing the deployed inventory, looking up IPs, or plugging into automation tooling.
cd netbox
docker compose up -d
sleep 60 # first-run migrations
./populate-netbox.sh # pushes deployed inventory into NetBoxOpen NetBox in your Mac browser:
| From | URL |
|---|---|
| Mac → VM (IP) | http://<ubuntu-vm-ip>:8000 |
| Mac → VM (alias) | http://lab.orb.local:8000 |
| Inside the VM | http://localhost:8000 |
Login: admin / admin (from netbox/env/netbox.env).
netbox/env/netbox.env and netbox/env/postgres.env ship with working lab
credentials. They're committed on purpose so the stack just runs — change
them if you ever expose this lab beyond your machine.
populate-netbox.py reads the runtime clab artifacts and pushes sites,
device types, devices, interfaces, and IPs into NetBox via the API. Re-run
it after every redeploy. It's idempotent across runs and topologies — point
it at any topology.clab.yml via CLAB_YAML=....
docker compose up -d only prints container names, no URL — that's compose
v2's behaviour. Use docker compose ps to confirm all six services
(netbox, netbox-worker, netbox-housekeeping, postgres, redis,
redis-cache) are Up. The netbox container shows health: starting
for the first minute while migrations run.
Two dnsmasq containers serve the orion.lab zone:
- Primary —
buc-dns-1at10.10.100.53. Authoritative fororion.lab; forwards everything else to8.8.8.8. - Secondary —
fra-dns-1at10.20.100.53. Same zone; forwards non-zone queries to the primary.
Every Linux endpoint gets nameserver 10.10.100.53 and search orion.lab
written into /etc/resolv.conf at boot (via the containerlab dns: field —
not the exec block, which Docker overrides). Routers and switches get
ip name-server 10.10.100.53 in their config, so ping buc-srv-1.orion.lab
from a router CLI resolves.
# From a router:
ssh admin@172.20.20.11
> ping buc-srv-1.orion.lab
# From any endpoint:
docker exec -it clab-orion-enterprise-buc-ws-1 nslookup fra-srv-1.orion.labThe host map is dns/hosts.orion. Regenerate it after editing sites.yaml:
python3 scripts/generate.py --target dns| Where | User | Password | How to change |
|---|---|---|---|
| IOS devices (routers + switches) | admin |
admin |
defaults.admin_password in sites.yaml, then regenerate |
| Linux endpoints (alpine-ssh) | admin |
admin |
Rebuild alpine-ssh:latest with a different chpasswd line |
| NetBox web UI | admin |
admin |
Edit netbox/env/netbox.env |
| NetBox API token | — | 0123456789abcdef0123456789abcdef01234567 |
Same file (SUPERUSER_API_TOKEN) |
Lab defaults. Change them before exposing the lab to anything beyond your machine.
This is the workflow the lab is built around. Edit one YAML file, regenerate, redeploy.
# 1. Edit sites.yaml — add a site, change an ASN, scale endpoints, change links.
$EDITOR sites.yaml
# 2. Regenerate configs + topology + DNS + connectivity test
python3 scripts/generate.py
# 3. See what changed
git diff configs/ topology.clab.yml dns/ tests/
# 4. Redeploy
sudo containerlab destroy --topo topology.clab.yml --cleanup
sudo containerlab deploy --topo topology.clab.yml
# 5. Re-test
./tests/connectivity.shscripts/generate.py --check renders in memory and reports which files would
change (no writes). --target configs topology dns tests limits which
artifact groups are written.
- Add a new site — append to
sites:with arole(hub/regional/branch),asn,router_id,lanprefix,mgmt_startIP. List its access switches and endpoint counts. Addwan_linksentries to attach it. - Resize a site — bump
endpointscounts under each access switch. - Reshape the WAN — edit
wan_links; every link is afrom/to/subnettriple. - Move internet egress — change
defaults.internet_egress_router(e.g.,buc-r1→fra-r1). NAT and BGPdefault-originatemove with it. - Change credentials —
defaults.admin_user/defaults.admin_password.
- Single OSPF area 0 within each site.
- eBGP between sites, iBGP inside hubs, redistribution
connected+ospf 1. - Interface numbering:
e0/0mgmt,e0/1LAN/uplink,e0/2+WAN. - Endpoint LAN host octets per type (workstations
.10x, printers.11x, …) — seeENDPOINT_CATALOGat the top ofscripts/generate.py.
Change those if you need to — the generator is ~500 lines and intentionally straightforward.
The full 100-node lab is the default. For something quicker to boot, or to
try a different network shape, examples/ ships four
self-contained sites.yaml files:
| Example | Sites | Containers | Shape |
|---|---|---|---|
two-site |
2 | ~13 | Minimal HQ ↔ Remote, single WAN link |
hub-spoke |
4 | ~28 | HQ + 3 branches, no inter-branch links |
triangle |
3 | ~19 | Full mesh between 3 sites |
chain |
3 | ~19 | Linear west ↔ center ↔ east (transit through center) |
Each example has its own sites.yaml plus generated topology.clab.yml,
configs/, dns/, and tests/. To use one:
# Destroy the main lab first if it's up — they share the mgmt /24
sudo containerlab destroy --topo topology.clab.yml --cleanup
# Pick one and deploy
cd examples/two-site
sudo containerlab deploy --topo topology.clab.yml
# Dashboard against this topology
cd ../../dashboard
CLAB_FILE=$PWD/../examples/two-site/topology.clab.yml ./run.sh
# NetBox populate against this topology
cd ../netbox
CLAB_YAML=$PWD/../examples/two-site/topology.clab.yml ./populate-netbox.sh --populateEdit + regenerate an example:
python3 scripts/generate.py --sites examples/two-site/sites.yaml \
--out examples/two-site/All examples set defaults.iourc_path: ../../iourc so they pick up the
repo-root iourc when deployed from the example directory.
orion/
├── sites.yaml # The source of truth — edit this
├── topology.clab.yml # Generated. Don't edit by hand.
├── configs/ # Generated IOS configs (router, dist, access)
├── dns/
│ ├── dnsmasq.conf # Generated
│ ├── dnsmasq-secondary.conf # Generated
│ └── hosts.orion # Generated
├── tests/ # Generated reachability tests + curated extras
│ ├── connectivity.sh
│ ├── routing.sh
│ ├── dns.sh
│ └── failover.sh
├── scripts/
│ ├── generate.py # sites.yaml -> all generated artifacts
│ └── gen-iourc.py # produces ./iourc bound to your host
├── examples/ # Smaller alternative topologies (see above)
│ ├── two-site/
│ ├── hub-spoke/
│ ├── triangle/
│ └── chain/
├── dashboard/ # Flask + JS dashboard (independent of clab)
├── netbox/ # Optional NetBox docker-compose stack
└── docs/screenshots/ # README images
iourc and clab-orion-enterprise/ are gitignored (host-specific license
file + clab runtime state).
IOL ERROR: License entry missing—iourcdoesn't match the host's hostname/hostid hash. Regenerate:python3 scripts/gen-iourc.py.- Nodes stuck in
health: starting— IOL takes 60–120 s per device to boot. Watch withsudo containerlab inspect --name orion-enterprise. - BGP not converging — give it 2–3 minutes after deploy. Check with
ssh admin@172.20.20.11 'show ip bgp summary'. No such image: vrnetlab/cisco_iol:17.16.01— see Prerequisites step 7.E: Unable to locate package iouyapduring the vrnetlab build — you forgotDOCKER_PLATFORM=linux/amd64. See step 7.unknown flag: --platformduring the vrnetlab build —docker-buildxplugin missing.sudo apt-get install -y docker-buildx.alpine-ssh:latestnot found — see Prerequisites step 8.permission denied while trying to connect to the Docker daemon socket— shell hasn't picked up the docker group.newgrp dockeror new shell.ensurepip is not availablewhendashboard/run.shruns —sudo apt-get install -y python3-venv python3-pip.unknown shorthand flag: 'd' in -dfromdocker compose— the compose v2 plugin isn't installed.sudo apt-get install -y docker-compose-v2.- Dashboard shows everything but can't reach nodes — make sure you're running it inside the same Ubuntu machine that hosts clab. The mgmt bridge isn't reachable from your Mac directly.
- Endpoint
ping google.comreturns IPv6 then 100% loss — busyboxpingprefers AAAA, and the lab has no routable IPv6. Eitherping -4 google.comper-shot, or addfilter-AAAAtodns/dnsmasq.conf. - Endpoint
traceroutefails withOperation not permitted— needs CAP_NET_RAW. Run as root:docker exec -u root <container> traceroute -n <target>. Or rebuildalpine-sshaddingsetcap cap_net_raw+ep /usr/bin/traceroute. - Endpoint can't reach the internet — check the egress router is up
(
docker ps | grep buc-r1), then verify a spoke has a BGP-learned default:ssh admin@172.20.20.50 'show ip route 0.0.0.0'should show0.0.0.0/0 [20/0] via <BGP-peer>(B = BGP route).
The lab code (configs, generator, dashboard, docker-compose, docs) is MIT licensed — see LICENSE.
Third-party components keep their own licenses:
- Cisco IOL — Cisco proprietary. You must obtain the binary yourself; it is not redistributed by this repo.
- vrnetlab — see https://github.com/hellt/vrnetlab.
- Containerlab — Apache-2.0.
- NetBox — Apache-2.0.
The scripts/gen-iourc.py algorithm is a publicly documented Cisco IOL
license computation, included for convenience for users who already possess
a valid IOL binary.



