The Aurora Libros compose.yaml works, but it has the PostgreSQL password written in the clear, the image version pinned by hand and port 8080 hardcoded. Like that it is no use for more than one environment, and it cannot be pushed to a shared repository.

This is the topic where most people get lost, and for one specific reason: there are two different systems that look alike and have nothing to do with each other. One acts on the YAML file before anything starts; the other defines what the process inside the container sees. Keep them apart from the first line and everything else falls into place.

Contents

  1. The two systems, separated from the start
  2. System A: variable substitution in the YAML
  3. Where Compose gets the values to substitute
  4. System B: variables inside the container
  5. The complete precedence table
  6. The project's .env file
  7. Aurora Libros parameterized: .env and .env.example
  8. Secret management: environment is no place for passwords
  9. Debugging with docker compose config

  1. The two systems, separated from the start

System A: substitution System B: the container's environment
Syntax ${VARIABLE} anywhere in the YAML The environment: and env_file: keys
When it acts Before anything is created, while reading the file When the container is created
Who processes it The docker compose process on your machine The Docker daemon, inside the container
What it is for Parameterizing the file itself: versions, ports, paths Configuring the application: DB_HOST, PORT...
Main source The project's .env file and your shell Whatever you write in environment / env_file
Does the process see it? No, unless you also pass it through system B Yes, it is its environment
graph LR
    E["project .env"] --> S
    SH["Shell environment"] --> S
    CLI["--env-file"] --> S
    S["SUBSTITUTION<br/>Compose resolves the ${VAR}"] --> Y["resolved compose.yaml<br/>(docker compose config)"]
    Y --> D["Docker creates the container"]
    EN["environment:"] --> D
    EF["env_file:"] --> D
    IMG["Dockerfile ENV"] --> D
    D --> P["Process environment<br/>inside the container"]

The classic confusion: somebody writes DB_PASSWORD=secret in the .env and thinks the application will see it. It will not. The .env feeds substitution; for it to reach the container you also need an environment: { DB_PASSWORD: ${DB_PASSWORD} } or an env_file.

  1. System A: variable substitution in the YAML

Any ${VARIABLE} or $VARIABLE in the file gets substituted before Docker sees anything. It works in any position: image names, ports, paths, limits.

services:
  aurora-api:
    image: auroralibros/aurora-api:${AURORA_API_VERSION}
    ports:
      - "${API_PORT}:3000"

Always use the braces, ${VAR}: without them, $LONG_VARIABLE can get truncated unexpectedly when it hits a non-alphanumeric character.

The modifiers are the part that really separates a fragile file from a robust one:

Syntax If the variable is undefined If it is defined but empty
${VAR} Empty string (silently) Empty string
${VAR:-default} default default
${VAR-default} default Empty string
${VAR:?message} Error, aborts Error, aborts
${VAR?message} Error, aborts Empty string

The colon means "treat an empty value the same as a missing one". In practice: :- for anything with a sensible default and :? for anything that cannot be missing.

    image: auroralibros/aurora-api:${AURORA_API_VERSION:-1.2.0}
    ports:
      - "${WEB_PORT:-8080}:80"
    environment:
      DB_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is missing; copy .env.example to .env}
docker compose config --quiet
error while interpolating services.aurora-db.environment.POSTGRES_PASSWORD:
required variable DB_PASSWORD is missing a value: DB_PASSWORD is missing; copy .env.example to .env

An immediate error with instructions, instead of a database starting up with an empty password. That is the difference between ${VAR} and ${VAR:?...}, and it is worth applying to every credential.

When you need a literal $ —very common in a shell command or in nginx patterns— double it:

    command: sh -c 'echo "current PID: $$$$"; exec node server.js'
    environment:
      TEMPLATE: "Hello $${NAME}"     # reaches the container as: Hello ${NAME}

A single $ is consumed by Compose; $$ produces a literal $ in the output. If you see errors like Invalid interpolation format, it is nearly always an unescaped $.

  1. Where Compose gets the values to substitute

