Fixing 'Port Is Already Allocated' Errors in Compose
docker compose up aborts with Bind for 0.0.0.0:8080 failed: port is already allocated because something already holds the host port your service wants to publish. This is the most common networking failure in Local Network & Port Mapping, and it has three distinct culprits: a leftover container, a host process, or a duplicate mapping in your own Compose files.
The error is blunt but honest: the Linux kernel refuses to let two sockets bind the same (address, port) tuple, and Docker's userland proxy (or the equivalent iptables DNAT rule) surfaces that refusal verbatim. The fix is never to retry blindly — it is to identify which of the three holders owns the port, release or remap it, and then add a guard so the same conflict cannot silently return the next time a teammate checks out the branch. Everything below is ordered so you can go from the raw daemon error to a running stack in a couple of minutes without guessing.
Diagnostic
Reproduce and identify the holder. The error names the exact host port:
Error response from daemon: driver failed programming external connectivity on endpoint app:
Bind for 0.0.0.0:8080 failed: port is already allocated
Read that line precisely: 0.0.0.0:8080 is the host side of the mapping, not the container side. The number after the colon is the value you feed into every command below. Do not assume it is 8080 in your own case — copy the port out of your actual error, because a stack that publishes several services can fail on any one of them.
First check whether another container already publishes the port:
#!/usr/bin/env bash
# who-has-the-port.sh
set -euo pipefail
PORT=8080
echo "== Docker containers publishing :$PORT =="
docker ps --filter "publish=${PORT}" --format '{{.Names}}\t{{.Ports}}'
echo "== Host processes listening on :$PORT =="
ss -tulpn "sport = :${PORT}" 2>/dev/null || lsof -nP -iTCP:${PORT} -sTCP:LISTEN
# BAD: a stale container from a previous run still holds the port
== Docker containers publishing :8080 ==
oldstack-app-1 0.0.0.0:8080->8080/tcp
== Host processes listening on :8080 ==
COMMAND PID USER FD TYPE DEVICE NODE NAME
com.docke 4312 you 37u IPv4 0x... TCP *:8080 (LISTEN)
The two probes answer two different questions. docker ps --filter "publish=8080" asks the Docker daemon which of its containers advertises the port; if a row comes back, the holder is a container and you resolve it with Compose or docker stop. The ss/lsof probe asks the host kernel which process owns the listening socket; when the command shown is com.docker (Docker Desktop) or docker-proxy, the port still belongs to Docker, but any other command name — node, python, postgres, nginx — means a non-Docker process on your machine has claimed it and Compose can never win that race until you free it. Run both probes every time; the combination is what tells the culprits apart.
Root cause
A host TCP port can be bound by exactly one process. The error fires when Docker's proxy tries to bind a port that is already held — usually a container from a prior docker compose up that was never torn down (Compose keeps containers across runs unless you down), a non-Docker host process such as a local dev server or a system service, or a second ports: mapping for the same host port elsewhere in your merged Compose files.
That third culprit is the easiest to miss because it lives in your own repository. Compose merges every file passed with -f, plus the automatically-loaded docker-compose.override.yml, into one effective configuration. If the base file publishes 8080:8080 and an override (or a second service) also publishes 8080:8080, the merged result asks the kernel for the same host port twice and the second bind loses. Run docker compose config to print the fully-merged configuration and grep it for the port — if the number appears under two different services, no amount of stopping containers will help, because the conflict is between two services in the same stack starting at once. Distinguishing this from a stale-container conflict is the single most valuable diagnostic judgement on this page: a stale container shows up in docker ps, but a duplicate mapping shows up only in docker compose config.
Resolution
- Stop the stale Compose stack that still owns the port. Running
down(not juststop) releases the published ports and removes the containers.
#!/usr/bin/env bash
set -euo pipefail
docker compose down --remove-orphans
- If a container from a different project holds it, stop that one specifically.
#!/usr/bin/env bash
set -euo pipefail
docker stop "$(docker ps --filter "publish=8080" -q)"
- If a host process (not Docker) holds it, stop that process or change your published port.
#!/usr/bin/env bash
set -euo pipefail
# Identify, then stop the host process by PID from the diagnostic above
kill "$(lsof -nP -iTCP:8080 -sTCP:LISTEN -t)"
- If you cannot free the port, remap to a free host port and bind to loopback. The container port stays the same, so internal service-to-service URLs are unaffected.
# docker-compose.override.yml
services:
app:
ports:
- "127.0.0.1:${APP_PORT:-8081}:8080"
- Bring the stack back up and confirm.
#!/usr/bin/env bash
set -euo pipefail
docker compose up -d --wait
Work the steps in order and stop at the first one that applies to your holder — you rarely need all five. Steps 1 and 2 cover the container culprits, step 3 covers a non-Docker host process, and step 4 is the escape hatch when the holder is a service you are not allowed to kill (a shared database, a corporate agent, a system service that respawns). Prefer remapping over killing whenever the other process is legitimately supposed to run; kill a coworker's dev server and you have merely moved the surprise onto them. Note that binding to 127.0.0.1 in step 4 is deliberate: it publishes the port only on loopback, so the service is reachable from your machine but not from the LAN, which both narrows the conflict surface and avoids exposing a dev service to the network.
Expected output
[+] Running 2/2
✔ Container app-db-1 Healthy
✔ Container app-app-1 Started
docker compose ps --format '{{.Name}}\t{{.Ports}}'
# app-app-1 127.0.0.1:8081->8080/tcp
The --wait flag is what makes this output trustworthy: docker compose up -d alone returns as soon as the containers are created, but --wait blocks until each service either becomes Healthy (if it declares a healthcheck) or reaches Started, and it exits non-zero if any container dies during startup. If the port was the only problem, both lines print and the command returns 0. The docker compose ps line then confirms the published mapping — here 127.0.0.1:8081->8080/tcp shows the remap from step 4 took effect, with 8081 on the host still forwarding to 8080 inside the container. If instead the command hangs or exits non-zero, the port was not the whole story: inspect docker compose logs app for a healthcheck that never turns green, which is a different failure mode from a bind conflict and is covered by the startup-order material linked below. A clean exit with both lines present is your signal that the allocation error is fully resolved and the stack is serving traffic on the mapping shown.
Prevention
- Always tear down before switching branches or stacks. A
make downthat runsdocker compose down --remove-orphanskeeps host ports clean. - Drive every published port through an
.envdefault so two stacks can coexist, and reject hardcoded ports in review.
# docker-compose.yml
services:
app:
ports:
- "${APP_PORT:-8080}:8080"
- Add a pre-up check that fails fast with a readable message instead of the raw daemon error.
#!/usr/bin/env bash
# bin/check-ports.sh — run before `docker compose up`
set -euo pipefail
for p in "${APP_PORT:-8080}" "${DB_PORT:-5432}"; do
if ss -tuln "sport = :${p}" 2>/dev/null | grep -q ":${p}"; then
echo "Port ${p} is in use; set a different value in .env before starting." >&2
exit 1
fi
done
echo "All required ports are free."
The point of the pre-up check is to convert a confusing daemon-level failure into an actionable message that names the port and tells the reader what to edit. Wire it into whatever wrapper the team already runs — a Makefile target, an npm pre-start script, or a Git pre-commit hook that lints Compose files for hardcoded host ports. The ${APP_PORT:-8080} default pattern is the other half of the strategy: because every host port is parameterised, a second developer can start the same stack by exporting APP_PORT=18080 in their .env and the two never collide, which is exactly what you want when several projects run side by side on one workstation.
down releases the published port; a stopped container still reserves it on next up if it restarts.Platform caveats
Port ownership is reported by the operating system, so the tool that reveals the holder — and sometimes the holder itself — changes with the platform. The one constant is that you must probe the same network namespace the daemon binds into; probing the wrong stack is the usual reason a port "looks free" yet the bind still fails.
macOS (Docker Desktop):
lsofmay needsudoto see all listeners; preferssinside the Linux VM. Some Apple system services (AirPlay Receiver) hold :5000 — disable it or remap. WSL2: run the scan inside the distro; from PowerShell you query the Windows host stack and miss WSL2-bound listeners. Bind to0.0.0.0rather than[::]to avoid IPv6 binding failures. Apple Silicon (ARM64): behavior matches AMD64, but BSDlsofoutput formatting differs from GNUlsof; thesspath is more portable.
Rollback
#!/usr/bin/env bash
set -euo pipefail
git checkout -- docker-compose.override.yml 2>/dev/null || true
docker compose down --remove-orphans && docker compose up -d --wait
If the remap in step 4 caused a downstream problem — a hardcoded client URL still pointing at the old port, for example — this one-liner discards the override, tears the stack down cleanly, and brings it back on the original mapping. Because the change lived entirely in docker-compose.override.yml, reverting is a single git checkout; nothing in the base configuration or in any volume is touched.
Frequently Asked Questions
Why does docker compose stop not free the port but down does?
stop sends the containers a stop signal but leaves them in place, and a stopped container still owns its published-port reservation, so the next up that restarts it re-binds the same host port. down removes the containers (and the default network) entirely, which releases the port back to the kernel. Use down --remove-orphans when you want the host ports genuinely free.
How do I tell whether a container or a host process holds the port?
Run both probes. docker ps --filter "publish=8080" returns a row only if a Docker container advertises the port, and ss -tulpn "sport = :8080" (or lsof -nP -iTCP:8080 -sTCP:LISTEN) names the host process that owns the socket. If the command shown is docker-proxy or com.docker, it is still Docker; any other command name means a non-Docker process on your machine holds it.
The port is free in docker ps but up still fails — why?
The conflict is inside your own merged configuration: two services (or an override plus the base file) both publish the same host port, so they collide the instant the stack starts. Run docker compose config to print the fully-merged file and search it for the port number. If it appears under two services, edit one mapping — stopping containers will never help because nothing external holds the port.
Is it safe to kill the process that holds the port?
Only if you own it. Killing your own stale dev server is fine, but a shared database, a system service, or a coworker's process should be remapped around instead — publish your service on a different host port with 127.0.0.1:${APP_PORT:-8081}:8080. The container port stays 8080, so internal service URLs are unaffected and you avoid disrupting whatever legitimately holds the original port.