Aurora Libros is declared and parameterized, but in real life the same platform gets brought up in three different ways: on the developer's laptop, on the continuous integration runner and on the server. The differences are few —build instead of pull, expose or hide certain ports, higher limits— and the temptation is to duplicate the file.

Duplicating it is the worst path: two files diverge within a week. Compose offers two complementary mechanisms to avoid that: profiles, which activate optional services on demand, and override files, which merge on top of a common base.

Contents

  1. Profiles: services you do not always want
  2. Profiles and dependencies: the rules
  3. The automatic override: compose.override.yaml
  4. Explicit overrides with several -f
  5. The merge rules
  6. !reset and !override: replacing instead of merging
  7. The Aurora Libros strategy: base, development and production
  8. extends: reusing definitions across projects
  9. include:: composing files from several teams
  10. File and project naming conventions

  1. Profiles: services you do not always want

A service with the profiles: key is not brought up unless its profile is active. It is how you keep optional tooling in the same file without it getting in the way.

  adminer:
    image: adminer:5
    profiles: [tools]
    ports:
      - "${ADMINER_PORT:-8081}:8080"
    environment:
      ADMINER_DEFAULT_SERVER: aurora-db
    depends_on:
      aurora-db: { condition: service_healthy }
    networks: [frontend, backend]

  mailhog:
    image: mailhog/mailhog:v1.0.1
    profiles: [tools]
    ports:
      - "8025:8025"     # web interface for reading the captured mail
    networks: [frontend]

  seed:
    image: auroralibros/aurora-api:${AURORA_API_VERSION:-1.2.0}
    profiles: [data]
    command: ["node", "seed.js", "--demo-catalog"]
    environment:
      DB_HOST: aurora-db
      DB_USER: ${DB_USER:-aurora}
      DB_NAME: ${DB_NAME:-aurora_books}
    depends_on:
      aurora-db: { condition: service_healthy }
    restart: "no"
    networks: [backend]

A service can belong to several profiles (profiles: [tools, ci]), and it is activated if any of them is active.

docker compose up -d                                   # only the 5 base services
docker compose --profile tools up -d                   # base + adminer + mailhog
docker compose --profile tools --profile data up -d    # everything
COMPOSE_PROFILES=tools,data docker compose up -d       # equivalent
docker compose --profile "*" up -d                     # every profile
docker compose ps --services
docker compose --profile tools ps --services
aurora-api
aurora-cache
aurora-db
aurora-migrations
aurora-web
(with the tools profile)
adminer
aurora-api
aurora-cache
aurora-db
aurora-migrations
aurora-web
mailhog

With Adminer up, http://localhost:8081 gives you a web interface for exploring the catalog without installing anything on your machine. And COMPOSE_PROFILES=tools in your local .env turns the profile on permanently for you, without affecting anybody else.

A very practical use of a profile for one-off tasks:

docker compose --profile data run --rm seed

  1. Profiles and dependencies: the rules

There are three behaviors worth being clear about here, because they are a source of surprises:

Situation What Compose does
Service with a profile, profile inactive It is not created, not even if another service has it in depends_on
Service without a profile depending on one with a profile Error: depends_on pointing at a service that is not enabled
Service with a profile depending on one without Fine: the dependency is brought up automatically
The service is named explicitly (up adminer) Its profile activates by itself, with no --profile
down without --profile It does not stop the active profiled services

The two important rows: naming a profiled service activates it implicitly —docker compose up -d adminer just works— and docker compose down leaves profiled services orphaned if you do not repeat the profile. It is the number one cause of "I ran down and there are still containers".

docker compose --profile tools down    # correct
docker compose down --remove-orphans   # blunter alternative

Design rule: base services must never depend on a profiled service. The dependency always runs the other way.

  1. The automatic override: compose.override.yaml

If a file called compose.override.yaml (or .yml) exists next to the compose.yaml, Compose loads and merges it automatically, without you having to say anything.

docker compose up -d
# is exactly equivalent to:
docker compose -f compose.yaml -f compose.override.yaml up -d

It is the perfect mechanism for the development environment: the compose.yaml describes the platform neutrally and the override adds what only makes sense on your machine. And since the override is ignored on the server —where other explicit files are used— there is no risk of a debugging port ending up in production.

When you pass files with -f, the automatic override stops being loaded: you are in charge.

  1. Explicit overrides with several -f

docker compose -f compose.yaml -f compose.prod.yaml up -d

Compose reads the files in the given order and merges each one over the accumulated result: the last one wins. Order matters and is a classic source of errors; if you swap the files around, the base file overwrites the specific one.

