Docker Compose for development environments
One file describes your app's entire stack and every dev brings up the same environment with one command. See the anatomy of a compose file, healthchecks, and where not to use it.
Every time someone new joined the team, day one was the same sad ritual: install Postgres by hand, discover the version was different from production, spin up a Redis, find the port already taken, and burn the whole afternoon just to get the app running locally. Multiply that by five developers and you have a week of productivity thrown away on "works on my machine." Docker Compose exists precisely to kill that ritual. Instead of an onboarding doc with twenty manual steps, you have a file that describes the entire stack: the app, the database, the cache, all of it. The new dev clones the repo, runs one command, and two minutes later has the same environment as everyone else.
This article covers what Compose solves day to day in development, the anatomy of a compose file, and where it shines (and where you should not use it). If containers are still new to you, it is worth reading the Docker for beginners guide first, which covers the basics of images and containers before we start stacking several of them.
The problem Compose solves
Running a single isolated container with docker run is simple right up until your application needs company. A real web app rarely lives alone: it talks to a database, maybe a cache, maybe a queue. Bringing that up by hand means memorizing a sequence of giant docker run commands, with port, volume, network, and environment flags scattered everywhere. You document it in the README, someone forgets to update it, and the README is lying within a week.
Compose swaps all that for a single declarative file, usually docker-compose.yml, versioned alongside the code. It describes each service, how it is built or which image it comes from, which ports it exposes, which volumes it mounts, and how it talks to the others. The command docker compose up reads that file and brings the whole stack up at once. The file becomes the source of truth: there is no lying README, because the environment IS the file.
Anatomy of a compose file
Let us look at a realistic example of an app that needs a Postgres database:
services:
app:
build: .
ports:
- "3000:3000"
volumes:
- .:/usr/src/app
- /usr/src/app/node_modules
environment:
DATABASE_URL: postgres://dev:dev@db:5432/appdev
depends_on:
- db
db:
image: postgres:16.3
ports:
- "5432:5432"
environment:
POSTGRES_USER: dev
POSTGRES_PASSWORD: dev
POSTGRES_DB: appdev
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:
Each key inside services is a container that will come up. Notice the decisions:
buildvsimage: theappservice usesbuild: ., meaning Compose builds the image from theDockerfilein the current folder. Thedbusesimage: postgres:16.3, a ready-made image pulled from the registry. You usebuildfor your code andimagefor off-the-shelf dependencies.ports:"3000:3000"maps the host port to the container port (host:container). That is what you reach from the browser.volumesfor hot-reload:.:/usr/src/appmounts your code inside the container. You edit a file in your editor and the container sees the change, no rebuild. The second volume,/usr/src/app/node_modules, is a common trick to stop your machine'snode_modulesfrom overwriting the container's.volumesfor DB persistence:pgdata:/var/lib/postgresql/datais a named volume. Without it, you lose all database data every time you rundocker compose down. With it, the data survives.environment: variables injected into the container. Here the app knows how to connect to the database throughDATABASE_URL.depends_on: defines startup order. Thedbcomes up before theapp. But watch out for a trap we will detail next.
depends_on does not wait for the database to be "ready"
This is the mistake that catches almost everyone in the first week. depends_on guarantees startup order, but it does not guarantee that the dependent service is ready to accept connections. Compose starts the Postgres container and, the moment the container exists, it considers the dependency satisfied and starts the app. Except Postgres takes a few seconds to actually initialize. Result: the app tries to connect, the database is still crawling, the connection fails, and the app dies.
The correct fix is a healthcheck on the database service, combined with the extended form of depends_on:
db:
image: postgres:16.3
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dev"]
interval: 5s
timeout: 3s
retries: 5
And on the app:
app:
depends_on:
db:
condition: service_healthy
Now Compose waits for pg_isready to return success before starting the app. That said, the more robust long-term approach is to make your application tolerate the database being unavailable and retry the connection, because a healthcheck solves startup but does not solve a database that goes down in the middle of the day.
Everyday commands
In practice you live with three or four commands. To bring the whole stack up in the background:
docker compose up -d
To follow what is happening, especially when something did not come up:
docker compose logs -f app
And to tear everything down at the end of the day, freeing the ports:
docker compose down
Worth remembering: down by default preserves named volumes. If you want a truly clean database, docker compose down -v deletes the volumes too. Use it carefully, because then the data is really gone.
Networking: services find each other by name
Notice that in the example the DATABASE_URL points to db:5432, not to localhost. This is not magic. Compose automatically creates a network for the project and each service becomes a hostname inside it, using the service's own name. The app resolves db to the Postgres container's IP without you configuring anything. That is why the service name matters: renaming db to database in the file means changing the DATABASE_URL too.
To understand the structure of a compose file or to check an indentation you are not sure sits at the right level, converting the YAML to JSON with a YAML to JSON converter helps you see explicitly what is nested inside what.
Dev vs prod, profiles, and override files
Compose is excellent for development and good enough for CI. But be careful treating it as a production orchestrator. It has no decent rolling deploy, no distributed auto-healing, no scaling across machines, none of the features Kubernetes offers. For serious production, Compose is the wrong tool; for your laptop, it is perfect.
Two features help keep a single repo serving different scenarios. Profiles let you mark services as optional: a data-seed service or an admin panel only comes up when you run docker compose --profile tools up. That keeps the default up lean. Override files are the docker-compose.override.yml, which Compose reads automatically on top of the base file, letting each dev tweak ports or volumes locally without touching the file the team versions.
Two opinions I will defend without blinking. First: pin image versions, never use :latest. postgres:latest means two devs can be running different database versions without noticing, and that a build of yours today differs from tomorrow's. postgres:16.3 is reproducible. Second: use named volumes for database data, never bind mounts. A named volume is managed by Docker, is portable across operating systems, and does not depend on a path that only exists on your machine.
Frequently asked questions
What is the difference between docker run and docker compose?
docker run starts one container at a time, with all the configuration passed on the command line. docker compose reads a declarative file that describes several services at once, with their networks, volumes, and dependencies, and brings them all up together. For anything beyond a lone container, Compose saves you a lot of memorized commands.
Do I still need a healthcheck if I use depends_on?
Yes, if readiness order matters. depends_on only guarantees that one container started before another, not that it is ready to receive connections. To wait for a database to accept queries, you need a healthcheck on the service and the service_healthy condition on depends_on.
Can I use the same docker-compose.yml in production?
You can in simple scenarios, but I would not recommend it. Compose was not built for distributed orchestration: it lacks scaling across machines, zero-downtime deploys, and serious auto-healing. For dev and CI it is great; for production with availability requirements, prefer a real orchestrator.
What happens to database data when I run docker compose down?
By default, down removes the containers and the network but preserves the named volumes, so the database data is still there the next time you bring it up. If you run docker compose down -v, the volumes are deleted too and the database comes back empty.
Key takeaway
Compose turns a one-day onboarding into a docker compose up -d, because a single file now describes the entire stack and becomes the source of truth for the environment. Pin image versions, use named volumes for data that needs to survive, and do not trust depends_on to wait for a database to be ready: use a healthcheck. Treat Compose as the brilliant development tool it is, and leave serious production to an orchestrator built for that. If the concepts of container and image still feel vague, the Docker for beginners guide covers the fundamentals before you stack several of them in one file.
- 01 UTF-8 and encoding problems: why accents break and how to fix them What UTF-8 is, code points, mojibake, and why accented text turns to garbage — the mechanism explained end to end.
- 02 MIT, Apache, and GPL Licenses: Practical Differences for Developers MIT, Apache 2.0, and GPL are not the same thing. Understand permissive vs copyleft, patent protection, and compatibility before shipping code to production.