Table of contents
- What Compose Does
- Verifying the Installation
- A Minimal compose.yaml
- Three Pillars — services, networks, volumes
- depends_on and healthcheck — The Truth About Startup Order
- Networks — Separation and Exposure
- Volumes — named vs bind
- .env and Variable Substitution
- profiles — Turning On Only What You Need
- Environment-Specific Overrides
- Frequently Used Commands
- Where We Stand
What Compose Does
Let’s first look at the entire flow of what happens when you run docker compose up:
flowchart LR
CMD["docker compose up"] --> PARSER["compose.yaml parsing"]
PARSER --> NET["default network creation"]
PARSER --> VOL["volume creation"]
PARSER --> BUILD["image build / pull"]
BUILD --> RUN1["web container"]
BUILD --> RUN2["db container"]
BUILD --> RUN3["cache container"]
NET --> RUN1
NET --> RUN2
NET --> RUN3
VOL --> RUN2
RUN2 -.->|"healthcheck OK"| RUN1
Two key points. First, services automatically join the same network so they can call each other by service name. Second, if you declare dependencies and healthchecks, Compose handles the startup order for you.
Verifying the Installation
The latest Docker Desktop and Docker Engine include Compose v2 as a built-in plugin. Use docker compose (with a space) instead of the old docker-compose (with a hyphen).
docker compose version
If you see a version printed, you are all set. If your team still uses the docker-compose command, that is the EOL v1, and upgrading to v2 is recommended.
A Minimal compose.yaml
The official recommended filename is compose.yaml. The older docker-compose.yml is still supported, but both work. Here is the smallest configuration:
# compose.yaml
services:
web:
image: nginx:1.27-alpine
ports:
- "8080:80"
Under services, we defined one service named web. The image is nginx, and host port 8080 is mapped to container port 80. Start it from the directory containing this file:
docker compose up -d
-d is detached mode, meaning it runs in the background. Open http://localhost:8080 in a browser and the default Nginx page appears. To shut it down, run docker compose down.
Three Pillars — services, networks, volumes
A Compose file is structured around three top-level keys. Understanding these three is the foundation; everything else is detail.
services: # Definition of each individual container
...
networks: # Networks to connect services
...
volumes: # Volumes for data persistence
...
Let’s look at a simple web + DB + cache setup as an example:
services:
web:
image: myapp:latest
ports:
- "3000:3000"
environment:
DATABASE_URL: postgres://app:secret@db:5432/app
REDIS_URL: redis://cache:6379
depends_on:
db:
condition: service_healthy
cache:
condition: service_started
networks:
- backend
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: app
volumes:
- db-data:/var/lib/postgresql/data
networks:
- backend
healthcheck:
test: ["CMD", "pg_isready", "-U", "app"]
interval: 5s
timeout: 3s
retries: 5
cache:
image: redis:7-alpine
networks:
- backend
networks:
backend:
volumes:
db-data:
Reading through it casually reveals the story. web opens port 3000 and depends on the DB and cache. db uses a postgres image with a volume attached so data survives restarts. All three join the same backend network.
The key thing to notice is db:5432 and cache:6379 in the environment section. Service names are used directly in the hostname position. Within the network Compose creates, service names function as DNS names. No need to know IPs.
depends_on and healthcheck — The Truth About Startup Order
When first using Compose, it is easy to assume depends_on guarantees startup order. To be precise, it only guarantees the order in which containers start. It does not guarantee that “the DB process is ready to accept queries.”
That is why condition is added:
sequenceDiagram
participant Compose
participant DB
participant Web
Compose->>DB: Start container
DB-->>Compose: started
loop Healthcheck every 5s
Compose->>DB: pg_isready
DB-->>Compose: ok?
end
DB-->>Compose: healthy
Compose->>Web: Start container (dependency satisfied)
service_started: Passes as soon as the container has started (default)service_healthy: Dependency is satisfied only when thehealthcheckpassesservice_completed_successfully: For one-shot tasks (like migrations) that run once and exit successfully
For services like databases that need preparation time, always define a healthcheck and use service_healthy. Otherwise, the web starts first, fails to connect to the DB, and enters a restart loop.
Networks — Separation and Exposure
Putting everything on one network works, but as scale grows, separating them is safer. For example, if the DB should be inaccessible from the outside and only the web should be exposed to the reverse proxy network:
services:
proxy:
image: traefik:v3
networks:
- edge
web:
image: myapp:latest
networks:
- edge # Communicates with proxy
- backend # Communicates with db
db:
image: postgres:16-alpine
networks:
- backend # Only on backend
networks:
edge:
backend:
internal: true # Blocks external internet access
Setting internal: true disconnects the network from external routing. Since a DB has no reason to fetch anything from the internet, this is a safe default.
Volumes — named vs bind
There are two approaches to volumes, with different use cases:
services:
db:
image: postgres:16-alpine
volumes:
- db-data:/var/lib/postgresql/data # named volume
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro # bind mount
volumes:
db-data:
- Named volume (
db-data:...): Storage managed by Docker. Used for data persistence in production. Located somewhere under/var/lib/docker/volumes/..., independent of host FS structure - Bind mount (
./init.sql:...): Mounts a host path directly. Useful for hot-reloading code during development, injecting init scripts, and sharing config files.:romeans read-only
In development environments, source code is bind-mounted so file changes are reflected immediately, while data is managed with named volumes.
.env and Variable Substitution
Passwords and environment-specific values should not be hardcoded in the file. Place a .env file in the same directory and Compose reads it automatically:
# .env
POSTGRES_PASSWORD=secret
APP_PORT=3000
services:
web:
image: myapp:latest
ports:
- "${APP_PORT}:3000"
db:
image: postgres:16-alpine
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
.env must be added to .gitignore. A single commit mistake and the password goes to the remote repo.
profiles — Turning On Only What You Need
Developers might only need the app + DB, but the QA environment might require Kafka, Mailhog, and more. Assign profiles to services so they only start in the desired scenarios:
services:
web:
image: myapp:latest
db:
image: postgres:16-alpine
mailhog:
image: mailhog/mailhog
profiles: ["dev"]
kafka:
image: confluentinc/cp-kafka:7.6.0
profiles: ["qa"]
The default startup includes only web and db:
docker compose up -d # web, db only
docker compose --profile dev up -d # + mailhog
docker compose --profile qa up -d # + kafka
Running up without options does not touch services with profiles. They are only brought up when explicitly requested.
Environment-Specific Overrides
You often want to share the same base config across development/staging/production but override a few settings. Compose defaults to merging compose.yaml + compose.override.yaml. You can also explicitly merge multiple files:
# compose.yaml — shared
services:
web:
image: myapp:latest
environment:
LOG_LEVEL: info
# compose.dev.yaml — development overrides
services:
web:
build: .
volumes:
- ./src:/app/src
environment:
LOG_LEVEL: debug
ports:
- "3000:3000"
# compose.prod.yaml — production overrides
services:
web:
restart: always
deploy:
resources:
limits:
cpus: "1.0"
memory: 512M
When running, list multiple files with -f and they are merged in order:
docker compose -f compose.yaml -f compose.dev.yaml up -d
docker compose -f compose.yaml -f compose.prod.yaml up -d
For duplicate settings on the same service key, the later file wins. Arrays are overwritten by default, while maps like environment variables are merged. Keeping this in mind, collisions are rare.
Frequently Used Commands
When using Compose, you will type these commands hundreds of times:
# Full start / stop
docker compose up -d
docker compose down
# Delete volumes too (data will be lost — caution)
docker compose down -v
# Specific service only
docker compose up -d web
docker compose restart web
# View logs
docker compose logs -f web
# Enter a container
docker compose exec web sh
# Rebuild images on startup
docker compose up -d --build
# View the final merged config (override merge result)
docker compose config
The last one, config, is often forgotten but very useful for debugging. It lets you preview what the final result looks like after merging multiple files.
Where We Stand
We have covered spinning up web + DB + cache with a single Compose file, separating networks, and configuring per-environment overrides. In practice, you just add monitoring (Prometheus, Grafana) or message brokers (Kafka, RabbitMQ) — the skeleton stays the same.
In the next part, we cover multi-stage builds for shrinking image sizes and speeding up builds. We will show firsthand how small the final image can get when you separate build artifacts from the runtime.

Loading comments...