3.4 · Variables, profiles and per-environment overrides

Level 3 · Docker Compose

3.4Variables, profiles and per-environment overrides

Goal: decline a compose into dev / prod without duplicating the file.
Estimated time: 12 min · self-paced reading · If you only have 5 min: read “The essentials in 30s” + the diagram.

The essentials in 30 seconds

  • .env: one value, several faces: ${PORT:-3000} reads the variable or its default — one .env file per environment, one compose for N faces.
  • Overrides: base + variant: compose.override.yml auto-loaded in dev, -f compose.yml -f compose.prod.yml in prod — each file describes only its difference.
  • Profiles: opt-in services: profiles: [dev] makes adminer absent by default, present with --profile dev — the default stack is minimal and safe.

Running mini-case — Idir unifies dev and prod

Idir, junior DevOps in Algiers, maintains two copied compose.yml files (dev with adminer, prod without) — they diverge, and one day the dev console goes to production. He redesigns: one unique base with ${PORT:-3000}, adminer under profiles: [dev], .env per uncommitted environment. Dev: --profile dev up -d; prod: up -d, lean by default. One source of truth, two faces — prod never contains dev tools again.

This same case returns in the lesson, the diagram, the script and the 48h challenge.

1. Interpolation and .env: parameterise without duplicating

In compose.yml, ${PORT:-3000} reads the PORT variable or 3000 by default: the compose becomes PARAMETERISABLE. An .env file next to the compose sets values per environment (dev: published ports + adminer; prod: other values, no adminer) — loaded automatically. One file, N faces: the same image, the same structure, changing values.

Hygiene rule: the .env containing secrets is NEVER committed — version a dummy .env.example documenting the expected keys. And ${X:-…} defaults are chosen prod-safe: without a variable, the stack starts in its leanest form, not its most open.

2. -f overrides and profiles: variants and opt-in

The OVERRIDES: keep a BASE compose.yml and add variants — compose.override.yml, automatically loaded in dev (bind mounts, debug), or compose.prod.yml called explicitly (-f compose.yml -f compose.prod.yml). Each file describes only its DIFFERENCE; left to right, each -f overrides the previous one. No more full duplication per environment.

The PROFILES: profiles: [dev] on adminer makes it OPT-IN — absent by default, present with --profile dev. No more "I forgot to remove the console in prod": by default the stack is minimal and safe, dev tools activate explicitly. Minimal base, options in profiles, prod-safe default.

Common misinterpretation: "One compose per environment is clearer, and dev profiles can stay active by default". Wrong, twice. Two copied files DIVERGE — every fix must be applied twice, and one day the dev console goes to prod. And a profile active by default is no longer a profile: opt-in only exists if the default is lean. One base, overrides, opt-ins.
Decline without duplicating Base, values, variants, opt-in Versioned basecompose.yml + ${VAR:-prod-safe default}+Values per envLocal uncommitted .env + .env.example+Variants + opt-in-f overrides, dev override, profiles By default: minimal and safe; dev activates explicitly.
Base, values, variants, opt-in
Text version of the diagram (SEO / screen readers / translation): 1) Base: Idir writes a single compose with ${PORT:-3000} instead of two copied files. 2) Values: .env per uncommitted environment, versioned .env.example, prod-safe defaults. 3) Variants: auto override in dev, explicit prod -f, adminer in dev profile — lean by default. Lesson: one base, values, differences in overrides.

🔎 Concrete example — Rich dev, lean prod: same base, two faces

Idir parameterises the port, puts adminer in the dev profile, and launches each environment.

# compose.yml (socle)
services:
  api:
    build: ./api
    ports: ["${PORT:-3000}:3000"]
    networks: [appnet]
  adminer:
    image: adminer:4.8
    profiles: [dev]
    ports: ["8081:8080"]
    networks: [appnet]
networks:
  appnet:
# .env (dev, NON commité) : PORT=3000
# Dev  : docker compose --profile dev up -d
# Prod : PORT=80 docker compose up -d

Reading: same base, adminer only in dev, port by variable.

Lesson: prod never contains dev tools — lean by default.

Section vocabulary

Interpolation
${VAR:-default} resolved from the environment or .env: the parameterisable compose.

Simple example: Idir's ${PORT:-3000}, 3000 without a variable.

What it is not: a hard-coded port duplicated in two files.

Reflex: prod-safe default after each :-.

Override
Extra -f file modifying the base: each -f overrides the previous one, left to right.

Simple example: -f compose.yml -f compose.prod.yml in production.

What it is not: a second compose copied 95% identical.

Reflex: each variant describes only its difference.

override
Dev override (compose.override.yml) loaded automatically: binds, debug, without touching the base.

Simple example: Idir's live mounts in dev only.

What it is not: dev binds leaking into production.

Reflex: dev in the override, never in the base.

Profile
Opt-in group (profiles: [dev]) enabled by --profile: service absent by default.

Simple example: Adminer present only with --profile dev.

What it is not: the dev console shipped to prod by mistake.

Reflex: dev tools in profiles, lean stack by default.

.env
Local values file next to the compose, auto-loaded — uncommitted if it holds secrets.

Simple example: Idir's .env with PORT and local passwords.

What it is not: a .env with secrets pushed to the repo.

Reflex: version .env.example, never secrets.

In practice — Dev/prod without duplication

  1. Add ${PORT:-3000} and a dev profile (adminer) to your base compose.
  2. Launch without a profile: check that adminer is absent (lean stack).
  3. Relaunch with --profile dev: adminer appears — explicit opt-in.
  4. Change PORT via the environment and relaunch: the port follows, without touching the file.
One base serves all environments: values per env, differences in overrides, tools in opt-in.

Ready-to-say script — justifying the single base (30 s)

"A single base compose: the port comes from a variable, the dev console from an opt-in profile, prod differences from an override. Rich dev with --profile dev, lean prod by default — no more copied files diverging, no more dev tools leaking into prod."

Say in architecture review; show the empty diff between dev and prod.

Copy-paste template — parameterised base + dev profile

# Socle : dev riche en opt-in, prod sobre par défaut.
services:
  api:
    build: ./api
    ports:
      - "${PORT:-3000}:3000"
    environment:
      DATABASE_URL: ${DATABASE_URL}
    networks:
      - appnet
  adminer:
    image: adminer:4.8
    profiles:
      - dev
    ports:
      - "8081:8080"
    networks:
      - appnet

networks:
  appnet:
# Dev  : docker compose --profile dev up -d
# Prod : docker compose -f compose.yml -f compose.prod.yml up -d

Freelancer

Base + .env.example and each client declines without touching the file.

SME in Algiers

Idir unifies dev and prod: one base, two .env files, zero divergence.

Project team

Require minimal base + opt-in profiles + prod-safe defaults in the charter.

Beginner

This week: one ${PORT:-3000} variable + one service in a dev profile.

Caution: Never commit a .env with secrets: version a dummy .env.example, keep real values local (or managed secret in prod). ${X:-…} defaults stay prod-safe — the stack without variables starts lean, never open.

✓ Best practices

  • Keep the base minimal, put options in opt-in profiles.
  • Choose prod-safe defaults after each :- (${X:-…}).
  • Version a .env.example, never real secrets.

✗ Common pitfalls

  • Duplicating the whole file per environment: copies diverge.
  • Dev profiles active by default: opt-in no longer exists, the console leaks into prod.
  • Committing a .env with passwords: secret exposed in history.

Key takeaways

  • ${VAR:-default} makes compose parameterisable: one file, N faces.
  • OVERRIDES for differences (-f, dev override): never duplicate.
  • OPT-IN profiles for tools: absent by default, --profile dev to enable.
  • DEFAULT = safe prod; versioned .env.example, secrets never committed.

48h challenge — apply tomorrow

Merge 2 copied composes into 1 base + 1 dev profile + 1 override, and prove prod starts lean.

Section exit checklist
  • My single base serves dev and prod with no copy-paste.
  • My dev tools are opt-in, lean by default.
  • My secrets are out of the repo, my defaults prod-safe.
Self-rating 1-5: how many near-identical composes do I have left?does my prod start lean with no variable?

Frequently asked questions

Where to place the .env, and how to keep it out of the repo?

NEXT TO compose.yml: loaded automatically, one per environment. If it holds secrets, never committed (add it to .gitignore) — provide a versioned .env.example with dummy values documenting each key. In prod, values come from the environment or a managed secret.

Several -f, in which order, and override or prod?

LEFT TO RIGHT: each -f overrides the previous one — base first, variant second (-f compose.yml -f compose.prod.yml). compose.override.yml is the exception: loaded AUTOMATICALLY (no -f), it carries dev (binds, debug). Rule: dev = auto override, prod = explicit -f, and each file describes only its difference.

Check your understanding

Adminer must exist only in dev. Which mechanism?

Additional resources (optional, to go further)

Level 3 examFresh, harder questions to test what you've learned