Configuring WireGuard on FreeBSD with pf for Encrypted Remote Access

Configuring WireGuard on FreeBSD with pf for Encrypted Remote Access

Running a private VPN tunnel on FreeBSD is one of those tasks that sounds complicated until you actually do it. WireGuard cuts through the noise. It uses modern cryptography, a lean codebase, and a configuration style that fits naturally into the BSD way of doing things. Once the tunnel is up, your remote traffic is encrypted, authenticated, and routing through a host you fully control. This guide walks you through every step, from loading the kernel module to verifying the first handshake.

Before You Start: Three Things Worth Knowing

  1. WireGuard ships as a kernel module in FreeBSD 13 and later, so no third-party package is strictly required to get started.
  2. The pf firewall needs an explicit pass rule for UDP on your chosen WireGuard port before any peer can connect to the server.
  3. Adding two lines to rc.conf makes the tunnel survive every reboot without any manual intervention.

Why WireGuard Is Worth Your Time on FreeBSD

Older VPN protocols carry years of complexity. OpenVPN ships with a long list of options, certificate chains, and TLS negotiation that can take an afternoon to tune correctly. IPsec is powerful, but its configuration syntax has earned a reputation for being deeply unfriendly. WireGuard takes a different path entirely.

The protocol is built on a fixed set of modern primitives: Curve25519 for key exchange, ChaCha20 for encryption, Poly1305 for authentication, and BLAKE2 for hashing. Those choices are deliberate. They are fast on hardware without AES acceleration, which matters on embedded routers and low-power BSD hosts. The entire implementation is small enough to audit in an afternoon.

FreeBSD includes native WireGuard support through the if_wg kernel module starting with version 13. That means you are not patching the kernel or pulling in an experimental port. It is a supported, in-tree module that loads with a single command.

Loading the Kernel Module With kldload

Before touching any configuration file, the WireGuard kernel module needs to be present. Open a root shell and run:

kldload if_wg

If the command returns without output, the module loaded successfully. Confirm it is active with:

kldstat | grep if_wg

You should see a line showing the module name and size. That confirms the kernel is ready to create WireGuard interfaces. If nothing appears, install the wireguard-tools package and try again.

Generating the Server Key Pair

WireGuard uses public-key cryptography for peer authentication. Each side of a tunnel holds a private key and shares its matching public key with the other side. The wg command handles generation cleanly.

Create a directory to hold your keys with tight permissions:

mkdir -p /etc/wireguard
chmod 700 /etc/wireguard

Now generate the server private key and derive the public key from it:

wg genkey | tee /etc/wireguard/server.key | wg pubkey > /etc/wireguard/server.pub
chmod 600 /etc/wireguard/server.key

The private key lives in server.key. The public key is in server.pub. Share the public key freely with peers. Guard the private key like a root password. Never let the private key leave the server in plaintext.

Creating and Configuring the wg0 Interface

With keys ready, write the server configuration. Create the file at /etc/wireguard/wg0.conf:

[Interface]
PrivateKey = <contents of /etc/wireguard/server.key>
Address = 10.0.0.1/24
ListenPort = 51820

The Address line assigns the server an IP inside the tunnel network. Port 51820 is the WireGuard convention over UDP. You can use any available UDP port, but 51820 is widely recognized and easy to remember. Bring the interface up with:

wg-quick up wg0

Run ifconfig wg0 to confirm the interface is present and holds the expected tunnel address. If you see 10.0.0.1 listed, the interface is up and ready for peer connections.

Writing pf Rules to Allow WireGuard UDP Traffic

The FreeBSD packet filter blocks inbound traffic by default in most configurations. Without a pass rule for UDP on port 51820, peers will never reach the server. Open /etc/pf.conf and add the following:

# WireGuard listen port
wg_port = "51820"

# Allow inbound WireGuard handshakes and data
pass in on egress proto udp to port $wg_port keep state

# Pass traffic on the tunnel interface in both directions
pass on wg0 keep state

The egress keyword tells pf to apply the rule on the default outbound interface automatically. That saves you from hardcoding a physical interface name like vtnet0 or em0, which is useful if you ever migrate the server to different hardware.

Reload pf to apply the changes:

pfctl -f /etc/pf.conf

Confirm the rules loaded cleanly:

pfctl -sr | grep wg

Common pf Checkpoints Before Testing a Connection

A few things tend to catch people out the first time they set this up. Verify each of these before moving forward:

  • Confirm pf is enabled in /etc/rc.conf with pf_enable="YES", or the rules reload fine but the filter is not actually running.
  • Check that pf_rules="/etc/pf.conf" points to the correct file path.
  • If you have a block all default policy, place the pass rules after it, not before.
  • If clients need to route all traffic through the server to the internet, add a NAT rule: nat on egress from wg0:network to any -> (egress).

Adding a Client Peer to the Server Configuration

Every WireGuard client needs its own key pair. Generate a client key pair on the client machine using the same wg genkey and wg pubkey steps described above. Then add the client as a peer on the server by appending a [Peer] block to /etc/wireguard/wg0.conf:

[Peer]
PublicKey = <client's public key here>
AllowedIPs = 10.0.0.2/32

