Exposing a Docker nginx Container Over IPv6 (There Is No NAT)

Exposing a Docker nginx Container Over IPv6 (There Is No NAT)
Photo by Ian Taylor on Unsplash

Once your LAN speaks IPv6, the next itch is obvious: make something on it reachable from the internet over IPv6. The example here is a self-hosted blog running behind nginx in Docker, but the recipe applies to any service.

The very first thing you have to unlearn is everything IPv4 taught you. In IPv4 you "expose a service" by forwarding a port. In IPv6 there is no NAT, no port-forward, no private address hiding behind a public one. The mental model is completely different, and once it clicks it is actually simpler. Here is the shift, the three layers it touches, a bit of address trivia, and the gotchas.

This assumes IPv6 is already live on your LAN. If it is not, start with enabling IPv6 on your LAN behind a MikroTik: you need a delegated prefix and SLAAC working first. The examples below use the documentation prefix 2001:db8::/32 (RFC 3849); substitute your own delegated prefix.


The core difference: no NAT

In IPv4, a self-hosted service is typically reachable through a single dst-nat rule: WAN port 80/443 gets translated to a fixed private address like 192.168.10.10. The public world only ever talks to the router's one public IP, and the server hides behind it.

In IPv6, that whole model is gone:

IPv4 IPv6
Server address private (192.168.10.10) real global address (GUA), routed
How the world reaches it dst-nat port-forward on the router direct: packets are routed straight to the GUA
Firewall's job translate and permit just permit (an accept rule)

So the server itself must carry a genuine Global Unicast Address out of your delegated prefix, and you open the firewall on it directly. No translation, no rewriting, just routing plus an accept. That is the whole mental shift, and the rest follows from it.


A vanity address (and the constraint nobody warns you about)

IPv6 addresses are 128 bits of hexadecimal, which means you can spell words in them: the 0-9 a-f "hexspeak" game. For a blog you might want something like:

2001:db8:abcd:ef0a::feed

::feed is a cheeky nod to a blog's RSS feed. But here is the constraint that surprises people: you cannot put a word in the subnet part. If your ISP delegates a /60, the first 60 bits are fixed (2001:db8:abcd:ef0X) and only that last nibble X (0 to f, sixteen subnets) is yours to choose. The playground for hexspeak is the interface ID: the lower 64 bits, the host part (::feed, ::cafe, ::dead:beef, and so on). The network half is whatever your ISP gave you.


The three layers

1. The router (MikroTik): address + firewall, no NAT

Give the LAN interface a /64 and RAs, make sure it is in the LAN interface list, then open the firewall with an accept rule, not a NAT rule:

/ipv6 address add interface=<lan> address=2001:db8:abcd:ef0a::1/64 advertise=yes
/interface list member add interface=<lan> list=LAN
/ipv6 firewall filter add chain=forward action=accept protocol=tcp \
  in-interface-list=WAN dst-address=2001:db8:abcd:ef0a::feed dst-port=80,443 \
  comment="web-server v6"
/ipv6 firewall filter move [find comment="web-server v6"] destination=11

Gotcha: the obvious place-before=[find ...] approach fails silently on RouterOS. The accept rule has to sit before the default drop in-interface-list=!LAN rule, or it never gets evaluated. Add it, then move it explicitly to the right position (adjust the destination index to your ruleset).

2. Docker: give one container IPv6 without rebuilding your network

This is usually the trickiest layer. A typical existing Docker network is external and IPv4-only, shared by several containers. The "clean" option, recreating it with IPv6, would bounce every container at once. Not worth it for one service.

Instead, add a second macvlan network on the same physical parent interface, and connect only the one container to it, live. Recent Docker versions happily allow two macvlan networks sharing a parent:

docker network create -d macvlan -o parent=<parent-iface> --ipv6 \
  --subnet 2001:db8:abcd:ef0a::/64 --gateway 2001:db8:abcd:ef0a::1 appnet6

docker network connect --ip6 2001:db8:abcd:ef0a::feed appnet6 nginx

Zero downtime: nginx keeps serving IPv4 the entire time. Persist it in docker-compose.yml (nginx joins appnet6 with its ipv6_address, appnet6 declared external: true, and the network create command kept as a comment for the next machine).

3. nginx: actually listen on v6

A common edge does SNI routing in a stream {} block on 443, and it often listens on IPv4 only. IPv6 needs its own listener:

listen [::]:443;     # in addition to the existing IPv4 listen

The backend stays 127.0.0.1:8443 (loopback does not care about v4 vs v6), and proxy_protocol carries the visitor's real IPv6 address all the way through to the http {} block. So your logs and rate-limits see the actual client, not the proxy.


DNS: the grey cloud matters

Add the AAAA record, and if you are behind Cloudflare it must be DNS-only (grey cloud):

example.com    AAAA    2001:db8:abcd:ef0a::feed     (DNS only)

Why grey is mandatory here: with the orange proxy on, Cloudflare is already dual-stack, so visitors would reach Cloudflare over IPv6 but the Cloudflare to origin hop would stay IPv4. The whole point, being reachable over IPv6 end to end, is lost. Grey cloud points the AAAA straight at your origin.

Note that a grey-cloud AAAA publishes your origin's real IPv6 address in public DNS. That is the trade-off for true end-to-end IPv6: anyone can resolve and reach the origin directly, so do not rely on Cloudflare to hide it. Keep your origin firewall tight.


Two DNS caches that will lie to you

While testing, two layers of caching can make it look broken when it is not:

  • DNS filter negative cache. A freshly created AAAA can stay invisible because a resolver cached the empty answer from before the record existed (negative TTL from the SOA, often 30 min). If you run AdGuard, Pi-hole or similar, restart it or flush its cache.
  • Your OS DNS cache. On macOS, curl -6 example.com may keep resolving to an IPv4-mapped ::ffff:203.0.113.x until you flush:
    sudo dscacheutil -flushcache; sudo killall -HUP mDNSResponder
    

When a cache is in your way, test against the literal address with --resolve to take DNS out of the equation entirely.

(One cosmetic wart: macvlan with --ipv6 but no explicit v4 subnet auto-assigns a junk v4 pool like 172.19.0.1. Harmless, ignore it.)


Verifying the real path (not the inter-VLAN shortcut)

The trap when verifying is accidentally testing the internal route instead of the real WAN path. Three checks that prove the genuine inbound path:

  • Firewall counter: /ipv6 firewall filter print stats where comment="web-server v6". If it climbs, external SYNs are arriving.
  • Conntrack: /ipv6 firewall connection print where dst-address~"feed". Connections from global source addresses (for example Cloudflare's v6 range) with flags SAC (SEEN-REPLY and ASSURED) mean nginx is actually replying and the handshake completes. It also proves your ISP genuinely routes inbound traffic for the whole delegated prefix.
  • The human test: phone on mobile data (4G/5G, which is IPv6 by default on most carriers), open your site. If it loads, you are done.

The generic recipe

Strip away the blog specifics and exposing any service over IPv6 is three moves:

  1. A GUA on the host: an address in the right /64, and the interface in the LAN list.
  2. An inbound accept firewall rule for that address and port (no NAT).
  3. An AAAA record (grey cloud if you are behind Cloudflare).

No port-forwarding, no translation tables. Once the no-NAT model clicks, IPv6 exposure is genuinely less fiddly than its IPv4 cousin: you are just routing packets to a real address and choosing to permit them.