Docker Compose Ports Explained
Every Compose stack you copy from a README defaults to the same handful of ports. Here is how host vs container ports actually work, why multi-stack homelabs collide, and when to add a reverse proxy instead of publishing ports directly.

Docker Compose Ports Explained
You add a third stack to the homelab host, run docker compose up -d, and get Error response from daemon: driver failed programming external connectivity: Bind for 0.0.0.0:8080 failed: port is already allocated. Nothing about your YAML is wrong. Two independent projects both reached for port 8080, and only one process on the host can hold it.
TL;DR
ports: "8080:80"publishes a container port to the host;expose: "80"only makes it reachable to other containers on the same Compose network.- Host-port collisions happen because every stack you copy from a README defaults to the same obvious ports — 80, 443, 8080, 3000.
- Fix a collision three ways: change the host-side number, let Docker pick a random free port, or stop publishing the port at all if nothing outside Compose needs it.
- Once you're running 2+ web-facing stacks on one host, stop publishing each one's port directly — put a reverse proxy on 80/443 and route by hostname instead.
- My own homelab runs nine Compose stacks on one host; the port collision above is one I hit for real adding the ninth.
Host Port vs Container Port: The Left/Right Split
Every entry under ports: in a docker-compose.yml follows HOST:CONTAINER:
services:
web:
image: nginx
ports:
- "8080:80" # host:container
The left number, 8080, is what you type into a browser on the machine running Docker. The right number, 80, is what the application inside the container actually listens on — and it almost never needs to change, because it's fixed by the image, not by you. According to the official Compose file reference, you only ever need to change the host side to avoid a clash; the container side stays whatever the app expects.
Compose also accepts a long form for the same mapping, useful once you need a specific protocol or host IP:
ports:
- target: 80
published: 8080
protocol: tcp
host_ip: 127.0.0.1
Note
Binding host_ip: 127.0.0.1 instead of the default 0.0.0.0 keeps a port reachable only from the host itself — useful for an admin panel you'll always reach over Tailscale or SSH tunnel, never directly from the internet.
ports vs expose: What's Actually Different
expose looks similar but does something else entirely. Per the same Compose file reference, expose documents that a container listens on a port and makes it reachable to other services on the same Compose network — it never touches the host's network stack at all.
ports: |
expose: |
|
|---|---|---|
| Reachable from the host machine | Yes | No |
| Reachable from the internet (if host is public) | Yes, unless firewalled | No |
| Reachable from other containers on the same Compose network | Yes | Yes |
| Needs a free host port | Yes | No |
| Typical use | The one service a human or external client hits directly | A database, cache, or backend API only your own stack calls |
A Postgres container that only your app container talks to almost never needs ports: — expose: "5432" (or nothing at all, since containers on the same network can already reach each other's ports) keeps it off your host entirely, and off the internet if that host is ever exposed by mistake.
Warning
Publishing a database port with ports: "5432:5432" out of habit is the single most common way a homelab Postgres or Redis instance ends up reachable from the open internet on a host with a public IP. If nothing outside the Compose project needs it, don't publish it.
Why Multi-Stack Homelabs Collide on Ports
This is the part most Compose tutorials skip, because they assume you're running one stack per machine. A homelab running many independent projects on the same Proxmox VM or bare-metal box doesn't get that luxury — and nearly every self-hosted app's example docker-compose.yml defaults to the same handful of ports: 80, 443, 8080, 3000, 8000.
KEY-STAT: 9 — Compose stacks running side by side on my homelab, per my Docker Compose self-hosting guide
The second stack that reaches for 8080 doesn't fail quietly — Docker refuses to start the container at all, with the "port is already allocated" error from the intro. Nothing is wrong with either stack in isolation; the collision only exists because both are running on the same host.
Diagnosing "Port Is Already Allocated"
Before touching any YAML, find out what's actually holding the port:
sudo ss -tlnp | grep :8080 # what's listening, and which process
sudo lsof -i :8080 # same question, different tool
docker ps --format '{{.Names}}\t{{.Ports}}' | grep 8080 # is it another Compose project?
ss and lsof tell you if the port is held by a container at all — sometimes it's a service running directly on the host (a system Nginx, a leftover docker run) rather than another Compose project. docker ps narrows it down to the exact container and stack name when it is Docker. If the culprit turns out to be a half-dead container from a previous docker compose up that never released its port, Debugging a Broken Docker Compose Stack covers that failure mode specifically.
Three Ways to Fix a Port Collision
- Change the host-side number. The fastest fix: edit
ports: "8081:80"instead of8080:80in the newer stack. The container-side number never needs to move. - Let Docker pick a free port. Omit the host side —
ports: "80"— and Compose publishes the container's port 80 to a random free host port, shown bydocker compose port <service> 80. Good for services you'll only ever reach through a link the dashboard generates for you. - Stop publishing it. If the port only needs to be reachable from another container in the same Compose project (a database, an internal API), drop
ports:entirely and rely on Compose's default network, where every service can already reach every other service by its service name and container port.
Option 3 is also the fix for the warning above — it's not just a workaround for collisions, it's the correct default for anything that isn't meant to be hit directly.
Decision: When to Put a Reverse Proxy in Front Instead
Changing host ports one collision at a time works fine for two stacks. It stops scaling around the third or fourth web-facing service, because now you're tracking a growing, undocumented list of "which port was Nextcloud on again?" As covered in Docker Compose Networking Explained, Compose's own per-project networks already isolate stacks from each other — a reverse proxy is what lets you stop publishing each one individually while still reaching all of them on the standard 80/443.
| Situation | Keep publishing ports directly | Add a reverse proxy (Caddy / Traefik / nginx-proxy) |
|---|---|---|
| Number of web-facing stacks on one host | 1 | 2+ |
| How you want to reach services | host:8080, host:8443, … |
service-a.example.com, service-b.example.com |
| TLS certificates | Managed per-service, manually | One place, often automatic (Caddy, Traefik + Let's Encrypt) |
| Adding a new stack later | Pick another free port, remember it | Add one router rule; port stays internal |
Once a reverse proxy sits on 80/443, every other web service in every other Compose project can go back to expose:-only — no more free host ports to hunt down, and no more list of "what's on 8080 again."
FAQ
What is the difference between ports and expose in Docker Compose?
ports: publishes a container's port to the host machine (and the network beyond it, if the host is reachable). expose: only documents and permits access from other containers on the same Compose network — it never touches the host's network stack, so it needs no free host port.
How do I fix "bind: address already in use" in Docker Compose?
Find what's holding the port with sudo ss -tlnp | grep :<port> or docker ps, then either change the host-side number in ports:, omit the host side to let Docker assign a free one, or remove ports: entirely if nothing outside the Compose project needs that port.
Can two Docker Compose services use the same port?
Two services can each listen on the same container port (e.g. both on 80) without conflict, because each container has its own network namespace. They cannot both publish that port to the same host port — only one process on the host can bind a given host port at a time.
Do I need to expose a port for containers to talk to each other?
No. Services in the same Compose project can already reach each other by service name on any port the container listens on, over the default Compose network. expose: is documentation plus a signal for tools that read it — it isn't required for container-to-container traffic to work.
More from Self-Hosting & Privacy

You ran docker-compose up -d on a box where that exact command has worked a dozen times before - or a Pi you just flashed, or a Synology you just rebooted - and the shell answers docker-compose: command not found. Docker itself is clearly installed; docker ps works fine. So what broke?

Bitdefender SecurePass, Kaspersky, and Norton all bundle a password manager into their antivirus suite — here is what each one actually includes, and skips, versus Bitwarden.

The real decision framework for outgrowing Docker Compose: numeric container and node thresholds, the K3s middle step most guides skip, and what migrating actually costs — grounded in a single-host homelab.
Stay in the loop
Get the latest articles delivered to your inbox. No spam, unsubscribe anytime.
Docker Compose: Command Not Found, Fixed
You ran docker-compose up -d on a box where that exact command has worked a dozen times before - or a Pi you just flashed, or a Synology you just rebooted - and the shell answers docker-compose: command not found. Docker itself is clearly installed; docker ps works fine. So what broke?
Continue Reading