Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save AlexDev404/ff0ea1901b6c14f29a17ed9387f3b2d3 to your computer and use it in GitHub Desktop.

Select an option

Save AlexDev404/ff0ea1901b6c14f29a17ed9387f3b2d3 to your computer and use it in GitHub Desktop.
Connecting containers to the internet on an IPv6-Only Hetzner Server

Dokploy on an IPv6-Only Hetzner Server

Environment

  • OS: Debian 13 (trixie)
  • Server: Hetzner VPS, IPv6-only (2a01:4f8:1c19:2b2a::1/64)
  • Dokploy version: v0.28.4 (Docker Swarm mode)

The Problem

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_AGAIN DNS errors in Dokploy logs
  • ConnectTimeoutError to Docker Hub, templates.dokploy.com, etc.
  • Dokploy falls back to local templates

Solution Overview

Three components work together:

  1. Jool — kernel NAT64 module (translates IPv6 → IPv4 at the kernel level)
  2. bind9 — local DNS64 resolver (synthesizes 64:ff9b:: AAAA records for IPv4-only hosts)
  3. 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.


Step-by-Step Setup

1. Install Jool

apt install -y jool-dkms jool-tools linux-headers-$(uname -r)
modprobe jool
jool instance add default --netfilter --pool6 64:ff9b::/96

2. Install and configure bind9 as DNS64

apt install -y bind9

Edit /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 bind9

3. Configure Docker daemon

Edit /etc/docker/daemon.json:

{
  "userland-proxy": false,
  "ipv6": true,
  "fixed-cidr-v6": "fd00::/80",
  "ip6tables": true,
  "dns": ["172.17.0.1"]
}
systemctl restart docker

4. Recreate docker_gwbridge with IPv6

This 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.1

Replace 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.

5. Create a systemd service to restore on reboot

/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.target
systemctl daemon-reload
systemctl enable dokploy-ipv6
systemctl start dokploy-ipv6

6. Recreate Dokploy services

Since 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:latest

Important Caveats

Never scale all services to 0

When 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 dokploy

If you do accidentally lose IPv6, the fix is to repeat step 4 and recreate all services (the swarm leave/rejoin destroys secrets and services).

Cleaning up for a fresh reinstall

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

Verification

# 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment