Common Docker Compose Errors and How to Fix Them
The Compose failures that eat an afternoon are almost always the same six. Here's how to spot each one fast and the fix that actually sticks.
On this page
I've lost more hours to Compose than I'd like to admit, and almost never to anything exotic. It's the same handful of failures on repeat, and once you've seen each one a couple of times you stop debugging and start recognizing. This is that list: the errors that show up in real projects, and the fix that holds up after you close the laptop.
Environment variables that don't interpolate
You write ${DB_PASSWORD} in your compose file, run docker compose up, and get WARN: The "DB_PASSWORD" variable is not set. Defaulting to a blank string. Then the container starts with an empty password and nothing works.
Two things trip people here. First, the .env file has to sit next to the compose file, in the directory you run the command from, not next to your Dockerfile or in a subfolder. Compose looks for .env in the working directory, full stop. If yours lives elsewhere, point at it with --env-file config/prod.env.
Second, know the difference between ${VAR} in the compose file and a variable your shell exports. Compose resolves ${VAR} from the .env file and the shell environment at parse time. But inside a container's command, $VAR is evaluated by the container's shell at runtime, using that container's environment. Those are two separate stages. A value in .env does not automatically land inside the container unless you pass it through environment: or env_file:.
services:
api:
image: myapp/api:1.4
env_file: .env
environment:
DATABASE_URL: ${DATABASE_URL}
If you want a literal dollar sign in a value, escape it as $$. That one has burned people writing cron strings and password hashes into Compose.
depends_on doesn't wait for readiness
This is the big one. New folks read depends_on and assume Compose waits for the database to be ready before starting the app. It does not. It waits for the container to start, which for Postgres means the process launched, not that it's accepting connections. So your app boots, fires a query at a database still doing its init, and dies with connection refused.
Start order is not readiness. The fix is a healthcheck on the dependency plus condition: service_healthy on the dependent:
services:
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
start_period: 10s
api:
image: myapp/api:1.4
depends_on:
db:
condition: service_healthy
Now api won't start until pg_isready passes. I'll say it plainly: use healthchecks, not sleep 30 in your entrypoint. A sleep is a guess. On a cold CI runner it's too short, on your laptop it's wasted time, and it never tells you why the dependency failed. A healthcheck is the actual signal.
Port and network conflicts
Error starting userland proxy: listen tcp4 0.0.0.0:5432: bind: address already in use. Something already holds that port. Usually it's a stray container from last week or a Postgres you installed on the host and forgot about. Find it with docker ps or lsof -i :5432, then either stop it or remap: change "5432:5432" to "5433:5432" so the host uses 5433 and the container stays on 5432.
Network conflicts are subtler. If two projects both define a bridge network in overlapping subnet ranges, or you've got a pile of orphaned networks, you'll see address pool errors on up. docker network prune clears the dead ones. If you need a fixed subnet, declare it explicitly rather than letting Docker guess.
"Service X refers to undefined network/volume"
Compose validates references before it does anything. If a service lists a network or volume under its own block, that name has to exist in the top-level networks: or volumes: section too. It's easy to add backend to a service and forget to declare it up top.
services:
api:
networks: [backend]
volumes:
- pgdata:/var/lib/postgresql/data
networks:
backend:
volumes:
pgdata:
The top-level declaration can be empty, but it has to be there.
The version field and Compose v2
If you're still writing version: "3.8" at the top of your file, drop it. Compose v2 ignores it and will warn the attribute version is obsolete. The Compose Specification doesn't use a version key anymore; the schema is just the schema. Delete the line. While you're at it, get used to docker compose (space, the v2 plugin) over the old docker-compose binary, which is end of life.
Build vs image confusion
A service can have build: or image:, and mixing them up causes quiet surprises. If you set both, Compose builds from the Dockerfile and tags the result with the image name. If you only set image: and expect your local code changes to show up, they won't, because it pulled a prebuilt image and never touched your source. When you edit a Dockerfile or app code and rerun up, Compose reuses the existing image unless you pass --build. So your fix appears to do nothing. docker compose up --build forces the rebuild.
Container name conflicts
Conflict. The container name "/my-api" is already in use. You set container_name: my-api explicitly, and a previous run left that container around. Fixed names block Compose from managing multiple instances and clash on restart. Unless you have a hard reason, drop container_name and let Compose generate names from the project and service. If you kept it deliberately, clear the stale one with docker rm -f my-api first.
Volumes not persisting or permission errors
Two flavors here. Data vanishing on restart usually means you used an anonymous volume or a bind mount pointing at the wrong path, so nothing actually wrote to the named volume. Confirm the mount target matches where the app writes, and use a named volume for anything you care about keeping.
Permission errors (permission denied on a mounted directory) come from a UID mismatch: the container process runs as a user that doesn't own the mounted host directory. Either run the container as your host UID with user: "1000:1000", or chown the data directory to match what the image expects. Named volumes usually sidestep this because Docker sets ownership on first init; bind mounts don't, which is why they bite more often.
compose up vs run networking
Last one that catches people. docker compose run api curl http://web behaves differently from a service started by up. By default run does not publish the service's mapped ports and attaches to the project network, so service-to-service DNS names still resolve, but host port mappings you rely on won't be there. If you need the same port behavior as up, add --service-ports. And remember run starts a fresh one-off container, so anything written to non-volume paths is gone when it exits.
For anything that outlives this list, the broader Docker troubleshooting guide covers the runtime and image-layer failures that sit underneath Compose.
The call we'd make
Put a healthcheck on every stateful service and gate dependents with condition: service_healthy. Drop the version line, drop container_name unless you truly need it, and name your volumes. Most of the afternoons I've lost to Compose came from trusting start order to mean readiness, and a five-line healthcheck ends that argument for good.
GitLab CI/CD Best Practices in 2026: Pipelines You Can Trust
A production-focused GitLab CI/CD guide: include/extends templates, rules and workflow, needs DAGs, cache vs artifacts, protected environments, masked/protected variables and Vault, built-in security scanning, and review apps β with copy-paste examples.
Fixing DNS Resolution Failures in Kubernetes
A pod can't reach a service by name, the app throws connection errors, and everyone blames the network. Usually it's DNS, and usually it's fixable.
More from DevOps
Explore more articles in this category
GitHub Copilot CLI Sandboxing Is GA: What Actually Changed
GitHub made local sandboxing generally available for Copilot CLI on October 7, so the default now restricts the agent instead of trusting it.
The Tensorlake npm Compromise Targeted Your AI Tool Configs
A compromised npm release went after cloud credentials as expected, and local AI coding-assistant config files, which most teams never treated as secrets.
GitLab's New Rate Limits: What to Fix Before Oct 19
GitLab is capping unauthenticated API calls at 60 an hour starting October 19, and the preview windows land before most teams will have noticed.
You might have missed
Evergreen posts worth revisiting.