Intro
Docker Compose profiles, override files, and environment precedence matter because production containers are easy to start but much harder to operate consistently. A useful technical guide shows what to configure, which command proves the configuration works, and what failure looks like when the setup is wrong.
This guide explains how Docker Compose profiles, override files, env_file values, and .env precedence interact so teams can avoid configuration drift between local and staging environments. You'll learn the exact precedence order, see concrete file layouts, and run verification commands that expose misconfigurations before they reach CI/CD.
The goal is practical: understand the moving parts, test them locally, and avoid surprises when the same pattern is reused in production-like environments.
Workflow Overview
Docker Compose evaluates configuration in a specific order. Understanding this precedence chain is the foundation for predictable multi-environment deployments.
Precedence order (highest wins):
- Command-line flags (
-f,--profile,-e) docker-compose.override.yml(or files passed via-fafter the base)- Base
docker-compose.yml .envfile in the project directory- Shell environment variables
env_fileentries in the compose file (processed per service, top to bottom)environment:keys in the compose file- Dockerfile
ENVinstructions
Concrete example: If POSTGRES_PASSWORD=secret is set in .env, overridden to POSTGRES_PASSWORD=staging123 in docker-compose.staging.yml, and the shell exports POSTGRES_PASSWORD=prod456, the container receives prod456 — shell environment wins.
Profiles let you enable optional services without editing files. Define a profile in the service:
services:
debug-tools:
image: busybox
profiles: [debug]
command: sleep infinity
Start it with docker compose --profile debug up -d. Without the flag, the service is ignored entirely — useful for sidecars, migration runners, or local-only tooling.
Override files layer on top of the base. A typical layout:
project/
├── docker-compose.yml # base: image, ports, volumes, networks
├── docker-compose.override.yml # local dev: bind mounts, debug ports, no resource limits
├── docker-compose.staging.yml # staging: resource limits, healthchecks, no bind mounts
└── .env # shared defaults: COMPOSE_PROJECT_NAME, TAG=latest
Run staging with docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d. The override file is automatically loaded unless you disable it with -f docker-compose.yml -f docker-compose.staging.yml (explicit files suppress the automatic override).
Practical verification commands:
# Show effective config after all merging
docker compose config
# Show config for a specific profile
docker compose --profile debug config
# Verify which env file values actually made it into the container
docker compose exec app env | grep POSTGRES
# Inspect merged service definition
docker compose config --services
Local Pilot Plan
Apply the precedence rules in a controlled local test before promoting to shared environments.
Step 1: Create the base file (docker-compose.yml):
services:
api:
image: myapp:${TAG:-latest}
ports:
- "8000:8000"
environment:
- APP_ENV=production
- LOG_LEVEL=info
env_file:
- .env.api
deploy:
resources:
limits:
memory: 512M
Step 2: Create .env in project root (shared defaults):
TAG=v1.2.3
COMPOSE_PROJECT_NAME=myapp
Step 3: Create service-specific env file (.env.api):
DATABASE_URL=postgres://user:pass@db:5432/app
REDIS_URL=redis://redis:6379/0
Step 4: Create local override (docker-compose.override.yml):
services:
api:
build:
context: .
dockerfile: Dockerfile.dev
volumes:
- ./src:/app/src:ro
environment:
- LOG_LEVEL=debug
ports:
- "9229:9229" # Node inspector
profiles:
- dev-tools
mailcatcher:
image: schickling/mailcatcher
ports:
- "1080:1080"
profiles:
- dev-tools
Step 5: Create staging override (docker-compose.staging.yml):
services:
api:
image: myapp:${TAG}
environment:
- APP_ENV=staging
- LOG_LEVEL=warn
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8000/health" ]
interval: 30s
timeout: 10s
retries: 3
deploy:
resources:
limits:
memory: 1G
cpus: '0.5'
db:
image: postgres:15
volumes:
- pgdata:/var/lib/postgresql/data
environment:
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
secrets:
- db_password
volumes:
pgdata:
secrets:
db_password:
external: true
Step 6: Test locally with dev profile
# Starts api + mailcatcher, uses bind mounts, debug logging
docker compose --profile dev-tools up -d
# Verify merged config
docker compose --profile dev-tools config | head -80
# Check environment inside container
docker compose exec api env | sort
Step 7: Simulate staging locally
# Explicit files suppress automatic override.yml
docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d
# Verify no bind mounts, healthcheck present, resource limits applied
docker compose -f docker-compose.yml -f docker-compose.staging.yml config
# Confirm secrets are mounted (requires Docker secrets setup)
docker compose -f docker-compose.yml -f docker-compose.staging.yml exec api ls -la /run/secrets/
Step 8: Restart test — stop and recreate, confirm data persists:
docker compose -f docker-compose.yml -f docker-compose.staging.yml down
docker compose -f docker-compose.yml -f docker-compose.staging.yml up -d
docker compose -f docker-compose.yml -f docker-compose.staging.yml exec db psql -U postgres -c "SELECT 1;"
If the database connection fails or data is missing, the volume wasn't declared correctly or the container wrote to its filesystem instead of the named volume.
Conclusion
Docker Compose profiles, override files, and environment precedence work best when the team treats the configuration as something to test, not just something to copy. The safest path is to keep examples small, run the verification commands locally, and confirm the expected behavior before adding more services or automation.
For a next step, choose one service and document the exact commands used to build, run, inspect, stop, and recreate it across each environment file. Then compare the result with related areas such as Docker Compose, local production-like workflows, and Docker configuration so the implementation fits the larger operating model.
A reliable container workflow makes failure visible: logs are easy to find, persistent data survives container rebuilds, and local behavior is close enough to production to catch mistakes early.