Setting Up a Local Development Environment with Docker Compose
Every team has a version of the same story. A new developer clones the repository on Monday morning, spends the next two days installing PostgreSQL, Redis, the right language runtime, and a queue worker, then finds out their local database behaves differently from everyone else's. Docker Compose exists to end that genre of suffering. One file in the repository describes the whole stack, one command starts it, and every machine — a laptop, a CI runner, a colleague's desktop — ends up with the same services, ports, and environment variables.
Why Compose Beats a Folder Full of Shell Scripts
You can run a handful of docker run commands and get by for a while. The trouble starts when those commands grow flags: a network here, a volume there, an environment variable someone added in March and never wrote down. Six months later nobody wants to touch the script that starts the database.
Compose flips the model. You declare the services you need, the image or build context each one comes from, the ports they publish, and how they talk to each other. Compose creates a private network per project, so a service named db is reachable at the hostname db from any other service in the same file. No more guessing IP addresses or editing /etc/hosts. The entire stack is versioned alongside your code, reviewed in pull requests, and reproducible on any machine that has Docker installed.
A Minimal docker-compose.yml, Annotated
Let's look at a realistic example for a Node.js application with PostgreSQL and Redis. The file is called docker-compose.yml and lives at the root of the repository.
It starts with a services block. Under that, you define three services: app, db, and redis. The app service uses build: ., which tells Compose to build an image from the Dockerfile in the current directory. It publishes port 3000 to the host so you can open the app in your browser. It sets depends_on to db and redis, which controls startup order. It also mounts the source code as a volume so changes on your laptop appear inside the container without rebuilding.
The db service uses the official postgres:15 image. It sets environment variables like POSTGRES_USER, POSTGRES_PASSWORD, and POSTGRES_DB. It maps a named volume to /var/lib/postgresql/data so your data survives a docker compose down. The redis service is even simpler: just an image and an optional volume.
Networks and Service Discovery
Compose automatically creates a network for the project. Every service joins that network. The hostname of each service is its service name. So from the app container, you connect to PostgreSQL at db:5432 and Redis at redis:6379. You don't need to expose those ports to your host machine unless you want to connect with a local GUI client. Exposing them is fine, but it's optional and can cause port conflicts if you run multiple projects.
Volumes: Persistence Without the Pain
Volumes are how Compose handles data that must outlive a container. There are two main types: named volumes and bind mounts. A named volume is managed by Docker and lives in a special directory. A bind mount maps a host directory directly into the container. For databases, named volumes are usually the right choice. For source code, bind mounts are common in development so you can edit files locally and see changes immediately.
Common Workflows That Compose Makes Painless
Once your Compose file is in place, daily development becomes a series of short commands. Here are the ones you'll use most.
- docker compose up starts the whole stack. Add -d to run in the background.
- docker compose down stops and removes containers, networks, and the default volume if you ask it to. Use down -v to wipe database data when you want a clean slate.
- docker compose logs -f app follows the logs from one service. Omit the service name to see everything interleaved.
- docker compose exec app sh drops you into a shell inside the running app container. This is invaluable for running migrations, debugging, or checking environment variables.
- docker compose build rebuilds images after you change the Dockerfile or dependencies. Add --no-cache if something is stubborn.
- docker compose ps shows which services are running and their health status.
Because the commands are predictable, you can document them in the README and trust that they work the same on macOS, Linux, and Windows with WSL2.
Environment Variables and Secrets
Hardcoding passwords in docker-compose.yml is a bad habit. Compose supports an .env file in the project root. Variables defined there are automatically available for substitution in the Compose file. For example, you can write POSTGRES_PASSWORD=${DB_PASSWORD} and keep the actual value in .env, which you add to .gitignore. For team onboarding, commit a file called .env.example with placeholder values and a comment explaining what to fill in.
For more sensitive material, Docker secrets are an option, but they require Swarm mode. In local development, an .env file is usually sufficient. Just be careful not to commit real credentials.
When to Graduate Beyond Compose
Compose is a development tool first. It can run in production on a single host, but it lacks the orchestration features you need for high availability: automatic failover, rolling updates, multi-node scheduling, and fine-grained health checks. When your team outgrows a single machine or needs zero-downtime deploys, it's time to look at Kubernetes, Nomad, or a managed container service. The good news is that the mental model you build with Compose — services, networks, volumes, environment variables — translates directly to those systems.
Tips for a Smooth Developer Experience
- Keep the Compose file boring. Avoid clever tricks. The more readable it is, the easier it is for new hires to understand.
- Use profiles for optional services. Compose profiles let you define services that only start when explicitly requested. Put your debug tools or a mail catcher behind a profile so the default up stays fast.
- Document the one-time setup. A short section in the README that says "copy .env.example to .env, then run docker compose up" saves hours of Slack questions.
- Pin image versions. Using postgres:15.4 instead of postgres:latest prevents surprise breakages when a new major version lands.
- Add health checks. A simple healthcheck on your database service lets the app wait until the database is truly ready, not just started.
Conclusion
Docker Compose doesn't solve every problem, but it solves the most annoying one: getting a new developer from git clone to a running stack in minutes instead of days. By describing your services in a single file, you turn a fragile collection of tribal knowledge into versioned, reviewable code. Start small with a database and one application service. Add Redis, a queue worker, or a mock email server as you need them. Before long, your local environment will be as reliable as your production environment — and your Monday morning onboarding stories will be a lot shorter.
Photo: Eduardo Rosas / Pexels