To resolve a ${VAR}, Compose looks in this order, and the first hit wins:

Order Source
1 A variable passed on the same line: AURORA_API_VERSION=1.3.0 docker compose up -d
2 A variable exported in your shell (export AURORA_API_VERSION=1.3.0)
3 The file given with --env-file
4 The .env file in the project directory
5 The default value inside ${VAR:-...} itself

The shell beating the .env is deliberate and very practical: it lets a CI pipeline override the image version without touching a single file.

AURORA_API_VERSION=1.3.0-rc1 docker compose up -d

  1. System B: variables inside the container

Map form (recommended: it reads better and merges well across override files):

    environment:
      PORT: "3000"
      DB_HOST: aurora-db
      NODE_ENV: production

List form, with one capability the map form does not have:

    environment:
      - PORT=3000
      - DB_HOST=aurora-db
      - HTTP_PROXY              # NO value: takes the host shell's (pass-through)

That last line is pass-through: if HTTP_PROXY exists in your shell, it is passed to the container with its value; if it does not exist, the variable is not defined. Useful for corporate proxies and temporary credentials you do not want written into any file.

env_file loads variables from files, in the order given, with the last one winning:

    env_file:
      - ./config/common.env
      - path: ./config/local.env
        required: false        # if it does not exist, it does not fail (long form)
Aspect environment env_file
Where the value lives In the compose.yaml In a separate file
Is ${...} interpolated? Yes Also, since Compose v2.24
Does it go into Git? Yes, with the file Usually no
Precedence Higher Lower
Good use Non-sensitive, structural values Many variables or per-environment values

Syntax of a .env/env_file file: one KEY=value per line, # for comments, no spaces around the =, no export, and quotes are kept as part of the value unless they wrap the whole string. DB_PASSWORD="secret" and DB_PASSWORD=secret give the same result; DB_PASSWORD=my secret also works, because you do not need to quote spaces.

  1. The complete precedence table

When the same variable appears in several places, this is the order that decides what the process sees, from highest to lowest priority:

# Source Example
1 docker compose run -e / exec -e docker compose run -e NODE_ENV=test aurora-api npm test
2 environment with no value (pass-through from the shell) - NODE_ENV with export NODE_ENV=debug in the shell
3 environment with a value environment: { NODE_ENV: production }
4 --env-file given on the CLI --env-file .env.prod
5 env_file declared in the service env_file: [./config/api.env]
6 The image's Dockerfile ENV ENV NODE_ENV=production

And one precision that saves you hours: the project's .env is not in this table. It injects nothing into the containers; it only feeds system A's substitution. If you want a variable from the .env to reach the process, you have to pass it explicitly.

# Empirical check of the precedence
docker compose exec aurora-api env | sort | grep -E "^(NODE_ENV|DB_HOST|PORT)="
docker compose run --rm -e NODE_ENV=test aurora-api env | grep NODE_ENV
DB_HOST=aurora-db
NODE_ENV=production
PORT=3000
NODE_ENV=test

  1. The project's .env file

It is a file called exactly .env, placed in the project directory (the one holding the compose.yaml, or whatever you give with --project-directory). Compose loads it automatically and uses it only for interpolation.

# .env — local values for Aurora Libros. NOT pushed to Git.
AURORA_API_VERSION=1.2.0
WEB_PORT=8080
DB_USER=aurora
DB_PASSWORD=aurora_secret
DB_NAME=aurora_books
COMPOSE_PROJECT_NAME=aurora-libros

Look at the last line: in the .env you can also set the CLI's own configuration variables —COMPOSE_PROJECT_NAME, COMPOSE_FILE, COMPOSE_PROFILES— which affect how Compose behaves, not the containers.

With --env-file you can use another file, or several:

docker compose --env-file .env.production config
docker compose --env-file .env --env-file .env.local up -d   # the last one wins

  1. Aurora Libros parameterized: .env and .env.example