The AllowedIPs entry tells the server which tunnel IP belongs to this peer. Each client gets a unique address inside the tunnel subnet. Keeping each client pinned to a /32 is safer than opening up the whole subnet to a single peer.

Setting Up the Client Configuration File

On the client side, the configuration references the server using the Endpoint field. This is where you supply the server’s public IP address and listening port. The full client config looks like this:

[Interface]
PrivateKey = <client private key>
Address = 10.0.0.2/24
DNS = 1.1.1.1

[Peer]
PublicKey = <server public key>
Endpoint = <server public IP>:51820
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25

Finding the Server’s Public IP Address

Many FreeBSD hosts sit behind a home router or have a dynamic IP assigned by the ISP. The address shown by ifconfig on the server is often a private RFC1918 address, not the address the outside world uses to reach it. Before filling in the Endpoint field, check your actual public-facing address. Visiting what is my IP from a browser on the server works well, and you can also grab it from a terminal without leaving the shell:

curl -s https://whatsmyip.now/

Copy that address directly into the Endpoint field in the client config. If the server IP rotates regularly, consider setting up a dynamic DNS hostname and using that as the endpoint instead. Clients will reconnect automatically even after an IP change, as long as the DNS record stays current.

Activating Peers and Confirming the Handshake

With both sides configured, bring up the WireGuard interface on the client:

wg-quick up wg0

On the server, run the following to check the peer state:

wg show

The output shows each peer, its public key, the most recent handshake time, and the amount of data transferred. A successful connection produces something like this:

peer: <client public key>
  endpoint: <client IP>:<ephemeral port>
  allowed ips: 10.0.0.2/32
  latest handshake: 8 seconds ago
  transfer: 1.44 KiB received, 1.02 KiB sent

A handshake time of “never” means the tunnel has not connected. The troubleshooting section below covers the most common causes.

Troubleshooting When the Handshake Does Not Appear

Work through these checks in order before touching any configuration file:

  1. Run pfctl -sr on the server and confirm the UDP pass rule for port 51820 is present.
  2. Verify the Endpoint in the client config holds the server’s actual public IP, not a LAN address.
  3. Compare the client’s public key in the server [Peer] block character by character. A single typo breaks authentication silently.
  4. Test raw UDP connectivity from the client with nc -zu <server IP> 51820 and look for a confirmation response.
  5. Check the upstream router or cloud security group for a UDP forwarding rule on port 51820 if the server sits behind NAT.

WireGuard vs Other VPN Protocols on FreeBSD

Protocol Config Complexity Kernel Support (FreeBSD 13+) Approximate Codebase Size Typical Fit
WireGuard Low Yes, via if_wg ~4,000 lines Personal tunnels, site-to-site links
OpenVPN Medium to High Via net/openvpn port ~100,000 lines Certificate-heavy enterprise setups
IPsec (strongSwan) High Built-in kernel plus ports ~350,000 lines Interoperability with hardware devices

Making the Tunnel Survive Reboots With rc.conf

A tunnel that drops on every reboot is not useful in any real deployment. FreeBSD’s init system reads /etc/rc.conf at boot time and can bring up WireGuard interfaces automatically. Add these lines:

wireguard_enable="YES"
wireguard_interfaces="wg0"

These two lines tell the FreeBSD service framework to bring up wg0 at startup using the matching config at /etc/wireguard/wg0.conf. To also auto-load the kernel module before the network stack initializes, add:

kld_list="if_wg"

This pattern follows the approach documented across the FreeBSD Handbook’s networking chapters, where combining kld_list with service entries in rc.conf is the standard way to persist kernel modules alongside their dependent services.

Test the rc.conf configuration without rebooting by starting the service manually:

service wireguard start
wg show

If wg show returns interface and peer details, the rc.conf wiring is correct. Run a real reboot to confirm everything comes up cleanly end to end.

Hardening the Setup Before the Tunnel Goes Into Regular Use

The tunnel works at this point, but a few additional touches make it more robust:

  • Set net.inet.ip.forwarding=1 in /etc/sysctl.conf if clients need the server to route their traffic out to the internet rather than just communicating peer to peer.
  • Consider adding a PresharedKey field to each peer block on both sides. This symmetric pre-shared layer gives post-quantum protection on top of the asymmetric key exchange, at the cost of one extra secret to distribute and store.
  • Keep each client’s AllowedIPs on the server pinned to a single /32 address. Broad subnets let one compromised client reach addresses that belong to other peers.

Your FreeBSD Host Is Now a Private Encrypted Gateway

The full picture is in place. A kernel module load, a pair of key generation commands, a lean interface configuration, three pf rules, a client peer block, and two lines in rc.conf give you a fully functional WireGuard server on FreeBSD. The tunnel encrypts every byte in transit, authenticates both ends by public key, and comes back up automatically after any reboot.

Adding more clients is the same [Peer] block repeated with a fresh key pair and a new tunnel IP. Removing a client is just deleting that block and reloading the configuration with wg syncconf. WireGuard’s small auditable design is not a trade-off against security. It is the security. You now have encrypted remote access on a platform that was built to be stable, correct, and yours to control fully.

No Responses

Leave a Reply

Your email address will not be published. Required fields are marked *