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

  1. Master table: service keys and their docker run equivalent
  2. Image and build: image and build
  3. Identity: container_name and hostname
  4. Execution: command, entrypoint, user, init and shutdown
  5. Network and ports: ports, expose, networks, aliases
  6. Data: volumes, tmpfs, read_only
  7. Health and dependencies: healthcheck and depends_on
  8. Resources and restart: restart and deploy.resources
  9. Labels: labels
  10. Top-level blocks: volumes: and networks:
  11. YAML anchors and references to avoid repeating yourself
  12. Aurora Libros: the four services declared

  1. Master table: service keys and their 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.

  1. Image and build: image and build

image 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 with

When 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>.

  1. Identity: container_name and hostname

    container_name: aurora-db     # almost always: do NOT set this
    hostname: aurora-db           # the name `hostname` returns inside

container_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.

  1. Execution: 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 SIGKILL

command 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.

  1. Network and ports: ports, expose, networks, aliases

ports 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 protocol

The 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.

    networks:
      backend:
        aliases: [database]        # an extra DNS name inside that 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.

  1. Data: volumes, tmpfs, read_only

The 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 volume

The 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 bytes

One 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          # immutable container root
    tmpfs: [/tmp, /run]      # the little it does need to write

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.

  1. Health and dependencies: healthcheck and depends_on

Compose'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_period

Three 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 formdepends_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.

  1. Resources and restart: restart and deploy.resources

restart 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.

  1. Labels: labels

    labels:
      com.auroralibros.component: "database"
      com.auroralibros.team: "platform"

It 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.

  1. Top-level blocks: 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 Compose

internal: 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.

  1. 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 ones

Two 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.

  1. 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: bridge

Fifty 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 --daily

Exercise 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:

docker compose config | grep -A3 "aurora-db:" | grep -A2 labels
    labels:
      com.auroralibros.component: database

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-store
docker 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"
touch: /backups/test: Read-only file system
aurora-backup-store

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

Module 2: Working with Docker Images

Module 3: Docker Containers

Module 4: Docker Compose

Module 5: Advanced Docker Concepts

Module 6: Docker in Production

Module 7: Docker Ecosystem and Tools

© Copyright 2026. All rights reserved