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.

Diagnostic pipeline for a bind failure Reading the error, then probing Docker containers, then probing the host socket table. From Error to Holder Read the error copy host port Probe containers docker ps --filter Probe host stack ss / lsof Run both probes: one names the container, the other names the host process.
The diagnostic runs left to right — the two probes together identify which of the three holders owns the port.

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.

Which holder owns the port A root question branching to three holders, each with its own fix. Who Holds the Port? Bind failed on :8080 Stale container docker compose down Host process stop it or remap Duplicate mapping edit compose config
Three holders, three fixes — the diagnostic probes route you to exactly one branch.

Resolution

  1. Stop the stale Compose stack that still owns the port. Running down (not just stop) releases the published ports and removes the containers.
#!/usr/bin/env bash
set -euo pipefail
docker compose down --remove-orphans
  1. 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)"
  1. 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)"
  1. 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"
  1. 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.

Resolution sequence Four ordered stages from tearing down the stale stack to confirming the running stack. Resolve in Order 1 — down the stale stack 2 — stop the foreign holder 3 — remap if you cannot free it 4 — up --wait and confirm
Stop at the first stage that frees the port; the last stage always confirms the stack is healthy.

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

  1. Always tear down before switching branches or stacks. A make down that runs docker compose down --remove-orphans keeps host ports clean.
  2. Drive every published port through an .env default so two stacks can coexist, and reject hardcoded ports in review.
# docker-compose.yml
services:
  app:
    ports:
      - "${APP_PORT:-8080}:8080"
  1. 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 versus stop Comparison of docker compose down and docker compose stop across what each releases. down vs stop compose down removes containers releases host ports removes the network keeps named volumes compose stop keeps containers holds host ports keeps the network fast to restart
Only 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): lsof may need sudo to see all listeners; prefer ss inside 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 to 0.0.0.0 rather than [::] to avoid IPv6 binding failures. Apple Silicon (ARM64): behavior matches AMD64, but BSD lsof output formatting differs from GNU lsof; the ss path 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.