Docker Compose Profiles Tutorial: Optional Services Done Right
Use Docker Compose profiles to start only the services you need. Learn the syntax, common patterns, and pitfalls when mixing profiles with depends_on.
What you'll learn
- ✓What Compose profiles are and why they exist
- ✓How to mark services as optional
- ✓How profiles interact with depends_on
- ✓Patterns for dev, test, and debug stacks
- ✓Common mistakes around default behavior
Prerequisites
- •Familiar with docker compose basics
A typical compose.yaml grows over time. Today it spins up your API and database. Next week someone adds Redis. Then a worker. Then a debugger, a mailcatcher, and a load tester. Before long, docker compose up boots a small datacenter on your laptop. Profiles are the Compose feature that lets you keep optional services in the same file without paying for them every time.
What and Why
A profile is a tag attached to a service. Services without any profile always start. Services with profiles only start when one of their profiles is explicitly activated. This means you can keep your full stack in one file but choose which slice to run.
The alternative is multiple compose files glued together with -f flags or environment overrides, which gets unwieldy fast. Profiles keep everything in one place.
Mental Model
- A service with no
profileskey: always part of the default stack. - A service with
profiles: [debug]: only runs when you say--profile debug. - A service with
profiles: [debug, test]: runs when either profile is active. - Activating multiple profiles at once:
--profile debug --profile test.
Profiles compose additively. There is no concept of “exclude this profile.” The default stack is whatever has no profiles attached. Anything tagged is opt-in.
Hands-on Example
A small stack with an always-on API and database, plus optional Redis and a mail catcher.
services:
api:
image: node:20-alpine
command: node server.js
ports: ["3000:3000"]
depends_on: [db]
db:
image: postgres:16
environment:
POSTGRES_PASSWORD: secret
redis:
image: redis:7-alpine
profiles: [cache]
mailhog:
image: mailhog/mailhog
ports: ["8025:8025"]
profiles: [dev]
k6:
image: grafana/k6
profiles: [loadtest]
command: run /scripts/load.js
volumes:
- ./scripts:/scripts
docker compose up
-> api, db
docker compose --profile cache up
-> api, db, redis
docker compose --profile dev up
-> api, db, mailhog
docker compose --profile dev --profile cache up
-> api, db, mailhog, redis
docker compose --profile loadtest run --rm k6
-> k6 (one-shot), api, db (deps) The day-to-day developer runs docker compose up and gets a minimal stack. The frontend folks who need a mail UI add --profile dev. The performance engineer adds --profile loadtest and runs a one-off k6 container.
You can also set the environment variable COMPOSE_PROFILES=cache,dev so teammates do not have to remember flags.
Common Pitfalls
Forgetting that depends_on does not auto-activate profiles. If api depends on redis and Redis is in the cache profile but api is not, then docker compose up will warn that api references a service that is not started. The fix is to either give api the same profile, or move Redis out of the profile, or activate the profile explicitly. Compose 2.x activates profiled dependencies automatically only when the depending service itself starts.
Expecting down to also stop profiled services. By default, docker compose down only touches whatever the current invocation knows about. If you started with --profile dev, you must include the same flag on down, or use --remove-orphans to sweep up.
Putting required services behind profiles “for cleanliness.” If your app cannot run without it, it belongs in the default set. Profiles are for genuinely optional things.
Using profiles to hide environment-specific config. Profiles are not a replacement for separate compose files when the differences are about images, env vars, or ports. Use overrides (compose.override.yaml) for that, profiles for optional services.
Practical Tips
Name profiles by intent, not by service. debug, test, loadtest, analytics read better than with-redis or extra-stuff.
Document profiles at the top of compose.yaml in a comment block. New contributors will not discover them otherwise.
Combine with run --rm for one-shot tools like k6 or a migration runner. Profile-gated services that you only ever invoke once are perfect for this pattern.
Set sensible defaults in a .env file: COMPOSE_PROFILES=dev means teammates get the dev tools without remembering the flag, while CI can pass an empty value for a minimal stack.
Audit periodically. If a profile has been activated by everyone every day for six months, consider removing the profile and just including the service.
Wrap-up
Profiles turn compose.yaml from a fixed stack definition into a flexible toolbox. The mental model is small — tag optional services, activate when needed — and it scales nicely as the file grows. Used well, profiles keep one source of truth for the whole stack while letting each developer or CI job run only what it needs.
Related articles
- Docker Docker Compose vs Kubernetes: When to Use Which
A pragmatic comparison of Docker Compose and Kubernetes covering scope, operational cost, and the signals that tell you it is time to graduate.
- Docker Docker Compose for Multi-Container Apps
Learn Docker Compose by building a small two-service app — a web API and a Postgres database — defined in a single compose.yaml file and started with one command.
- DevOps Docker Compose Tutorial: Run Multi-Service Apps Locally
A practical Docker Compose tutorial. Define services, networks, and volumes in one YAML file, then start and stop a full local stack with a single command.
- Docker Docker Build Context and .dockerignore Tips
Why your Docker builds are slow and your images are bloated, and how to fix both with a tight build context and a thoughtful .dockerignore.