Running Docker containers using IPv6

It's been over 20 years since IPv6 was first defined, but many applications and systems (Docker included) shy away from actively using it. Running Docker with IPv6 is not complicated, but requires certain preparations and a slightly deeper understanding of Docker networking. The main difference between running on IPv4 and on IPv6 is the fact that with IPv6 all ports are by default visible to the whole Internet, so there's no need to map them, but at the same time this approach requires a stand-alone firewall setup that can block all unsolicited traffic towards the containers.

The main advantage is the direct reachability of the containers. So, for example, multiple HTTPS servers can be easily run on the same host machine. Some documentation on IPv6 on Docker is available on Docker docs.

Enabling IPv6

In order to enable IPv6 for Docker on the default network you have to enable it in your config.json. For Linux that's most likely in /etc/docker/daemon.json:

  "ipv6": true,
  "fixed-cidr-v6": "2406:XXXX:2:2708:9::/80"

(please adjust the subnet accordingly)

It's also possible to create new Docker networks with IPv6:

docker network create --subnet="" --gateway=""  --ipv6=true --subnet="2406:XXXX:2:2708:9::/80" --gateway="2406:XXXX:2:2708:9::1" -o ""="docker-mynet" mynet

Enabling IPv6 for Docker using /etc/docker/daeamon.json will enable IPv6 forwarding, which in turn disables listening for Router Advertisement packets. If RA are required for default routing they have to be re-enabled:

sysctl net.ipv6.conf.ens3.accept_ra=2

For setups that don't use the default Docker network IPv6 forwarding has to be enabled manually:

sysctl net.ipv6.conf.default.forwarding=1

Those settings should be put into /etc/sysctl.conf so they can get loaded automatically:


Ensuring connectivity

Enabling IPv6 forwarding is not always sufficient in order to make IPv6 work. The IPv6 subnet selected for the Docker network must be already routed towards the host. This can be done in two main ways:

  1. By using a separate subnet from existing host subnet. This is applicable when the hosting provider routes another subnet towards the host/VM. The host/VM might have an IP address from a shared /64 subnet.
  2. By subdividing the host subnet. This is applicable when the hosting provider allocates a single IPv6 subnet per host/VM. The routed subnet might be a /64 or smaller, down to /112 in some providers.

In the second case there is one additional step that needs to be taken care of. When an upstream router wants to send a packet to an IPv6 address on the host it will first send an ICMP packet called Neighbour Discovery in order to identify the MAC (Layer 2) address to use for the packet. Since that IPv6 address is not on the same network - that's going to fail, as the host will not reply to that request.

The easiest way of fixing this is to use ndppd, which is a Neighbour Discovery proxy. The configuration of it is very simple. /etc/ndppd.conf should contain the following:

proxy ens3 {
          rule 2406:XXXX:2:2708:9::/80 {
          rule 2406:XXXX:2:2708:a::/80 {

ens3 is the name of the host main interface. Each of the Docker networks should have it's own rule. Once that's configured the kernel must be set to allow to proxy the packets:

systemctl net.ipv6.conf.ens3.proxy_ndp=1

Setting up the container

The container can be simply started as usual:

$ sudo docker run -it --rm  ubuntu /bin/bash

Let's install some tools inside the container so we can inspect basic networking:

root@0e4295da74a3:/# apt update && apt -y install iputils-ping iproute2

Let's check addressing:

root@d4e1daa0d3ce:/# ip a sh
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
259: eth0@if260: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default
    link/ether 02:42:ac:11:00:03 brd ff:ff:ff:ff:ff:ff link-netnsid 0
    inet brd scope global eth0
       valid_lft forever preferred_lft forever
    inet6 2406:XXXX:2:2708:9:242:ac11:3/80 scope global nodad
       valid_lft forever preferred_lft forever
    inet6 fe80::42:acff:fe11:3/64 scope link
       valid_lft forever preferred_lft forever

and routing:

root@8bb847f466b7:/# ip -6 r sh
2406:XXXX:2:2708:9::/80 dev eth0 proto kernel metric 256 pref medium
fe80::/64 dev eth0 proto kernel metric 256 pref medium
default via 2406:XXXX:2:2708:9::1 dev eth0 metric 1024 pref medium

So, with everything in place ping should work. Let's try Google:

root@8bb847f466b7:/# ping -c5 -W5
PING (2404:6800:4006:809::2004)) 56 data bytes
64 bytes from (2404:6800:4006:809::2004): icmp_seq=1 ttl=56 time=25.5 ms
64 bytes from (2404:6800:4006:809::2004): icmp_seq=2 ttl=56 time=25.3 ms
64 bytes from (2404:6800:4006:809::2004): icmp_seq=3 ttl=56 time=25.3 ms
64 bytes from (2404:6800:4006:809::2004): icmp_seq=4 ttl=56 time=25.4 ms
64 bytes from (2404:6800:4006:809::2004): icmp_seq=5 ttl=56 time=25.4 ms

--- ping statistics ---
5 packets transmitted, 5 received, 0% packet loss, time 4006ms
rtt min/avg/max/mdev = 25.334/25.446/25.589/0.085 ms

Using smaller subnets

Docker docs recommend using at least a /80 for the subnet. That allows Docker to use the virtual MAC addresses to generate container's IPv6 addresses without the need for duplicate detection. If the subnet is smaller Docker starts to allocate IPs sequentially, I suspect in the "right" set of circumstances it's possible to end up with multiple containers using the same IPv6 address, so it using manual allocation is probably safer here.

Exposing the services

With IPv6 enabled all applications are by default exposed and visible on the Internet. Since Docker allocates the IPv6 effectively at random that makes it difficult to use them. Instead of exposing ports with -p it's easier to use --ip6 to specify an IPv6 address. One caveat is that IPs can only be specified on non-default subnets.

Many containers internally run more services than they expose, so it's a good idea to put and IPv6 firewall in front of those containers. Docker takes care of the IPv4 rules automatically by the virtue of NAT, but for IPv6 the rules have to be added manually. That's another reason to allocate the IPv6 addresses manually, as it allows for easy creation of those rules.

A sample set of rules might look like this:

$ ip6tables -A FORWARD -i ens3 -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
$ ip6tables -A FORWARD -d 2406:XXXX:2:2708:9::8/128 -i ens3 -p tcp -m tcp --dport 80 -m comment --comment nginx-grafana -j ACCEPT
$ ip6tables -A FORWARD -d 2406:XXXX:2:2708:9::8/128 -i ens3 -p tcp -m tcp --dport 443 -m comment --comment nginx-grafana -j ACCEPT
$ ip6tables -A FORWARD -d 2406:XXXX:2:2708:9::2/128 -i ens3 -p tcp -m tcp --dport 80 -m comment --comment nginx-gogs -j ACCEPT
$ ip6tables -A FORWARD -d 2406:XXXX:2:2708:9::2/128 -i ens3 -p tcp -m tcp --dport 443 -m comment --comment nginx-gogs -j ACCEPT
$ ip6tables -A FORWARD -i ens3 -j LOG
$ ip6tables -A FORWARD -i ens3 -j DROP