This lab builds a complete DHCPv4 service on OPNsense: a scope on the LAN interface with reservations, the VoIP provisioning options, and a relay for a second VLAN whose traffic cannot reach the server directly. By the end you will have walked a lease through three layers — server, relay, client — and read the result back out of OPNsense’s lease database.
The lab uses a single-tenant design that is small enough to fit on one nested host: two VLANs on a trunk, one DHCP server, one relay, two clients. Scaling this up to a real estate is a matter of repeating the same scope, not rewriting it.
Objective
By the end of this lab, you can:
- Build a DHCPv4 scope with the right pool, gateway, DNS and lease time for a subnet.
- Add static reservations by MAC address without breaking dynamic allocation for everyone else.
- Advertise DHCP option 66 (TFTP server) and option 150 (TFTP server IP for VoIP) without contaminating option 1 (subnet mask) or option 3 (router).
- Configure a DHCP relay on a VLAN whose broadcast domain does not reach the server.
- Read the lease database to verify the four-tuple a working lease produces.
Requirements
You need:
- An OPNsense instance with at least three interfaces: WAN, LAN (the DHCP server’s home), and a VLAN-capable parent interface for VLAN 20 (the relayed segment).
- Two DHCP clients — physical hosts, VMs, or containers — one on each VLAN.
- Shell access on OPNsense (
ssh root@<lan-ip>or the console). - Optional but recommended:
tcpdumpon a Linux client so you can watch the four-packet DORA exchange.
If you do not have a switch that can carry 802.1Q, you can run the whole topology on one nested host using VLAN sub-interfaces on Linux or two OPNsense VLANs.
Tasks
Task 1: Lay down the address plan
Before touching the GUI, write the address plan. This is the part most operators skip, and it is the part most operators get wrong. A DHCP scope is just the dynamic half of an address plan; the reservations and the static hosts must fit in the static half.
VLAN 10 (LAN) 10.10.10.0/24
gateway 10.10.10.1 (OPNsense LAN)
DNS 10.10.10.1 (Unbound on OPNsense)
DHCP pool 10.10.10.100 - 10.10.10.200
reserved (static) 10.10.10.10 - 10.10.10.99
reservations .50 printer, .51 AP, .52 NAS, .53 camera NVR
lease time 12h
option 66 10.10.10.20 (TFTP, lab only)
option 150 10.10.10.21 (VoIP provisioning)
VLAN 20 (relayed) 10.10.20.0/24
gateway 10.10.20.1 (OPNsense VLAN 20)
relay points at 10.10.10.1 (the DHCP server on LAN)
DHCP pool 10.10.20.100 - 10.10.20.200
lease time 6h
The pool is the top half of the subnet; the bottom half is for static hosts. If you do not enforce that split on paper, the dynamic pool will silently overlap a static reservation one day and two devices will fight over the address.
Task 2: Configure the LAN scope
From the OPNsense GUI, Services → DHCPv4 → [LAN]:
| Field | Value |
|---|---|
| Enable | on |
| Range from | 10.10.10.100 |
| Range to | 10.10.10.200 |
| Gateway | 10.10.10.1 (default is fine) |
| DNS servers | 10.10.10.1 (Unbound) |
| Domain name | lab.local |
| Default lease time | 7200 (2h, in seconds) |
| Maximum lease time | 43200 (12h) |
| Static ARP entries | off |
Click Save, then Apply changes at the top of the page.
Confirm the daemon actually started before doing anything else:
# On OPNsense
service dhcpd status
sockstat -l -p 67
sockstat should show dhcpd listening on *:67 on the LAN
interface. If it is not listening, the GUI said “applied” but the
service did not restart — check System → Log → General for the
reason. Common causes: the LAN interface address is not in the
range you configured the pool for, or you have another DHCP server
on the same broadcast domain.
Task 3: Add static reservations
Services → DHCPv4 → [LAN] → scroll to Static ARP entries below the pool. Add one entry per device that needs a stable IP:
printer 10.10.10.50 aa:bb:cc:dd:ee:01 printer.lab.local
ap 10.10.10.51 aa:bb:cc:dd:ee:02 ap.lab.local
nas 10.10.10.52 aa:bb:cc:dd:ee:03 nas.lab.local
nvr 10.10.10.53 aa:bb:cc:dd:ee:04 nvr.lab.local
Save and apply. The GUI writes the entries to
/etc/dhcpd.conf (or /var/dhcpd/etc/dhcpd.conf on some builds)
and restarts the daemon. Verify:
grep -A4 'host printer' /etc/dhcpd.conf
Task 4: Advertise option 66 and option 150
Option 66 is a string (a hostname, conventionally); option 150 is a list of IPv4 addresses (used by Cisco VoIP handsets and similar gear that will not accept hostnames). Both are configured under the per-interface scope, in the Additional BOOTP/DHCP options box.
option 66 tftp.lab.local
option 150 10.10.10.21
OPNsense takes the option number and the value and emits the right DHCP option wire format. Save and apply.
Verify the resulting config file:
grep -E 'option (tftp-server|tftp-server-name)' /etc/dhcpd.conf
You should see both option tftp-server-name "tftp.lab.local";
and option tftp-server 10.10.10.21;. The names differ because
the underlying ISC DHCP daemon calls them that, but both option
codes 66 and 150 are present.
If a VoIP phone in this lab refuses to provision, the first thing to check is whether the phone received option 150 at all. Many phones log this on boot; you can also confirm by capturing on the LAN side:
sudo tcpdump -i eth0 -vvv -s 0 -n port 67 and port 68 2>&1 \
| grep -E 'option 66|option 150'
Task 5: Get a lease on the LAN
From a client on the LAN, request a fresh lease. The exact command depends on the OS:
# Linux (NetworkManager)
sudo nmcli connection down "Wired connection 1" && \
sudo nmcli connection up "Wired connection 1"
# Linux (dhclient)
sudo dhclient -r eth0
sudo dhclient -v eth0
# Windows
ipconfig /release && ipconfig /renew
# macOS
sudo ipconfig set en0 DHCP && sudo ipconfig getpacket en0
Confirm the client received the values you configured:
ip -4 addr show dev eth0 | grep inet
ip -4 route show default
resolvectl status | grep -A2 eth0
The IP should be inside 10.10.10.100 - 10.10.10.200, the gateway
should be 10.10.10.1, and the DNS server should be 10.10.10.1.
Task 6: Read the lease database
The DHCP server’s record of the lease is the authoritative answer to “did this work?”, because the GUI’s status bar lies — it counts handshakes, not leases handed out. Read the file directly:
cat /var/dhcpd/var/db/dhcpd.leases | tail -40
A successful entry looks like:
lease 10.10.10.105 {
starts 4 2026/08/14 12:00:00;
ends 4 2026/08/14 14:00:00;
cltt 4 2026/08/14 12:00:00;
binding state active;
next binding state free;
hardware ethernet aa:bb:cc:dd:ee:10;
client-hostname "laptop01";
}
The four-tuple to read off is IP, MAC, hostname, expiry. If any of those is wrong, the client did not get what you intended — even if the GUI said it did.
Task 7: Build the relay on VLAN 20
Services → DHCPv4 → Relay. Add an entry:
| Field | Value |
|---|---|
| Enabled | on |
| Interface | VLAN20 |
| Agent | 10.10.10.1 (the DHCP server’s IP) |
| Source address | 10.10.20.1 (the relay’s VLAN 20 IP) |
Save and apply. Confirm the relay daemon is up:
sockstat -l -p 67
ps -axo pid,command | grep -E 'dhcrelay|dhcpd'
You should see both dhcpd (on *:67 for LAN) and dhcrelay
(on *:67 for VLAN 20, plus a unicast socket to 10.10.10.1).
Task 8: Get a lease on the relayed VLAN
From a client on VLAN 20, request a lease (same commands as Task 5, just on a different interface). Confirm:
ip -4 addr show dev eth0.20 | grep inet
The IP must be inside 10.10.20.100 - 10.10.20.200. That is the
proof the relay worked: the client is on VLAN 20, but the address
it got is from the pool the LAN’s DHCP server is serving for the
10.10.20.0/24 range.
Task 9: Capture the DORA on the relay
This is the diagnostic every operator should be able to produce from memory. Capture on the relayed VLAN side, watch the four packets:
sudo tcpdump -i eth0.20 -nn -vvv -s 0 -c 8 port 67 or port 68
From the VLAN 20 client, release and renew. The capture shows:
12:00:00.000 client → ff:ff:ff:ff:ff:ff DHCP Discover
12:00:00.010 server → client DHCP Offer
12:00:00.020 client → ff:ff:ff:ff:ff:ff DHCP Request
12:00:00.030 server → client DHCP ACK
If the relay is broken, you will see the Discover go out and
nothing come back. If the relay is up but the server is missing
the VLAN 20 scope, you will see the Discover go out, the server
log a DHCPOFFER on 10.10.10.1, and the client get nothing.
Validation
- A client on LAN leases an address in
10.10.10.100 - 10.10.10.200, with gateway10.10.10.1and DNS10.10.10.1. - A client on VLAN 20 leases an address in
10.10.20.100 - 10.10.20.200. /etc/dhcpd.confshows both option 66 (string) and option 150 (IPv4 address).- A reservation for
aa:bb:cc:dd:ee:01maps to10.10.10.50, confirmed in the lease file by IP, MAC, hostname and expiry. sockstat -l -p 67shows bothdhcpdanddhcrelaylistening.- A
tcpdumpon VLAN 20 shows the four-packet DORA exchange between the relayed client and the LAN’s server.
Expected Result
A working DHCPv4 service that serves both the directly-connected LAN and a second VLAN through a relay, with reservations for the static half of the subnet and option 66/150 advertised to all dynamic clients. The lease database contains entries for both VLANs and the relay daemon is logged as handing requests to the server.
Troubleshooting
The client gets no offer.
Check, in order: (1) sockstat -l -p 67 on OPNsense shows dhcpd
on the right interface, (2) the LAN/VLAN 20 interface address is
inside the pool’s parent subnet, (3) no firewall rule on the
client’s VLAN blocks UDP/67 and UDP/68 between the broadcast domain
and OPNsense, (4) System → Log → General does not contain
dhcpd: ... not authoritative.
The relay works but the wrong scope answers.
OPNsense shows the active scope per interface. A relayed request
arrives at the server with giaddr set to the relay’s source IP.
The server matches the scope whose subnet contains giaddr, not
the interface the request came in on. If the relay’s giaddr is
on 10.10.20.1, you need a scope for 10.10.20.0/24 enabled on
the OPNsense interface that owns that address (VLAN 20).
Reservations silently disappear after a GUI change.
Static mappings are stored in the GUI’s config.xml, not in
/etc/dhcpd.conf. A XML export/import round-trip preserves them;
a hand-edited dhcpd.conf does not. Always edit reservations
through the GUI.
Option 150 arrives empty.
The DHCP daemon parses option 150 as a comma-separated list of
IPv4 addresses, not as a string. Use 10.10.10.21 and not
tftp.lab.local for option 150; that is option 66’s job.
Cleanup
Reset OPNsense to a clean state and undo the client changes.
# On OPNsense: disable the DHCP scope and the relay through the GUI.
# Services → DHCPv4 → [LAN] → uncheck Enable → Apply changes
# Services → DHCPv4 → [VLAN20] → uncheck Enable (only if you created one)
# Services → DHCPv4 → Relay → uncheck Enabled → Apply changes
# Confirm both daemons are stopped
sockstat -l -p 67 | grep -E 'dhcpd|dhcrelay' || echo "no DHCP daemons listening"
# On each client, release the lease
sudo dhclient -r # Linux
ipconfig /release # Windows
If you created a test-only scope on VLAN 20 for this lab, delete
it now. The GUI keeps deleted scopes in /var/dhcpd/etc/ for
reference but they will re-appear in the GUI on every firmware
upgrade until you remove them through Services → DHCPv4 → [LAN]
→ Delete.
What you learned
- A DHCP scope is the dynamic half of an address plan; the static half has to be reserved by the operator, never by accident.
- Options 66 (string) and 150 (IPv4 list) are different wire types even though they look similar in the GUI. Mixing them up is the most common VoIP-provisioning failure.
- A relay does not run a DHCP server. It only repackages broadcasts
as unicasts. The scope the server hands out must exist on the
interface that owns the relay’s
giaddrsubnet. - The lease file is the authoritative answer. The GUI’s status page counts handshakes; the lease file counts leases that completed and were not yet released.