The docker-compose.yml File
A docker-compose.yml file is the recipe Docker Compose reads to create a multi-container application. Instead of typing long docker run commands for each container, you describe services, networks, volumes, ports, environment variables, and build settings in one YAML file. This matters because real applications usually need more than one process: a web app, a database, a cache, a worker, and shared configuration that should be repeatable on every machine.
Overview: How The File Works
Compose is a thin orchestration layer on top of the same Docker Engine you already use with docker run, docker build, and docker volume. The modern command is docker compose, with a space, because Compose V2 is a Docker CLI plugin. The old standalone docker-compose command still exists in some environments, but new lessons and new projects should use docker compose.
When you run docker compose up, the CLI reads docker-compose.yml from the current directory by default. It validates the YAML, resolves variables such as ${POSTGRES_PASSWORD}, creates a project name, builds any services with a build: section, pulls any missing images named by image:, creates networks and volumes, and then creates containers for each service. Each service container is still a normal Docker container: it has an image, a thin writable layer, environment variables, mounts, network attachments, and one main process.
The important mental model is that Compose does not create a special kind of container. It creates ordinary Docker objects with consistent names and labels so they can be managed together. A service named web may become a container like myproject-web-1. A named volume such as db-data becomes a Docker-managed volume, and a default network lets services resolve each other by service name. This is why an app container can connect to postgres:5432 without knowing the database container’s IP address.
A Compose file is declarative. You describe the desired local application, then Compose compares that description with existing containers. If the config changes, Compose recreates only what it needs to recreate. If an image is unchanged and the service definition is unchanged, the existing container can often be reused. This makes the file both documentation and automation: it records how the development or small deployment stack is supposed to run.
Syntax
The file is YAML. Indentation matters, lists start with -, and strings containing punctuation are often clearer when quoted. A practical top-level structure looks like this:
services:
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: "changeme"
volumes:
db-data:
networks:
app-net:
| Key | Purpose |
|---|---|
services |
Defines the containers Compose should run. Each service becomes one or more containers. |
image |
Uses an existing image, usually from Docker Hub or another registry. Pin specific tags instead of using latest. |
build |
Builds an image from a local Dockerfile before starting the service. |
ports |
Publishes container ports to the host, similar to docker run -p. |
environment |
Sets environment variables inside the container. Do not put real production secrets in source control. |
env_file |
Loads environment variables from a file, commonly .env for local development. |
volumes |
Mounts named volumes or host paths into containers. |
depends_on |
Controls startup order. By itself, it does not prove the dependency is ready to accept traffic. |
networks |
Attaches services to specific Docker networks. Most small apps can use the default network. |
restart |
Configures restart behavior, such as unless-stopped, for containers managed by the Docker daemon. |
The command shape is usually simple:
docker compose up -d
docker compose ps
docker compose logs web
docker compose down
up creates and starts the application. -d runs it in the background. ps lists containers in the Compose project. logs web shows logs for one service. down stops and removes the containers and default network; it does not remove named volumes unless you add --volumes.
Examples
Example 1: One Nginx Service
This smallest useful Compose file starts a web server and publishes it on host port 8080.
services:
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
Run it with:
docker compose up -d
docker compose ps
Output:
NAME IMAGE COMMAND SERVICE STATUS PORTS
site-web-1 nginx:1.27-alpine "/docker-entrypoint.…" web Up 3 seconds 0.0.0.0:8080->80/tcp
Compose creates one service container from the pinned nginx:1.27-alpine image. The ports entry maps host port 8080 to container port 80. This is the Compose version of docker run -p 8080:80 nginx:1.27-alpine.
Example 2: Web App Plus Database
A more realistic file has an application service and a PostgreSQL database. The database stores its data in a named volume so deleting the container does not delete the database files.
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "3000:3000"
environment:
DATABASE_URL: "postgres://app:changeme@db:5432/appdb"
depends_on:
- db
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: "app"
POSTGRES_PASSWORD: "changeme"
POSTGRES_DB: "appdb"
volumes:
- db-data:/var/lib/postgresql/data
volumes:
db-data:
docker compose up --build -d
docker compose logs api
Output:
api-1 | Server listening on port 3000
api-1 | Connected to database host db
The api service is built locally, while db is pulled from the registry if needed. Both services join the same default network, so the hostname db resolves to the database container. depends_on starts db before api, but your application should still retry database connections because PostgreSQL may need a few seconds before it is ready.
Example 3: A Complete Development File
For local development, bind mount the source code into the container and use a named volume for dependencies that should stay inside Docker.
services:
web:
build: .
command: npm run dev -- --host 0.0.0.0
ports:
- "5173:5173"
environment:
NODE_ENV: "development"
volumes:
- .:/app
- node-modules:/app/node_modules
volumes:
node-modules:
docker compose up --build
Output:
web-1 | VITE v5.4.0 ready in 420 ms
web-1 | Local: http://localhost:5173/
web-1 | Network: http://172.19.0.2:5173/
The bind mount .:/app makes source changes visible inside the container immediately. The named volume at /app/node_modules prevents the host bind mount from hiding container-installed dependencies. This pattern is common for Node.js development, but production Compose files usually avoid bind mounting application source.
How It Works Step By Step
- Compose chooses a project name, usually from the directory name unless you set one with
--project-nameorCOMPOSE_PROJECT_NAME. - It parses
docker-compose.ymland any additional files passed with-f. Later files can override earlier ones. - It substitutes variables from the shell environment and the local
.envfile. The.envfile is for Compose interpolation;env_fileis for variables passed into containers. - It creates networks and named volumes declared at the top level. Unnamed defaults are created when needed.
- It builds services with
build:. Docker still uses normal image layers and the build cache; changing a Dockerfile line invalidates that layer and later layers. - It pulls missing images for services with
image:. - It creates containers with the configured command, environment, mounts, ports, labels, and network aliases.
- It streams logs in the foreground, or returns control to your terminal when
-dis used.
Stopping the stack with docker compose down removes the service containers and the project network. Named volumes remain because Docker treats them as persistent data. To remove them, use docker compose down --volumes only when you truly want to delete that data.
Common Mistakes
Using latest in a shared file
services:
db:
image: postgres:latest
latest is a moving tag. A teammate or CI runner may pull a different PostgreSQL version tomorrow. Pin a real version instead:
services:
db:
image: postgres:16-alpine
Expecting depends_on to wait for readiness
services:
api:
image: mycompany/api:1.4.2
depends_on:
- db
db:
image: postgres:16-alpine
This starts db before api, but it does not guarantee PostgreSQL is accepting connections. Fix this in the application with connection retries, or add a suitable healthcheck and readiness-aware startup pattern when the stack needs it.
Confusing EXPOSE with published ports
A Dockerfile line such as EXPOSE 3000 is metadata and documentation. It does not publish a port to the host. In Compose, use ports: to publish a port:
services:
api:
image: mycompany/api:1.4.2
ports:
- "3000:3000"
Deleting data by removing volumes
docker compose down --volumes
That command removes named volumes declared by the Compose project. It is useful for resetting development data, but dangerous if you expected a database volume to survive.
Best Practices
- Use
docker compose, not the legacydocker-compose, for modern projects. - Pin image tags such as
postgres:16-alpineandnginx:1.27-alpineinstead of usinglatest. - Commit
docker-compose.ymlwhen it describes the shared development stack. - Keep real secrets out of the Compose file. Use obvious local placeholders, ignored env files, Docker secrets, or your deployment platform’s secret store.
- Use named volumes for persistent service data, especially databases.
- Use bind mounts for local source-code editing, not for production application code.
- Prefer service names for internal communication, such as
http://api:3000ordb:5432. - Keep Compose files small enough to understand. Split development overrides into a second file when needed.
- Use
docker compose configto view the fully resolved configuration before debugging a confusing stack.
Practice Exercises
- Create a Compose file with two services:
webusingnginx:1.27-alpineandredisusingredis:7-alpine. Publish only the web service to the host. - Modify a database service so PostgreSQL data survives
docker compose down. Hint: use a top-level named volume and mount it at PostgreSQL’s data directory. - Run
docker compose configon a file that uses${APP_PORT}. Try settingAPP_PORTin your shell and then in a.envfile to see how interpolation changes the resolved config.
Summary
docker-compose.ymldescribes a multi-container Docker application in YAML.- Each Compose service becomes ordinary Docker containers, images, networks, and volumes managed as one project.
ports:publishes ports; DockerfileEXPOSEalone does not.- Service names work as DNS names on the Compose network.
- Named volumes preserve data until you explicitly remove them.
- Pin image versions, avoid real secrets in source control, and use
docker compose configwhen debugging.
