Research
Default port collisions
Every self-hosted stack eventually hits a port that is already taken. The map of where that happens is more useful than the folklore about it.
Which default ports collide most often in self-hosted software?
Across the 105 indexed tools, port 80 is a shipped default for 21 of them, 8080 for 19, 443 for 17 and 3000 for 11, followed by 9000 for six, 8000 for five, 22 for four, and 53 and 9090 for three each. Almost none of these matter once a reverse proxy is in front, because the container-side port is never scarce and you can publish whatever host port you like. The collisions that do matter are on 53, on anything below 1024 under rootless containers, and on protocols a proxy cannot multiplex.
Across the 105 tools in the index, port 80 is a shipped default for 21 of them. Port 8080 is a default for 19, port 443 for 17, and port 3000 for 11. After that the tail falls off fast: 9000 for six, 8000 for five, 22 for four, and 53 and 9090 for three each. Roughly a hundred distinct ports appear in total, and a couple of dozen of them are claimed by more than one project.
The clusters are inherited, not chosen#
Nobody picked these numbers independently. Each cluster is a framework default that leaked into a shipped product.
3000 is the Node development server. Express, Next.js and Create React App have all defaulted to it, so anything with a JavaScript front end that started life as npm run dev still listens there: Grafana, Gitea and Forgejo, Wiki.js, HedgeDoc, Outline, Docmost, Karakeep, Homepage and Dokploy. AdGuard Home is in the list for a different reason, since 3000 is only its first-run installer.
8080 is the JVM and Go convention. It is the historic "alternate HTTP" port from the era before containers, when you could not have root and needed something above 1024. Keycloak, Headscale, Miniflux, Trilium, Zigbee2MQTT, code-server, SearXNG, LocalAI, Open WebUI, Dozzle, Gatus, Glance, CrowdSec, Scrutiny, Firefly III and File Browser all sit there, plus Traefik's dashboard and Stalwart's management interface.
80 and 443 belong to two different kinds of software. The first is the proxies themselves, Caddy, Traefik and Nginx Proxy Manager, which are supposed to own those ports. The second is appliances that expect to be the whole machine: Unraid, TrueNAS, CasaOS, GitLab CE, mailcow, Harbor, Coolify, Nextcloud and Seafile. That second group is where the pain is, because two of them on one host is a genuine fight, not a mapping change.
9000 is php-fpm heritage plus coincidence. MinIO, Portainer, authentik, Keycloak's management interface, Mealie and Woodpecker CI all landed there independently.
22 is the one people forget. Forgejo, Gitea, GitLab CE and Proxmox VE all want SSH. Inside a container that is fine, but the host already has sshd, so you publish 222 and then hit the second-order problem: the clone URL in the web UI comes from SSH_DOMAIN and SSH_PORT in app.ini, not from Docker. Get it wrong and every developer copies a URL that times out while the forge looks healthy.
Most of these collisions are not real#
The container-side port is not a scarce resource. Every container has its own network namespace, so twenty of them can listen on 8080 simultaneously and never notice each other. The only scarce thing is the host port on the left of a ports: mapping, and you choose that number.
So the answer to "Grafana and Dokploy both want 3000" is "3001:3000", and that is the end of the incident. The answer to "I have nineteen things on 8080" is that eighteen of them should not be publishing a host port at all.
Which is the real point. Once a reverse proxy exists, publishing host ports is a habit, not a requirement:
services:
miniflux:
image: ghcr.io/miniflux/miniflux:latest
environment:
LISTEN_ADDR: 0.0.0.0:8080
networks: [edge]
# no ports: key at all
networks:
edge:
external: trueThe proxy joins the same edge network and reaches http://miniflux:8080 by service name. Nothing is published, so nothing can collide, and nothing on your LAN can reach the app except through the proxy. That last clause matters more than the tidiness: a published port bypasses UFW entirely, because Docker writes its own iptables chains ahead of the INPUT chain. Reverse proxy and TLS has the full proxy configuration and Docker Compose conventions has the network layout.
The four cases where it still bites#
DNS on 53. This is the one collision that a proxy cannot fix, because it is UDP and it is a system service. systemd-resolved holds a stub listener on 127.0.0.53:53, and a container that wants 53 on the host has to be given it first. AdGuard Home documents the exact fix: a drop-in at /etc/systemd/resolved.conf.d/ containing DNSStubListener=no and DNS=127.0.0.1, then repoint /etc/resolv.conf at /run/systemd/resolve/resolv.conf and reload. Set the DNS= line, because with the stub gone, the default 127.0.0.53 nameserver resolves nothing and the host loses DNS. Beyond that, Pi-hole, AdGuard Home and Technitium DNS Server are all competing for the same address on your network, and the answer is to run one of them. Pi-hole's prerequisites list 53 TCP and UDP, 67 for DHCP, 80 and 443 for the interface and 123 for NTP, which is a lot of surface for one container. DNS for self-hosters covers the split-horizon setup.
Ports below 1024 under rootless containers. The kernel refuses them without CAP_NET_BIND_SERVICE, and rootless Docker Engine and Podman do not have it. This bites at exactly the moment you move a working Caddy container to a rootless daemon. Docker's rootless tips document sudo setcap cap_net_bind_service=ep $(which rootlesskit), or net.ipv4.ip_unprivileged_port_start=0 via sysctl; Podman points at the same sysctl. Both are host-level decisions that people make once and forget, so write them down next to the compose file.
Two things that genuinely want the same socket. TLS multiplexing solves most of 443, because SNI lets one proxy host many names. It does not solve AdGuard Home's inbound DNS-over-HTTPS, which wants 443 with its own certificate for the hostname clients dial, nor DoT and DoQ on 853. On a host already running a proxy, one of them loses. The workable arrangements are a second IP address, proxying the DoH path through the existing terminator, or moving DoH to a port you configure into your clients. The mail servers are the extreme version: mailcow and Stalwart collide on nine ports each, 25, 110, 143, 443, 465, 587, 993, 995 and 4190, and none of them are proxyable in any useful sense. Mail wants its own host.
Protocols a proxy cannot carry. WireGuard on 51820 is claimed by both wg-easy and k3s, whose flannel backend uses it. SSH on 22 is a stream, not HTTP. These are the cases where you actually have to allocate, and where a second machine or a second address is the fix rather than a smarter config.
A convention that ends the problem#
- Publish nothing by default. New service goes on the proxy network with no
ports:key. This is the rule that does the work. - When you must publish, bind the interface.
"127.0.0.1:8081:8080"for anything you only reach through a proxy or an SSH tunnel, and an explicit LAN address for the rest. Never a bare port number. - Allocate host ports from one file. Keep a single
.envat the root of your compose tree withGRAFANA_PORT=21001,MINIFLUX_PORT=21002and so on, referenced as"127.0.0.1:${GRAFANA_PORT}:8080". Start at 21000, allocate sequentially, never reuse a number even after you delete the service. Two minutes of bookkeeping removes the entire class of problem, andgrep -r PORT= .envanswers "what is on 21004" instantly. - Keep a list of host-network services. Home Assistant, Plex, Netdata, Pi-hole, AdGuard Home and Frigate commonly run with
network_mode: hostfor discovery, mDNS or raw sockets. These are the only real contenders for host ports, and there should be few enough to fit on one line.
Next step#
Run your intended stack through Port conflict checker before you build it: it reads the same default port data behind the table above and tells you which numbers overlap, including the ones you would not think to check like 9000 and 51820. Then set up the proxy properly with Reverse proxy and TLS, and if DNS is part of the plan, read DNS for self-hosters first, because that is the one you cannot proxy your way out of.
Questions#
Do two containers using the same default port conflict?
No, not by themselves. Each container has its own network namespace, so ten of them can listen on 8080 at once. The scarce resource is the host port on the left side of a ports: mapping. Two containers both mapping 8080:8080 will collide and the second one fails to start with address already in use, but changing the left number fixes it, and not publishing at all avoids it entirely.
Why does port 53 conflict on Ubuntu and Debian?
systemd-resolved holds a stub listener on 127.0.0.53:53, and publishing a container port with -p 53:53 asks for 0.0.0.0:53, which the kernel refuses while that address is held. The container exits immediately with address already in use. AdGuard Home documents the fix: create /etc/systemd/resolved.conf.d/adguardhome.conf with DNSStubListener=no and DNS=127.0.0.1, replace /etc/resolv.conf with a symlink to /run/systemd/resolve/resolv.conf, then reload systemd-resolved. Setting the DNS line matters, because without the stub listener the default nameserver of 127.0.0.53 stops resolving anything.
Can I run Pi-hole and AdGuard Home at the same time?
On one host, not on the same address. Both want 53 on the LAN interface and both want a web port, and Pi-hole's FTL binds 80 and 443 and only falls back to 8080 when 80 is already taken. If you want to compare them, give each its own IP (a second address on the interface, or a macvlan network), or run one in a VM. Pointing half your clients at each also produces split query logs and inconsistent blocking, which is worse than either alone.
Why can't my rootless container bind port 80?
The kernel refuses ports below 1024 to processes without CAP_NET_BIND_SERVICE, and rootless Docker and Podman run in a user namespace without it. Docker documents two fixes: sudo setcap cap_net_bind_service=ep $(which rootlesskit) followed by a daemon restart, or setting net.ipv4.ip_unprivileged_port_start=0 in sysctl. Podman's guidance is the same sysctl. The alternative is publishing a high host port and letting a root-owned proxy or an nftables redirect own 80 and 443.
What should I do when two services genuinely need 443?
Give one of them a different IP address, or terminate both at one proxy. A reverse proxy can host many names on 443 because TLS carries SNI, so a wiki and a git forge coexist trivially. What does not work is two processes both wanting to own the socket, which is the AdGuard Home DNS-over-HTTPS case: it wants 443 for DoH with its own certificate. Options are a second address, proxying the DoH path through your existing terminator, or moving DoH to a non-standard port that your clients can be told about.
Is there any downside to not publishing ports at all?
You lose direct access for debugging, so curl localhost:8080 from the host stops working and you use docker compose exec or a temporary published port instead. You also need the proxy and the app on the same Docker network, which means writing the network into both compose files. In exchange, port collisions stop existing, and every service becomes unreachable except through a path that terminates TLS and can enforce authentication.
Sources#
- AdGuard Home FAQ, freeing port 53 from systemd-resolved
- AdGuard Home wiki, Docker ports and volumes
- Pi-hole documentation, prerequisites and required ports
- Docker documentation, rootless mode tips and privileged ports
- Podman documentation, shortcomings of rootless Podman
- Frigate documentation, authentication and the port 5000 warning
- Dokploy documentation, installation requirements and ports
- Nginx Proxy Manager setup, ports 80, 443 and 81
- Forgejo documentation, installation with Docker and SSH port mapping
- Syncthing documentation, firewall setup and port numbers
Published . Last reviewed . Found something out of date? Tell us and we will fix it and log the change.