docker compose up: what it does, flag by flag
What docker compose up decides for each service on every run, and how --wait, --build, --pull and --remove-orphans behave when a script or CI job runs it.
What docker compose up decides for each service
docker compose up makes the containers of a Compose project match the Compose file. On every run it looks at each service and picks one of four outcomes: create a container, replace it, start a stopped one, or leave a running one alone. An attached run then streams the containers' output until you press Ctrl+C. A run with -d returns at once, and the containers keep running.
That one decision per service explains most surprises. A container that restarts when you changed nothing was replaced for a reason Compose can see. A change that never shows up was skipped for a reason too. This guide builds a small lab and runs each flag against it. Every result is proved by comparing container IDs, not by reading Compose's progress lines. Those lines change wording between Compose versions. A container ID does not.
Which Compose version is this?
This guide uses the docker compose CLI plugin. Docker's Compose history page lists two supported CLI versions, Compose v2 and Compose v5, and says that v5, released in 2025, is functionally identical to v2. The version number jumped from 2 to 5 to avoid confusion with the legacy Compose file formats labelled "v2" and "v3". The newest release on the docker/compose GitHub page is v5.6.0, published on 2026-10-02, so a current install reports a 5.x version. The source code quoted below comes from that release and from the project's main branch. Check your own version first:
docker compose versionIf yours is older, the flags below still exist, but verify the source-level details on your version. The old docker-compose command (Compose v1) is no longer maintained, and Docker's documentation tells users to migrate to v2. If you are not sure which one you have, read whether Docker Compose is deprecated and what replaced v1.
How does Compose decide to replace a container?
Compose stores a hash of each service's configuration on the container it creates. A hash is a short fingerprint of the configuration: change any value and the fingerprint changes. On the next run, Compose computes the hash again from your file and compares the two. In the Compose v5.6.0 source (pkg/compose/reconcile.go), any one of these makes it replace a container:
- the configuration hash differs from the one stored on the container
- the image digest differs, which means the tag now points at a different image
- the source image of a
type: imagevolume now has a different digest - the container is running and its networks no longer match the file
- the container's volumes no longer match the file
- a service whose network, IPC or PID namespace it shares (
network_mode,ipcorpidset toservice:<name>), or whose volumes it mounts withvolumes_from, was replaced in the same run
If none of these is true, Compose keeps the container. If that container is stopped, Compose starts it. A plain depends_on is not on the list, and the source leaves it out of the configuration hash. Two flags override this check: --force-recreate replaces every container even when none of these is true, and --no-recreate never replaces an existing one.
The hash is computed from the file after variable substitution. So a variable from your shell, such as ${GREETING}, is part of the configuration. Run up from a shell with a different value and the container is replaced, even though the file did not change. The lab below shows this.
Build a small lab to test each flag
You need Docker Engine with the Compose plugin on a Linux VPS. Run everything below as a user who can talk to the Docker daemon. Create a directory with a two-line Dockerfile for a locally built service:
mkdir -p ~/compose-up-lab/tool
cd ~/compose-up-lab
cat > tool/Dockerfile <<'EOF'
FROM alpine:3.24.2
RUN echo "build 1" > /build-id
EOFNow write the Compose file. It has two services. web uses a pinned alpine image and has a healthcheck you can break on purpose. tool is built from the Dockerfile above.
cat > compose.yaml <<'EOF'
services:
web:
image: alpine:3.24.2
init: true
command: ["sh", "-c", "touch /tmp/healthy && exec tail -f /dev/null"]
environment:
GREETING: ${GREETING:-hello}
healthcheck:
test: ["CMD-SHELL", "test -f ${HEALTH_FILE:-/tmp/healthy}"]
interval: 2s
timeout: 2s
retries: 3
tool:
build: ./tool
init: true
command: ["tail", "-f", "/dev/null"]
EOFSome lines need a reason. tail -f /dev/null keeps a container running without doing any work. init: true runs a small init process as PID 1 (process ID 1), and that process passes stop signals on to tail. Without it, tail itself is PID 1. The kernel does not apply default signal actions to PID 1 in a container, so every stop or replace waits for Docker's stop timeout before the container is killed.
The healthcheck runs test -f against a file the container creates when it starts. The ${HEALTH_FILE:-/tmp/healthy} part means "use the shell variable HEALTH_FILE if it is set and not empty, otherwise use /tmp/healthy". Point it at a file that does not exist and the check fails. A healthcheck command exits 0 for healthy and 1 for unhealthy. The short interval and timeout matter. The defaults are 30 seconds each, with 3 retries, so a failure would take a minute and a half or more to appear.
Last, a small shell function that prints each service's container ID. Every proof in this guide uses it:
snap() { for s in web tool; do printf '%s %s\n' "$s" "$(docker compose ps -a -q "$s")"; done; }docker compose ps -q prints only container IDs, and -a includes stopped containers. By default, ps shows only running containers.
What is the difference between attached and -d?
Run the stack attached first:
docker compose upCompose builds the tool image because no image exists for it yet. It creates both containers, starts them, and attaches your terminal to their output. The command does not return. That is attached mode: the command lives as long as the stack does. Press Ctrl+C, then check the exit code and the containers:
echo "exit code: $?"
docker compose ps
docker compose ps -aDocker's reference states that when an attached run is interrupted with SIGINT (the signal Ctrl+C sends) or SIGTERM, the containers are stopped and the exit code is 0. So echo prints exit code: 0. The plain ps lists nothing, because no container is running. ps -a still lists both, because Ctrl+C stops the containers and does not remove them.
Now start the same stack detached, and prove the containers were started, not replaced:
snap > before.txt
docker compose up -d
snap > after.txt
diff before.txt after.txt && echo "same containers, started again"diff prints nothing and the echo runs. The configuration did not change, so Compose started the existing stopped containers. -d (--detach) starts the containers in the background and leaves them running, so the command returns and your SSH session is free. Use attached mode to watch a stack start. Use -d for anything that should keep running after you log out. If you only want to start or replace one service, running docker compose up for a single service covers that case, including --no-deps and --scale.
How do you prove what a run changed?
Run up -d again with nothing changed:
snap > before.txt
docker compose up -d
snap > after.txt
diff before.txt after.txt && echo "nothing replaced"Both IDs are the same. Now change the configuration of web through the shell variable, without touching the file:
snap > before.txt
GREETING=hi docker compose up -d
snap > after.txt
diff before.txt after.txt
docker compose exec web printenv GREETINGThis time diff prints the web line twice: once with the old ID and once with the new one. The tool line is not in the output, so tool kept its container. printenv prints hi. The resolved value of GREETING changed, so the hash of the web configuration changed, so Compose replaced that one container. tool shares no namespace or volumes with web, and its own configuration did not change, so it was left alone.
Run plain docker compose up -d again and web is replaced once more, because GREETING falls back to hello. This is the most common cause of a replace that looks random on a server: a variable that is set in one shell and not in another, or a .env file that changed.
When should you use --force-recreate or --no-recreate?
--no-recreate tells Compose not to replace containers that already exist, even if their configuration changed:
snap > before.txt
GREETING=bye docker compose up -d --no-recreate
snap > after.txt
diff before.txt after.txt && echo "nothing replaced"
docker compose exec web printenv GREETINGdiff is empty, and printenv prints the old value, not bye. The change was skipped. That is what the flag is for, and it is also why it is risky. The file and the running container now disagree, and nothing reminds you.
--force-recreate does the opposite. It replaces every container, even when the configuration and the image are unchanged:
snap > before.txt
docker compose up -d --force-recreate
snap > after.txt
diff before.txt after.txtBoth lines change. Use it when a container is in a bad state and you want a fresh one from the same configuration. Docker's reference marks the two flags as incompatible, so passing both is an error. Replacing a container, restarting it and rebuilding its image are different operations, and docker compose restart versus a rebuild explains when each one applies.
Does docker compose up rebuild my image?
Not if the image already exists. Change the Dockerfile and run a plain up:
sed -i 's/build 1/build 2/' tool/Dockerfile
docker compose up -d
docker compose exec tool cat /build-idIt prints build 1. Compose found the image it built earlier and used it. The Dockerfile is not part of the configuration hash, so the edit did not cause a replace either. --build builds the images before it starts the containers:
snap > before.txt
docker compose up -d --build
snap > after.txt
diff before.txt after.txt
docker compose exec tool cat /build-idNow it prints build 2. Only the tool line appears in diff. The new image is a different image, so tool was replaced. web uses a registry image, so it kept its container. The opposite flag, --no-build, never builds, even when the pull or build policy would. To decide whether a service should have an image:, a build:, or both, read build versus image in a Compose file.
What does --pull do?
--pull controls whether Compose pulls registry images before it starts containers. It accepts always, missing and never. Its default value is policy, which means each service's own pull_policy from the Compose file applies. For a service with no build: section, the documented default pull_policy is missing: Compose pulls the image only if it is not already on the host.
With that default, a plain up does not check the registry for a newer copy of a tag you already have. To check, ask for it:
snap > before.txt
docker compose up -d --pull always web
snap > after.txt
diff before.txt after.txtThe command names web because tool has no registry image to pull. If the registry still serves the same image for alpine:3.24.2, the image digest matches and web keeps its container. If the tag now points at a different image, the digest differs and Compose replaces web. The diff tells you which one happened. This is also why a full version tag matters. With a moving tag such as latest, --pull always can replace a container with a different version of the software.
Inside the Compose file, pull_policy also accepts build, daily, weekly and every_<duration>. So a service can check for a newer image on a schedule, without a flag on every run.
How do you make docker compose up wait for healthy containers?
--wait waits until the services are running or healthy, and it implies -d. In the v5.6.0 source, Compose waits for a service with a healthcheck to become healthy. It waits for a service without one to be running. Here web has a healthcheck and tool does not.
docker compose up --wait --wait-timeout 60
echo "exit code: $?"The command returns when web passes its check, and echo should print exit code: 0. That exit code is the whole point. A deploy script can stop as soon as the stack is not ready, without parsing any text.
Now break the healthcheck on purpose. Pointing HEALTH_FILE at a missing file changes the web configuration. Compose replaces the container, and the new one can never pass its check:
HEALTH_FILE=/tmp/missing docker compose up --wait --wait-timeout 60
echo "exit code: $?"
docker inspect --format '{{json .State.Health}}' "$(docker compose ps -q web)"This time the exit code is not 0. Docker's reference documents 1 for a run that hits an error, but a script should test zero against non-zero, not one exact number. The docker inspect output shows the reason. The health Status is unhealthy, and each entry in Log carries an ExitCode of 1, which is the healthcheck reporting failure.
Restore the healthy configuration. The file path changes back, so web is replaced again and passes its check:
docker compose up --wait --wait-timeout 60
echo "exit code: $?"Always pass --wait-timeout in scripts. In the v5.6.0 source, no timeout applies unless you set one. One more rule from the source: if another service depends on a service with condition: service_completed_successfully, --wait waits for that service to finish successfully instead. A healthcheck that tests the real service, and not only that a process exists, is a job of its own. See writing Docker Compose healthchecks that mean something, and the database case in a Postgres healthcheck in Docker Compose.
Why is an old container still running after I renamed a service?
Compose finds its containers through labels that record the project name and the service name. Rename a service in the file and the container labelled with the old name no longer matches anything in the file. Compose calls it an orphan, and a plain up does not remove it. Rename tool to builder and look:
sed -i 's/^ tool:/ builder:/' compose.yaml
docker compose up -d --wait --wait-timeout 60
docker compose psps lists the web container, the new builder container, and the old tool container, which keeps running. docker compose ps includes orphaned containers by default. Remove the orphan with --remove-orphans:
docker compose up -d --remove-orphans
docker compose psNow only web and builder are listed. Without the flag, orphans are never planned for removal. On a server this matters for more than tidiness. If the old service published a port, the orphan still holds it, so the renamed service cannot bind the same port. docker compose down accepts the same flag, and docker compose down versus stop explains what each one removes.
How do you use docker compose up in CI?
CI (continuous integration) needs a run that starts a test container next to the stack and returns that container's exit code. Two flags do this. --abort-on-container-exit stops all containers as soon as any container stops. --exit-code-from <service> returns the exit code of that service's container, and it implies --abort-on-container-exit. Use a separate directory, so this is a separate Compose project:
mkdir -p ~/compose-up-lab/ci
cd ~/compose-up-lab/ci
cat > compose.yaml <<'EOF'
services:
web:
image: alpine:3.24.2
init: true
command: ["tail", "-f", "/dev/null"]
test:
image: alpine:3.24.2
command: ["sh", "-c", "echo running tests; exit 3"]
EOF
docker compose up --exit-code-from test
echo "exit code: $?"
docker compose psThe run is attached, so you see the test's output. When test exits, Compose stops web too. echo prints exit code: 3, which is the exit code of the test container. A CI job that runs this line fails when the tests fail. The empty docker compose ps confirms that nothing was left running.
--abort-on-container-exit is incompatible with -d. In the Compose source, --wait cannot be combined with these flags either. To make the test container wait for a dependency that is ready, give the dependency a healthcheck, and add depends_on with condition: service_healthy to the test service.
The reference also lists --abort-on-container-failure. It stops the stack only when a container exits with a failure, and it cannot be combined with --abort-on-container-exit.
Clean up the lab
cd ~/compose-up-lab/ci && docker compose down
cd ~/compose-up-lab && docker compose down --remove-orphans --rmi local
docker image ls--rmi local removes the images built for the services in the file and leaves the pulled alpine image in place. docker image ls shows what is left, including the image built under the old tool name, which you can remove with docker image rm. For the other subcommands on one page, keep the Docker Compose cheat sheet open.
FAQ
Does docker compose up rebuild my image when the Dockerfile changes?
No. If an image for a build: service already exists, a plain docker compose up uses it. A Dockerfile edit is not part of the configuration hash, so the container is not replaced either. Run docker compose up -d --build to build first. Compose then replaces only the containers whose image changed.
Why did docker compose up replace a container I did not change?
Compose replaces a container when the hash of its resolved configuration differs, when its image digest differs, when the source image of a type: image volume changed, when a running container's networks no longer match, when its volumes no longer match, or when a service whose namespace or volumes_from it shares was replaced. --force-recreate replaces it regardless. The most common hidden cause is variable substitution: a ${VAR} in the file that has a different value in this shell or in the .env file. Save the output of docker compose ps -a -q before and after a run, then compare the IDs to see exactly which containers were replaced.
What happens when I press Ctrl+C during docker compose up?
In an attached run, Ctrl+C sends SIGINT, which stops the containers, and the command exits with code 0. The containers are stopped, not removed. docker compose ps -a still lists them, and the next docker compose up -d starts them again. Use -d for a stack that should keep running after you close the terminal.
How do I make docker compose up wait until the stack is healthy?
Run docker compose up --wait --wait-timeout 60. It implies detached mode. It waits for services with a healthcheck to become healthy, and for the others to be running. Test the exit code: 0 means ready, and anything else means the stack is not ready. Always set --wait-timeout, because in Compose v5.6.0 no timeout applies without it.