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.
Key takeaways
- 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
Common Docker Compose Errors and How to Fix Them#
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.
Get the DevOps Troubleshooting Cheat Sheet
Subscribe and get our free one-page reference for the errors that eat an afternoon β CrashLoopBackOff, OOMKilled, Terraform state locks, and more β plus new guides as we publish them.
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.
Ansible vs Terraform β Configuration vs Provisioning
Terraform provisions infrastructure and Ansible configures machines, so pitting them against each other is the wrong question to ask.
More from DevOps
Explore more articles in this category
Best Kubernetes IDE and GUI Tools in 2026
kubectl is fine until you're juggling five namespaces across three clusters. These are the tools that make that manageable, compared.
Chef vs Puppet vs Ansible: Configuration Management in 2026
One is agentless and Python-based, the other two run a persistent agent and a domain-specific language. The architecture difference matters more than the syntax.
PagerDuty vs Opsgenie: Choosing an Incident Alerting Tool
Both page the right person at 3am and both integrate with everything. The real differences show up in pricing structure, workflow depth, and who already owns the ecosystem around you.
You might have missed
Evergreen posts worth revisiting.