Skip to content
Go back

IPv6 Dual-Stack: The Hard Parts

By KingPin 12 min read
IPv6 Dual-Stack: The Hard Parts
Contents

Your Router Turned On IPv6 Without Asking You

Check your WAN interface right now. There’s a decent chance you already have a routable IPv6 address and you’ve just been ignoring it, the same way you ignore the “update available” badge on your router’s admin page. IPv6 has been running quietly next to your IPv4 network for years, and your firewall rules have been quietly not covering it.

The plan: get a real prefix from your ISP with DHCPv6 prefix delegation, pick one of three ways to make your server addresses stop changing, and then build a firewall for a network where every host has a public address instead of hiding behind one. That last part is the one people skip, and it’s the one that matters. Network Address Translation was never a firewall. It’s an accident of address scarcity that happened to also hide your hosts, and once every device on your LAN gets a globally routable IPv6 address, that accident stops helping you.

This assumes you already know what an IPv6 address looks like and roughly how SLAAC works. If you need the primer first, read IPv6 on Your Home Lab: You Should Care (Here’s Why). This one is the “okay, now actually deploy it” companion.

Getting a Prefix Worth Having

Your ISP hands out prefixes over DHCPv6-PD (Prefix Delegation). Your router asks for a block of address space, the ISP’s DHCPv6 server delegates one, and your router carves subnets out of it for each of your internal networks. A /56 gives you 256 /64 subnets. A /48 gives you 65,536. A /64 gives you exactly one, which means exactly zero subnets, because SLAAC and most IPv6 tooling assume every link gets its own /64.

If your ISP only offers a /64, you have three real options: call support and ask if a larger delegation is available (many residential ISPs support /56 even if the default order is /64), run one flat network with no internal IPv6 routing, or use ULA (fd00::/8) internally for anything that doesn’t need to be reachable from the internet and reserve the single /64 for whatever segment actually needs public reachability. Don’t waste a week on NPTv6 prefix translation for a home lab. It solves a problem enterprises have and you don’t.

Requesting a prefix with systemd-networkd

On the WAN-facing interface, request DHCPv6 and hint the delegation size you want:

[Match]
Name=wan0
[Network]
DHCP=yes
[DHCPv6]
PrefixDelegationHint=::/56

On the LAN-facing interface, consume the delegated prefix and announce it:

[Match]
Name=lan0
[Network]
DHCPPrefixDelegation=yes
IPv6SendRA=yes
[DHCPPrefixDelegation]
SubnetId=1
Announce=yes

SubnetId picks which /64 out of your delegated block this interface gets. A second LAN interface would use SubnetId=2, and so on. Older systemd releases spelled the enabling key DHCPv6PrefixDelegation= and the section [DHCPv6PrefixDelegation], so check man systemd.network against your installed version before copying this verbatim.

Requesting a prefix with NetworkManager

Terminal window
nmcli connection modify wan0 ipv6.method auto
nmcli connection modify wan0 ipv6.dhcp-pd-hint "::/56"
nmcli connection up wan0

The ipv6.dhcp-pd-hint value is sent to the DHCPv6 server as the requested prefix size (IA_PD). Your ISP can still hand you something smaller. It’s a request, not a demand.

SLAAC Privacy Extensions Are Why Your DNS Records Keep Lying

Enable IPv6 on a LAN with default settings and every workstation grows a second address that rotates on a timer. That’s SLAAC privacy extensions (RFC 4941) doing exactly what they’re designed to do: making your laptop harder to track across networks by periodically generating a new temporary address and preferring it for outbound connections. Great for a phone on public wifi. Useless for a server you want to reach by a stable AAAA record, because the address it’s actually listening on this week isn’t the one you wrote down.

You have three ways to get an address that holds still, and each fits a different kind of host.

SLAAC with a static EUI-64 identifier. Let the router advertise the prefix, but derive the interface identifier deterministically from the interface’s MAC address instead of a random rotating one. The address changes only when the prefix changes, not every few hours.

[Match]
Name=eth0
[Network]
IPv6AcceptRA=yes
[IPv6AcceptRA]
Token=eui64

The tradeoff: the last 64 bits of the address embed the interface’s MAC, which is a mild privacy leak on a public-facing box. Fine for a home lab server sitting behind your own firewall.

