🔐 WireGuard on FreeBSD: A Private Tunnel Between Two Machines
A working encrypted tunnel from nothing but the base system, with the keys, the config file, the proof that the traffic is encrypted, the AllowedIPs trap, a routed subnet, and a setup that survives a reboot
What WireGuard is
WireGuard is a small, modern VPN. You give each machine a key pair, you tell each machine the public key of the other, and you get a network interface (wg0) that carries encrypted traffic between them. There are no certificates, no user database and no long negotiation. It talks over UDP, on one port you choose.
The part that surprises people on FreeBSD 15.0: everything you need is already in the base system. The kernel module if_wg and the wg tool are installed with FreeBSD, with no packages. We checked on the lab machine:
root@prod:~ # freebsd-version; ifconfig vtnet1 | grep inet; kldstat | grep -c wg; ls /boot/kernel/if_wg.ko; which wg wg-quick 15.0-RELEASE inet 192.168.99.1 netmask 0xffffff00 broadcast 192.168.99.255 0 /boot/kernel/if_wg.ko /usr/bin/wg root@prod:~ # pkg search -x "^wireguard" wireguard-go-0.0.20250522_19,1 WireGuard implementation in Go wireguard-tools-1.0.20260223 Fast, modern and secure VPN Tunnel wireguard-tools-lite-1.0.20260223 Fast, modern and secure VPN Tunnel (lite flavor)
(The which line found /usr/bin/wg and ls found /boot/kernel/if_wg.ko. The pkg search lines show the extra packages, wireguard-tools with the wg-quick helper and wireguard-go: we did not install them, and you do not need them for this page.)
This page connects two real FreeBSD 15.0 virtual machines, the prod and backup machines from the ZFS backups page. They already share a plain network, 192.168.99.1 and 192.168.99.2. We build a tunnel on top, with its own addresses 10.10.10.1 (prod) and 10.10.10.2 (backup), and then use it.
Step 1: load the module and make the keys
root@prod:~ # kldload if_wg; kldstat | grep wg; wg --version 2>&1 | head -1; wg help 2>&1 | head -3 kldload: can't load if_wg: module already loaded or in kernel 14 1 0xffffffff830aa000 2f5e0 if_wg.ko wireguard-tools v1.0.20250521 - https://git.zx2c4.com/wireguard-tools/ Usage: wg <cmd> [<args>] Available subcommands: root@backup:~ # kldload if_wg; kldstat | grep wg kldload: can't load if_wg: module already loaded or in kernel 14 1 0xffffffff830aa000 2f5e0 if_wg.ko
kldload if_wg loads the driver, and wg is the tool (version v1.0.20250521). You will see below that you do not even have to do the kldload yourself: creating the interface loads the module, and after a reboot it was there without anything in loader.conf.
Each machine makes its own private key, and from it the public key. The private key never leaves the machine. The public key is what you give to the other side, and it is fine to send it by e-mail or chat.
root@prod:~ # mkdir -p /etc/wireguard && chmod 700 /etc/wireguard; (cd /etc/wireguard && umask 077 && wg genkey > prod.key && wg pubkey < prod.key > prod.pub); ls -l /etc/wireguard; cat /etc/wireguard/prod.pub total 1 -rw------- 1 root wheel 45 Oct 6 18:44 prod.key -rw------- 1 root wheel 45 Oct 6 18:44 prod.pub x4Inrrtz43Vt8+w7ha4Msh1n7gwa0+0hqi4+Fod9Q3Q= root@backup:~ # mkdir -p /etc/wireguard && chmod 700 /etc/wireguard; (cd /etc/wireguard && umask 077 && wg genkey > backup.key && wg pubkey < backup.key > backup.pub); cat /etc/wireguard/backup.pub l3fc8eNTias7Vs1hreApmCMl9wgXT2vr4a3jwtNABz8=
- wg genkey prints a new private key, and wg pubkey reads one on its input and prints the matching public key.
- umask 077 makes the files readable only by their owner: look at the -rw------- in the listing, and at the directory, which is mode 700. Anyone who reads a private key can pretend to be that machine.
- The public keys here are: prod x4Inrrtz43Vt8+w7ha4Msh1n7gwa0+0hqi4+Fod9Q3Q= and backup l3fc8eNTias7Vs1hreApmCMl9wgXT2vr4a3jwtNABz8=. They belong to a throwaway lab, which is why we can print them: never publish a private key.
Step 2: the config files
The same shape on both sides: an [Interface] section that describes this machine, and one [Peer] section for each other machine it talks to.
root@prod:~ # cat > /etc/wireguard/wg0.conf <<EOF > [Interface] > PrivateKey = $(cat /etc/wireguard/prod.key) > ListenPort = 51820 > > [Peer] > PublicKey = l3fc8eNTias7Vs1hreApmCMl9wgXT2vr4a3jwtNABz8= > AllowedIPs = 10.10.10.2/32 > Endpoint = 192.168.99.2:51820 > PersistentKeepalive = 25 > EOF root@prod:~ # chmod 600 /etc/wireguard/wg0.conf; sed "s/^PrivateKey.*/PrivateKey = (hidden)/" /etc/wireguard/wg0.conf [Interface] PrivateKey = (hidden) ListenPort = 51820 [Peer] PublicKey = l3fc8eNTias7Vs1hreApmCMl9wgXT2vr4a3jwtNABz8= AllowedIPs = 10.10.10.2/32 Endpoint = 192.168.99.2:51820 PersistentKeepalive = 25
On prod: the private key (we hide it in the display with sed, the real file has it), the port to listen on, and the peer: the backup's public key, the addresses allowed from it (explained in a moment), where to find it (Endpoint), and PersistentKeepalive, which sends a small packet every 25 seconds so that a tunnel behind a firewall or NAT stays open.
root@backup:~ # cat > /etc/wireguard/wg0.conf <<EOF > [Interface] > PrivateKey = $(cat /etc/wireguard/backup.key) > ListenPort = 51820 > > [Peer] > PublicKey = x4Inrrtz43Vt8+w7ha4Msh1n7gwa0+0hqi4+Fod9Q3Q= > AllowedIPs = 10.10.10.1/32 > EOF root@backup:~ # chmod 600 /etc/wireguard/wg0.conf; sed "s/^PrivateKey.*/PrivateKey = (hidden)/" /etc/wireguard/wg0.conf [Interface] PrivateKey = (hidden) ListenPort = 51820 [Peer] PublicKey = x4Inrrtz43Vt8+w7ha4Msh1n7gwa0+0hqi4+Fod9Q3Q= AllowedIPs = 10.10.10.1/32
On backup: the mirror image. It has no Endpoint line: it learns where prod is from the first packet it receives. That is the usual shape when one side has a fixed address (a server) and the other side may move (a laptop). Here we could have given both an endpoint.
AllowedIPs is the one idea you must understand. It does two jobs at once:
- Outgoing: it is a routing table. A packet for an address in this list is encrypted for this peer.
- Incoming: it is a filter. A packet that arrives from this peer, after decryption, is accepted only if its source address is in this list.
Step 3: bring the interface up
root@prod:~ # ifconfig wg0 create root@prod:~ # wg setconf wg0 /etc/wireguard/wg0.conf root@prod:~ # ifconfig wg0 inet 10.10.10.1/24 up root@prod:~ # ifconfig wg0 wg0: flags=10080c1<UP,RUNNING,NOARP,MULTICAST,LOWER_UP> metric 0 mtu 1420 options=80000<LINKSTATE> inet 10.10.10.1 netmask 0xffffff00 groups: wg nd6 options=109<PERFORMNUD,IFDISABLED,NO_DAD> root@backup:~ # ifconfig wg0 create root@backup:~ # wg setconf wg0 /etc/wireguard/wg0.conf root@backup:~ # ifconfig wg0 inet 10.10.10.2/24 up
Three commands on each machine:
- ifconfig wg0 create makes the interface (and loads the module if needed).
- wg setconf wg0 /etc/wireguard/wg0.conf gives it the keys and the peer.
- ifconfig wg0 inet 10.10.10.1/24 up gives it an address and turns it on. Note the mtu 1420: smaller than the usual 1500, because the tunnel needs room for its own headers.
We did the same on backup (no output expected, so none is shown). Six seconds later:
root@prod:~ # sleep 6; wg show wg0 interface: wg0 public key: x4Inrrtz43Vt8+w7ha4Msh1n7gwa0+0hqi4+Fod9Q3Q= private key: (hidden) listening port: 51820 peer: l3fc8eNTias7Vs1hreApmCMl9wgXT2vr4a3jwtNABz8= endpoint: 192.168.99.2:51820 allowed ips: 10.10.10.2/32 latest handshake: 2 seconds ago transfer: 124 B received, 328 B sent persistent keepalive: every 25 seconds root@backup:~ # wg show wg0 interface: wg0 public key: l3fc8eNTias7Vs1hreApmCMl9wgXT2vr4a3jwtNABz8= private key: (hidden) listening port: 51820 peer: x4Inrrtz43Vt8+w7ha4Msh1n7gwa0+0hqi4+Fod9Q3Q= endpoint: 192.168.99.1:51820 allowed ips: 10.10.10.1/32 latest handshake: 2 seconds ago transfer: 180 B received, 124 B sent
wg show is the status command. The line to look for is latest handshake: 2 seconds ago on both machines: the two have found each other and agreed on session keys. If it says nothing, or the time is minutes old while you are sending traffic, the tunnel is not working (see the trap below). transfer counts the bytes. And now the tunnel carries traffic:
root@prod:~ # ping -c 3 10.10.10.2 PING 10.10.10.2 (10.10.10.2): 56 data bytes 64 bytes from 10.10.10.2: icmp_seq=0 ttl=64 time=1.269 ms 64 bytes from 10.10.10.2: icmp_seq=1 ttl=64 time=1.055 ms 64 bytes from 10.10.10.2: icmp_seq=2 ttl=64 time=1.194 ms --- 10.10.10.2 ping statistics --- 3 packets transmitted, 3 packets received, 0.0% packet loss round-trip min/avg/max/stddev = 1.055/1.172/1.269/0.089 ms root@prod:~ # wg show wg0 transfer l3fc8eNTias7Vs1hreApmCMl9wgXT2vr4a3jwtNABz8= 472 712
Is it really encrypted?
Look at the network from the outside, on the plain interface vtnet1, while the backup machine pings prod through the tunnel. Then look at the tunnel interface itself:
root@backup:~ # (sleep 1; ping -c 2 10.10.10.1 >/dev/null) & tcpdump -n -i vtnet1 -c 4 udp port 51820 2>&1 | grep -v "^tcpdump\|listening" 18:46:28.687941 IP 192.168.99.1.51820 > 192.168.99.2.51820: UDP, length 32 18:46:28.730478 IP 192.168.99.2.51820 > 192.168.99.1.51820: UDP, length 128 18:46:28.731652 IP 192.168.99.1.51820 > 192.168.99.2.51820: UDP, length 128 18:46:29.792566 IP 192.168.99.2.51820 > 192.168.99.1.51820: UDP, length 128 4 packets captured 5 packets received by filter 0 packets dropped by kernel [1] Done (sleep 1; ping -c 2 10.10.10.1 >/dev/null) root@backup:~ # (sleep 1; ping -c 1 10.10.10.1 >/dev/null) & tcpdump -n -i wg0 -c 2 2>&1 | grep -v "^tcpdump\|listening" 18:46:31.916015 IP 10.10.10.2 > 10.10.10.1: ICMP echo request, id 36872, seq 0, length 64 18:46:31.917017 IP 10.10.10.1 > 10.10.10.2: ICMP echo reply, id 36872, seq 0, length 64 2 packets captured 2 packets received by filter 0 packets dropped by kernel [1] Done (sleep 1; ping -c 1 10.10.10.1 >/dev/null)
On vtnet1, an observer sees only UDP packets between port 51820 and 51820, of a few fixed sizes (128 bytes). No ping, no addresses of the tunnel, nothing readable. On wg0, the same traffic is plain: an ICMP echo from 10.10.10.2 to 10.10.10.1 and its reply. That is the idea: inside the machine it looks like an ordinary network, outside it is noise.
Using the tunnel
Anything that works on a network works across it. The backup routine from the previous page can send over 10.10.10.2 instead of the plain address. First, a login to the tunnel address:
root@prod:~ # ssh -i /root/.ssh/zbackup_key -o BatchMode=yes zbackup@10.10.10.2 "hostname; zfs list -H -o name zroot/backups" Host key verification failed.
Host key verification failed. That is ssh being correct, not WireGuard being broken: ssh remembers host keys by address, and 10.10.10.2 is a new address it has never seen. Accept the new one once (with a known good machine, as here) and it works:
root@prod:~ # ssh -i /root/.ssh/zbackup_key -o BatchMode=yes -o StrictHostKeyChecking=accept-new zbackup@10.10.10.2 "hostname; zfs list -H -o name zroot/backups" 2>&1 Warning: Permanently added '10.10.10.2' (ED25519) to the list of known hosts. backup zroot/backups
The AllowedIPs trap
The most common WireGuard problem is a wrong AllowedIPs, and it fails silently. We broke it on purpose: on the backup machine we told the peer prod that it may only use the address 10.10.10.99. Then we pinged both ways:
root@backup:~ # wg set wg0 peer x4Inrrtz43Vt8+w7ha4Msh1n7gwa0+0hqi4+Fod9Q3Q= allowed-ips 10.10.10.99/32; wg show wg0 allowed-ips x4Inrrtz43Vt8+w7ha4Msh1n7gwa0+0hqi4+Fod9Q3Q= 10.10.10.99/32 root@prod:~ # ping -c 2 -t 4 10.10.10.2 2>&1 | tail -4 PING 10.10.10.2 (10.10.10.2): 56 data bytes --- 10.10.10.2 ping statistics --- 2 packets transmitted, 0 packets received, 100.0% packet loss root@backup:~ # ping -c 1 10.10.10.1 2>&1 | tail -3 --- 10.10.10.1 ping statistics --- 1 packets transmitted, 0 packets received, 100.0% packet loss root@backup:~ # wg set wg0 peer x4Inrrtz43Vt8+w7ha4Msh1n7gwa0+0hqi4+Fod9Q3Q= allowed-ips 10.10.10.1/32; wg show wg0 allowed-ips x4Inrrtz43Vt8+w7ha4Msh1n7gwa0+0hqi4+Fod9Q3Q= 10.10.10.1/32 root@prod:~ # ping -c 2 10.10.10.2 | tail -2 2 packets transmitted, 2 packets received, 0.0% packet loss round-trip min/avg/max/stddev = 1.157/1.238/1.320/0.081 ms
Both pings: 100 percent packet loss, and no error message at all. The handshake still works (the keys are right), but the traffic is dropped: prod's packets arrive with the source 10.10.10.1, which is not in the allowed list for that peer, so they are thrown away, and the replies cannot be routed. After wg set put the right address back, the pings worked at once, with no restart. That is also a useful lesson on the command: wg set changes a running tunnel without taking it down.
When the tunnel is up (a recent handshake) and the ping does not work, check wg show wg0 allowed-ips on both sides first.
Reaching a whole network
AllowedIPs can hold a whole network, to reach what is behind a peer. To keep it simple we pretended that the backup machine had a small network behind it: a second loopback interface lo1 with the address 192.168.50.1/24. The steps, with the failure first:
root@backup:~ # ifconfig lo1 create inet 192.168.50.1/24 up; ifconfig lo1 | grep inet inet 192.168.50.1 netmask 0xffffff00 inet6 fe80::1%lo1 prefixlen 64 scopeid 0x5 root@prod:~ # ping -c 1 -t 3 192.168.50.1 2>&1 | tail -3 --- 192.168.50.1 ping statistics --- 1 packets transmitted, 0 packets received, 100.0% packet loss root@prod:~ # wg set wg0 peer l3fc8eNTias7Vs1hreApmCMl9wgXT2vr4a3jwtNABz8= allowed-ips 10.10.10.2/32,192.168.50.0/24; wg show wg0 allowed-ips l3fc8eNTias7Vs1hreApmCMl9wgXT2vr4a3jwtNABz8= 192.168.50.0/24 10.10.10.2/32 root@prod:~ # route add -net 192.168.50.0/24 -interface wg0 add net 192.168.50.0: gateway wg0 root@prod:~ # route -n get 192.168.50.1 | grep -E "destination|interface" destination: 192.168.50.0 interface: wg0 root@prod:~ # ping -c 2 192.168.50.1 | tail -3 --- 192.168.50.1 ping statistics --- 2 packets transmitted, 2 packets received, 0.0% packet loss round-trip min/avg/max/stddev = 1.033/1.132/1.231/0.099 ms root@backup:~ # sysctl net.inet.ip.forwarding net.inet.ip.forwarding: 0
- Before any change, the ping to 192.168.50.1 gets 100 percent loss: prod has no idea where that network is.
- wg set ... allowed-ips 10.10.10.2/32,192.168.50.0/24 tells WireGuard that the peer is also responsible for that network. (Note: this replaces the list, so you must repeat the old address.)
- Then the route. FreeBSD does not add one for you: route add -net 192.168.50.0/24 -interface wg0, and route -n get confirms that the interface is wg0.
- Then the ping works.
Here the address was on the backup machine itself, so no forwarding was needed. For a real network behind the peer, that peer must act as a router: net.inet.ip.forwarding=1 (it is 0 by default, as the last line shows) and gateway_enable="YES" in rc.conf, and its firewall must allow the traffic. Not run here, from the documentation. A route added by hand is gone after a reboot: to keep it, use static_routes in rc.conf (also from the documentation, not run here).
Making it survive a reboot
All we typed so far disappears at shutdown. The FreeBSD way is rc.conf, and for the one thing that ifconfig cannot do, loading the keys, a small file /etc/start_if.wg0 (the network start-up script runs start_if.INTERFACE before it sets up that interface):
root@prod:~ # sysrc cloned_interfaces; grep -c wg0 /etc/rc.conf cloned_interfaces: 0 root@prod:~ # sysrc cloned_interfaces+=wg0 cloned_interfaces: -> wg0 root@prod:~ # sysrc ifconfig_wg0="inet 10.10.10.1/24" ifconfig_wg0: -> inet 10.10.10.1/24 root@prod:~ # echo 'wg setconf wg0 /etc/wireguard/wg0.conf' > /etc/start_if.wg0 root@prod:~ # ifconfig wg0 destroy; service netif restart wg0 2>&1 | tail -3; ifconfig wg0 | grep inet; wg show wg0 peers inet 10.10.10.1 netmask 0xffffff00 groups: wg nd6 options=109<PERFORMNUD,IFDISABLED,NO_DAD> inet 10.10.10.1 netmask 0xffffff00 l3fc8eNTias7Vs1hreApmCMl9wgXT2vr4a3jwtNABz8= root@backup:~ # sysrc cloned_interfaces+=wg0 cloned_interfaces: -> wg0 root@backup:~ # sysrc ifconfig_wg0="inet 10.10.10.2/24" ifconfig_wg0: -> inet 10.10.10.2/24 root@backup:~ # echo 'wg setconf wg0 /etc/wireguard/wg0.conf' > /etc/start_if.wg0 root@backup:~ # ifconfig wg0 destroy; service netif restart wg0 2>&1 | tail -3; ifconfig wg0 | grep inet; wg show wg0 peers inet 10.10.10.2 netmask 0xffffff00 groups: wg nd6 options=109<PERFORMNUD,IFDISABLED,NO_DAD> inet 10.10.10.2 netmask 0xffffff00 x4Inrrtz43Vt8+w7ha4Msh1n7gwa0+0hqi4+Fod9Q3Q=
- cloned_interfaces is the list of "virtual" interfaces to create at boot. It was empty, so now it is wg0.
- ifconfig_wg0 is the address, as for any interface.
- /etc/start_if.wg0 holds one line, the wg setconf command.
- We tested it without a reboot first: we destroyed wg0 and ran service netif restart wg0, and it came back with its address and its peer.
Then the real test, a full reboot of both machines. After 51 seconds of uptime:
root@prod:~ # uptime | cut -c1-30; kldstat | grep wg; ifconfig wg0 | grep inet; wg show wg0 peers 6:48PM up 51 secs, 2 users, 14 1 0xffffffff830aa000 2f5e0 if_wg.ko inet 10.10.10.1 netmask 0xffffff00 l3fc8eNTias7Vs1hreApmCMl9wgXT2vr4a3jwtNABz8= root@prod:~ # ping -c 2 10.10.10.2 | tail -3 --- 10.10.10.2 ping statistics --- 2 packets transmitted, 2 packets received, 0.0% packet loss round-trip min/avg/max/stddev = 1.119/1.138/1.157/0.019 ms root@prod:~ # wg show wg0 latest-handshakes l3fc8eNTias7Vs1hreApmCMl9wgXT2vr4a3jwtNABz8= 1791312457 root@backup:~ # ping -c 1 10.10.10.1 | tail -2 1 packets transmitted, 1 packets received, 0.0% packet loss round-trip min/avg/max/stddev = 1.232/1.232/1.232/0.000 ms
The module if_wg is loaded (by the interface, as promised), wg0 has its address and its peer, the ping works, and the handshake time is fresh (that large number is the time in seconds since 1970). Nothing was typed after the boot.
The firewall
In our lab the firewalls were off, so the packets went straight through. In real life, on a machine with pf or ipfw, you need to allow UDP on port 51820 from the other side to the outside interface, and then write your rules for what may come in through wg0 as for any other interface. From the documentation, not run here. If the handshake never happens, that is the first place to look. The hardening page's advice also applies: once the tunnel works, you can make services such as sshd listen only on the tunnel address, so that they are invisible from the rest of the network (also not run here).
What we did not do
- wg-quick and the packages. pkg install wireguard-tools gives wg-quick and an rc script (wireguard_enable, wireguard_interfaces) that do the same as our three commands from a single file. Not run here.
- More than two machines, and roaming clients such as phones and laptops (each is one more [Peer] section). Not run here.
- Preshared keys (wg genpsk), an extra symmetric key that makes the tunnel safe even against a future attacker with a quantum computer. Not run here.
- IPv6 inside or outside the tunnel, NAT for sending all traffic through one machine, and wireguard-go. Not run here.
- Key rotation. To replace a key, you change one [Peer] line on the other side. We did not.
The cheat sheet
- Is it there? ls /boot/kernel/if_wg.ko, wg --version. Both are in the base system. The module loads itself when the interface is created.
- Keys: (umask 077; wg genkey > NAME.key); wg pubkey < NAME.key > NAME.pub. Keep /etc/wireguard at mode 700, and never share the private key.
- Config (/etc/wireguard/wg0.conf, mode 600): [Interface] with PrivateKey and ListenPort, and one [Peer] per machine with PublicKey, AllowedIPs, optionally Endpoint and PersistentKeepalive = 25.
- Up by hand: ifconfig wg0 create, wg setconf wg0 FILE, ifconfig wg0 inet 10.10.10.1/24 up. Down: ifconfig wg0 destroy.
- Status: wg show, wg show wg0 latest-handshakes, wg show wg0 allowed-ips, wg show wg0 transfer.
- Change while running: wg set wg0 peer PUBKEY allowed-ips A,B. It replaces the list.
- AllowedIPs: outgoing routing and incoming filter in one. If the handshake works but the ping does not, check it on both sides.
- A network behind a peer: add it to allowed-ips, add a route (route add -net NET -interface wg0), and make the far side a router (net.inet.ip.forwarding=1).
- Persistent: sysrc cloned_interfaces+=wg0, sysrc ifconfig_wg0="inet 10.10.10.1/24", and /etc/start_if.wg0 with the wg setconf wg0 FILE line.
- Look at the wire: tcpdump -n -i vtnet1 udp port 51820 (noise) against tcpdump -n -i wg0 (the real traffic).
- Firewall: allow UDP 51820 in, then write rules for wg0 itself. ssh remembers host keys by address: a new tunnel address is a new host to ssh.
← ZFS Backups to Another Machine: Send, Receive, Restore · ↑ FreeBSD Corner · File Sharing with NFS: One Server, Many Machines →