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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
One bundles your whole toolchain, the other lets you build anything from parts. The right pick depends on how much glue you want to own.
GitHub Actions OIDC gets all the attention, but GitLab, Buildkite, and CircleCI issue the same signed tokens. Here's how to trust them without opening a hole.
Explore more articles in this category
The IaC landscape fractured after the Terraform license change. This is the map to what each tool is actually best at, and how to choose without regret.
Terragrunt keeps large Terraform setups DRY and orchestrated, but small teams often pay its learning curve for little return.
A practical comparison of Kubernetes-native continuous reconciliation against the classic CLI-driven, state-file IaC model for platform teams.
Evergreen posts worth revisiting.