Apply system A to the three things that change between environments: image version, published ports and credentials.

services:
  aurora-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: ${DB_USER:-aurora}
      POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in your .env file}
      POSTGRES_DB: ${DB_NAME:-aurora_books}

  aurora-api:
    image: auroralibros/aurora-api:${AURORA_API_VERSION:-1.2.0}
    environment:
      PORT: "3000"
      DB_HOST: aurora-db
      DB_USER: ${DB_USER:-aurora}
      DB_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in your .env file}
      DB_NAME: ${DB_NAME:-aurora_books}
      REDIS_HOST: aurora-cache
      NODE_ENV: ${NODE_ENV:-production}
      LOG_LEVEL: ${LOG_LEVEL:-info}

  aurora-web:
    image: nginx:alpine
    ports:
      - "${WEB_PORT:-8080}:80"

Now, the cultural piece that turns this into something a team can actually use: the .env is not versioned, but its template is.

# .env.example — template versioned in Git.
# Copy it to .env and fill in your local values:  cp .env.example .env
AURORA_API_VERSION=1.2.0
WEB_PORT=8080
DB_USER=aurora
DB_NAME=aurora_books
NODE_ENV=production
LOG_LEVEL=info

# Mandatory and with no default: everyone sets their own locally.
DB_PASSWORD=
# .gitignore
.env
.env.local
.env.*.local
secrets/
git add .env.example .gitignore compose.yaml
git status --short
A  .env.example
A  .gitignore
A  compose.yaml

The real .env does not show up: it is ignored. With this, whoever clones the repository runs cp .env.example .env, writes their password and brings the platform up. And if they forget, they do not get a cryptic failure but the ${DB_PASSWORD:?...} message telling them exactly what to do.

  1. Secret management: environment is no place for passwords

Everything above parameterizes nicely, but it protects nothing. A password in environment is visible to anyone with access to the Docker daemon:

docker inspect aurora-libros-aurora-db-1 --format '{{range .Config.Env}}{{println .}}{{end}}' | grep PASS
docker compose exec aurora-api env | grep PASSWORD
docker compose exec aurora-api cat /proc/1/environ | tr '\0' '\n' | grep PASS
POSTGRES_PASSWORD=aurora_secret
DB_PASSWORD=aurora_secret
DB_PASSWORD=aurora_secret

Three different routes, the same password in the clear. And there is more: environment variables end up in container state dumps, they are inherited by every child process (including any third-party dependency that decides to ship them off in a crash report) and they leak into audit logs.

Compose's alternative is the secrets: block, which mounts each secret as a file inside /run/secrets/:

secrets:
  db_password:
    file: ./secrets/db_password.txt      # the file lives outside Git

services:
  aurora-db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: aurora
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password   # the PATH, not the value
      POSTGRES_DB: aurora_books
    secrets:
      - db_password

  aurora-api:
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
    secrets:
      - source: db_password
        target: db_password        # final path: /run/secrets/db_password
        mode: 0400                 # read-only for the owner

The official PostgreSQL, MySQL and many other images support the _FILE suffix on their variables: if you define POSTGRES_PASSWORD_FILE, the entrypoint reads the password from the file and never exposes it as an environment variable. For your own code, the pattern is three lines:

import { readFileSync } from 'node:fs';

// Prefer the secret file; fall back to the variable only if it is absent.
const dbPassword = process.env.DB_PASSWORD_FILE
  ? readFileSync(process.env.DB_PASSWORD_FILE, 'utf8').trim()
  : process.env.DB_PASSWORD;
mkdir -p secrets && chmod 700 secrets
printf 'aurora_secret' > secrets/db_password.txt   # printf, not echo: no trailing newline
chmod 600 secrets/db_password.txt
docker compose up -d
docker compose exec aurora-db env | grep -c "PASSWORD=aurora_secret" || echo "no longer in the environment"
docker compose exec aurora-db cat /run/secrets/db_password
no longer in the environment
aurora_secret

