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
- Profiles: services you do not always want
- Profiles and dependencies: the rules
- The automatic override:
compose.override.yaml - Explicit overrides with several
-f - The merge rules
!resetand!override: replacing instead of merging- The Aurora Libros strategy: base, development and production
extends: reusing definitions across projectsinclude:: composing files from several teams- File and project naming conventions
- 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 profileaurora-api
aurora-cache
aurora-db
aurora-migrations
aurora-web
(with the tools profile)
adminer
aurora-api
aurora-cache
aurora-db
aurora-migrations
aurora-web
mailhogWith 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:
- 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 alternativeDesign rule: base services must never depend on a profiled service. The dependency always runs the other way.
- The automatic override:
compose.override.yaml
compose.override.yamlIf 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 -dIt 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.
- Explicit overrides with several
-f
-fCompose 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:
- 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"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.
!reset and !override: replacing instead of merging
!reset and !override: replacing instead of mergingCompose 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.
- 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 --waitAlways verify a deployment before running it:
extends: reusing definitions across projects
extends: reusing definitions across projectsWhere 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.
include:: composing files from several teams
include:: composing files from several teamsinclude: 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 fileThe 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.
- 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 lsNAME 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.yamlTwo 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-prodCommon 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/srcdocker 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/srcYou 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(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 everythingSolution 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: alwaysAURORA_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: 1The 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
- 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