DHCPv6 stateful. Run a DHCPv6 server that hands out addresses from a pool and tracks leases, the same model as DHCPv4. This is the option if you want central control over who gets what address, independent of any single interface’s hardware.

Static addresses, pinned by hand. For the handful of boxes you actually reference by IP (your NAS, your reverse proxy, your Pi-hole), skip autoconfiguration entirely and assign the address directly:

[Match]
Name=eth0
[Network]
Address=2001:db8:1:1::10/64
Gateway=fe80::1

On NetworkManager the same idea looks like this in a keyfile:

[ipv6]
method=manual
address1=2001:db8:1:1::10/64
gateway=fe80::1
ip6-privacy=0

ip6-privacy takes three values: 0 disables privacy extensions, 1 enables them but prefers the stable public address for outbound connections, 2 enables them and prefers the rotating temporary address. Set it to 0 on servers. Leave it at 2 (or the distro default) on laptops and phones.

The sysctl equivalent, if you’d rather manage this centrally instead of per-connection-manager:

net.ipv6.conf.eth0.use_tempaddr = 0
net.ipv6.conf.eth0.autoconf = 1
net.ipv6.conf.eth0.accept_ra = 1

use_tempaddr=0 kills the rotating addresses. autoconf=1 keeps SLAAC deriving the stable address from router advertisements. accept_ra controls whether the host listens to RAs at all, which matters more once a box is doing any routing of its own, covered next.

Firewalling a Network Where Every Host Is Public

This is the part IPv4 muscle memory gets wrong. On IPv4, your home network probably has one public address on the router and everything behind it is RFC 1918 space that literally cannot be reached from the internet without a port forward. That’s not a firewall decision, it’s a side effect of NAT running out of addresses to hand out. With IPv6 dual-stack, your smart TV, your printer, and that Raspberry Pi running a project you forgot about all get real, globally routable addresses. Nothing about NAT is protecting them anymore, because there’s no NAT to speak of.

The firewall has to do the job NAT was accidentally doing. On Linux that’s nftables, and the rules aren’t complicated, they’re just rules you actually have to write instead of getting for free.

Here’s a working ruleset for a router doing both dual-stack forwarding and local filtering. Replace 2001:db8:1::/64 with your actual delegated subnet and fd00::/8 with your ULA range if you’re using one:

table ip6 filter {
chain input {
type filter hook input priority 0; policy drop;
iifname "lo" accept
ct state established,related accept
ct state invalid drop
icmpv6 type {
destination-unreachable,
packet-too-big,
time-exceeded,
parameter-problem,
echo-request,
echo-reply,
nd-router-solicit,
nd-router-advert,
nd-neighbor-solicit,
nd-neighbor-advert
} accept
ip6 saddr 2001:db8:1::/64 tcp dport 22 accept
}
chain forward {
type filter hook forward priority 0; policy drop;
ct state established,related accept
ct state invalid drop
icmpv6 type {
destination-unreachable,
packet-too-big,
time-exceeded,
parameter-problem
} accept
iifname "lan0" oifname "wan0" accept
ip6 daddr 2001:db8:1:1::10 tcp dport 443 accept
}
chain output {
type filter hook output priority 0; policy accept;
}
}

The forward chain is what actually protects your LAN, since that’s the traffic passing between the internet and your internal hosts. iifname "lan0" oifname "wan0" accept lets your own network initiate outbound connections; the ct state established,related rule lets the replies back in. Nothing initiates a new inbound connection to anything except the one host and port you explicitly punched a hole for. Default drop, explicit allow, same as you’d write for IPv4, just without NAT quietly doing half the job for you.

The ICMPv6 Types You Must Not Block

Blocking ICMPv6 wholesale is the single fastest way to break an IPv6 network, because this protocol leans on ICMPv6 for load-bearing infrastructure, not just diagnostics. RFC 4890 (“Recommendations for Filtering ICMPv6 Messages in Firewalls”) lays out exactly which types have to pass, split into two groups.

Traffic that must transit a firewall between networks (RFC 4890 section 4.3.1):

Traffic addressed to the firewall’s own interfaces, required for the firewall to take part in its own link’s address configuration and neighbor discovery (RFC 4890 section 4.4.1):

