Compose Profiles and Targeted Environments

A monolithic docker compose up that boots every database, worker, message broker, and observability sidecar wastes RAM and slows the dev loop when an engineer only needs the frontend. Docker Compose profiles let one manifest declare every service while launching a named subset per workflow — frontend-only, full-stack, or with-observability — so you stop maintaining divergent docker-compose.*.yml files. This pattern sits inside the broader Containerized Local Environments & Docker Compose Patterns baseline and pairs naturally with the orchestration and startup-ordering work elsewhere in this section.

Prerequisites

  • Docker Compose v2 (run docker compose version; profiles require the v2 plugin, not legacy docker-compose).
  • A single docker-compose.yml that already defines all services. Profiles tag existing services; they do not create new ones.
  • Healthchecks on stateful dependencies so subset launches still respect readiness — see resolving service startup order and healthcheck races.
  • Ports driven through .env defaults so subsets that overlap on a port do not collide.

Tagging services with profiles

A service without a profiles: key always runs. A service with one runs only when at least one of its profiles is requested. Use this to keep core services (app, db) always-on while gating optional ones.

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "${APP_PORT:-3000}:3000"
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_PASSWORD: postgres
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 3s
      timeout: 5s
      retries: 5
  worker:
    build: .
    command: ["npm", "run", "worker"]
    profiles: ["full-stack"]
    depends_on:
      db:
        condition: service_healthy
  grafana:
    image: grafana/grafana:11.1.0
    profiles: ["observability"]
    ports:
      - "${GRAFANA_PORT:-3001}:3000"

Steps:

  1. Leave app and db untagged so every workflow gets them.
  2. Tag worker with full-stack so it only starts when that profile is selected.
  3. Tag grafana with observability so the metrics stack is opt-in.
  4. Verify which services a profile resolves to before launching:
#!/usr/bin/env bash
# show what `full-stack` would start
set -euo pipefail
docker compose --profile full-stack config --services

Defining workflow-shaped profiles

Map profiles to how people actually work, not to individual services. A frontend engineer wants app plus a mocked API; a backend engineer wants the full data plane; an SRE wants observability bolted on.

# docker-compose.yml (profile assignment excerpt)
services:
  mock-api:
    image: mockoon/cli:latest
    profiles: ["frontend"]
    command: ["--data", "/data/mocks.json", "--port", "3100"]
  payments:
    build: ./payments
    profiles: ["full-stack"]
  prometheus:
    image: prom/prometheus:v2.53.0
    profiles: ["observability"]

Steps:

  1. Name profiles after intent (frontend, full-stack, observability).
  2. Allow a service to carry multiple profiles when several workflows need it: profiles: ["full-stack", "observability"].
  3. Combine profiles at launch when an engineer needs more than one slice (covered in running a subset of services with Compose profiles).
#!/usr/bin/env bash
set -euo pipefail
docker compose --profile frontend --profile observability up -d --wait

Making the default selection ergonomic

Typing --profile on every command invites mistakes. Pin the team's default with COMPOSE_PROFILES in a committed .env and let individuals override it per shell.

# .env (committed defaults)
COMPOSE_PROFILES=full-stack
APP_PORT=3000
GRAFANA_PORT=3001
#!/usr/bin/env bash
# bin/up.sh — honor COMPOSE_PROFILES, allow per-run override
set -euo pipefail
: "${COMPOSE_PROFILES:=full-stack}"
echo "Starting profiles: ${COMPOSE_PROFILES}"
docker compose up -d --wait

Steps:

  1. Commit a sane default in .env so docker compose up "just works" for most engineers.
  2. Document the override (COMPOSE_PROFILES=frontend docker compose up) in the README.
  3. Wrap the common case in a make up target so newcomers do not need to learn the flag immediately, mirroring the one-command setup goal in reducing setup friction for junior engineers.

Drift diagnostics

The subtle failure with profiles is silent omission: a dependency lives in a profile the developer did not select, so the app starts but a feature is dead. Detect it before it confuses someone.

#!/usr/bin/env bash
# bin/profile-audit.sh — flag dependencies hidden behind unselected profiles
set -euo pipefail
active=$(docker compose config --services | sort)
echo "== Active services =="
echo "${active}"
echo "== Services declared but NOT active =="
comm -13 <(echo "${active}") <(docker compose --profile frontend --profile full-stack --profile observability config --services | sort)
# A dependency sitting in an unselected profile shows up here:
== Services declared but NOT active ==
payments

Cross-check that no always-on service depends_on a profiled one; that combination starts the dependency implicitly and surprises people. Compose will pull in a profiled service if a started service depends on it, so audit depends_on edges when you add a profile.

Platform caveats

macOS (Docker Desktop): every profiled service still runs inside the shared Linux VM, so a with-observability launch competes for the global RAM slider; raise it before adding Prometheus and Grafana. WSL2: COMPOSE_PROFILES set in Windows PowerShell does not propagate into the distro — export it inside WSL2 or put it in the committed .env. Apple Silicon (ARM64): observability images (Grafana, Prometheus, OpenTelemetry collectors) usually ship arm64 manifests; for any that do not, pin platform: linux/amd64 only on that service rather than the whole stack.

Rollback / recovery

If a profile launch leaves a partial stack, tear down everything it could have started — down without a profile only removes currently active services, so pass the profiles to clean up their containers too.

#!/usr/bin/env bash
set -euo pipefail
docker compose --profile frontend --profile full-stack --profile observability down --remove-orphans
git checkout -- docker-compose.yml .env 2>/dev/null || true
docker compose up -d --wait