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.
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/bookworm64box (amd64). Vagrant downloads it on the firstvagrant 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_networkwithvirtualbox__intnetputs 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 withip routeis not saved.ip route replacemakes it safe to run twice. - No default route change.
eth0keeps the NAT default route, whichvagrant sshneeds. 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.
- Wrong LAN name. Both ends of a link must use the same
virtualbox__intnetstring. A typo creates a new, empty network, and the ping fails with no error anywhere. RunVBoxManage showvminfo <vm-name> | grep NICon the host to see which internal network each adapter joined. - Forwarding is off. Run
cat /proc/sys/net/ipv4/ip_forwardon the router. It must print1. If you edited the provisioner, runvagrant provision routerto apply it again. - 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 routeon both clients. - 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 sshuses, though. Ifvagrant uphangs 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_networkalso works with libvirt and VMware providers, but options such asvirtualbox__intnetare 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.