Docker Compose has become the go‑to tool for developers who need to spin up multiple containers that work together—think a web server, a database, and a cache, all running side by side on your laptop. If you’re new to container orchestration, the idea of wiring several services together can feel daunting. This guide walks you through the entire process, from installing Docker to scaling services, with real commands, common pitfalls, and handy tips along the way. By the end, you’ll have a fully functional, multi‑container development environment that you can version‑control and share with teammates.
What You’ll Need
- A modern operating system (Linux, macOS, or Windows 10/11)
- Docker Engine installed (Docker Desktop works for macOS/Windows)
- Docker Compose (usually bundled with Docker Desktop; otherwise install separately)
- A text editor or IDE (VS Code, Sublime, etc.)
- Basic command‑line familiarity
Step 1: Install Docker and Docker Compose
First, verify that Docker Engine is running. Open a terminal and run:
docker --version
You should see something like Docker version 24.0.5, build abcdefg. Next, check Docker Compose:
docker compose version
If you get an error, install Compose separately (Linux example):
sudo curl -L "https://github.com/docker/compose/releases/download/v2.24.0/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
Now both commands should work. Keeping Docker up to date ensures you have the latest features and security patches.
Step 2: Create a Project Directory and Files
Pick a folder for your demo project, e.g., a simple Node.js app with a PostgreSQL database. In your terminal:
mkdir my‑compose‑app && cd my‑compose‑app
mkdir src && touch src/app.js .env Dockerfile docker-compose.yml
The .env file will store environment variables, Dockerfile defines the custom image for the Node app, and docker‑compose.yml orchestrates everything. Open src/app.js and add a tiny Express server (you can copy‑paste the code from the Docker docs). The key point is that the files already exist so you can focus on wiring them together.
Step 3: Define Services in docker-compose.yml
Here’s a minimal yet functional docker-compose.yml for our Node‑Postgres stack:
version: "3.9"
services:
web:
build: .
ports:
- "3000:3000"
env_file:
- .env
depends_on:
- db
networks:
- appnet
db:
image: postgres:15-alpine
restart: unless‑stopped
environment:
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: ${POSTGRES_DB}
volumes:
- db_data:/var/lib/postgresql/data
networks:
- appnet
networks:
appnet:
driver: bridge
volumes:
db_data:
This file does several things:
- Defines two services:
web(our custom Node app) anddb(PostgreSQL). - Uses
build: .to build thewebimage from the localDockerfile. - Maps host port 3000 to container port 3000 so you can reach the app in a browser.
- Shares environment variables from
.env(more on that later). - Creates a persistent volume
db_dataso database data survives container restarts.
Step 4: Build and Run the Containers
With the compose file in place, start everything with a single command:
docker compose up -d --build
The -d flag runs containers in the background, and --build forces a fresh image build. To watch the logs in real time, omit -d or use:
docker compose logs -f
Once the containers are healthy, open http://localhost:3000 in your browser. You should see the “Hello, world!” response from the Express server, confirming that the web service can communicate with the database.
Step 5: Manage Environment Variables and Networking
Environment variables are essential for keeping secrets out of source code. Populate .env with sensible defaults:
POSTGRES_USER=devuser
POSTGRES_PASSWORD=devpass
POSTGRES_DB=devdb
Because docker compose automatically loads .env, you don’t need to repeat them in the compose file. If you need to override a variable for a specific run, prepend it to the command:
POSTGRES_PASSWORD=supersecret docker compose up -d
Networking is handled by the appnet bridge network we defined. Services can reach each other by their service name (e.g., the Node app connects to postgres://devuser:devpass@db:5432/devdb). No extra IP management is required.
Step 6: Scale and Update Services
One of Compose’s strengths is the ability to scale stateless services. To run three instances of the web server:
docker compose up -d --scale web=3
Docker will create web_1, web_2, and web_3 containers, each listening on the same host port via an internal load‑balancer (only the first instance will bind to the host port; the others share the network). For true load balancing in production you’d add a reverse proxy like Nginx or Traefik, but for local development this is enough to test horizontal scaling.
When you modify the Dockerfile or docker-compose.yml, apply changes with:
docker compose up -d --build
The --build flag forces a rebuild of any images that have changed, and Compose will restart affected containers while preserving volumes.
Step 7: Clean Up Resources
When you’re done experimenting, shut down the stack and remove all associated resources:
docker compose down --volumes --remove-orphans
The --volumes flag deletes the persisted db_data volume, and --remove-orphans cleans up any containers that were started outside the current compose file but share the same project name. This keeps your Docker environment tidy and prevents stale containers from consuming disk space.
Common Mistakes to Avoid
Even beginners run into the same pitfalls. Here are the most frequent ones and how to fix them:
- Forgetting to add a
.envfile. Without it, services may start with empty credentials, causing database connection errors. Always double‑check that.envis in the project root and listed underenv_file. - Port conflicts. If another process is already using port 3000, Docker will fail to bind. Change the host side of the mapping, e.g.,
"8080:3000", or stop the conflicting service. - Not persisting data. Omitting the volume declaration for the database will cause data loss on every
docker compose down. Always declare a named volume for stateful services. - Using
latesttags. Pullingpostgres:latestcan introduce breaking changes. Pin to a specific version likepostgres:15-alpinefor reproducibility. - Mismatched network names. If you reference a network that isn’t defined, Compose will create a default one, which can lead to unexpected isolation. Keep network definitions explicit.
Tips and Tricks
Here are a few shortcuts that make daily Compose work smoother:
- Use
docker compose execfor quick debugging. Example:docker compose exec web bashdrops you into the container’s shell. - Leverage multi‑stage builds. In the
Dockerfile, compile assets in a builder stage and copy only the runtime artifacts to a slim final image. This reduces image size dramatically. - Override settings with a
docker‑compose.override.ymlfile. Place developer‑specific tweaks (like mounting source code) in this file; Docker Compose automatically merges it with the main file. - Run Compose as a non‑root user. Add your user to the
dockergroup (`sudo usermod -aG docker $USER`) to avoidsudoon every command. - Use healthchecks. Define a
healthcheckfor the database so dependent services wait until the DB is ready.
Frequently Asked Questions
Do I need Docker Compose if I only have two containers?
While you could run two docker run commands, Compose simplifies networking, environment management, and lifecycle handling. It’s especially helpful when you add more services later.
Can I use Docker Compose in production?
Compose is great for development and small‑scale deployments, but for large‑scale production you’ll typically move to Docker Swarm or Kubernetes. That said, many small teams run production stacks with Compose combined with a reverse proxy and proper monitoring.
How do I share my Compose setup with teammates?
Commit docker-compose.yml, Dockerfile, and .env.example (a template without secrets) to your repository. Teammates copy .env.example to .env and run docker compose up -d. All containers are reproducible across machines.
Conclusion
Docker Compose turns a handful of Docker commands into a single, declarative file that describes an entire development stack. By following this guide you’ve installed Docker, written a practical docker‑compose.yml, built and run multi‑container services, handled environment variables, scaled your app, and learned how to avoid common mistakes. Keep experimenting—add a Redis cache, a background worker, or a front‑end service—and you’ll quickly see why Compose is a cornerstone of modern, container‑based development.
Photo by Ian Taylor on Unsplash






