Virtual home network lab
Every article up to this point in this course either described a network from the outside or ran code on a single machine talking to itself over 127.0.0.1. This lab is the first one that puts more than one machine on a network you control and makes you look at the traffic passing between them — the same three-way handshake, the same DHCP exchange, the same ARP resolution this course has explained on paper, now happening on packets you capture yourself.
Goal and end result
By the end of this lab, two virtual machines will sit on the same private network, one acting as a simple router between that network and the outside world, and you'll have confirmed — with real commands, not assumptions — that each machine can reach the other, that DHCP actually assigned their addresses, and that traffic between them is genuinely visible on the wire rather than happening in some abstraction neither of you can see.
Topology
+------------------+
| Host machine |
| (your laptop) |
+--------+---------+
|
virtual network: 192.168.100.0/24
|
+-----------------+-----------------+
| |
+------+-------+ +-------+------+
| vm-a | | vm-b |
| 192.168.100.10| | 192.168.100.11|
+---------------+ +---------------+
Both VMs sit on one isolated virtual network, with no dependency on your home router at all — this lab intentionally stays contained, which is why it comes before the real home network lab that follows it.
Requirements
- A hypervisor capable of creating an isolated virtual network: VirtualBox (free, cross-platform) or, if you'd rather avoid full VMs, two Linux containers on the same Docker bridge network work equally well for everything this lab actually tests. The steps below use VirtualBox terminology; the container-based equivalent is noted where it meaningfully differs.
- Two Ubuntu Server installations (any recent LTS release), or two
ubuntu:24.04containers if using Docker instead. - Wireshark or
tcpdumpinstalled on whichever machine will do the capturing — the Wireshark lab that follows this one goes into using it properly; here it's only used to confirm traffic is visible at all.
Safety note
Everything in this lab lives on an isolated virtual network with no route to your home LAN or the internet beyond normal outbound NAT — nothing here is reachable from outside your machine, and nothing here changes your host's own network configuration. There's no risk of exposing a service to your home network or beyond, which is exactly why this is the right starting point before the real home network lab, where the stakes for a misconfiguration are higher.
Step 1: create the isolated virtual network
In VirtualBox, create a new internal network (not "NAT" or "Bridged") named lab-net — an internal network is deliberately isolated from your host's own network entirely, connecting only the VMs attached to it, which is exactly the isolation this lab wants. If using Docker instead:
The long hexadecimal string is the new network's ID — confirm it exists and check its configured subnet before moving on:
Step 2: bring up both machines on that network
For VirtualBox, create vm-a and vm-b, each with one network adapter attached to the lab-net internal network created above — not a second, separate adapter for internet access; this lab deliberately keeps both machines isolated to the internal network only, so there's nothing for either VM to reach besides the other one and, later, a router VM you'd add explicitly.
For Docker, --cap-add=NET_ADMIN is worth adding from the start — a later step in this lab inspects and clears ARP entries, which needs that capability, and it's simpler to include it here than to recreate both containers later:
docker run -d --name vm-a --network lab-net --hostname vm-a --cap-add=NET_ADMIN ubuntu:24.04 sleep infinity
docker run -d --name vm-b --network lab-net --hostname vm-b --cap-add=NET_ADMIN ubuntu:24.04 sleep infinity
A bare ubuntu image doesn't include ping, ip, or tcpdump by default, so install them in both containers before continuing:
docker exec vm-a apt-get update -qq
docker exec vm-a apt-get install -y iproute2 iputils-ping tcpdump
docker exec vm-b apt-get update -qq
docker exec vm-b apt-get install -y iproute2 iputils-ping tcpdump
Confirm both containers actually attached to lab-net and received an address on the expected subnet, rather than assuming the docker run succeeded silently:
{
"5ff1b6830c5999d648bbfa74398cd14e6bab512aeda029e95d425cd5e6ed5c39": {
"Name": "vm-a",
"MacAddress": "02:9d:40:f3:10:c3",
"IPv4Address": "192.168.100.2/24",
"IPv6Address": ""
},
"6c692a6ca10b65ee9a41fb8c9c35470f1c64a8fe34ec9654664c5bca3fbc80b1": {
"Name": "vm-b",
"MacAddress": "22:a8:c8:64:8e:5b",
"IPv4Address": "192.168.100.3/24",
"IPv6Address": ""
}
}
The exact addresses, container IDs, and MAC addresses will differ from these — Docker's built-in address assignment picks the next available address in the subnet, not a fixed one, so don't expect .2 and .3 specifically.
Step 3: confirm each machine can reach the other
From vm-a, ping vm-b by its address:
PING 192.168.100.3 (192.168.100.3) 56(84) bytes of data.
64 bytes from 192.168.100.3: icmp_seq=1 ttl=64 time=0.187 ms
64 bytes from 192.168.100.3: icmp_seq=2 ttl=64 time=0.203 ms
64 bytes from 192.168.100.3: icmp_seq=3 ttl=64 time=0.200 ms
64 bytes from 192.168.100.3: icmp_seq=4 ttl=64 time=0.091 ms
--- 192.168.100.3 ping statistics ---
4 packets transmitted, 4 received, 0% packet loss, time 3110ms
rtt min/avg/max/mdev = 0.091/0.170/0.203/0.046 ms
A ttl=64 and sub-millisecond round trip both make sense for two machines on the same virtual segment — the ICMP article already explained why TTL starts at a fixed value and decrements per hop, and there are zero real hops between two containers on the same bridge.
Step 4: watch the ARP resolution that made step 3 possible
Before vm-a could send that ICMP echo request to 192.168.100.3, it needed vm-b's MAC address — the ARP article covered exactly this resolution step. Clear vm-a's ARP cache and watch it happen fresh:
docker exec vm-a ip neigh flush 192.168.100.3
docker exec vm-a ping -c 1 192.168.100.3
docker exec vm-a ip neigh show 192.168.100.3
That lladdr is vm-b's MAC address, learned through the ARP exchange the ping itself triggered. The state may show DELAY immediately after the ping and settle into REACHABLE a second or two later — both mean the entry is current and correctly resolved, not stale; ip neigh show run again after a short pause should show REACHABLE. This is the same neighbor table the ARP article's own scenario inspected when diagnosing a spoofed gateway, here showing a correct, unremarkable entry instead of a forged one.
Step 5: confirm the traffic is actually visible on the wire
Capture a few packets of that same ping exchange directly, to confirm this lab's traffic is genuinely observable and not some abstraction internal to the hypervisor:
Run the capture in the background, briefly, while a fresh ping generates the traffic to see:
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
4 packets captured
4 packets received by filter
0 packets dropped by kernel
08:53:06.439030 IP 192.168.100.2 > 192.168.100.3: ICMP echo request, id 59959, seq 1, length 64
08:53:06.439066 IP 192.168.100.3 > 192.168.100.2: ICMP echo reply, id 59959, seq 1, length 64
08:53:07.460138 IP 192.168.100.2 > 192.168.100.3: ICMP echo request, id 59959, seq 2, length 64
08:53:07.460198 IP 192.168.100.3 > 192.168.100.2: ICMP echo reply, id 59959, seq 2, length 64
The -n flag keeps addresses numeric instead of letting tcpdump resolve container names, which is easier to read against this lab's own diagram. Four lines, two requests each matched by a reply — exactly the request/reply pair the ICMP ping article described, now captured directly rather than taken on faith. The summary counts appearing before the packet lines is normal — it's a buffering artifact of piping tcpdump's output, not a sign anything happened out of order.
Troubleshooting
pingreports 100% packet loss. Confirm both containers actually attached tolab-netwithdocker network inspect lab-net --format '{{json .Containers}}', the same command used in step 2 — a container started before the network existed, or attached to the default bridge instead by mistake, simply won't appear in that list, and won't route to the other one at all.ip neigh showreturns nothing after the ping. Re-run the ping first; the neighbor entry is created by the resolution the ping itself triggers, and checking before that traffic happens will correctly show an empty table.tcpdumpshows no output at all. Confirm the interface name withdocker exec vm-a ip link show— a container's interface isn't alwayseth0depending on how it was created, and capturing on the wrong interface produces silence, not an error.
Cleanup
An empty result confirms the network was actually removed rather than left behind for a future lab to collide with.
Evaluation criterion
You've completed this lab correctly if you can produce, on demand, all three of: a successful ping between the two machines with a real RTT number, an ARP neighbor entry showing the other machine's real MAC address, and a tcpdump capture showing the exact ICMP request/reply pair that ping generated. If any of the three is missing, something in the topology isn't actually working the way the others suggest it is.
This lab deliberately stayed inside an isolated network with no connection to anything you use day to day. The next lab moves the same kind of inspection onto your actual home network — a real router, real DHCP, and the added care that comes with working on infrastructure other devices in your house actually depend on.
Sources
- Docker Docs, Networking overview
- tcpdump.org, tcpdump(1) man page