IPv6 for Home Networks: A FreeBSD Walkthrough

Published on

FreeBSD IPv6 Networking pf

Why You Should Care About v6 in 2026

Most residential ISPs in North America and Europe now hand out real IPv6 prefixes for free, alongside CGNAT'd IPv4. If you're not using v6, you're sharing one IPv4 address with your neighbours and inheriting all the weirdness that comes with it: failed inbound connections, broken games, mysterious rate limits, and zero ability to host anything.

Native v6 fixes all of that. Every device on your LAN gets a globally routable address. Hosting a service to a friend becomes a one-line pf rule. And the configuration on FreeBSD is genuinely smaller than the IPv4 NAT setup it replaces — there's nothing to translate.

The Moving Pieces

That's the whole stack. No NAT66, no proxies, no gateway VMs. The router forwards packets and the LAN gets real addresses.

Step 1: Tell the Kernel It's a v6 Router

# /etc/rc.conf

# IPv4 forwarding (you presumably already have this)
gateway_enable="YES"

# IPv6 forwarding and accept-RA on the WAN
ipv6_gateway_enable="YES"
ipv6_cpe_wanif="igc0"

# Accept RAs only on the WAN; advertise on the LAN sides
ifconfig_igc0_ipv6="inet6 accept_rtadv -ifdisabled"
ifconfig_igc1_ipv6="inet6 -ifdisabled"
ifconfig_igc2_ipv6="inet6 -ifdisabled"
ifconfig_igc3_ipv6="inet6 -ifdisabled"

# Daemons
rtsold_enable="YES"
rtsold_flags="-aF"
rtadvd_enable="YES"
rtadvd_interfaces="igc1 igc2 igc3"

ipv6_cpe_wanif is the magic switch that flips a FreeBSD box into "I am the customer-premises router for v6" mode. It tightens up forwarding and ICMP defaults so the box behaves correctly as the edge.

Step 2: dhcp6c — Ask for a Prefix

Install the client and add a config:

$ pkg install dhcp6
# /usr/local/etc/dhcp6c.conf
interface igc0 {
    send ia-pd 0;
    send ia-na 0;
    request domain-name-servers;
    script "/usr/local/etc/dhcp6c-script";
};

id-assoc pd 0 {
    prefix-interface igc1 {
        sla-id  1;
        sla-len 8;
    };
    prefix-interface igc2 {
        sla-id  2;
        sla-len 8;
    };
    prefix-interface igc3 {
        sla-id  3;
        sla-len 8;
    };
};

id-assoc na 0 { };

If your ISP delegates a /56, sla-len 8 carves it into 256 independent /64s — one per LAN, with 253 to spare. sla-id picks which slice each LAN gets.

# /etc/rc.conf (continued)
dhcp6c_enable="YES"
dhcp6c_interfaces="igc0"

Step 3: rtadvd — Tell the LAN What Its Prefix Is

rtadvd's defaults are reasonable; you usually only need rtadvd_interfaces in rc.conf. If you want to override things explicitly, drop a config:

# /etc/rtadvd.conf  (optional — defaults are usually fine)
igc1:\
    :raflags="mo":\
    :rltime#1800:\
    :addrs#1: \
    :addr="auto":\
    :pltime#600:vltime#1200:

igc2:\
    :raflags="mo":\
    :rltime#1800:

raflags="mo" sets the Managed and Other flags so clients also do DHCPv6 if you want stateful assignment. For pure SLAAC, drop the m.

Step 4: pf, but for v6

Most of your existing pf ruleset handles both families if you wrote inet-agnostic rules. But IPv6 needs a few specific rules to behave:

# --- IPv6 must-allow ---
# ICMPv6 is structural, not optional. Path MTU, NDP, RA all live here.
icmp6_ok = "{ echoreq echorep neighbrsol neighbradv routersol routeradv \
              unreach toobig timex paramprob }"

pass inet6 proto icmp6 all icmp6-type $icmp6_ok keep state

# DHCPv6 client traffic to/from the WAN
pass in  on $ext_if inet6 proto udp from any to any port { 546 547 } keep state
pass out on $ext_if inet6 proto udp from any to any port { 546 547 } keep state

# Anti-spoofing for v6 (covers v4 with the same antispoof you already have)
antispoof quick for { $lan_if $srv_if $iot_if } inet6

Do not blanket-block ICMPv6. v6 depends on it for neighbour discovery (the v6 equivalent of ARP), Path MTU, and RA — block it and your network silently falls apart in interesting ways.

Step 5: Smoke Test

# On the router
$ ifconfig igc1 inet6
        inet6 fe80::1%igc1 prefixlen 64 scopeid 0x2
        inet6 2001:db8:cafe:1::1 prefixlen 64

$ ndp -an              # neighbour table
$ netstat -rn -f inet6 # routing table

# On a LAN client
$ ping6 -c 3 ipv6.google.com
$ traceroute6 ipv6.google.com
$ curl -6 https://ifconfig.co

If the LAN client gets a global address starting with 2 or 3 and pings the outside, you have working native v6.

Hosting a Service: NAT-Free and Beautiful

With v6 there's no port forwarding because there's no NAT. The server has its own address, you just open the port:

# Allow inbound HTTPS to a server inside the homelab
pass in on $ext_if inet6 proto tcp \
  from any to 2001:db8:cafe:2::20 port https keep state

Hand a friend the AAAA record and they connect directly. No router config on their side, no UPnP, no STUN. This is what the protocol was designed to do.

Common Pitfalls

What You Lose, What You Gain

You lose the comforting illusion of NAT-as-firewall — every device is now directly reachable, in principle, from anywhere. That's why pf's default-deny on the WAN is non-negotiable.

You gain a working internet. Real addresses, real routing, no more games of port-forward tetris. Connections that "just don't work" over IPv4 mostly do over v6.

Where to Go Next

Run a different ISP or a different setup? Send me your config. I'd love to add a section for setups that aren't mine.

$ subscribe --to newsletter

FreeBSD, pf, and SRE notes — straight to your inbox. No spam, just signal.

Powered by Buttondown. Unsubscribe anytime. Or grab the RSS feed.

Related Posts

← Back to Blog