Build an OSPF lab with FRR in Docker
Build a three-router OSPF lab with FRRouting in Docker: two LANs, a shared backbone, DR and BDR election, and real neighbor and route output.
You will build a small OSPF network on your laptop: three FRRouting (FRR) routers in Docker, a shared backbone, and two LANs with a host each. By the end you will have seen OSPF elect a DR and BDR, learn routes, and carry a ping from one LAN to the other. Every command and output below comes from a real run.
What you'll build
host1 192.168.1.10 host2 192.168.2.10
| lan1 192.168.1.0/24 lan2 192.168.2.0/24 |
| |
[r1] .254 .254 [r3]
| .11 .13 |
+-------------------- core 10.0.0.0/24 ---------------------+
| .12
[r2]
Loopbacks: r1 10.255.0.1 r2 10.255.0.2 r3 10.255.0.3 (all area 0)
The three routers share one backbone subnet, so OSPF treats it as a broadcast network and elects a Designated Router (DR). r1 and r3 each own a LAN. r2 only has its loopback, which is enough to show it in the routing tables.
What you need
- Docker on Linux, or Docker Desktop on macOS or Windows. Tested on Docker 29.
- The FRR image from the project's registry:
quay.io/frrouting/frr:10.7.1. - The
nicolaka/netshootimage for the hosts. It shipsping,tracerouteandip.
About 1 GB of disk for the images. No VMs, no nested virtualization.
Step 1: Create the networks
docker network create --subnet 192.168.1.0/24 ospf-lan1
docker network create --subnet 10.0.0.0/24 ospf-core
docker network create --subnet 192.168.2.0/24 ospf-lan2
Docker keeps the .1 address of each network for the host bridge. That is why the routers use .11 to .13 on the core and .254 on the LANs.
Step 2: Write the FRR configs
FRR reads two files from /etc/frr. The daemons file says which routing daemons start. The frr.conf file holds the configuration. Copy the image's default daemons file and switch ospfd on:
mkdir -p r1 r2 r3
for n in 1 2 3; do
docker run --rm quay.io/frrouting/frr:10.7.1 cat /etc/frr/daemons \
| sed 's/^ospfd=no/ospfd=yes/' > r$n/daemons
touch r$n/vtysh.conf
done
Now write r1/frr.conf:
frr defaults traditional
hostname r1
!
interface lo
ip address 10.255.0.1/32
!
router ospf
ospf router-id 10.255.0.1
network 10.255.0.1/32 area 0
network 10.0.0.0/24 area 0
network 192.168.1.0/24 area 0
passive-interface lo
!
r2/frr.conf has no LAN:
frr defaults traditional
hostname r2
!
interface lo
ip address 10.255.0.2/32
!
router ospf
ospf router-id 10.255.0.2
network 10.255.0.2/32 area 0
network 10.0.0.0/24 area 0
passive-interface lo
!
r3/frr.conf mirrors r1:
frr defaults traditional
hostname r3
!
interface lo
ip address 10.255.0.3/32
!
router ospf
ospf router-id 10.255.0.3
network 10.255.0.3/32 area 0
network 10.0.0.0/24 area 0
network 192.168.2.0/24 area 0
passive-interface lo
!
The network statements match interfaces by address, not by name. That matters in Docker: you can't always predict whether a network becomes eth0 or eth1, and with this style you don't have to.
Step 3: Start the routers
Create each router on the core network, attach the LANs, then start them:
for n in 1 2 3; do
docker create --name ospf-r$n --hostname r$n --privileged \
--sysctl net.ipv4.ip_forward=1 \
--network ospf-core --ip 10.0.0.1$n \
-v "$PWD/r$n:/etc/frr" quay.io/frrouting/frr:10.7.1
done
docker network connect --ip 192.168.1.254 ospf-lan1 ospf-r1
docker network connect --ip 192.168.2.254 ospf-lan2 ospf-r3
docker start ospf-r1 ospf-r2 ospf-r3
--sysctl net.ipv4.ip_forward=1 makes sure each router forwards packets between its interfaces. docker create plus docker network connect attaches every interface before FRR starts.
Step 4: Start the hosts
docker run -d --name ospf-host1 --hostname host1 --cap-add NET_ADMIN \
--network ospf-lan1 --ip 192.168.1.10 nicolaka/netshoot:v0.13 sleep infinity
docker run -d --name ospf-host2 --hostname host2 --cap-add NET_ADMIN \
--network ospf-lan2 --ip 192.168.2.10 nicolaka/netshoot:v0.13 sleep infinity
docker exec ospf-host1 ip route replace default via 192.168.1.254
docker exec ospf-host2 ip route replace default via 192.168.2.254
Docker points each container's default route at the bridge (.1). The last two commands send the hosts' traffic to their router instead. That is why the hosts need NET_ADMIN.
Step 5: Verify OSPF
Wait about 40 seconds. On a broadcast network, OSPF waits for the Wait timer (equal to the dead interval) before it elects the DR. Then check r1's neighbors:
docker exec ospf-r1 vtysh -c 'show ip ospf neighbor'
Neighbor ID Pri State Up Time Dead Time Address Interface RXmtL RqstL DBsmL
10.255.0.2 1 Full/Backup 5.666s 39.133s 10.0.0.12 eth0:10.0.0.11 1 0 0
10.255.0.3 1 Full/DR 10.668s 39.334s 10.0.0.13 eth0:10.0.0.11 0 0 0
Both neighbors are Full. r3 won the DR election and r2 is the BDR. When routers start together with equal priorities, the highest router ID wins, and r3 has the highest. From r2's side, r1 shows as Full/DROther.
Now the routes r1 learned through OSPF:
docker exec ospf-r1 vtysh -c 'show ip route ospf'
IPv4 unicast VRF default:
O>* 10.255.0.2/32 [110/10] via 10.0.0.12, eth0, weight 1, 00:00:06
O>* 10.255.0.3/32 [110/10] via 10.0.0.13, eth0, weight 1, 00:00:06
O>* 192.168.2.0/24 [110/20] via 10.0.0.13, eth0, weight 1, 00:00:06
[110/20] is the administrative distance (110 for OSPF) and the metric. Each interface in this lab has an OSPF cost of 10, so the far LAN costs 20: the core plus r3's LAN.
show ip ospf interface eth0 on r2 confirms the timers and roles:
Router ID 10.255.0.2, Network Type BROADCAST, Cost: 10
Transmit Delay is 1 sec, State Backup, Priority 1
Designated Router (ID) 10.255.0.3 Interface Address 10.0.0.13/24
Backup Designated Router (ID) 10.255.0.2, Interface Address 10.0.0.12
Timer intervals configured, Hello 10s, Dead 40s, Wait 40s, Retransmit 5
Finally, end to end:
docker exec ospf-host1 ping -c 3 192.168.2.10
docker exec ospf-host1 traceroute -n 192.168.2.10
64 bytes from 192.168.2.10: icmp_seq=1 ttl=62 time=0.218 ms
64 bytes from 192.168.2.10: icmp_seq=2 ttl=62 time=0.118 ms
64 bytes from 192.168.2.10: icmp_seq=3 ttl=62 time=0.085 ms
traceroute to 192.168.2.10 (192.168.2.10), 30 hops max, 46 byte packets
1 192.168.1.254 0.006 ms 0.004 ms 0.004 ms
2 10.0.0.13 0.003 ms 0.004 ms 0.004 ms
3 192.168.2.10 0.003 ms 0.006 ms 0.004 ms
A TTL of 62 means two routers on the way, and the traceroute shows them: r1, then r3.
What OSPF did
OSPF is a link-state protocol. Each router does three things:
- Finds neighbors. It sends hellos to 224.0.0.5 every 10 seconds on each OSPF interface. Routers that agree on area, subnet and timers become neighbors.
- Floods its links. Each router describes its own links in a Router LSA. On the broadcast core, the DR also sends a Network LSA that lists every router on that segment. Neighbors sync these into the same database.
- Runs SPF. Each router runs Dijkstra's shortest path algorithm over the database and installs the best routes.
You can see the database on any router:
docker exec ospf-r1 vtysh -c 'show ip ospf database'
Router Link States (Area 0.0.0.0)
Link ID ADV Router Age Seq# CkSum Link count
10.255.0.1 10.255.0.1 101 0x80000007 0x4e14 3
10.255.0.2 10.255.0.2 102 0x80000006 0x9d46 2
10.255.0.3 10.255.0.3 106 0x80000007 0x97c1 3
Net Link States (Area 0.0.0.0)
Link ID ADV Router Age Seq# CkSum
10.0.0.13 10.255.0.3 106 0x80000002 0xbd4f
There is one Router LSA per router. r1 and r3 list 3 links (loopback, core, LAN) and r2 lists 2. The single Network LSA comes from r3, the DR, and its Link ID is r3's core address. All three routers hold this same database, so they all compute consistent routes.
Why a DR at all? Without one, every router on a shared segment would form a full adjacency with every other router. With a DR, each router syncs only with the DR and the BDR. On three routers the saving is small. On a LAN with twenty routers it matters.
If something doesn't work
- No neighbors at all. Check that each router has its core address with
docker exec ospf-r1 ip -br addr. A typo in anetworkline silently leaves an interface out of OSPF. - Neighbors stuck in
ExStartorExchange. This usually means an MTU mismatch. All Docker bridge networks default to 1500, so it only happens if you changed one. - Routes present but ping fails. Check the host's default route with
ip route, andsysctl net.ipv4.ip_forwardon the routers. Address already in use. You asked for the.1address, which belongs to the Docker bridge.
You may also notice a kernel default route on each router, K>* 0.0.0.0/0 via 10.0.0.1. Docker adds it. It doesn't affect the lab.
Clean up
docker rm -f ospf-r1 ospf-r2 ospf-r3 ospf-host1 ospf-host2
docker network rm ospf-lan1 ospf-core ospf-lan2
Going further
- Change a cost. Add
ip ospf cost 100under an interface and watch the metric change inshow ip route ospf. - Make LAN interfaces passive. No other router lives on the LANs, so
passive-interfacestops useless hellos there. Look up the interface name first withip -br addr. - Add a second area. Put r3's LAN in area 1 and look at the summary LSAs in
show ip ospf database. - Break things. Stop r3 with
docker stop ospf-r3and see how long r1 keeps the route. Then lower the hello and dead intervals. - Move on to BGP. The eBGP lab with FRR in Docker uses the same setup with three autonomous systems.
The FRR OSPFv2 documentation lists every command used here, and RFC 2328 is the protocol itself, including the DR election rules. The daemons file is described in the FRR setup guide.
If you build this lab with Docker nodes inside GNS3 instead and they can't reach the internet, see GNS3 Docker container has no internet.
Draw it instead
Typing docker network connect for each cable gets old fast. In Isothrum's editor you can draw the same network lab in the browser, with no account, and use FRR as the router. It exports an isoloom.yml plus Docker Compose files, and you run the lab with Docker as above. You still write the OSPF config yourself, which is the part worth learning.