Relative paths are resolved against the first file's directory, unless you use --project-directory. If your overrides live in a subdirectory, keep that in mind:

docker compose -f compose.yaml -f environments/compose.prod.yaml --project-directory . up -d

  1. The merge rules

What happens when merging depends on each key's type:

Key type Examples Behavior
Scalar image, restart, user, container_name Replaces: the last file wins
Map environment (map form), labels, deploy, healthcheck Merged key by key: the last one wins on matching keys, the rest survive
List ports, volumes, dns, env_file, networks Concatenated: items from both appear
List treated as a block command, entrypoint, healthcheck.test Replaced entirely: no concatenation
environment in list form - KEY=value Merged by variable name, not duplicated

The distinction between the first two rows and the third explains 90% of the surprises. An example:

# compose.yaml
  aurora-api:
    image: auroralibros/aurora-api:1.2.0
    environment:
      NODE_ENV: production
      LOG_LEVEL: info
    ports:
      - "3000:3000"
# compose.override.yaml
  aurora-api:
    environment:
      LOG_LEVEL: debug
    ports:
      - "9229:9229"
docker compose config | grep -A6 "aurora-api:"
    environment:
      LOG_LEVEL: debug
      NODE_ENV: production
    ports:
      - "3000:3000"
      - "9229:9229"

environment has been merged (NODE_ENV survives, LOG_LEVEL is replaced) and ports has been concatenated (both ports). Lists concatenating is convenient for adding, but it means you cannot remove a port or a volume from an override... except with what comes next.

  1. !reset and !override: replacing instead of merging

Compose v2.24 introduced two YAML tags that solve exactly that problem:

# compose.prod.yaml
services:
  aurora-api:
    ports: !reset []          # removes ALL inherited ports
    volumes: !override        # replaces the list instead of concatenating it
      - aurora-logs:/app/logs

!reset empties the inherited key (and with null it removes it entirely), and !override replaces the value instead of merging it. They are the only clean way to remove in an override what the base adds, and they do away with the old workaround of maintaining two nearly identical base files.

  1. The Aurora Libros strategy: base, development and production

The recommended structure is three files with clearly separated responsibilities:

File Content When it is used
compose.yaml The neutral platform: services, networks, volumes, dependencies, probes Always
compose.override.yaml Development conveniences Automatically, locally
compose.prod.yaml Hardening and server settings Explicitly with -f

The base is the one from lesson 04-04, with one added rule: nothing environment-specific. No build, no debugging ports, no NODE_ENV pinned by hand.

# compose.override.yaml — local development (automatic)
services:

  aurora-api:
    build:                       # build from the code, do not pull
      context: ./api
      dockerfile: Dockerfile
    environment:
      NODE_ENV: development
      LOG_LEVEL: debug
    ports:
      - "3000:3000"              # hit the API directly
      - "9229:9229"              # Node inspector (lesson 04-07)
    volumes:
      - ./api/src:/app/src       # code mounted from the host
    restart: "no"                # so a failure does not hide inside a loop

  aurora-db:
    ports:
      - "127.0.0.1:5432:5432"    # psql from the host
    networks: [backend, frontend] # needed in order to publish the port

  adminer:
    profiles: [tools]
    image: adminer:5
    ports: ["8081:8080"]
    environment: { ADMINER_DEFAULT_SERVER: aurora-db }
    networks: [frontend, backend]

The details of code bind mounts and automatic reloading are lesson 04-07; what matters here is where that configuration lives: in the override, never in the base.

# compose.prod.yaml — server
services:

  aurora-api:
    image: auroralibros/aurora-api:${AURORA_API_VERSION:?set the version to deploy}
    build: !reset null           # the server does not build: it only pulls
    environment:
      NODE_ENV: production
      LOG_LEVEL: warn
    ports: !reset []             # no direct access: everything goes through the proxy
    restart: always
    deploy:
      resources: { limits: { memory: 512M, cpus: "2.0" } }
    logging:
      driver: json-file
      options: { max-size: "50m", max-file: "5" }

  aurora-db:
    restart: always
    deploy:
      resources: { limits: { memory: 2G, cpus: "2.0" }, reservations: { memory: 1G } }

  aurora-web:
    restart: always
    ports:
      - "80:80"
    deploy:
      resources: { limits: { memory: 256M, cpus: "1.0" } }

Note three decisions: the image version is mandatory (:?), because deploying latest is deploying anything at all; build: !reset null guarantees the server never builds; and ports: !reset [] removes the inherited 3000, it does not add to it.

# Development: the override loads on its own
docker compose up -d --build

