Outbound IPv6 for Docker containers without restarting them
* WRITER 2026-11-04: backlog #18 resumed as the oldest stalled draft (standing order 2026-11-03). Source draft: agent workspace revenue/blog/daily/docker-outbound-ipv6/ (brief.md + post.md, written 2026-10-29). Slug docker-outbound-ipv6 is not in the live sitemap (checked 2026-11-04).
* Theme fixes this pass: added topic networking, tldr (plain text, under 300 characters), feature_image_alt describing the image, tested-on block, and console fences with $ prompts. Tags now docker, ipv6, compose (networking moved to topic). Opening paragraph reworded so it does not repeat the tl;dr.
* Console blocks carry commands only. The source note records no command output, so none is pasted. Do not add invented output.
* Delivers on post #7 (docker-network-segmentation), which says the IPv6-only network is "its own post". Link it root-relative from the first section: /docker-network-segmentation/
* RESEARCHER 2026-11-04 (5 of 5 fetches, notes in researcher workspace revenue/blog/daily/docker-outbound-ipv6/research.md): verified network connect on a running container, compose external: true, implicit default dropped when a service lists networks, ip6tables on by default in current Engine, current Engine 29.9.3 (2026-11-03). Not verified: --ipv4=false release, ip6tables-default release, documented ULA masquerade. Changes made: (1) masquerade paragraph no longer claims "on by default since v29"; it now says current Engine enables ip6tables by default and keeps the NAT as observed on this host; (2) router advertisement caveat CUT (unverified). tldr checked, no change needed.
* tested-on: Portainer and Watchtower versions are not in the note. Commander may add exact versions, otherwise leave as is.
* Placeholders only: docker-01, v6-egress, fd12:3456:789a:1::/64, eth0, app-web, app-db, app-redis, example/web. No real prefix, network names, NIC, counts beyond "about a dozen", or stack numbers.
* About 1,200 body words, short of the 1,500 target on purpose.
* DESIGNER 2026-11-04: feature image done in designer workspace revenue/blog/daily/docker-outbound-ipv6/ (docker-outbound-ipv6.svg and docker-outbound-ipv6.png, 1200x675, slate/green terminal brand, no window chrome because the theme adds it). Image headline "Outbound IPv6, no restarts". Labels: docker-01, app-web, app-db, mail, v6-egress, fd12:3456:789a:1::/64, eth0, internet; all are in the post. Green marks only the outbound path. Lint: no IPv4 strings, no forbidden SVG elements, no blog domain. Checked at 600 px wide: nothing clipped or overlapping. feature_image_alt rewritten to describe the image.
* GHOSTWRITER 2026-11-04: voice pass against renstur-voice.md v1. Opening now names Docker in the first sentence. Added the root-relative link to /docker-network-segmentation/ the writer asked for. Softened "Watchtower keeps a container's networks" to what was observed, since the researcher didn't verify it. Added a short "What I saw and what I didn't check" section (checklist rule 13) built only from facts already in the draft and research notes; no new experience invented, so no FILL slots. tldr reworded so it names docker network connect in plain text and ends on what I did; checked plain text, under 300 characters, not a copy of meta_description. No em dashes, no bold, title sentence case. Topic, tested-on block and all ## sections kept.
* PRIVACY GUARD 2026-11-04: final gate CLEARED. Checked body, tldr, tags, tested-on, alt text, SVG and PNG. 0 IPv4 strings, 0 domains, 0 real hostnames or usernames; only placeholders (docker-01, v6-egress, fd12:3456:789a:1::/64, eth0, app-web, app-db, app-redis, mail, example/web). One edit: "Postfix" became "the mail server" so the post does not fingerprint the mail stack. Kept "about a dozen" and "June 2026" (generic). renstur-voice.md in these notes is internal and goes when the notes are removed.
Every app container on my Docker host could reach the internet over IPv4 and nothing else. The host itself had a working global v6 address. I wanted the containers to have outbound IPv6 without restarting anything and without opening any of them up to inbound v6. In June 2026 I got there with one extra network and a `docker network connect` per container.
docker engine: 29.x
compose: compose spec, external networks
portainer: stacks from on-disk compose files
watchtower: image updates enabled
## What I wanted, and what I didn't
The host already had a global IPv6 address on its uplink. The goal was egress only. Containers should be able to open connections out over v6. Nothing on the internet should be able to open one in.
I also didn't want to touch the existing networks. The app containers already sat on their stack's default network with IPv4, and that was fine. Rebuilding that network with IPv6 turned on would have meant recreating every container on it.
So the v6 side went on a separate network, and the containers got a second interface. In my post on splitting one Docker host into network tiers I said the IPv6-only network was its own post. This is it.
## An IPv6-only network with a ULA prefix
Docker can create a network with no IPv4 at all. I gave it a ULA /64, so the container addresses are private and only the host's global address ever shows up on the internet.
Create the network:
$ docker network create --ipv6 --ipv4=false --subnet fd12:3456:789a:1::/64 v6-egress
Pick your own ULA prefix. The `fd` range is meant to be generated randomly per site, so don't copy mine.
On my host, Docker masqueraded outbound v6 traffic from that ULA subnet behind the host's global address, the same way it does for IPv4. That relies on ip6tables, which current Docker Engine turns on by default. If you've switched it off in `daemon.json`, this won't work until you turn it back on.
## Turning on forwarding for good
The host has to forward v6 packets between the bridge and the uplink. I put it in a file of its own so it survives a reboot:
net.ipv6.conf.all.forwarding=1
Load it without a reboot:
$ sudo sysctl --system
## Attaching running containers
No restarts here. `docker network connect` adds a network to a running container, and the container gets a new v6 interface on the spot:
$ docker network connect v6-egress app-web
I did that for every container in the app stack, about a dozen of them. Each one kept its IPv4 address on the stack network and picked up a second address from the ULA range.
Then I took a few back off. The database and Redis containers don't need to reach the internet, so they don't get a route to it:
$ docker network disconnect v6-egress app-db
$ docker network disconnect v6-egress app-redis
Check what a container ended up with:
$ docker exec app-web ip -6 addr
$ docker exec app-web ip -6 route
You want an address from your ULA prefix and a default route through the network's gateway. To test egress, use whatever the image ships with. `curl -6` or `wget -6` against any site that answers over v6 will do. Plenty of slim images have neither, so test from one that does.
## The container I left off
The mail container stayed IPv4 only, on purpose.
If it had a v6 route, outbound mail could leave from the host's global v6 address. Receiving servers check that address. Without a matching reverse DNS record and an SPF record that covers it, that mail is a lot more likely to be rejected or filed as spam.
So the mail server waits until the v6 address has rDNS and is in SPF. Until then it sends over IPv4, where both are already set up.
If you run mail in a container, leave it off this network until you've done the same.
## What survives, and what doesn't
The attachments held up better than I expected. They survived:
* a host reboot
* a Docker daemon restart
* Watchtower pulling new images and recreating containers
Every time Watchtower recreated a container, the second interface came back with it.
A stack redeploy is different. Redeploying a stack from Portainer rebuilds the containers from the compose file. The compose file doesn't know about `v6-egress`, so the new containers come up without it. No error. The v6 interface is just gone.
Anything attached by hand only lasts until the next redeploy.
## Making it permanent in compose
The fix is to put the network in each service that should have it. The network already exists, so compose should treat it as external and not try to create it:
services:
web:
image: example/web:latest
networks:
- default
- v6-egress
networks:
v6-egress:
external: true
Keep `default` in the list. Once you name networks on a service, compose only attaches the ones you list. Drop `default` and the container is cut off from the rest of its stack.
Leave the database, Redis and mail services alone. They stay off it.
Then redeploy the stack. This time the redeploy is what adds the network, so the problem goes away instead of coming back.
On my host, Portainer's stack editor reads the compose file from disk, so editing the file directly and redeploying from Portainer stuck. If your Portainer keeps the stack in its own database instead, make the edit in the Portainer editor or it'll get overwritten.
## What I saw and what I didn't check
Observed on my host, Docker Engine 29.x, June 2026:
* outbound v6 from the ULA subnet left NATed behind the host's global address
* hand attachments survived a reboot, a daemon restart and Watchtower recreates
* a Portainer stack redeploy dropped them without an error
Not checked: which Engine release added `--ipv4=false` or made ip6tables the default, and whether Docker documents the ULA masquerade or I just got it. I also didn't read Watchtower's code to see how it carries networks over. I only watched it happen.
## What I'd tell myself
Put the network in compose first and attach by hand second. The live attach was nice because nothing restarted. But it left me with a setup that only half existed on disk, and the next redeploy would have taken it away without saying anything.