Overview
This issue documents the manual steps required after running the deployer when
the server uses Hetzner floating IPs and/or needs IPv6 UDP tracker support.
These are not planned to be automated by the deployer. They are specific to
multi-IP setups where separate floating IPs are used for separate tracker endpoints
(e.g. one IP for the HTTP tracker, one for the UDP tracker) so that both can be
listed independently on newTrackon, which tracks one
tracker per IP.
The reference implementation is
torrust/torrust-tracker-demo,
which uses this setup with two floating IPs:
- HTTP tracker:
http1.torrust-tracker-demo.com → 116.202.176.169 / 2a01:4f8:1c0c:9aae::1
- UDP tracker:
udp1.torrust-tracker-demo.com → 116.202.177.184 / 2a01:4f8:1c0c:828e::1
The full incident investigation that led to this documentation is in
torrust-tracker-demo#2.
Why floating IPs require manual steps
The deployer configures the tracker to listen on the server's primary public IP
only. When traffic arrives on a Hetzner floating IP, the kernel's default routing
uses the primary IP as the reply source. The client then receives a reply from a
different address than it sent to and treats it as a timeout (asymmetric routing).
This applies to both IPv4 and IPv6 floating IPs.
Step 1 — Floating IP policy routing (required for each floating IP)
For each floating IP, add a policy routing rule so that packets arriving on that IP
also leave via that IP.
On Hetzner, this means adding routing tables (e.g. 100 for IPv4, 200 for IPv6)
with a default route via the floating IP gateway, then adding ip rule / ip -6 rule
entries that match source addresses on those tables.
Persist via netplan in /etc/netplan/60-floating-ip.yaml:
network:
version: 2
renderer: networkd
ethernets:
eth0:
addresses:
- 116.202.177.184/32 # floating IPv4 (UDP1)
- 2a01:4f8:1c0c:828e::1/64 # floating IPv6 (UDP1)
routing-policy:
- from: 116.202.177.184
table: 100
- from: 2a01:4f8:1c0c:828e::1
table: 200
routes:
- to: default
via: 172.31.1.1
table: 100
- to: default
via: fe80::1
table: 200
Apply:
Verify:
ip rule list
ip route show table 100
ip -6 rule list
ip -6 route show table 200
Repeat for every new floating IP pair. Without this, replies from floating IP
endpoints leave via the wrong source address.
Step 2 — Enable Docker ip6tables management (required for IPv6 UDP)
By default, Docker has ip6tables: false. This means:
- Docker does not insert ip6tables rules for published ports (unlike IPv4 where it
does this automatically via iptables).
- Every time Docker starts or restarts a container, it rewrites its own chain tables.
This flush wipes ufw's live ip6tables rules from the kernel. ufw does not
automatically reload after this, so IPv6 UDP traffic is silently dropped after
every container restart.
Fix: create /etc/docker/daemon.json:
Apply:
sudo systemctl restart docker
Verify:
sudo ip6tables -L ufw6-user-input -n
# Must show: ACCEPT 17 -- ::/0 ::/0 udp dpt:6969
Step 3 — Enable IPv6 on the Docker bridge network (required for IPv6 UDP)
Even with ip6tables: true, native IPv6 UDP still fails. Docker spawns
docker-proxy processes for each published port. For IPv6, docker-proxy receives
packets on an :: socket but the container only has an IPv4 address — docker-proxy
cannot relay across address families and silently drops all native IPv6 UDP.
Fix: add enable_ipv6: true and a ULA subnet to the bridge network in
docker-compose.yml:
proxy_network:
driver: bridge
enable_ipv6: true
ipam:
config:
- subnet: "fd01:db8:1::/64"
With an IPv6 address on the container, Docker creates ip6tables DNAT rules that
route native IPv6 traffic directly to the container, bypassing docker-proxy entirely.
Apply:
cd /opt/torrust
docker compose down
docker compose up -d
Verify the container has an IPv6 address:
docker inspect tracker --format '{{range .NetworkSettings.Networks}}{{.GlobalIPv6Address}} {{end}}'
# Expected: fd01:db8:1::x (non-empty)
Verify the DNAT rule exists:
sudo ip6tables -t nat -L DOCKER -n -v | grep 6969
# Expected: DNAT rule for dpt:6969
Step 4 — SNAT for IPv6 UDP replies via floating IP (required when using floating IPv6)
After Step 3, the container has a ULA IPv6 address (fd01:db8:1::x). When it
replies, Docker's MASQUERADE rule rewrites the source to the server's primary
IPv6 address (2a01:4f8:1c19:620b::1). Clients that probed the floating IPv6
(2a01:4f8:1c0c:828e::1) receive a reply from the wrong address and time out.
Fix: prepend a SNAT rule to /etc/ufw/before6.rules before the existing
*filter section:
# NAT: rewrite source of Docker UDP tracker IPv6 replies to the floating IP
*nat
:POSTROUTING ACCEPT [0:0]
-A POSTROUTING -s fd01:db8:1::/64 -o eth0 -p udp --sport 6969 \
-j SNAT --to-source 2a01:4f8:1c0c:828e::1
COMMIT
Apply:
Verify:
sudo ip6tables -t nat -L POSTROUTING -n -v | grep 6969
# Expected: SNAT ... fd01:db8:1::/64 ... udp spt:6969 to:2a01:4f8:1c0c:828e::1
This rule must be in before6.rules (not added via ufw CLI) so it persists in
the *nat table. ufw loads this file at startup, before Docker starts. The SNAT
fires before Docker's MASQUERADE and takes precedence.
If you change the subnet in docker-compose.yml, update the -s match here too.
If you add a second floating IPv6, add a second SNAT rule for its subnet/address.
Which steps are needed for which scenario
| Scenario |
Step 1 |
Step 2 |
Step 3 |
Step 4 |
| Floating IPv4 only |
✅ |
— |
— |
— |
| IPv6 UDP, primary IP only |
— |
✅ |
✅ |
— |
| IPv6 UDP, floating IP |
— |
✅ |
✅ |
✅ |
| Floating IPv4 + IPv6 UDP floating |
✅ |
✅ |
✅ |
✅ |
References
Overview
This issue documents the manual steps required after running the deployer when
the server uses Hetzner floating IPs and/or needs IPv6 UDP tracker support.
These are not planned to be automated by the deployer. They are specific to
multi-IP setups where separate floating IPs are used for separate tracker endpoints
(e.g. one IP for the HTTP tracker, one for the UDP tracker) so that both can be
listed independently on newTrackon, which tracks one
tracker per IP.
The reference implementation is
torrust/torrust-tracker-demo,
which uses this setup with two floating IPs:
http1.torrust-tracker-demo.com→116.202.176.169/2a01:4f8:1c0c:9aae::1udp1.torrust-tracker-demo.com→116.202.177.184/2a01:4f8:1c0c:828e::1The full incident investigation that led to this documentation is in
torrust-tracker-demo#2.
Why floating IPs require manual steps
The deployer configures the tracker to listen on the server's primary public IP
only. When traffic arrives on a Hetzner floating IP, the kernel's default routing
uses the primary IP as the reply source. The client then receives a reply from a
different address than it sent to and treats it as a timeout (asymmetric routing).
This applies to both IPv4 and IPv6 floating IPs.
Step 1 — Floating IP policy routing (required for each floating IP)
For each floating IP, add a policy routing rule so that packets arriving on that IP
also leave via that IP.
On Hetzner, this means adding routing tables (e.g.
100for IPv4,200for IPv6)with a default route via the floating IP gateway, then adding
ip rule/ip -6 ruleentries that match source addresses on those tables.
Persist via netplan in
/etc/netplan/60-floating-ip.yaml:Apply:
Verify:
Step 2 — Enable Docker ip6tables management (required for IPv6 UDP)
By default, Docker has
ip6tables: false. This means:does this automatically via iptables).
This flush wipes ufw's live ip6tables rules from the kernel. ufw does not
automatically reload after this, so IPv6 UDP traffic is silently dropped after
every container restart.
Fix: create
/etc/docker/daemon.json:{ "ip6tables": true }Apply:
Verify:
sudo ip6tables -L ufw6-user-input -n # Must show: ACCEPT 17 -- ::/0 ::/0 udp dpt:6969Step 3 — Enable IPv6 on the Docker bridge network (required for IPv6 UDP)
Even with
ip6tables: true, native IPv6 UDP still fails. Docker spawnsdocker-proxyprocesses for each published port. For IPv6, docker-proxy receivespackets on an
::socket but the container only has an IPv4 address — docker-proxycannot relay across address families and silently drops all native IPv6 UDP.
Fix: add
enable_ipv6: trueand a ULA subnet to the bridge network indocker-compose.yml:With an IPv6 address on the container, Docker creates ip6tables DNAT rules that
route native IPv6 traffic directly to the container, bypassing docker-proxy entirely.
Apply:
cd /opt/torrust docker compose down docker compose up -dVerify the container has an IPv6 address:
Verify the DNAT rule exists:
Step 4 — SNAT for IPv6 UDP replies via floating IP (required when using floating IPv6)
After Step 3, the container has a ULA IPv6 address (
fd01:db8:1::x). When itreplies, Docker's MASQUERADE rule rewrites the source to the server's primary
IPv6 address (
2a01:4f8:1c19:620b::1). Clients that probed the floating IPv6(
2a01:4f8:1c0c:828e::1) receive a reply from the wrong address and time out.Fix: prepend a SNAT rule to
/etc/ufw/before6.rulesbefore the existing*filtersection:Apply:
Verify:
Which steps are needed for which scenario
References