Docker Compose Tutorial: Examples and YAML Syntax
A practical Docker Compose tutorial: a working docker-compose.yml example, service and volume syntax, environment variables, and the commands you need.
Docker Compose is a powerful tool for managing and deploying multi-container applications. It allows users to define and run multiple containers in a single configuration file, making it easy to set up and manage complex environments. In this article, I will guide you through the process of using Docker Compose to deploy and manage your containers, starting from a minimal file and ending with a three-service stack that starts in the right order and survives a restart.
# Sample Docker Compose File
services:
web:
build: .
ports:
- "80:80"
volumes:
- ./app:/app
environment:
- NODE_ENV=production
db:
image: mongo
volumes:
- ./data:/data/db
environment:
- MONGO_INITDB_ROOT_USERNAME=admin
- MONGO_INITDB_ROOT_PASSWORD=password
networks:
default:
driver: bridgeFirst, let’s take a look at what Docker Compose is and how it works. Docker Compose is a command-line utility that allows users to define and manage multiple containers in a single configuration file. This file, called a compose file, is written in the YAML format and contains all the necessary information for running the containers, including their names, configurations, and dependencies. With Docker Compose, users can easily set up and manage complex environments, such as microservices architectures and multi-tier applications.
To use Docker Compose, you will need to have Docker installed on your system. Docker Desktop and recent Docker Engine installs ship Compose as a plugin, so the command is docker compose with a space. The older standalone docker-compose binary still works on many machines, but the plugin is the maintained one and every command in this article uses it.
The compose file
To create a compose file, you can use a text editor or a tool like Visual Studio Code. The compose file should be named docker-compose.yml (or compose.yaml, which Compose also picks up) and should be placed in the root directory of your project. In the compose file, you define your containers and their configurations, such as the image, ports, environment variables, and volumes.
One thing you will notice in older tutorials is a version: '3' line at the top. You can leave it out. Compose ignores it now and prints a warning if it is present.
The building blocks are:
- services: one entry per container you want to run.
build: .builds an image from the Dockerfile in the current directory,image: mongopulls one from a registry. - ports:
"host:container"."80:80"publishes the container’s port 80 on the host’s port 80. Quote these; YAML reads80:80unquoted as a base-60 number. - volumes: either a bind mount from a host path (
./app:/app) or a named volume that Docker manages for you (more on that below). - environment: variables passed into the container.
- networks: Compose creates one network per project by default and puts every service on it, so you rarely need this section. Containers reach each other by service name, which is the part most people miss on their first day: the
webcontainer talks to the database atdb:27017, notlocalhost:27017.
A complete example with a database, an API and a frontend
The sample at the top is enough to get started, but real projects usually have a database that must be ready before the application connects to it. This is the file I actually use as a starting point:
services:
db:
image: postgres:16
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: app
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app -d app"]
interval: 5s
timeout: 3s
retries: 10
api:
build: ./api
environment:
DATABASE_URL: postgres://app:${POSTGRES_PASSWORD}@db:5432/app
depends_on:
db:
condition: service_healthy
ports:
- "8080:8080"
restart: unless-stopped
web:
build: ./web
depends_on:
- api
ports:
- "3000:3000"
volumes:
pgdata:Three things in this file do real work.
depends_on with a condition. Plain depends_on only controls start order. It starts db first, then api, but it does not wait for Postgres to be ready to accept connections, and Postgres takes a few seconds to initialise on the first run. The result is an API that crashes once with “connection refused” and then works after a restart. condition: service_healthy fixes that: Compose waits until the db healthcheck passes before it starts api.
healthcheck. The test is a command that runs inside the container. Exit code 0 means healthy. pg_isready ships with the Postgres image; for a web service you would use something like curl -f http://localhost:8080/health || exit 1. interval is how often it runs, retries is how many failures in a row mark the container unhealthy. With the numbers above, Compose gives Postgres up to 50 seconds to come up.
A named volume for the data. pgdata:/var/lib/postgresql/data uses a volume that Docker manages, declared in the top-level volumes: section. The database survives docker compose down and container rebuilds. The bind mount in the first example (./data:/data/db) also survives, but it writes database files into your project directory with root ownership, which gets in the way of git and of file permissions on Linux. I use bind mounts for source code I want to edit live and named volumes for anything a service writes.
${POSTGRES_PASSWORD} is read from a .env file next to the compose file, or from your shell. Keep secrets there rather than in the YAML that goes into version control:
# .env
POSTGRES_PASSWORD=change-meStarting, stopping and inspecting
To start your containers, use docker compose up. Add -d to run them in the background, and --build to rebuild images first:
docker compose up -d --buildThe commands you will use every day:
docker compose ps # status of every service
docker compose logs -f api # follow the logs of one service
docker compose exec db psql -U app # run a command inside a running container
docker compose restart api # restart one service
docker compose down # stop and remove containers and the network
docker compose down -v # ...and the named volumes too (this deletes the database)To build a container, you can use the docker compose build command, which will rebuild the image for the specified service. To scale a service, use the --scale flag on up:
docker compose up -d --scale api=3This starts three copies of api. It only works if the service does not publish a fixed host port, because three containers cannot all bind to 8080 on the host. Drop the ports mapping for scaled services and put a reverse proxy such as nginx or Traefik in front of them.
Containers on the same Compose project can already talk to each other. There is no separate connect command; if two services need to be isolated from a third, define two networks and attach each service to the one it belongs on:
services:
web:
networks: [frontend]
api:
networks: [frontend, backend]
db:
networks: [backend]
networks:
frontend:
backend:Here web cannot reach db at all, and api bridges the two.
Development versus production
The same file rarely fits both. For development you want the source mounted into the container and hot reload; for production you want a built image and no bind mounts. Compose handles this with an override file. docker compose up automatically merges docker-compose.yml with docker-compose.override.yml if it exists, so put the development-only settings there:
# docker-compose.override.yml (development only, not deployed)
services:
api:
volumes:
- ./api:/app
command: npm run devOn the server, run with an explicit file list so the override is skipped:
docker compose -f docker-compose.yml up -dProfiles solve a related problem: services you only want sometimes, like a database admin UI or a load generator. Mark them with profiles: [tools] and they stay off until you run docker compose --profile tools up.
Mistakes I see most often
- Using
localhostinside a container to reach another container. Use the service name. - Forgetting that
downkeeps volumes anddown -vdeletes them. The second one is the one that wipes your local database. - Editing the compose file and running
upwithout--build. Changes to the Dockerfile or source in a built image do not apply until you rebuild. - Relying on
depends_onalone for services that take time to start. Add a healthcheck and a condition. - Committing
.env. Add it to.gitignoreand commit a.env.examplewith the variable names instead.
In conclusion, Docker Compose is a powerful and easy-to-use tool for managing and deploying multi-container applications. With its simple configuration file and command-line interface, users can easily set up and manage complex environments. The file above, with a healthcheck, a named volume and an override for development, is the shape most projects settle into.
Compose is only useful once the individual images exist. Spring Boot in Docker and How to Dockerize a React Application build the two services most of these files end up orchestrating.
AuraLyrics
If you listen to music while you write code, AuraLyrics puts the lyrics over your editor instead of behind another window.
brew install --cask ozcberkay/tap/auralyricsNotes like this one, once a month
Java, AWS and what shipping a side product actually costs.