# Production
docker compose -f compose.yaml -f compose.prod.yaml -p aurora-prod up -d --wait

# CI: no development override, no server settings
docker compose -f compose.yaml -f compose.ci.yaml up -d --wait

Always verify a deployment before running it:

docker compose -f compose.yaml -f compose.prod.yaml config | grep -E "image:|restart:|memory:"
    image: auroralibros/aurora-api:1.2.0
    restart: always
      memory: "536870912"

  1. extends: reusing definitions across projects

Where overrides merge whole files, extends imports one specific service, even from another file or project:

# common/base-services.yaml
services:
  base-node:
    image: node:22-alpine
    working_dir: /app
    user: "1001:1001"
    init: true
    restart: unless-stopped
# compose.yaml
services:
  aurora-api:
    extends:
      file: common/base-services.yaml
      service: base-node
    image: auroralibros/aurora-api:1.2.0    # the local one wins
    environment:
      PORT: "3000"
Advantage Limitation
Shares definitions across different projects It does not import depends_on, volumes_from or links
Does not depend on -f ordering Only one extends per service, though it can be chained
Explicitly documents the origin Relative paths are resolved from the extended file

The depends_on limitation is deliberate: a dependency only makes sense inside the project that defines it. Use extends for configuration templates (base image, user, restart policy) and YAML anchors for repetition within the same file.

  1. include:: composing files from several teams

include: goes one step further: it pulls in complete Compose files, with their services, networks and volumes, as if they had been written in yours.

# compose.yaml
include:
  - path: ../common-platform/compose.observability.yaml
  - path: ./payments/compose.yaml
    env_file: ./payments/.env          # each included file resolves ITS OWN variables
    project_directory: ./payments      # and its own relative paths

services:
  aurora-api:
    depends_on:
      - metrics-collector              # service defined in the included file

The difference from -f is important: with several -f the files are merged (they are expected to talk about the same services), whereas include aggregates independent files that contribute services of their own. Each included file keeps its own path and variable context, which lets the payments team maintain their compose.yaml without coordinating with the platform one. In exchange, service names have to be unique across the whole set.

  1. File and project naming conventions

File Use
compose.yaml Neutral base. Always
compose.override.yaml Local development. Automatic
compose.prod.yaml Server
compose.ci.yaml Continuous integration
compose.tests.yaml Integration tests with throwaway data

And a project name per environment, so two stacks can coexist on the same machine without stepping on each other:

docker compose -p aurora-dev up -d
docker compose -f compose.yaml -f compose.prod.yaml -p aurora-prod up -d
docker compose ls
NAME          STATUS       CONFIG FILES
aurora-dev    running(5)   /home/joan/aurora-libros/compose.yaml,...override.yaml
aurora-prod   running(5)   /home/joan/aurora-libros/compose.yaml,...prod.yaml

Two complete stacks, with containers, networks and volumes prefixed differently, not sharing a single piece of data. The only thing that remains global to the machine is the published ports: that is why the development override uses 8080 and the production one 80.

To avoid typing the -f a hundred times a day, pin the set in the environment:

# .env.prod (loaded with --env-file, or exported on the server)
COMPOSE_FILE=compose.yaml:compose.prod.yaml
COMPOSE_PROJECT_NAME=aurora-prod

Common Mistakes and Tips

Swapping the order of the -f. -f compose.prod.yaml -f compose.yaml makes the base overwrite production. The specific one goes last, always.

Expecting an override to remove a port. Lists are concatenated. To remove, !reset or !override.

Running down without repeating the profile. Profiled services keep running. Repeat --profile or use --remove-orphans.

Putting build in the base file. A server will end up building the image instead of pulling the version you tested. build belongs in the development override.

Deploying without pinning the image tag. latest in production means two servers may be running different code. Use ${AURORA_API_VERSION:?...}.

Assuming extends brings the dependencies along. It does not import depends_on: you have to redeclare it in the extending service.

Tip: before any deployment, run docker compose -f ... config and read it. Thirty seconds of reading keeps you from deploying a debugging port open to the Internet.

Exercises

Exercise 1. Create a compose.override.yaml that, for aurora-api, changes LOG_LEVEL to debug, adds port 9229 and mounts ./api/src. Without bringing anything up, prove with docker compose config that the inherited NODE_ENV survives, that there are two ports and that the volume appears alongside the base ones.

Exercise 2. Add an adminer service under the tools profile and answer with commands: (a) does it show up in docker compose ps --services without the profile?, (b) what happens if you name it explicitly in up without --profile?, and (c) what happens when you run docker compose down without the profile? Propose the correct way to bring everything down.

Exercise 3. Write a compose.prod.yaml that completely removes the API's port 3000 publication, requires the AURORA_API_VERSION variable and prevents building on the server. Prove with config that the result contains neither build nor port 3000, and that it fails clearly if the version is not defined.

Solutions

Solution 1.

# compose.override.yaml
services:
  aurora-api:
    environment:
      LOG_LEVEL: debug
    ports:
      - "9229:9229"
    volumes:
      - ./api/src:/app/src
docker compose config | sed -n '/aurora-api:/,/aurora-cache:/p' | grep -E "NODE_ENV|LOG_LEVEL|published|source"
      LOG_LEVEL: debug
      NODE_ENV: production
        published: "3000"
        published: "9229"
        source: /home/joan/aurora-libros/api/src

You can see the three rules in action: environment is a map, so NODE_ENV survives and only the matching key is replaced; ports and volumes are lists, so they get concatenated. Notice too that config has normalized the relative path ./api/src into an absolute one: that normalization is exactly what makes a pre-deployment review trustworthy.

Solution 2.

# (a)
docker compose ps --services | grep adminer || echo "adminer is NOT active"
# (b)
docker compose up -d adminer
docker compose ps --services | grep adminer
# (c)
docker compose down
docker ps --format "{{.Names}}" | grep adminer
adminer is NOT active
adminer
aurora-libros-adminer-1

(a) Without the profile, the service is not even considered. (b) Naming it activates it implicitly: you do not need --profile if you ask for it by name. (c) And here is the surprise: after docker compose down, adminer is still alive, because down without the profile does not take it into account. It is an orphaned container still holding port 8081 and still able to reach the database.

docker compose --profile tools down    # the correct way
docker compose down --remove-orphans   # alternative that sweeps everything

Solution 3.

# compose.prod.yaml
services:
  aurora-api:
    image: auroralibros/aurora-api:${AURORA_API_VERSION:?set the version to deploy}
    build: !reset null
    ports: !reset []
    environment:
      NODE_ENV: production
      LOG_LEVEL: warn
    restart: always
AURORA_API_VERSION=1.2.0 docker compose -f compose.yaml -f compose.prod.yaml config \
  | sed -n '/aurora-api:/,/aurora-cache:/p' | grep -E "image:|build|3000" || echo "no build and no 3000"
unset AURORA_API_VERSION
docker compose -f compose.yaml -f compose.prod.yaml config --quiet; echo "exit code: $?"
    image: auroralibros/aurora-api:1.2.0
error while interpolating services.aurora-api.image: required variable
AURORA_API_VERSION is missing a value: set the version to deploy
exit code: 1

The image is pinned to a specific tag, neither build nor port 3000 shows up!reset removed them, something a plain redefinition would not have achieved with lists— and without the variable the deployment fails before touching anything, with a message that says what to do. That early failure is exactly what you want in a pipeline: better a red config --quiet than a server serving an unexpected version.

Conclusion

A single project now serves three environments without duplicating a single service. Profiles give you optional services on demand —adminer and mailhog under tools, seed under data— activatable with --profile, with COMPOSE_PROFILES or simply by naming the service, with two rules people forget: a base service must never depend on a profiled one, and down without repeating the profile leaves orphaned containers running.

Overrides cover the rest: the automatic compose.override.yaml for development conveniences and explicit combination with several -f where the last one wins. You have mastered the merge rules —scalars that get replaced, maps merged key by key, lists concatenated, and blocks like command or healthcheck.test replaced wholesale— and you know how to get out of the list dead end with !reset and !override, the clean way to remove a debugging port in production. The strategy is clear: a neutral compose.yaml with no build and no debugging ports, a development override and a compose.prod.yaml with a mandatory image tag, restart: always, its own limits and logging configured; and docker compose config read before every deployment.

You also know when to use each reuse tool: YAML anchors within a file, extends for service templates across projects —remembering that it does not carry depends_on— and include: for aggregating complete files maintained by other teams, each with its own path and variable context. Plus the file and project naming convention (-p aurora-dev, -p aurora-prod) that lets two stacks coexist on the same machine without sharing a single byte, with COMPOSE_FILE so you do not have to repeat the -f.

What is left is the part you will use most: the day-to-day. In the next lesson, Local Development with Docker Compose, you will turn that development override into a real working environment: a bind mount of the code with the solution to the eternal node_modules problem, automatic reloading with Node 22's --watch and with Compose's native develop.watch block, step-by-step debugging from VS Code with the inspector on port 9229, tests in throwaway containers with their database in tmpfs, development data and a one-command reset, bind mount performance on macOS and Windows, and a Makefile with the team's shortcuts.

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