RFC 4890 section 4.4.1 also covers MLD listener messages (types 130-132 and 143), Inverse Neighbor Discovery (141-142), SEND certificate path messages (148-149), and multicast router discovery (151-153). None of those apply to a home lab that isn’t running multicast routing or SEND, so the ruleset below implements the four NDP types plus the shared error and echo types above.

Drop Packet Too Big and you break Path MTU Discovery, which IPv6 depends on completely since routers no longer fragment packets in flight. Drop Neighbor Solicitation and Advertisement and hosts on the same subnet can’t resolve each other’s link-layer addresses at all, which is the IPv6 replacement for ARP. That’s the ruleset above: the four RA/NS/NA/RS types stay open on input for local link operation, and the four error types plus echo stay open on both input and forward so path discovery and basic reachability testing keep working across the firewall.

IPv6 Inside Docker

Docker does not enable IPv6 for you by default. You opt in, on the daemon and per network. On the daemon side, in /etc/docker/daemon.json:

daemon.json
{
"ipv6": true,
"fixed-cidr-v6": "2001:db8:1::/64",
"ip6tables": true
}

ipv6 turns on IPv6 for the default bridge network. fixed-cidr-v6 assigns it a subnet to allocate container addresses from, carved out of your delegated prefix or a ULA block if you’re not exposing containers directly. ip6tables is enabled by default already; it’s what lets Docker manage IPv6 isolation and port publishing rules the same way it manages iptables for IPv4. Restart the daemon after editing this file.

For user-defined bridge networks, per the current Docker Engine docs, you don’t need fixed-cidr-v6 or a manual subnet at all:

Terminal window
docker network create --ipv6 mynet

If you skip --subnet, Docker picks a Unique Local Address prefix automatically. That’s the right default for containers that only need to talk to each other and to the host; give a network an explicit --subnet from your delegated GUA range only for containers you actually intend to reach from outside.

Docker Engine 28.0 extended a piece of default hardening to IPv6 that previously only applied to IPv4: when Docker has to enable IP forwarding on the host, it now sets the forwarding policy to drop by default and only opens it where a published port needs it, for both protocols. That’s Docker doing the “NAT was never a firewall” lesson for you at the daemon level. It doesn’t replace the host firewall rules above; a container’s published port is still a hole you’re choosing to punch.

One habit worth keeping from the IPv4 side: if a container doesn’t need to be reachable from the internet, don’t put it on a network with a GUA subnet at all. Give it ULA-only reachability and let your reverse proxy be the one thing with a real public address.

Common Questions

Do I need to block ICMPv6 on my home firewall?

No, not the required types. Block ICMPv6 entirely and you break Neighbor Discovery, Path MTU Discovery, and address autoconfiguration, which stops the network from functioning at all. Filter it selectively instead: keep RFC 4890’s required types open (destination unreachable, packet too big, time exceeded, parameter problem, echo, router and neighbor discovery) and drop anything else you don’t recognize.

What happens when my ISP changes my delegated IPv6 prefix?

Every address built from that prefix changes too, including any static addresses you pinned by hand. DHCPv6-PD leases can renew with a different prefix on reconnect, especially after an outage or a modem reboot. Use short DNS TTLs for anything with a public AAAA record, or run dynamic DNS against the delegated prefix, so your 2 AM self isn’t debugging a “server unreachable” ticket that’s really just a renumbering event.

Should I just disable IPv6 on my home network?

No. Disabling it doesn’t remove exposure, since most modern OSes still prefer IPv6 when it’s available and misconfigured tunneling (6to4, Teredo) can leak traffic around a firewall that only filters IPv4. Firewall IPv6 properly instead of turning it off; turning it off just means you never notice when something enables it anyway.

Should my home lab servers use ULA or GUA addresses?

Use both. Assign ULA (fd00::/8) for internal-only traffic between your own hosts, since it’s stable regardless of what your ISP does to your delegated prefix. Assign GUA from your delegated prefix only to hosts that actually need inbound reachability from the internet, and gate that reachability with the firewall, not by hoping nobody finds the address.

Does Docker support IPv6 by default?

No. IPv6 is off on Docker’s default bridge network until you set "ipv6": true in daemon.json, and user-defined networks need docker network create --ipv6. The ip6tables daemon option that manages the resulting firewall rules is on by default once IPv6 itself is enabled, per current Docker Engine documentation.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
RomM and EmulatorJS: Retro on a NAS

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts