You cloned the repo. The README says docker compose up. You have never liked Docker. Postgres fails because the port is taken. The API cannot see the database because you used localhost inside the container. The volume ate a stale node_modules. You consider installing everything on the host and pretending the README is optional.
Compose is not a personality. It is a way to run the same processes the README promised, with less “it works on my machine.” You can learn enough in an afternoon to be dangerous in a useful way.
This is the minimum Docker Compose mental model for application developers, the errors you will hit on day one, and how to debug them without becoming the platform team.
What Compose is doing
A compose.yml (or docker-compose.yml) names services: api, web, db, redis. Each service is a container. They share a network Compose creates. From api, the hostname db is the database service, not localhost. From your laptop browser, localhost:3000 is a published port.
That split is the whole game. Inside the network, use service names. On the host, use published ports.
build: means Compose will build an image from a Dockerfile. image: means it will pull one. Postgres is an image. Your app is often a build.
volumes: persist data. If you do not volume the Postgres data directory, compose down -v will wipe the database. If you do volume it, you will wonder why a migration from last month is still there. Both are correct depending on whether you wanted a reset.
The localhost bug
The API config says DATABASE_URL=postgres://postgres:postgres@localhost:5432/app. That works when the API runs on the host. Inside the api container, localhost is the API container, which is not running Postgres. Use db as the host: postgres://postgres:postgres@db:5432/app.
The frontend running in the browser is not inside Compose’s network. fetch('http://api:3001') from the browser will fail. The browser needs http://localhost:3001 (or whatever you published). Server-side rendering in a container talking to the API should use http://api:3001. Two different callers, two different hostnames. Document both. This confusion produces more Slack threads than any Dockerfile syntax.
Ports already allocated
Bind for 0.0.0.0:5432 failed: port is already allocated means your host already has Postgres, or a dead Compose project. docker ps and stop the other container, or change the left side of "5433:5432" to publish 5433 on the host. Inside Compose, services still talk to 5432 on db. Only the host mapping changed.
Do not run host Postgres and Compose Postgres on the same port and expect peace.
Volumes and the node_modules trap
A common pattern: bind-mount the source for hot reload, and an anonymous volume for /app/node_modules so the Linux modules inside the container are not overwritten by your Mac’s node_modules. If install still fails, you may need to rebuild without cache after changing package.json: docker compose build --no-cache api.
If the app cannot see your file changes, the bind mount path is wrong (Windows path conversion, or you are editing a file that is not mounted). If changes appear but crashes persist, you might be running an old image. compose up --build.
Reading logs like a grown-up
docker compose logs -f api is your new tail. If db is still starting, the API will crash-loop on connection refused. restart: on-failure plus a wait-for-it script, or a healthcheck on db and depends_on: condition: service_healthy, is the fix. depends_on without health only waits for start, not for “ready for connections.” Postgres is famous for this.
Healthchecks belong on the database. Copying a random healthcheck that hits the wrong path will delay forever.
One-off commands
docker compose exec api sh gets you a shell. docker compose run --rm api npm test runs a command in a new container. exec is for a running service. run is for a task. Mixing them is why people think the container “does not have the env.”
docker compose down stops and removes containers. Add -v to destroy volumes. Do not -v on a database you cared about unless you meant it.
Environment files
env_file: .env is not the same as the host’s environment. Compose interpolates ${VAR} from the shell and from a .env next to the compose file, with rules that surprise people. If a value is missing, you get an empty string and a mysterious auth failure.
Put secrets in .env which is gitignored. Provide .env.example with dummy values. Do not commit cloud keys. Compose is not a secret manager. It is a convenience for local.
When you should not use Compose
If the team is fully on a hosted dev environment, follow that. If you only need Postgres, a local install or a cloud instance might be less pain than Docker Desktop on a tiny laptop. Compose wins when the stack is “api + db + redis + mail hog” and you are tired of version drift.
Production is not “the same compose file with restart always” unless you are very small. Orchestrators exist. For day one, local Compose is enough.
A day-one checklist
- Install Docker Desktop or the engine. Confirm
docker version. - Copy
.env.exampleto.env. docker compose up --build.- If it fails,
docker compose psandlogson the first exited service. - Fix localhost vs service names in config.
- Fix port collisions.
- Wait for db health before blaming the API.
If the README’s Compose is wrong, fix the README in the same PR you used to survive. The next person is you on a new laptop.
File watching on Mac, Windows, and Linux VMs
Bind mounts plus file watchers are flaky. Webpack or Vite may not see changes on Docker Desktop for Mac without polling (CHOKIDAR_USEPOLLING or Vite’s usePolling). That is ugly and it works. Native Linux in a VM is smoother. If hot reload is more pain than it is worth, run the API in Compose and the frontend on the host against published ports. Hybrid setups are allowed. Purity is not the goal.
Multiple compose files
compose.yml plus compose.override.yml for local mounts is a common split. CI uses the base file without the override. If a teammate’s override is committed by accident, everyone’s paths break. Keep overrides local or document the -f flags.
docker compose -f compose.yml -f compose.test.yml for test databases on another port keeps you from wiping the dev volume. Name the project (-p) if two checkouts fight over container names.
Images that keep growing
If you rebuild often, dangling images fill the disk. docker compose down does not always prune images. docker image prune and knowing docker system df are part of living with Compose. Do not prune volumes unless you mean to delete databases.
When the build is slow, check whether you are copying node_modules into the image because .dockerignore is missing. A good .dockerignore is as important as the Dockerfile.
Networks and extra hosts
If the API must call a host-only service, extra_hosts: ["host.docker.internal:host-gateway"] is the usual bridge. It is different on Linux vs Desktop. If a teammate’s Linux box cannot resolve it, document the workaround. Do not assume everyone runs Mac.
Artikals is for the unglamorous onboarding hour. Compose looks like ops. It is mostly hostnames, ports, and volumes. Learn those three and the rest is copy from a working file. You did not need yesterday’s Docker certification. You needed db instead of localhost, and a log tail.