Skip to main content
A compose service can publish a UDP port on the host with HOST:CONTAINER/udp. This covers services such as a DNS server or a STUN endpoint. It is the first slice of ADR 049: UDP publishing only.

What it does

  • Publishes the UDP port straight on the host. UDP does not go through the HTTP reverse proxy, which cannot carry it.
  • Accepts the short form ("53:53/udp") and the long form (target, published, protocol: udp).
  • Treats TCP and UDP on the same number as two separate ports. 53:53/tcp and 53:53/udp can both be declared.
  • Shows each published port as 53/udp (or 53/tcp). The API returns it under published_ports on the app and on each compose deploy services[] entry:
App spec files take the same published_ports shape.

What it does not do

  • It works on the control plane’s own node only. A service placed on a remote node is refused with published_port_remote_node.
  • It gives no reachability probe. A UDP port only reports whether it is bound.
  • It does not add capabilities, sysctls or host networking (see Not yet).

Safety

  • The port binds to the service’s bind address, which is private by default, like a TCP host port. Making it public follows the same exposure rules as a TCP host port, see App ports and exposure.
  • The host or cloud firewall must still allow the port from the clients you want. Publishing does not open it for you.
  • A public UDP service is easy to abuse. An open DNS resolver can be used for amplification, so keep it private unless you mean to serve the internet.
  • Exposure findings already list UDP ports from Docker.
  • The interaction with the Docker DOCKER-USER firewall chain has not been tested on a real server yet. Do not rely on a DOCKER-USER rule to restrict a UDP port until it is.
  • The host firewall view (GET /api/v1/firewall/host, see Host firewall) lists each published UDP port with its app, bind address and whether ufw allows it.
  • A deploy is refused when another process on the server already binds the UDP port on a clashing address. systemd-resolved on 127.0.0.53:53 does not clash with the private default (127.0.0.1), but it does clash with a public bind. If the socket table cannot be read (not Linux), the check is skipped and Docker reports the bind failure.

Errors

A running deploy that hits the remote node case reports the reconcile reason PublishedPortsRemoteNode.

Example: UDP echo test

The port is private by default, so run the test from the server itself or set a public bind address and open UDP 9000 in the firewall. The echo server is a short Python loop because busybox nc -e serves only one client per container start, so a second test would hang.

Example: DNS-style service

This publishes container port 53 on host port 5354 for UDP (long form) and for TCP (short form, declared as the same number to show they are separate). Test it with dig @<server-ip> -p 5354 app.test, which answers 10.0.0.5. Host port 5354 avoids a clash with a resolver already on port 53 and with mDNS on 5353. --interface=eth0 makes dnsmasq answer queries that arrive through the port forward; without it dnsmasq drops them as non-local. The container installs dnsmasq at start, so it needs outbound network access.

Not yet

These are not built, so compose files that use them are still rejected:
  • cap_add (for example NET_ADMIN)
  • sysctls
  • network_mode: host
That means a WireGuard server cannot run as an app yet. See ADR 049 for the plan.
Last modified on October 28, 2026