Skip to content

Build a multi-VM network lab with Vagrant

A tested Vagrantfile for three VirtualBox VMs: two LANs on private networks and a Linux router between them, with provisioning and checks.

Published on 5 min read

This tutorial builds a three-VM lab with one Vagrantfile: two clients on separate LANs and a Linux router between them. You get a file you can copy, the exact checks to prove routing works, and real output from a run on VirtualBox.

What you'll build

 client-a                 router                  client-b
 10.10.1.10   eth1  10.10.1.1   10.10.2.1  eth2   10.10.2.10
     |                  |   |                         |
     +---- lab-lan-a ---+   +------- lab-lan-b -------+
       (VirtualBox internal network)  (internal network)

Each VM also has eth0, the NAT interface Vagrant uses for vagrant ssh. The two lab LANs are VirtualBox internal networks: only the VMs that join them can talk on them. Your host has no address on either one, so the lab stays isolated.

What you need

  • Vagrant and VirtualBox. This post was tested with Vagrant 2.4.9 and VirtualBox 7.2.20 on an Intel Mac.
  • The debian/bookworm64 box (amd64). Vagrant downloads it on the first vagrant up.
  • About 1.5 GB of free RAM: three VMs with 512 MB each.
vagrant --version
VBoxManage --version
Vagrant 2.4.9
7.2.20r175154

Step 1: Write the Vagrantfile

Create an empty folder and save this as Vagrantfile:

# Three VMs: two clients on separate LANs, one Linux router between them.
#
#   client-a (10.10.1.10) --[lab-lan-a]-- router --[lab-lan-b]-- client-b (10.10.2.10)

BOX = "debian/bookworm64"

Vagrant.configure("2") do |config|
  config.vm.box = BOX
  config.vm.synced_folder ".", "/vagrant", disabled: true

  config.vm.provider "virtualbox" do |vb|
    vb.memory = 512
    vb.cpus = 1
  end

  config.vm.define "router" do |r|
    r.vm.hostname = "router"
    r.vm.network "private_network", ip: "10.10.1.1", netmask: "255.255.255.0",
      virtualbox__intnet: "lab-lan-a"
    r.vm.network "private_network", ip: "10.10.2.1", netmask: "255.255.255.0",
      virtualbox__intnet: "lab-lan-b"
    r.vm.provision "shell", inline: <<-SHELL
      echo "net.ipv4.ip_forward = 1" > /etc/sysctl.d/99-router.conf
      sysctl --system > /dev/null
      sysctl net.ipv4.ip_forward
    SHELL
  end

  clients = {
    "client-a" => { ip: "10.10.1.10", lan: "lab-lan-a", gw: "10.10.1.1", other: "10.10.2.0/24" },
    "client-b" => { ip: "10.10.2.10", lan: "lab-lan-b", gw: "10.10.2.1", other: "10.10.1.0/24" },
  }

  clients.each do |name, c|
    config.vm.define name do |node|
      node.vm.hostname = name
      node.vm.network "private_network", ip: c[:ip], netmask: "255.255.255.0",
        virtualbox__intnet: c[:lan]
      node.vm.provision "shell", run: "always", inline: <<-SHELL
        ip route replace #{c[:other]} via #{c[:gw]}
        ip route show #{c[:other]}
      SHELL
    end
  end
end

What each part does:

  • private_network with virtualbox__intnet puts the adapter on a named internal network. Two adapters with the same name share a LAN. Vagrant writes the static IP into the guest for you.
  • The router provisioner turns on IPv4 forwarding and saves it in /etc/sysctl.d, so it survives a reboot.
  • The client provisioner adds a route to the other LAN through the router. run: "always" re-applies it on every boot, because a route added with ip route is not saved. ip route replace makes it safe to run twice.
  • No default route change. eth0 keeps the NAT default route, which vagrant ssh needs. Each client only learns one extra subnet.
  • The synced folder is off. The lab does not need it, and it avoids Guest Additions issues.

Why not a host-only network? The VirtualBox manual says that on Linux, macOS and Solaris, host-only adapters only accept addresses in 192.168.56.0/21 unless you list other ranges in /etc/vbox/networks.conf. Internal networks have no such limit and keep your host out of the lab.

Step 2: Validate and start the lab

vagrant validate
vagrant up

vagrant validate checks the file without creating anything:

==> router: You assigned a static IP ending in ".1" or ":1" to this machine.
==> router: This is very often used by the router and can cause the
==> router: network to not work properly. If the network doesn't work
==> router: properly, try changing this IP.
Vagrant validated successfully.

The .1 warning is about host-only networks, where the host often holds .1. On an internal network the host has no address, so the router can keep it.

vagrant up creates the VMs in order. It took about two minutes here with the box already downloaded. Trimmed output:

==> client-b: Preparing network interfaces based on configuration...
    client-b: Adapter 1: nat
    client-b: Adapter 2: intnet
==> client-b: Configuring and enabling network interfaces...
==> client-b: Running provisioner: shell...
    client-b: Running: inline script
    client-b: 10.10.1.0/24 via 10.10.2.1 dev eth1

If another VM already uses port 2222, Vagrant picks another SSH port and says so (Fixed port collision for 22 => 2222). That is normal.

