The compose.yaml from the previous lesson uses seven keys. The Compose specification defines more than sixty, but you do not need to memorize them: most are the direct translation of a docker run option you already mastered in module 3.
This lesson is the practical reference for a service, organized by blocks and applied to Aurora Libros. By the end you will have the four services declared in a single file. How they interact with each other is lesson 04-04, and environment variables get a lesson of their own, 04-05.
Contents
- Master table: service keys and their
docker runequivalent - Image and build:
imageandbuild - Identity:
container_nameandhostname - Execution:
command,entrypoint,user,initand shutdown - Network and ports:
ports,expose,networks, aliases - Data:
volumes,tmpfs,read_only - Health and dependencies:
healthcheckanddepends_on - Resources and restart:
restartanddeploy.resources - Labels:
labels - Top-level blocks:
volumes:andnetworks: - YAML anchors and references to avoid repeating yourself
- Aurora Libros: the four services declared
- Master table: service keys and their
docker run equivalent
docker run equivalent| Compose key | docker run option |
What it is for |
|---|---|---|
image |
final argument | The image the container starts from |
build |
a separate docker build |
Build the image from a Dockerfile |
container_name / hostname |
--name / --hostname |
Container name and internal host name |
command / entrypoint |
arguments after the image / --entrypoint |
They replace the image's CMD and ENTRYPOINT |
user / working_dir |
-u / -w |
Process UID:GID and working directory |
init |
--init |
Inserts tini as PID 1 |
stop_signal / stop_grace_period |
--stop-signal / --stop-timeout |
Shutdown signal and grace period before SIGKILL |
environment / env_file |
-e / --env-file |
Variables inside the container |
ports |
-p |
Publish ports on the host |
expose |
--expose |
Document internal ports |
networks |
--network |
Networks it connects to |
volumes |
-v / --mount |
Data mounts |
tmpfs / read_only |
--tmpfs / --read-only |
Files in RAM and an immutable root |
healthcheck |
--health-* |
Health probe |
depends_on |
(does not exist) | Startup order between services |
restart |
--restart |
Restart policy |
deploy.resources.limits |
--memory, --cpus |
Resource limits |
labels |
--label |
Metadata |
cap_add / cap_drop |
--cap-add / --cap-drop |
Kernel capabilities |
extra_hosts |
--add-host |
Entries in /etc/hosts |
The only row without an equivalent is depends_on, and that is no accident: the relationship between services is exactly what docker run cannot express.
- Image and build:
image and build
image and buildimage takes a reference just like docker run: repository and tag (postgres:16-alpine) or, better for production, an immutable digest (postgres@sha256:9f3d0e...).
build tells Compose to build the image instead of pulling it. In short form it is just the context (build: ./api); in long form, everything you learned in module 2:
build:
context: ./api # build context directory
dockerfile: Dockerfile # relative to the context
args: { NODE_VERSION: "22" } # values for the ARG instructions
target: production # a specific stage in multi-stage builds
cache_from: ["auroralibros/aurora-api:cache"]
image: auroralibros/aurora-api:1.2.0 # the name it is tagged withWhen both keys appear together, build rules at build time and image gives the result its name. It is the most useful combination: docker compose build tags the image with that name and docker compose push publishes it.
| Situation | Use | Reason |
|---|---|---|
| Third-party service (Postgres, Redis, Nginx) | image |
You do not have their code |
| Developing your own code | build (+ image) |
You rebuild whenever it changes |
| Production | image with an immutable tag |
You deploy exactly what you tested |
| CI that builds and publishes | Both | build + push with a fixed name |
With build alone, if you leave out image, Compose tags the image as <project>-<service>.
- Identity:
container_name and hostname
container_name and hostname container_name: aurora-db # almost always: do NOT set this
hostname: aurora-db # the name `hostname` returns insidecontainer_name pins the exact name and disables project prefixing. It sounds convenient, which is why plenty of people set it; it has three serious drawbacks: it makes docker compose up --scale impossible (two containers cannot share a name), it prevents bringing up two projects from the same file on the same machine, and you do not need it, because services talk to each other using the service name. Set it only when something external —a legacy script, a monitoring agent— demands a specific name.
- Execution:
command, entrypoint, user and shutdown
command, entrypoint, user and shutdown entrypoint: ["/usr/local/bin/startup.sh"] # replaces the ENTRYPOINT
command: ["node", "server.js"] # replaces the CMD
user: "1001:1001" # unprivileged UID:GID
working_dir: /app
init: true # tini as PID 1
stop_signal: SIGQUIT # nginx shuts down cleanly with this one
stop_grace_period: 30s # grace period before SIGKILLcommand and entrypoint accept a string form (command: node server.js, which goes through the shell) and a list form (["node", "server.js"], direct execution). Prefer the list: it is the exec form and it guarantees your process is PID 1 and receives SIGTERM, exactly as you saw in lesson 03-02. init: true inserts a minimal init as PID 1 that forwards signals and reaps zombies, useful when your process spawns children and does not clean them up. And stop_grace_period accepts units (10s, 1m30s): for PostgreSQL, which needs to close a checkpoint before dying, push it up to 30 seconds.
- Network and ports:
ports, expose, networks, aliases
ports, expose, networks, aliasesports publishes a container port on the host. Short form, "[IP:]hostPort:containerPort[/protocol]":
ports:
- "8080:80" # every host interface
- "127.0.0.1:5432:5432" # localhost only
- "3000" # random host port → 3000
- "5514:514/udp" # explicit protocolThe long form is wordier but explicit: - {target: 80, published: "8080", protocol: tcp, mode: host}, where target is the internal port, published the host one, and mode: ingress only makes sense in Swarm.
expose publishes nothing: it documents which ports the container listens on. It is informational, because inside a user-defined network every port between containers is already reachable.
networks connects the service to the networks declared in the top-level block. Without this key, the service goes to the project's default network.
A service can be on several networks at once —which is exactly what aurora-api will do in lesson 04-04— and aliases gives it extra DNS names, handy for migrating without touching the clients' code.
- Data:
volumes, tmpfs, read_only
volumes, tmpfs, read_onlyThe short form is the source:target[:options] syntax of docker run -v, and the source decides the mount type:
volumes:
- aurora-data:/var/lib/postgresql/data # named volume
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro # bind (starts with ./)
- /app/node_modules # anonymous volumeThe rule is the module 3 one: if the source starts with . or / it is a bind mount; if not, it is a named volume that must be declared in the top-level volumes: block. The long form is the equivalent of --mount:
volumes:
- type: volume
source: aurora-data
target: /var/lib/postgresql/data
- type: bind
source: ./db/init.sql
target: /docker-entrypoint-initdb.d/init.sql
read_only: true
- type: tmpfs
target: /tmp
tmpfs: { size: 67108864 } # 64 MB, in bytesOne important difference: with the short form, if a bind's source does not exist, Docker creates an empty directory; with the long form it fails with a clear error and saves you from the classic "I mounted a directory where I expected a file".
read_only: true plus a couple of tmpfs entries is one of the cheapest hardening measures there is; the full topic is lesson 05-03.
- Health and dependencies:
healthcheck and depends_on
healthcheck and depends_onCompose's healthcheck replaces or complements the Dockerfile's HEALTHCHECK:
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
interval: 10s # how often it runs
timeout: 3s # how long it waits for the answer
retries: 3 # consecutive failures before marking it unhealthy
start_period: 20s # initial grace: failures here do not count
start_interval: 2s # more frequent probing during start_periodThree ways to write test:
| Form | Example | How it runs |
|---|---|---|
CMD |
["CMD", "pg_isready", "-U", "aurora"] |
Directly, no shell |
CMD-SHELL |
["CMD-SHELL", "curl -f localhost/health || exit 1"] |
With /bin/sh -c: pipes and || allowed |
| String | test: pg_isready -U aurora |
Equivalent to CMD-SHELL |
| Disable | test: ["NONE"] |
Cancels the image's HEALTHCHECK |
start_period is the key almost nobody uses and almost everybody needs: PostgreSQL takes a few seconds to accept connections the first time, and without that grace period the container is marked unhealthy before it ever had a chance to start.
depends_on in its short form —depends_on: [aurora-db, aurora-cache]— only guarantees startup order: Compose launches the PostgreSQL container, waits until the process is running and immediately launches the API. But PostgreSQL takes another five seconds to accept connections, so the API starts up against a database that is not answering yet. The long form solves exactly that:
depends_on:
aurora-db:
condition: service_healthy # wait for the healthcheck to pass
restart: true # restart this service if the dependency is recreated
aurora-cache:
condition: service_started # being started is enough
aurora-migrations:
condition: service_completed_successfully # wait until it exits with code 0| Condition | Waits until the dependency... | Requires |
|---|---|---|
service_started |
Is running (same as the short form) | Nothing |
service_healthy |
Passes its health probe | A defined healthcheck |
service_completed_successfully |
Exits with status code 0 | A one-shot service |
The distinction between "started" and "ready" is what separates a compose.yaml that works on your machine from one that always works. It is developed in lesson 04-04.
- Resources and restart:
restart and deploy.resources
restart and deploy.resourcesrestart accepts the same four docker run values with the same meaning: no (the default), always, on-failure[:n] and unless-stopped. Limits, on the other hand, live under deploy, a block that was originally exclusive to Swarm:
deploy:
resources:
limits:
memory: 512M # equivalent to --memory 512m
cpus: "1.0" # equivalent to --cpus 1.0
pids: 200 # equivalent to --pids-limit 200
reservations:
memory: 256M # minimum guarantee (equivalent to --memory-reservation)
cpus: "0.25"There is a classic confusion here worth clearing up: outside Swarm, docker compose does apply deploy.resources.limits. What gets ignored are replicas, placement, update_config and rollback_config, because they make no sense on a single host. And remember from lesson 03-07 that limits is a hard ceiling —exceeding the memory one means OOM and exit code 137— whereas reservations is a soft guarantee that influences who gives up memory under pressure.
- Labels:
labels
labelsIt also accepts a list form (- "key=value"). Use reverse DNS notation in the keys so you do not clash with other tools'. They are there for filtering (docker ps --filter "label=..."), and many tools in the ecosystem —automatic reverse proxies, metrics agents— are configured exclusively through labels.
- Top-level blocks:
volumes: and networks:
volumes: and networks:Every named volume you use in a service must be declared at the top:
volumes:
aurora-data: # the usual case: default local driver
aurora-backups:
name: aurora-libros-backups # exact name, NO project prefix
labels: { com.auroralibros.retention: "30d" }
aurora-shared:
external: true # already exists; Compose neither creates nor deletes it
aurora-nfs:
driver: local
driver_opts: # options passed through to the driver
{ type: nfs, o: "addr=10.0.0.20,rw,nfsvers=4", device: ":/exports/aurora" }external: true is the ultimate protection for critical data: Compose assumes the volume exists and not even down -v touches it. If it does not exist, up fails instead of silently creating an empty one.
Networks follow the same pattern:
networks:
frontend:
driver: bridge
backend:
driver: bridge
internal: true # no access to the Internet or the outside world
labels: { com.auroralibros.zone: "data" }
external-net:
external: true
name: aurora-net # a network created outside Composeinternal: true is the key that holds up the segmentation in lesson 04-04: containers on that network have no route to the outside, nor the outside to them. A database in there cannot download anything from the Internet even if somebody manages to run code inside it.
- YAML anchors and references to avoid repeating yourself
When four services share a restart policy, logging setup and labels, copying it four times is asking for them to drift apart. YAML offers anchors (&), references (*) and map merging (<<:), and Compose adds the x- extension keys, which it ignores when processing the file:
x-common: &common # defines the "common" anchor
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
x-fast-probe: &fast-probe
{ interval: 10s, timeout: 3s, retries: 3, start_period: 20s }
services:
aurora-cache:
<<: *common # merges the whole content of the anchor
image: redis:7-alpine
healthcheck:
<<: *fast-probe # anchors work for sub-blocks too
test: ["CMD", "redis-cli", "ping"]
aurora-web:
<<: *common
image: nginx:alpine
restart: always # local keys WIN over merged onesTwo limits worth knowing: anchors only work within the same file (they do not cross several -f), and <<: merges at the first level, not deeply: if the anchor brings labels and the service also defines labels, the service's replaces the anchor's entirely, it does not blend them. Always check the result with docker compose config.
- Aurora Libros: the four services declared
With everything above, here is the complete file. The credentials are still written by hand —that gets fixed in lesson 04-05— and there is a single network; segmentation arrives in 04-04.
# compose.yaml — Aurora Libros S.L.
name: aurora-libros
x-common: &common
restart: unless-stopped
logging:
driver: json-file
options: { max-size: "10m", max-file: "3" }
labels:
com.auroralibros.project: "aurora-libros"
x-probe: &probe
{ interval: 10s, timeout: 3s, retries: 3, start_period: 20s }
services:
aurora-db:
<<: *common
image: postgres:16-alpine
environment:
POSTGRES_USER: aurora
POSTGRES_PASSWORD: aurora_secret
POSTGRES_DB: aurora_books
ports:
- "127.0.0.1:5432:5432"
volumes:
- aurora-data:/var/lib/postgresql/data
- ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
<<: *probe
test: ["CMD-SHELL", "pg_isready -U aurora -d aurora_books"]
start_period: 30s # the first initialization takes longer
stop_grace_period: 30s
deploy:
resources:
limits: { memory: 512M, cpus: "1.0", pids: 200 }
reservations: { memory: 256M }
networks: [aurora-net]
aurora-cache:
<<: *common
image: redis:7-alpine
command: ["redis-server", "--maxmemory", "200mb", "--maxmemory-policy", "allkeys-lru"]
healthcheck:
<<: *probe
test: ["CMD", "redis-cli", "ping"]
deploy:
resources:
limits: { memory: 256M, cpus: "0.5", pids: 100 }
networks: [aurora-net]
aurora-api:
<<: *common
build:
context: ./api
dockerfile: Dockerfile
image: auroralibros/aurora-api:1.2.0
environment:
PORT: "3000"
DB_HOST: aurora-db
DB_USER: aurora
DB_PASSWORD: aurora_secret
DB_NAME: aurora_books
REDIS_HOST: aurora-cache
ports:
- "3000:3000" # only for testing directly against the API
healthcheck:
<<: *probe
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/health"]
restart: on-failure:3 # overrides the anchor's unless-stopped
deploy:
resources:
limits: { memory: 256M, cpus: "1.0", pids: 100 }
reservations: { memory: 128M }
networks: [aurora-net]
aurora-web:
<<: *common
image: nginx:alpine
ports:
- "8080:80"
volumes:
- ./web/index.html:/usr/share/nginx/html/index.html:ro
- ./web/nginx.conf:/etc/nginx/conf.d/default.conf:ro
stop_signal: SIGQUIT
healthcheck:
<<: *probe
test: ["CMD", "wget", "--spider", "-q", "http://localhost/"]
deploy:
resources:
limits: { memory: 128M, cpus: "0.5", pids: 50 }
networks: [aurora-net]
volumes:
aurora-data:
networks:
aurora-net:
driver: bridgeFifty lines of imperative bash have turned into a declarative, versionable, readable file, and along the way it has gained things the script never had: health probes on all four services, log rotation and consistent labels. Validate it and bring it up:
cd ~/aurora-libros
docker compose config --quiet && docker compose up -d --build
docker compose ps --format "table {{.Service}}\t{{.Status}}"SERVICE STATUS
aurora-api Up 25 seconds (healthy)
aurora-cache Up 35 seconds (healthy)
aurora-db Up 35 seconds (healthy)
aurora-web Up 24 seconds (healthy)All four services healthy. That they started in the right order is luck, because there is not a single depends_on yet; that gets fixed in lesson 04-04.
Common Mistakes and Tips
Setting container_name out of habit. It breaks scaling and the coexistence of projects, and it adds nothing: services talk to each other using the service name.
Using a named volume without declaring it at the top. Compose fails with service refers to undefined volume. Bind mounts need no declaration; named volumes do.
Expecting a bare depends_on to wait until the service is ready. It only waits until it starts. Without condition: service_healthy you will still have startup races.
Defining a healthcheck without start_period. The service gets marked unhealthy during its normal startup and drags down everything that depends on it.
Writing test: curl -f http://localhost/health in an Alpine image. curl does not come installed in the official Alpine images; wget does. A healthcheck that invokes a nonexistent binary always fails, and the symptom —unhealthy with no further clues— is very misleading.
Tip: start every new service with image, restart and healthcheck, and add the rest only when you need it. A compose.yaml full of keys nobody can explain is just as bad as a bash script.
Exercises
Exercise 1. Translate this command into a Compose service, without leaving out a single option, and explain where each one goes:
docker run -d --name aurora-reports --network aurora-net \
-u 1001:1001 -w /app -e TZ=Europe/Madrid --read-only --tmpfs /tmp \
-v aurora-reports-output:/output --memory 192m --cpus 0.5 \
--restart on-failure:2 --stop-timeout 20 \
--health-cmd 'wget --spider -q http://localhost:4000/health' --health-interval 15s \
auroralibros/aurora-reports:0.3.0 node reports.js --dailyExercise 2. The x-common block of the Aurora Libros file includes labels. Add to aurora-db a label of its own, com.auroralibros.component: "database", and check with docker compose config what happens to the inherited label. Explain the result and propose a fix.
Exercise 3. Declare a volume aurora-backups pointing at an external volume that already exists called aurora-backup-store, mount it read-only in aurora-db at /backups, and prove that docker compose down -v does not delete it.
Solutions
Solution 1.
aurora-reports:
image: auroralibros/aurora-reports:0.3.0
command: ["node", "reports.js", "--daily"] # everything after the image
user: "1001:1001"
working_dir: /app
environment: { TZ: Europe/Madrid }
read_only: true
tmpfs: [/tmp]
volumes:
- aurora-reports-output:/output
restart: on-failure:2
stop_grace_period: 20s
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:4000/health"]
interval: 15s
deploy:
resources:
limits: { memory: 192M, cpus: "0.5" }
networks: [aurora-net]--name is deliberately dropped (the service name already identifies the container), --network becomes networks, the limits move down into deploy.resources.limits, --stop-timeout turns into stop_grace_period with units, and the arguments after the image become command in list form.
Solution 2. Adding labels: { com.auroralibros.component: "database" } to the aurora-db service:
The com.auroralibros.project label has vanished: <<: merges only at the first level, and since the service defines its own labels key, it replaces the inherited map entirely. The fix is a second anchor for the label contents:
x-labels: &labels
com.auroralibros.project: "aurora-libros"
services:
aurora-db:
<<: *common
labels:
<<: *labels
com.auroralibros.component: "database"Now config shows both. General rule: whenever you merge anchors, always verify with docker compose config; what you think is inherited and what actually is do not always match.
Solution 3. After docker volume create aurora-backup-store, add the mount to aurora-db and the external declaration:
volumes:
- aurora-backups:/backups:ro # inside aurora-db
volumes:
aurora-data:
aurora-backups:
external: true
name: aurora-backup-storedocker compose up -d
docker compose exec aurora-db touch /backups/test # must fail: :ro
docker compose down -v
docker volume ls --format "{{.Name}}" | grep -E "backup-store|aurora-data"down -v has deleted aurora-libros_aurora-data, which belonged to the project, but aurora-backup-store is still there: being external, Compose treats it as borrowed and does not manage it. That is the mechanism to use for any volume whose loss would be unacceptable.
Conclusion
You now have the complete reference for a Compose service, and above all the mental map that holds it together: nearly every key is a docker run option under another name, and only depends_on expresses something the imperative CLI could not say. You know when to use image and when build —and why often both together—, why container_name is almost always redundant, how command, entrypoint, user, init, stop_signal and stop_grace_period translate, and the two forms —short and long— of ports and volumes, with the nuance that the long one fails instead of creating an empty directory when the bind source does not exist.
You have a firm grip on the whole healthcheck block, including the start_period that keeps a service that is merely starting up from being marked as sick, and you know the difference between the three depends_on conditions: service_started, service_healthy and service_completed_successfully. You know restart has the same four values as always and that deploy.resources.limits does apply outside Swarm. You declare volumes and networks in the top-level blocks, you shield critical data with external: true, you isolate with internal: true, and you avoid repetition with x-/&/*/<<: anchors, knowing that the merge is shallow. And you have the compose.yaml with the four Aurora Libros services declared.
In the next lesson, Docker Compose Commands, you will move from the file to the CLI: the complete lifecycle (up and its reconciliation, down and the danger of -v, start, stop, restart, pause), observation (ps, logs, top, stats, events), the essential difference between exec and run, building and publishing, local scaling with --scale and why it clashes with fixed ports, and selecting files and projects with -f, -p and COMPOSE_FILE.
Docker: From Beginner to Advanced
Module 1: Introduction to Docker
- What Is Docker?
- Installing Docker
- Docker Architecture
- Basic Docker Commands
- Understanding Docker Images
- Creating Your First Docker Container
- The Course Project: The Aurora Libros Platform
Module 2: Working with Docker Images
- Docker Hub and Repositories
- Building Docker Images
- Dockerfile Basics
- Advanced Dockerfile Instructions
- Managing Docker Images
- Tagging and Publishing Images
Module 3: Docker Containers
- Running Containers
- Container Lifecycle
- Managing Containers
- Inspecting and Debugging Containers
- Docker Networking
- Data Persistence with Volumes
- Resource Limits and Restart Policies
Module 4: Docker Compose
- Introduction to Docker Compose
- Defining Services in Docker Compose
- Docker Compose Commands
- Multi-Container Applications
- Environment Variables in Docker Compose
- Profiles, Overrides and Multiple Environments
- Local Development with Docker Compose
Module 5: Advanced Docker Concepts
- Docker Networking Deep Dive
- Docker Storage Options
- Docker Security Best Practices
- Optimizing Docker Images
- Advanced Builds with BuildKit and Buildx
- Logging and Monitoring in Docker
- The Runtime Inside: Namespaces, Cgroups and Layers
Module 6: Docker in Production
- Preparing an Image for Production
- CI/CD with Docker
- Orchestrating Containers with Docker Swarm
- Introduction to Kubernetes
- Deploying Docker Containers in Kubernetes
- Scaling and Load Balancing
- Deployment Strategies and Rollback
