- OS: Debian 13 (trixie)
- Server: Hetzner VPS, IPv6-only (
2a01:4f8:1c19:2b2a::1/64) - Dokploy version: v0.28.4 (Docker Swarm mode)
Dokploy runs via Docker Swarm. Swarm overlay networks are IPv4-only — containers
get addresses like 10.0.1.x and 172.18.x.x with no IPv6 assigned. On an
IPv6-only host, these containers have no route to the internet at all.
Symptoms:
EAI_AGAINDNS errors in Dokploy logsConnectTimeoutErrorto Docker Hub,templates.dokploy.com, etc.- Dokploy falls back to local templates
Three components work together:
- Jool — kernel NAT64 module (translates IPv6 → IPv4 at the kernel level)
- bind9 — local DNS64 resolver (synthesizes
64:ff9b::AAAA records for IPv4-only hosts) - docker_gwbridge with IPv6 — Docker's Swarm gateway bridge, recreated with a real IPv6 subnet so containers actually get IPv6 addresses and a default route
The key insight: Docker must know about the IPv6 subnet at network creation time
via its IPAM config. Adding the IP later with ip addr add gives the host an address
but containers never receive IPv6 assignments.
Additionally, disabling Docker's userland proxy ("userland-proxy": false) is
required for Dokploy's port 3000 to be reachable externally.
apt install -y jool-dkms jool-tools linux-headers-$(uname -r)
modprobe jool
jool instance add default --netfilter --pool6 64:ff9b::/96apt install -y bind9Edit /etc/bind/named.conf.options:
options {
directory "/var/cache/bind";
dns64 64:ff9b::/96 {
clients { any; };
};
forwarders { 2a00:1098:2c::1; 2a00:1098:2b::1; };
listen-on { 172.17.0.1; };
listen-on-v6 { any; };
allow-query { any; };
};
systemctl restart bind9Edit /etc/docker/daemon.json:
{
"userland-proxy": false,
"ipv6": true,
"fixed-cidr-v6": "fd00::/80",
"ip6tables": true,
"dns": ["172.17.0.1"]
}systemctl restart dockerThis must be done from outside the swarm. Scale down services first, then:
# Disconnect swarm internal endpoints
docker network disconnect -f docker_gwbridge gateway_ingress-sbox 2>/dev/null
docker network disconnect -f docker_gwbridge gateway_<id> 2>/dev/null
docker swarm leave --force
docker network rm docker_gwbridge
docker network create \
--driver bridge \
--ipv6 \
--subnet 172.18.0.0/16 \
--gateway 172.18.0.1 \
--subnet 2a01:4f8:1c19:2b2a:8000::/66 \
--gateway 2a01:4f8:1c19:2b2a:8000::1 \
-o "com.docker.network.bridge.name"="docker_gwbridge" \
-o "com.docker.network.bridge.enable_icc"="false" \
-o "com.docker.network.bridge.enable_ip_masquerade"="true" \
docker_gwbridge
docker swarm init --advertise-addr 172.17.0.1Replace 2a01:4f8:1c19:2b2a:8000::/66 with a /66 or larger sub-prefix of your
server's /64 allocation. The gateway must be an address within that subnet.
/etc/systemd/system/dokploy-ipv6.service:
[Unit]
Description=Dokploy IPv6 setup for docker_gwbridge
After=docker.service
Requires=docker.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/bin/bash -c 'ip addr add 2a01:4f8:1c19:2b2a:8000::1/66 dev docker_gwbridge 2>/dev/null || true'
ExecStart=/bin/bash -c 'jool instance add default --netfilter --pool6 64:ff9b::/96 2>/dev/null || true'
[Install]
WantedBy=multi-user.targetsystemctl daemon-reload
systemctl enable dokploy-ipv6
systemctl start dokploy-ipv6Since leaving/rejoining the swarm destroys all services and secrets:
# New postgres password
POSTGRES_PASSWORD=$(openssl rand -base64 32 | tr -d "=+/" | cut -c1-32)
echo "$POSTGRES_PASSWORD" | docker secret create dokploy_postgres_password -
# Wipe old postgres volume (password mismatch otherwise)
docker volume rm dokploy-postgres 2>/dev/null
docker network create --driver overlay --attachable dokploy-network
docker service create \
--name dokploy-postgres \
--constraint 'node.role==manager' \
--network dokploy-network \
--env POSTGRES_USER=dokploy \
--env POSTGRES_DB=dokploy \
--secret source=dokploy_postgres_password,target=/run/secrets/postgres_password \
--env POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password \
--mount type=volume,source=dokploy-postgres,target=/var/lib/postgresql/data \
postgres:16
docker service create \
--name dokploy-redis \
--constraint 'node.role==manager' \
--network dokploy-network \
--mount type=volume,source=dokploy-redis,target=/data \
redis:7
docker service create \
--name dokploy \
--replicas 1 \
--network dokploy-network \
--mount type=bind,source=/var/run/docker.sock,target=/var/run/docker.sock \
--mount type=bind,source=/etc/dokploy,target=/etc/dokploy \
--mount type=volume,source=dokploy,target=/root/.docker \
--secret source=dokploy_postgres_password,target=/run/secrets/postgres_password \
--publish published=3000,target=3000,mode=host \
--update-parallelism 1 \
--update-order stop-first \
--constraint 'node.role==manager' \
--env ADVERTISE_ADDR=172.17.0.1 \
--env POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password \
--env RELEASE_TAG=latest \
dokploy/dokploy:latestWhen you scale a Swarm service to 0, Docker may recreate docker_gwbridge
without the IPv6 IPAM config, losing IPv6 connectivity for all containers.
Instead of scaling to 0, use:
docker service update --force dokployIf you do accidentally lose IPv6, the fix is to repeat step 4 and recreate all services (the swarm leave/rejoin destroys secrets and services).
If you need to start completely from scratch:
docker service rm dokploy dokploy-postgres dokploy-redis 2>/dev/null
docker rm -f dokploy-traefik 2>/dev/null
docker network rm dokploy-network 2>/dev/null
docker swarm leave --force 2>/dev/null
docker secret rm dokploy_postgres_password 2>/dev/null
docker volume rm dokploy-postgres dokploy-redis dokploy 2>/dev/null# Check IPv6 on gateway bridge
ip addr show docker_gwbridge
# Check container has IPv6 and can reach the internet
docker exec $(docker ps -q -f name=dokploy.1) ip addr show eth1
docker exec $(docker ps -q -f name=dokploy.1) curl -v https://hub.docker.com 2>&1 | head -10
# Check Dokploy is responding
curl http://127.0.0.1:3000
# Expected: /register (redirect to registration page)Dokploy UI is accessible at http://[your-ipv6]:3000.