The secret has disappeared from the environment and lives in a file with restricted permissions, mounted on a tmpfs that never touches the container's disk and leaves nothing in any image layer.

Method Visible in inspect In child processes' environment In image layers Recommendation
ENV in the Dockerfile Yes Yes Yes, forever Never
environment in Compose Yes Yes No Non-sensitive values only
env_file Yes (it ends up in the environment) Yes No Non-sensitive values only
secrets: with a file No No No Fine for development and a single host
External manager (Vault, KMS...) No No No Production

Important warning. The above is Compose's basic mechanism and it is appropriate for local development and small single-host deployments, with fictitious data like this course's. Handling real credentials —rotation, access control, auditing, encryption at rest— must always be validated with your organization's security officer, who will decide which secret manager applies. Nothing you do with local files replaces that conversation. General container hardening is covered in lesson 05-03.

  1. Debugging with docker compose config

When a variable does not arrive where you expect, do not guess:

docker compose config                       # everything resolved
docker compose config --no-interpolate      # with the ${...} unresolved
docker compose config --format json | jq '.services["aurora-api"].environment'
{
  "DB_HOST": "aurora-db",
  "DB_NAME": "aurora_books",
  "DB_PASSWORD": "aurora_secret",
  "NODE_ENV": "production",
  "PORT": "3000"
}

Comparing config with config --no-interpolate tells you instantly whether the problem is in the substitution (system A) or in the handoff to the container (system B). And docker compose exec <service> env gives you the definitive truth: what the process really sees.

Careful: config's output contains the resolved secrets in the clear. Never dump it into a CI log or paste it into a ticket.

Common Mistakes and Tips

Believing the .env reaches the containers. It does not. It only feeds interpolation. You need environment or env_file as well.

Using ${VAR} without :? for mandatory values. A missing password becomes an empty string and you silently start a database with no password.

Pushing the .env to Git. It is the single most frequent credential leak there is. Ignore it from the very first commit and version .env.example.

Forgetting the $$ for a literal $. It causes Invalid interpolation format or, worse, an empty string where you expected a pattern.

Using echo to create a secret's file. It adds a trailing newline that becomes part of the password. Use printf or trim it with .trim().

Putting quotes in the .env expecting them to be ignored. In some cases they are part of the value; when in doubt, check with docker compose config.

Tip: a golden rule for deciding where each thing goes. Does it change between environments and is it not sensitive? Interpolation with ${VAR:-default}. Is it sensitive? secrets:. Is it structural and never changes, like DB_HOST: aurora-db? Write it straight into the compose.yaml.

Exercises

Exercise 1. Parameterize the web front end's port with ${WEB_PORT:-8080} and demonstrate the four sources of a value along with their precedence: with nothing defined, with .env, with a variable exported in the shell and with a variable on the command line itself. Use docker compose config to verify each case without bringing anything up.

Exercise 2. Define NODE_ENV: ${NODE_ENV:-production} in the compose.yaml and, on top of that, an env_file containing NODE_ENV=from-file. Predict what value the process will see, verify it, and explain the result using the precedence table.

Exercise 3. Turn the PostgreSQL password into a file secret for aurora-db and aurora-api, and prove with three different commands that it no longer appears in either container's environment.

Solutions

Solution 1.

# (a) With nothing: the default inside ${...} wins
rm -f .env; unset WEB_PORT
docker compose config | grep -A1 "published"
# (b) With .env
echo "WEB_PORT=9090" > .env
docker compose config | grep -A1 "published"
# (c) The shell beats the .env
export WEB_PORT=7070
docker compose config | grep -A1 "published"
# (d) The command line beats everything
WEB_PORT=6060 docker compose config | grep -A1 "published"
published: "8080"
published: "9090"
published: "7070"
published: "6060"

The full scale in four commands: default < .env < exported shell < command line. The shell beating the .env is what lets a CI pipeline change the image version without touching files, and at the same time it explains a very common bafflement: if you have an old variable exported in your session, the .env does not override it. Clear it with unset before you start blaming the file.

unset WEB_PORT   # leave the session clean

Solution 2. The process will see production.

  aurora-api:
    environment:
      NODE_ENV: ${NODE_ENV:-production}
    env_file:
      - ./config/api.env       # contains NODE_ENV=from-file
docker compose up -d aurora-api
docker compose exec aurora-api env | grep NODE_ENV
NODE_ENV=production

In the precedence table, environment (level 3) is above env_file (level 5). The file only contributes the variables the environment block does not define, so it serves as a baseline and environment as a targeted override. And watch out for ${NODE_ENV:-production}: if you also export NODE_ENV=development in your shell, interpolation resolves it to development and the result changes, even though the precedence level is still 3.

Solution 3.

mkdir -p secrets && chmod 700 secrets
printf 'aurora_secret' > secrets/db_password.txt && chmod 600 secrets/db_password.txt
grep -q "^secrets/" .gitignore || echo "secrets/" >> .gitignore
secrets:
  db_password:
    file: ./secrets/db_password.txt

services:
  aurora-db:
    environment:
      POSTGRES_USER: aurora
      POSTGRES_PASSWORD_FILE: /run/secrets/db_password
      POSTGRES_DB: aurora_books
    secrets: [db_password]

  aurora-api:
    environment:
      DB_PASSWORD_FILE: /run/secrets/db_password
    secrets: [db_password]

With the server.js tweak from section 8 to read DB_PASSWORD_FILE, the verification:

docker compose up -d --force-recreate
docker inspect aurora-libros-aurora-db-1 --format '{{range .Config.Env}}{{println .}}{{end}}' | grep -i pass
docker compose exec aurora-api env | grep -i password
docker compose exec aurora-api cat /proc/1/environ | tr '\0' '\n' | grep -i password
curl -s http://localhost:8080/api/books | jq -r '.books | length'
POSTGRES_PASSWORD_FILE=/run/secrets/db_password
DB_PASSWORD_FILE=/run/secrets/db_password
DB_PASSWORD_FILE=/run/secrets/db_password
9

All three routes show the path, never the value, and the nine books confirm the connection still works. One essential detail: if you change this on an already-initialized database, the password is not updated, because POSTGRES_PASSWORD* is only applied on the volume's first initialization. In a test environment, docker compose down -v; in a real one, an ALTER USER.

Conclusion

You have separated the two systems that cause almost all the confusion with Compose. System A is the ${VAR} substitution Compose performs on the YAML before anything is created, with its :- and - modifiers for defaults and :? and ? for mandatory values that abort with a useful message, the $$ escape for a literal dollar sign, and its lookup order: command line, exported shell, --env-file and the project's .env. System B is what the process sees: environment in map or list form —with the pass-through of a key with no value— and env_file with its required: false.

You know the complete precedence table, from run -e down to the Dockerfile's ENV, and the trap it hides: the project's .env is not in that table, because it injects nothing into the containers. Aurora Libros is now parameterized on image version, ports and credentials, with a local .env ignored by Git and a versioned .env.example documenting what is needed in order to start.

And you know environment is no place for a password: you have seen it in the clear through three different routes, and you have pulled it out with the secrets: block, which mounts files in /run/secrets/ and fits the _FILE pattern of the official images and three lines of your own code. With the warning you must not forget: for real credentials, always validate the approach with your organization's security officer.

In the next lesson, Profiles, Overrides and Multiple Environments, you will make a single project serve development, testing and production without duplicating files: profiles for bringing up tools like Adminer or a seeding service on demand, the automatic compose.override.yaml and explicit combination with several -f along with their merge rules, extends and include: for sharing definitions between teams, and the project-naming convention that lets two environments' stacks live side by side on the same machine.

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