Running a Subset of Services with Compose Profiles
You ran docker compose up and a service you expected never started — or one you wanted excluded came up anyway — because profile selection at launch does not match how the services are tagged. This page covers the exact --profile and COMPOSE_PROFILES mechanics for launching a subset, building on the design in Compose Profiles and Targeted Environments. If you are still shaping which services belong in which profile, read that parent topic first; here we assume the profiles already exist in your compose.yaml and the problem is invoking them correctly.
The symptom is quiet by design. Profiles were built so that an unselected service is not an error — it is simply absent. That makes a mis-selection hard to notice until a request hits the missing container and fails downstream. The fastest way to stop guessing is to make Compose print the resolved set before you start anything.
Diagnostic
Confirm which services a given selection actually resolves to before you launch. docker compose config --services prints the list Compose would act on for the current profile selection, so it is the single source of truth — not the compose.yaml you remember writing.
#!/usr/bin/env bash
# what will actually start?
set -euo pipefail
echo "== default (no profile) =="
docker compose config --services
echo "== with frontend profile =="
docker compose --profile frontend config --services
echo "== declared profiles in this file =="
docker compose config --profiles
# BAD: the worker you needed is gated behind a profile you didn't select
== default (no profile) ==
app
db
# (worker is missing — it carries profiles: ["full-stack"])
Two follow-up checks isolate the cause quickly. docker compose config --profiles enumerates every profile name declared across the file, which catches typos — asking for --profile fullstack when the service is tagged full-stack selects nothing and raises no warning. And docker compose config (no --services) dumps the fully merged configuration, so you can grep for the profiles: key on the service that went missing and confirm the exact tag it carries.
Keep two related commands distinct in your head. docker compose config --services reports what would start for the current selection — it is a static read of the resolved file and needs nothing running. docker compose ps reports what is running right now. When they disagree, the file resolves correctly but a previous launch used a different selection; when config --services itself is wrong, the tag or the selection is the problem. Diagnose with config --services first, because it removes runtime state from the equation.
#!/usr/bin/env bash
set -euo pipefail
# show the profiles attached to a single service
docker compose config | grep -A2 -E '^\s{2}worker:' | grep -i profiles || \
echo "worker has no profiles: key (it is always-on)"
Root cause
A service tagged with profiles: is excluded by default and only starts when one of its profiles is requested — via a --profile <name> flag or the COMPOSE_PROFILES environment variable. Untagged services always start; they belong to the implicit default profile. If you launch without naming the profile that contains a service, Compose silently leaves it out; there is no error, just a missing container.
The matching rule is a set union, not a filter. Compose starts the always-on services plus every service whose profiles: list intersects the selection. Selecting two profiles does not narrow the result to services in both — it adds the services of each. There is no "exclude" selector: you cannot subtract a service from the always-on set with a profile, only add optional ones on top of it. Understanding the resolution as "baseline + matched optionals" explains almost every surprising launch.
Resolution
Once you know the tag a service carries, launching the right subset is a matter of naming the correct profile. Work through these steps in order — each command is self-contained and safe to re-run.
- Start a single profile by name. This launches untagged services plus everything tagged with that profile.
#!/usr/bin/env bash
set -euo pipefail
docker compose --profile frontend up -d --wait
- Select multiple profiles by repeating the flag. The union of all named profiles starts.
#!/usr/bin/env bash
set -euo pipefail
docker compose --profile full-stack --profile observability up -d --wait
- Or set
COMPOSE_PROFILES(comma-separated) so the selection applies to every subsequentcomposecommand in the shell, includingdown,logs, andps.
#!/usr/bin/env bash
set -euo pipefail
export COMPOSE_PROFILES=full-stack,observability
docker compose up -d --wait
docker compose ps
- Start one specific profiled service directly by naming it — Compose enables that service's profile automatically when you target it explicitly.
#!/usr/bin/env bash
set -euo pipefail
docker compose run --rm worker npm run worker:once
- Tear down using the same profile selection, or the profiled containers linger.
#!/usr/bin/env bash
set -euo pipefail
docker compose --profile full-stack --profile observability down --remove-orphans
The --wait flag on steps 1 through 3 blocks until every started container reports healthy (or the command fails), so a subset launch that races its own healthchecks surfaces as a non-zero exit rather than a half-up stack. If a dependency ordering problem appears once more services enter the subset, resolve it at the healthcheck layer described in resolving service startup order and healthcheck races rather than by reshuffling profiles.
Step 4 deserves emphasis because it is the tightest possible subset: a single profiled service and nothing optional around it. docker compose run --rm worker … creates a one-off container that runs your command and is removed on exit, which suits a batch job or a one-shot migration you do not want left running. If instead you want the worker to stay up alongside the baseline, use docker compose up worker — naming the service on up enables its profile just as run does, but leaves a persistent container. Choose run for tasks that end and up for services that stay.
Expected output
A correct full-stack launch brings up the baseline plus the profiled worker and reports each container healthy:
[+] Running 3/3
✔ Container db-1 Healthy
✔ Container app-1 Started
✔ Container worker-1 Started
Verify the resolved list matches your intent — the same command from the Diagnostic section, now showing the worker present:
docker compose --profile full-stack config --services
# app
# db
# worker
If the printed set matches what you expect and docker compose ps shows every one of them running, the subset launched correctly. Treat the config --services output as the contract: if it is right, the up will be right, because up acts on exactly that list.
Choosing between --profile and COMPOSE_PROFILES
The two mechanisms select the same way but differ in scope and durability, and mixing them causes confusion. The --profile flag is scoped to the single command you attach it to: up sees the profile, but a later down without the flag does not, so it fails to remove the profiled containers. COMPOSE_PROFILES lives in the shell environment, so every compose invocation in that session — up, down, logs, ps, stop — sees the identical selection. For interactive, one-off work the flag is fine; for a session where you will start, inspect, and tear down the same subset repeatedly, export the variable once and stop repeating yourself.
When both are present, the --profile flag on a command wins for that command and Compose ignores COMPOSE_PROFILES for it — it does not union the two sources. That is the trap behind "I set the env var but a different set came up": a stray flag on the command overrode it. Pick one mechanism per workflow and stay with it.
A service can also carry more than one profile, e.g. profiles: ["full-stack", "worker-only"]. Selecting either name starts it, and selecting both still starts it once — membership is a set test, not a counter. This is how you let a single optional service belong to several named workflows without duplicating its definition. Use it deliberately: the more profiles a service lists, the more selections silently include it, which is exactly the kind of surprise the config --services preview exists to catch.
Prevention
- Pin the team default in a committed
.envso the common case needs no flag, and document the override in the README. Compose readsCOMPOSE_PROFILESfrom a.envin the project directory automatically, so a new hire's firstdocker compose upbrings up the intended subset with zero flags. Keep this file consistent with your wider dotenv configuration management approach so profile defaults do not drift from other environment settings.
# .env
COMPOSE_PROFILES=full-stack
- Avoid the most common pitfall: an always-on service that
depends_ona profiled service. Compose will pull the profiled dependency in even when you did not select it, producing surprising startups. Keep dependencies in the same profile as their dependents, or leave shared dependencies untagged.
#!/usr/bin/env bash
# bin/check-profile-deps.sh — fail if a non-profiled service depends on a profiled one
set -euo pipefail
docker compose config --services > /tmp/always.txt
docker compose --profile full-stack --profile observability config --services > /tmp/all.txt
if ! diff -q /tmp/always.txt /tmp/all.txt >/dev/null; then
echo "Profiled services exist; verify no always-on service depends on them." >&2
fi
- Make the resolved subset visible in CI so a bad profile tag fails the pipeline instead of a developer's laptop. A single assertion that the default selection resolves to the expected service list catches an accidentally-untagged service before it ships.
#!/usr/bin/env bash
# bin/assert-default-subset.sh
set -euo pipefail
expected="app
db"
got="$(docker compose config --services | sort)"
if [ "$got" != "$expected" ]; then
echo "Default subset drifted. Expected:" >&2; echo "$expected" >&2
echo "Got:" >&2; echo "$got" >&2
exit 1
fi
echo "Default subset OK."
The chart below shows how the launched service count grows as you add profiles to a selection — a useful sanity check when a "small" subset unexpectedly saturates your machine.
macOS (Docker Desktop): all selected profiles share the VM's resource budget; selecting
observabilityon top offull-stackcan exceed the RAM slider and trigger OOM kills. WSL2:COMPOSE_PROFILESexported in PowerShell does not reach the distro; export it inside WSL2 or rely on the committed.env. Apple Silicon (ARM64): if a profiled image lacks an arm64 manifest, pinplatform: linux/amd64on that one service so a subset launch does not fail under emulation.
Rollback
If a selection left orphaned containers or you simply want to return to the baseline, unset the session variable and tear down with the full profile set so nothing lingers:
#!/usr/bin/env bash
set -euo pipefail
unset COMPOSE_PROFILES
docker compose --profile full-stack --profile observability down --remove-orphans
Passing every profile you might have started to down --remove-orphans guarantees the teardown covers containers from any selection, because down only removes what its own selection resolves to. When in doubt, name all profiles on the way down. The --remove-orphans flag additionally sweeps up containers that belong to the project but no longer appear in the resolved set — precisely the profiled containers a narrower down would strand — so it is the safest default whenever you have been switching subsets during a session.
Frequently Asked Questions
Why did a service start even though I did not select its profile?
Almost always because an always-on (untagged) service lists it under depends_on. Compose starts declared dependencies regardless of profile selection, so an untagged service that depends on a profiled one drags it in. Either tag both services with the same profile, or leave the shared dependency untagged so it is genuinely part of the baseline. Run bin/check-profile-deps.sh from the Prevention section to detect this.
Do I need to repeat --profile on docker compose down?
Yes. The --profile flag is scoped to the single command it is attached to, so a down without it resolves only the always-on services and leaves profiled containers running. Repeat the same --profile flags on down, or export COMPOSE_PROFILES once so every command in the shell — including down — shares the selection.
Can I run just one profiled service without starting its whole profile?
Yes. Naming a service explicitly enables its profile automatically for that command, so docker compose run --rm worker … or docker compose up worker starts the worker plus the always-on baseline, without pulling in other services that share the profile. This is the cleanest way to exercise a single optional service.
What happens if I select a profile name that no service uses?
Nothing starts beyond the always-on baseline, and Compose raises no error or warning — an unmatched profile is silently a no-op. This is why a typo like --profile fullstack for a full-stack tag appears to "do nothing". Confirm valid names with docker compose config --profiles before launching.