Step 3: Verify the lab

Check that all three VMs run:

vagrant status
router                    running (virtualbox)
client-a                  running (virtualbox)
client-b                  running (virtualbox)

Look at the router's interfaces:

vagrant ssh router -c "ip -br addr"
lo               UNKNOWN        127.0.0.1/8 ::1/128
eth0             UP             10.0.2.15/24 ...
eth1             UP             10.10.1.1/24 ...
eth2             UP             10.10.2.1/24 ...

Now test from client-a to client-b, across the router:

vagrant ssh client-a -c "ip route; ping -c 3 10.10.2.10; traceroute -n 10.10.2.10"
default via 10.0.2.2 dev eth0
10.0.2.0/24 dev eth0 proto kernel scope link src 10.0.2.15
10.10.1.0/24 dev eth1 proto kernel scope link src 10.10.1.10
10.10.2.0/24 via 10.10.1.1 dev eth1
PING 10.10.2.10 (10.10.2.10) 56(84) bytes of data.
64 bytes from 10.10.2.10: icmp_seq=1 ttl=63 time=2.65 ms
64 bytes from 10.10.2.10: icmp_seq=2 ttl=63 time=1.50 ms
64 bytes from 10.10.2.10: icmp_seq=3 ttl=63 time=1.85 ms

--- 10.10.2.10 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2001ms
traceroute to 10.10.2.10 (10.10.2.10), 30 hops max, 60 byte packets
 1  10.10.1.1  0.791 ms  0.993 ms  1.177 ms
 2  10.10.2.10  1.934 ms  2.661 ms  2.550 ms

Two signs that the router does the work: the TTL is 63 (one hop used), and traceroute shows 10.10.1.1 as the first hop.

Step 4: Prove the router matters

Turn forwarding off on the router and ping again from client-b. sysctl lives in /usr/sbin, so call it with sudo:

vagrant ssh router -c "sudo sysctl -w net.ipv4.ip_forward=0"
vagrant ssh client-b -c "ping -c 2 -W 1 10.10.1.10"
net.ipv4.ip_forward = 0
--- 10.10.1.10 ping statistics ---
2 packets transmitted, 0 received, 100% packet loss, time 1015ms

Turn it back on and the ping works again:

vagrant ssh router -c "sudo sysctl -w net.ipv4.ip_forward=1"
vagrant ssh client-b -c "ping -c 2 10.10.1.10"
2 packets transmitted, 2 received, 0% packet loss, time 1001ms

To confirm the setup survives a reboot, run vagrant reload router client-a. The router comes back with forwarding on from /etc/sysctl.d, and client-a's provisioner re-adds its route because of run: "always". The ping from client-a works again after the reload.

If something does not work

Most problems in a lab like this come from one of four places. Check them in this order.

  1. Wrong LAN name. Both ends of a link must use the same virtualbox__intnet string. A typo creates a new, empty network, and the ping fails with no error anywhere. Run VBoxManage showvminfo <vm-name> | grep NIC on the host to see which internal network each adapter joined.
  2. Forwarding is off. Run cat /proc/sys/net/ipv4/ip_forward on the router. It must print 1. If you edited the provisioner, run vagrant provision router to apply it again.
  3. Missing return route. Routing has to work both ways. If client-a has its route but client-b does not, the echo request arrives and the reply has no path back. Check ip route on both clients.
  4. The host firewall or VPN. Internal networks never touch the host, so a host firewall does not block lab traffic. It can block the NAT port that vagrant ssh uses, though. If vagrant up hangs at "Waiting for machine to boot", check that first.

One more thing to know: right after a reload, ip route can show linkdown next to the client route for a moment while the interface comes up. It clears on its own.

Clean up

vagrant destroy -f

This deletes the three VMs and their disks. The internal networks disappear with them. The box stays in Vagrant's cache; remove it with vagrant box remove debian/bookworm64 if you no longer need it.

Going further

  • Run real routing. Install FRR on the router and a second router, and replace the static routes with OSPF. The OSPF lab with FRR in Docker has configs you can reuse inside VMs.
  • Add a firewall. Put nftables on the router and allow only some traffic between the LANs, as in the DMZ firewall lab.
  • Use other providers. private_network also works with libvirt and VMware providers, but options such as virtualbox__intnet are VirtualBox-only. Check your provider's networking docs.
  • Make the link realistic. Add delay or loss on the router with tc netem, as shown in simulate latency and packet loss with tc netem. The commands are the same inside a VM.
  • Mix in containers. A virtual machine is the right choice for full operating systems. For many routers and hosts, containers start faster and use less RAM.

Draw it instead of typing it

Isothrum's editor is free, runs in the browser with no account, and exports a lab as isoloom.yml plus a Vagrantfile, Docker Compose, Proxmox, Kubernetes or cloud files. You draw the VMs and cables, and you still run the lab yourself with vagrant up. It also opens GNS3 .gns3 projects, so you can turn an existing topology into Vagrant files.

Related articles

Twelve home network lab projects, from a first Linux router to BGP, VLANs, firewalls and VPNs, each with what you learn, what it needs and its difficulty.
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.
Practise for the CCNA without routers: Packet Tracer, CML-Free, IOL with your own licence, and FRR. Costs, and which images need a licence.