A macvlan container (ipvlan behaves the same) has no IPv6 by default: a link-local address and nothing else, no default route. The network is fine — Docker sets disable_ipv6 to 1 on the container interface whenever the network’s EnableIPv6 is false.

Here is the configuration that works on a home connection, where the ISP hands out the prefix dynamically.

Confirm that’s the problem

$ docker exec <container> ip -6 addr show eth0
    inet6 fe80::ef5:b3ae:a64e:e782/64 scope link      # link-local only

$ docker exec <container> cat /proc/sys/net/ipv6/conf/eth0/disable_ipv6
1                                                     # administratively off

Do not declare a v6 subnet on the docker network

The official-looking version is wrong for residential broadband:

networks:
  macvlan_net:
    enable_ipv6: true
    ipam:
      config:
        - subnet: 2408:xxxx:xxxx:xxx::/64   # ← don't

That /64 came from the ISP over DHCPv6-PD, and it changes. When it does, the container keeps an address carved out of a prefix that no longer routes to your house. IPv6 fails silently while the config file still shows an address that looks perfectly reasonable.

Pinning the subnet only makes sense when the prefix is static: your own PI allocation, a fixed assignment in a datacenter, or a ULA you intend to NPT.

Otherwise declare no v6 subnet at all. Just clear disable_ipv6 and let the kernel do SLAAC from the router’s RAs. The prefix changes, the kernel follows, and the config never moves.

The configuration

services:
  myapp:
    networks:
      macvlan_net:
        ipv4_address: 10.0.0.6
        driver_opts:
          com.docker.network.endpoint.sysctls: "net.ipv6.conf.IFNAME.disable_ipv6=0,net.ipv6.conf.IFNAME.accept_ra=2"
    sysctls:
      net.ipv6.conf.all.disable_ipv6: 0
      net.ipv6.conf.all.forwarding: 1     # only if the container forwards traffic

networks:
  macvlan_net:
    driver: macvlan
    driver_opts:
      parent: enp1s0
    ipam:
      config:
        - subnet: 10.0.0.0/24
          gateway: 10.0.0.1

Four things that trip people up:

  • Interface-scoped sysctls cannot go under sysctls:. Recent Docker rejects them outright:

    Error response from daemon: interface specific sysctl setting
    "net.ipv6.conf.eth0.accept_ra" must be supplied using driver option
    'com.docker.network.endpoint.sysctls'
    

    The interface may not exist when the container is created, and a container can join several networks, so hardcoding eth0 is ambiguous. These settings belong to a network endpoint.

  • IFNAME is a literal placeholder. Do not substitute eth0. Docker replaces it with the real interface name as it moves the interface into the container — which is the entire point of the API.

  • Separate multiple settings with commas inside one string.

  • Endpoint options live under services.<name>.networks.<network>.driver_opts, not the top-level driver_opts in the networks: block. Those are different things: the top-level one configures the network driver (parent), this one configures the endpoint.

Settings without an interface name — net.ipv6.conf.all.*, net.ipv4.ip_forward — stay in sysctls: as before.

Why accept_ra must be 2

Skip this and everything else is wasted, with no error to tell you.

Linux assumes that a netns with IPv6 forwarding enabled is a router, and routers should not listen to other people’s RAs — so accept_ra=1 is silently ignored. A container acting as a gateway or transparent proxy almost certainly has forwarding on. The two collide and you end up staring at disable_ipv6 = 0 and accept_ra = 1 with still no address.

accept_ra=2 means exactly “accept RAs even while forwarding”. If you want both forwarding and SLAAC, it has to be 2.

Without forwarding, accept_ra=1 works fine — but 2 has no downside, so just always write 2.

Verify

$ docker exec <container> ip -6 addr show eth0 | grep global
    inet6 2408:xxxx:xxxx:xxx:a4be:81ff:fead:d100/64 scope global dynamic

$ docker exec <container> ip -6 route | grep default
default via fe80::xxxx:xxxx:xxxx:xxxx dev eth0  metric 1024  expires 0sec

$ docker exec <container> ping -c4 <a v6 target>
4 packets transmitted, 4 packets received, 0% packet loss

Global address, default route, replies coming back. Done.

Ignore the alarming expires

    inet6 2408:xxxx:xxxx:xxx:a4be:81ff:fead:d100/64 scope global dynamic
       valid_lft 3061sec preferred_lft 2469sec

It reads like the address dies in 50 minutes. It doesn’t. That’s the lifetime carried in the RA, and every RA resets it. The router advertises far more often than the lifetime, so the counter keeps getting pushed back up. The host shows identical numbers, because both are listening to the same RA.

What happens when the prefix does change

This is the payoff. The line drops and redials, the router gets a new PD, it advertises the new prefix while setting the old one’s lifetime to 0, and the container’s kernel deprecates the old address and configures a new one. No config edit, no restart. The only cost is that established connections break once and reconnect.

With a pinned subnet, the same event means editing the compose file, recreating the network, and recreating the container — after first working out that this is what went wrong.

Wrap-up

  • No IPv6 in a macvlan container is usually Docker’s disable_ipv6=1, not a broken network
  • Residential prefixes are dynamic — keep the v6 subnet out of the docker network and let the container do SLAAC
  • Interface-scoped sysctls must go through com.docker.network.endpoint.sysctls, with IFNAME as the placeholder
  • With forwarding on, accept_ra must be 2 or SLAAC fails silently
  • The valid_lft countdown is refreshed by every RA — leave it alone

For the mihomo gateway this came out of, see Running mihomo as a LAN Gateway with Docker macvlan.