Overview
Docker Compose throws “exported ports must be unique” when two or more port publishes in the same Compose project collide. The conflict is usually the same published (host) port used multiple times with the same protocol and host IP.
Key points:
- The uniqueness check applies to the merged Compose config (after profiles, overrides, and variable expansion).
- Duplicates can exist across services or within a single service’s ports list.
- Runtime conflicts can also occur with other containers or host processes even if Compose validation passes in separate projects.
Quickstart: fix duplicates fast
Render the merged config
- This shows the actual ports after applying all overrides and variables.
docker compose config > merged.ymlList published ports and spot duplicates
- Look for repeated published ports (and protocol) across services.
# Quick scan for published ports in the merged file grep -n "published:" merged.yml | sed 's/.*published: \(.*\)/\1/' | sort | uniq -cApply one of these fixes
- Make host ports unique: change published ports so each is different.
- Remove unnecessary publishes: most internal services don’t need
portsat all. - Use ephemeral publishes: specify only the target (container) port so Docker picks a free host port.
- Split TCP/UDP explicitly if you truly need both.
Recreate the stack
docker compose up -d --remove-orphans --force-recreate docker compose ps
Broken example (for reference)
This fails because both services publish host port 8080/tcp.
services:
web:
image: nginx:alpine
ports:
- "8080:80" # publishes 8080/tcp
admin:
image: nginx:alpine
ports:
- "8080:80" # duplicate publish of 8080/tcp → error
Minimal working example
Two services, each with a distinct published port:
services:
web:
image: nginx:alpine
ports:
- "8080:80" # host 8080 → container 80 (tcp)
admin:
image: nginx:alpine
ports:
- "8081:80" # host 8081 → container 80 (tcp)
Alternative: publish only what’s public, keep internal services private:
services:
api:
image: nginx:alpine
# Only the gateway is published externally
gateway:
image: nginx:alpine
depends_on: [api]
ports:
- "8080:80"
# api is reachable internally at http://api:80 without publishing api
Ephemeral host ports (Docker picks an available host port):
services:
svc1:
image: nginx:alpine
ports:
- "80" # publish container 80 → random host port
svc2:
image: nginx:alpine
ports:
- "80" # allowed; different random host port
Check assigned ports with:
docker compose ps
Common scenarios and fixes
| Symptom | Cause | Fix |
|---|---|---|
Two services use 8080:80 | Same published port | Change one to 8081:80 or remove one publish |
| Duplicate entries inside one service | Copy/paste or YAML anchor expanded twice | Remove duplicates or consolidate to long syntax |
| Profiles/overrides reintroduce a port | Merge from multiple files | Inspect docker compose config and adjust specific file |
| Need both TCP and UDP on same port | Protocol not specified | Use two entries: 80:80/tcp and 80:80/udp |
| Only one service should be public | Unnecessary publishes | Replace ports with expose or remove entirely |
Long syntax tips (clearer and less error-prone)
Prefer long syntax for explicitness:
services:
web:
image: nginx:alpine
ports:
- target: 80 # container port
published: 8080 # host port
protocol: tcp
mode: ingress
- Uniqueness is evaluated on (host_ip, published, protocol). If you publish the same port on different host IPs, specify
host_ipexplicitly in long syntax.
Example binding to loopback only:
services:
admin:
image: nginx:alpine
ports:
- target: 80
published: 8080
protocol: tcp
host_ip: 127.0.0.1
Pitfalls to avoid
- Overlaps from multiple files:
-f docker-compose.yml -f docker-compose.prod.ymlcan re-add ports. Always inspect the merged output. - Variable collisions:
${PORT:-8080}used in multiple services resolves to the same published port. - YAML anchors/aliases: reused anchors can duplicate a port unintentionally.
- Subtle duplicates:
80:80and80:80/tcpare the same for TCP; specifying protocol doesn’t make them unique unless protocols differ. - network_mode: host: avoid combining with
ports(it’s ignored). Host networking means container ports are on the host directly. - Different projects on the same host: validation passes per project, but runtime may still fail if the port is already taken by another container or process.
Performance notes
- Publish only what you need. Each published port adds NAT/iptables rules; fewer publishes reduce overhead and startup time on busy hosts.
- Use a single public entry point (reverse proxy or gateway) rather than publishing many service ports.
- Internal traffic over the default bridge network is fast and avoids NAT; keep backend services private when possible.
- Ephemeral host ports are fine for development; for production, prefer fixed ports or a stable frontend.
Step-by-step remediation checklist
- Run
docker compose configand search forpublished:lines. - Ensure each (host_ip, published, protocol) tuple appears at most once.
- Remove
portsfrom services that don’t need external access; useexposeif you want documentation-only exposure. - Convert to long syntax for clarity and to set
host_ipor protocol explicitly when needed. - Recreate the stack and verify with
docker compose psanddocker ps.
Tiny FAQ
Why does Compose error before starting containers?
- It validates the merged spec and blocks known conflicts (duplicate publishes) early.
Can I publish the same port for TCP and UDP?
- Yes. Use two entries: one
/tcp, one/udp.
- Yes. Use two entries: one
Do I need
portsfor service-to-service calls?- No. Containers on the same Compose network can talk via service name and container port without publishing.
How do I see which process holds a port outside Compose?
- On Linux/macOS:
lsof -i :PORTorss -tulpen | grep :PORT. On Windows:netstat -ano | findstr :PORT.
- On Linux/macOS: