Chapter 8: Docker Compose
Topic 50

Profiles and Multiple Environments

ProfilesConcept

Not every service should start every time. A database seeder, a debug shell, a one-off migration runner, or an admin UI belongs in the same compose.yaml but should stay dormant unless asked for. Compose profiles tag services so they are excluded from up by default and only start when their profile is activated.

That lets one file describe the full set of optional components without launching all of them on a plain docker compose up. The core stack comes up clean; the extras wait until you name them.

The Problem Profiles Solve

A stack usually has core services that always run — Driftwood's web, db, and proxy — and optional ones you want declared but not started by default: a seed job that loads sample data, a debug container for poking at the database, a DB admin UI. Without profiles you have two bad choices: run them every time, paying for containers you rarely need, or maintain a second compose file and keep the two in sync. Profiles give a third option — one file, with the optional pieces gated.

profiles: on a Service

Listing one or more profile names under a service's profiles: key opts that service out of the default up. A service with no profiles: always runs; a service tagged ["debug"] runs only when the debug profile is active. Tagging is therefore opt-in activation: the absence of a tag means always-on, and the presence of one means dormant until requested. That inversion is the one rule to internalize.

A profiles: tag inverts a service's default startup
Default services
No profiles: key — web, db, proxy. They always start on a bare docker compose up, with no flag needed.
Profiled services
Tagged with a profile — seed, debug. They start only when the profile is activated, and sit out of a bare up until then.
compose.yaml — core services untagged, optional services behind profiles
services:
  web:
    build: ./web
    image: driftwood/web
    networks:
      - driftwood-net

  db:
    image: postgres:16
    volumes:
      - driftwood-db-data:/var/lib/postgresql/data
    networks:
      - driftwood-net

  proxy:
    image: nginx:1.27-alpine
    ports:
      - "80:80"
      - "443:443"
    networks:
      - driftwood-net

  seed:
    build: ./web
    command: ["python", "seed.py"]
    profiles: ["seed"]
    depends_on:
      - db
    networks:
      - driftwood-net

  debug:
    image: driftwood/web
    command: ["sleep", "infinity"]
    profiles: ["debug"]
    networks:
      - driftwood-net

networks:
  driftwood-net:

volumes:
  driftwood-db-data:

A bare docker compose up starts web, db, and proxy — the three untagged services — and leaves seed and debug alone. The optional services are fully declared and version-controlled; they just sit out of the default bring-up.

Activating a Profile

docker compose --profile debug up turns the debug profile on for that one invocation, or you set COMPOSE_PROFILES=debug in the environment to the same effect. Multiple profiles can be active at once — --profile seed --profile debug — and naming a profiled service directly on the command line (docker compose run seed) also pulls it in. Activation is per-command, so the file stays stable regardless of who needs what on a given run.

Driftwood's Optional Services

Driftwood's seed service loads sample bookmarks into db and carries the seed profile; its debug service is a shell container on driftwood-net for running psql against the database and is tagged debug. Both stay out of the normal up and start only when a developer asks: docker compose --profile seed up seed to load fixtures, docker compose --profile debug run debug bash to get a shell on the network. The core stack never carries them.

Profiles vs Separate Files

Profiles toggle whole services within one file and are the right tool for optional add-ons to the same stack — present or absent, on or off. What they do not do is vary the configuration of services that are already there. Running the same web with a bind mount in dev and a pinned image in prod is a different job: that is per-environment configuration of shared services, handled by override files in the next topic. Profiles add and remove; override files reshape.

Common Mistakes
  • Tagging a core service like db with a profile and then losing it on a plain up — profiled services do not start by default, so web comes up with no database and races into a connection error.
  • Expecting docker compose down after a profiled run to clean up the profiled containers — once started, profiled services are part of the running project and need the profile active (or an explicit target) to be torn down cleanly.
  • Using profiles to model dev-versus-prod differences in the same service's config — profiles add or remove whole services; per-environment config of shared services is what override files are for, and forcing profiles into that role bloats the file.
  • Forgetting that a depends_on target living behind a profile is not pulled in automatically unless that profile is active — the dependency silently stays down and the dependent fails.
Best Practices
  • Leave always-on services (web, db, proxy) untagged and reserve profiles: for genuinely optional components (seed, debug, admin tooling), so a bare up brings exactly the core stack.
  • Activate profiles per invocation with --profile or COMPOSE_PROFILES rather than editing the file, keeping the file stable across who needs what on a given run.
  • Use profiles to toggle whole services and override files (next topic) to vary the configuration of shared services, matching each tool to its job.
  • Name profiles by intent — seed, debug, e2e — so the activation command reads as what it is for rather than as an opaque flag.
Comparable tools A docker run workflow models this only by choosing which commands to run Podman podman-compose supports the same profiles key Kubernetes no direct equivalent — optional manifests via kustomize overlays or Helm toggles

Knowledge Check

What does adding profiles: ["debug"] to a service do to its default startup?

  • It excludes the service from a bare docker compose up; it starts only when debug is active
  • It makes the service start on every up with higher scheduling priority than untagged ones
  • It quietly removes the service from the project network so that it can no longer be reached by name
  • It prevents the service's image from ever being built, even when the profile is later activated

How do you start a profiled service, and can more than one profile be active?

  • With --profile or COMPOSE_PROFILES per invocation, and several profiles can be active at once
  • By editing the file to remove the profiles: key from it, and only one profile may ever be active
  • With a dedicated docker compose profile up subcommand, strictly limited to one profile at a time
  • Compose activates whichever profiles this host has used on a previous run, doing so fully automatically

When should you use override files instead of profiles?

  • When you need to vary the configuration of a service that exists in both environments, not add or remove one
  • When you want to toggle whether an optional seed service runs at all in a given environment
  • When you want to run two completely unrelated stacks out of one shared project directory simultaneously, isolated from each other
  • When you need the services in the stack to resolve each other by name over the project network

Why does tagging db with a profile break a plain docker compose up?

  • db becomes dormant by default, so it does not start and web races into a connection error
  • The profile detaches the driftwood-db-data volume, so the database comes up with an empty data dir
  • The profile removes db from the project's embedded DNS, so its service name no longer resolves at all
  • The profile blocks db's image from pulling from the registry, so the whole stack fails to build

You got correct