How to Run a UDP Tracker Behind a Floating IP on Ubuntu

A practical guide to running a UDP BitTorrent tracker behind floating IPs (also known as static, reserved, or elastic IPs) on Ubuntu, including policy routing, Docker IPv6 networking, and SNAT for correct reply paths.

Jose Celano - 14/04/2026
How to Run a UDP Tracker Behind a Floating IP on Ubuntu

Introduction

In this article, we document how we configured the server for the Torrust Tracker Demo to run a UDP tracker behind a floating IP on Ubuntu.

The same approach applies to other cloud providers where floating IPs are called static IPs, reserved IPs, or elastic IPs. The naming changes, but the network behavior is the same.

Why This Matters

Using floating IPs is a common strategy to isolate infrastructure from public endpoint addresses. It lets you replace, resize, or rebuild the internal server while keeping the same public DNS records and tracker announce URLs.

For HTTP services this is usually straightforward. For UDP trackers, there is an extra requirement: the response must come back from the same public IP that received the request. If replies leave via another source IP, many clients treat it as a timeout.

Core problem: with default routing, packets that arrive via floating IP A can leave through primary IP B. This asymmetric path is enough to break UDP tracker probes.

Tested Environment

  • Cloud provider: Hetzner Cloud
  • OS: Ubuntu 24.04 LTS
  • Tracker endpoint: udp://udp1.torrust-tracker-demo.com:6969/announce
  • Floating IPv4: 116.202.177.184
  • Floating IPv6: 2a01:4f8:1c0c:828e::1
  • Container stack: Docker + Docker Compose

Architecture Summary

LayerWhat must happen
DNSUDP tracker domain resolves to dedicated floating IPs
FirewallUDP port 6969 allowed on IPv4 and IPv6
Kernel routingSource policy routing for each floating IP
Docker IPv6ip6tables enabled and bridge network has IPv6 subnet
NAT (IPv6)SNAT replies to the floating IPv6 for UDP/6969

Step 1: Configure Policy Routing for Floating IPs

For every floating IP, add a source-based routing policy so replies use the matching public address. On our server we persist this in /etc/netplan/60-floating-ip.yaml.

yaml
network:
  version: 2
  renderer: networkd
  ethernets:
    eth0:
      addresses:
        - 116.202.177.184/32
        - 2a01:4f8:1c0c:828e::1/64
      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
bash
sudo netplan apply
ip rule list
ip route show table 100
ip -6 rule list
ip -6 route show table 200
If you use cloud-init, keep your custom floating-IP and routing rules in a separate netplan file with a higher numeric prefix (for example, 60-floating-ip.yaml) rather than editing 50-cloud-init.yaml.

Step 2: Open UDP Port 6969 in the Firewall

In our investigation, one blocker was firewall path behavior on IPv6. The server had ufw in default deny mode, and UDP 6969 was not explicitly allowed.

Important nuance: with Docker, published ports on IPv4 are often reachable even when ufw looks restrictive, because Docker installs its own NAT and forwarding rules. That does not guarantee equivalent behavior for IPv6 in every setup. For this reason, verify IPv4 and IPv6 paths separately instead of assuming both families behave the same way.

bash
sudo ufw allow 6969/udp
sudo ufw status verbose

Expected result includes both 6969/udp and 6969/udp (v6) as ALLOW IN. Treat this as one control in a layered setup, not as the only explanation for reachability.

Step 3: Enable Docker ip6tables Management

Docker frequently handles IPv4 iptables automatically, but IPv6 behavior depends on daemon settings and network topology. To keep IPv6 UDP handling predictable across restarts, enable ip6tables in Docker.

json
{
  "ip6tables": true
}

Add it to /etc/docker/daemon.json, then restart Docker:

bash
sudo systemctl restart docker
sudo ip6tables -L ufw6-user-input -n

Step 4: Enable IPv6 on the Docker Bridge Network

If the bridge network has no IPv6 subnet, containers only get IPv4 addresses. In that case, native IPv6 UDP forwarding can fail. We solved this by enabling IPv6 in the Docker network.

yaml
proxy_network:
  driver: bridge
  enable_ipv6: true
  ipam:
    config:
      - subnet: "fd01:db8:1::/64"
bash
cd /opt/torrust
docker compose down
docker compose up -d
docker inspect tracker --format '{{range .NetworkSettings.Networks}}{{.GlobalIPv6Address}} {{end}}'
sudo ip6tables -t nat -L DOCKER -n -v | grep 6969

Step 5: Add SNAT for IPv6 UDP Replies

After enabling IPv6 inside Docker, replies can still leave with the primary IPv6 because of MASQUERADE behavior. For floating IPv6 UDP endpoints, add an explicit SNAT rule.

text
# /etc/ufw/before6.rules
# 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
bash
sudo ufw reload
sudo ip6tables -t nat -L POSTROUTING -n -v | grep 6969

Verification Checklist

  1. Domain points to the correct floating IPs: dig A and dig AAAA.
  2. Firewall allows UDP 6969 on both families.
  3. Policy rules and custom tables are active after reboot.
  4. Container has a non-empty IPv6 on the bridge network.
  5. DNAT and SNAT counters increase when probes run.
  6. Tracker accepts announces and replies from the expected source IP.
bash
sudo tcpdump -i eth0 -n udp port 6969 -v

For external validation, we used newTrackon and the raw status page at newtrackon.com/raw.

Cloud Provider Naming Equivalents

The same server-side setup is useful across providers, even if naming differs.

ProviderTypical name
HetznerFloating IP
DigitalOceanReserved IP
AWSElastic IP
Linode/AkamaiStatic IP

Conclusion

To run a UDP tracker reliably behind floating IPs, you need more than DNS and a port mapping. You need symmetric routing, correct IPv6 firewall behavior, container IPv6 networking, and explicit SNAT when floating IPv6 is involved.

This is exactly how we fixed the Torrust Tracker Demo deployment on Hetzner Ubuntu. In a follow-up update, we can extend this article with packet-flow diagrams and provider-specific adaptations for DigitalOcean, AWS, and Linode.

If you want broader context around this setup, these articles cover the full deployment story, newTrackon requirements, and the demo infrastructure decisions.

The following links were used during investigation and documentation. Local files are listed with absolute paths exactly as provided.