Skip to main content
12 min read

How to Rebuild, Restart and Upgrade a Docker Compose Container

How to Rebuild, Restart and Upgrade a Docker Compose Container

You change a file, run a Compose command, reload the page, and the old version is still there. Docker Compose has four commands that look interchangeable: restart, up -d, up -d --build and up -d --force-recreate. They apply different kinds of change, and only one of them rebuilds an image that already exists. Pick the wrong one and you test old code, or lose the data you meant to keep.

This post shows which command picks up which change, then covers restarting one service, upgrading an image without losing data, and getting a shell inside a service, including one that crashes on start. Every terminal block comes from one script run on a fresh GitHub Actions runner with Docker 28.0.4 and Docker Compose 5.6.0. The blocks are excerpts: BuildKit progress lines and [exit 0] markers are left out, and the container ID lines are notes the script printed. The script and the full log, in run order, are linked at the end.

TLDR

What changed Command What it does
Code or Dockerfile of a service with build: docker compose up -d --build web Builds, then recreates the container if the image changed
environment, ports, volumes or other settings in compose.yaml docker compose up -d Recreates the services whose config or image changed
The image tag in compose.yaml docker compose up -d Uses the new tag (pulls it if needed) and recreates that service
Same tag, newer image in the registry docker compose pull web, then docker compose up -d pull updates the local image, up swaps the container
Nothing, but you want a fresh container docker compose up -d --force-recreate web Recreates even when nothing changed
Nothing, the process is stuck docker compose restart web Same container, same config, new process

restart never applies changes from compose.yaml or a new image. docker compose down keeps volumes, and down -v deletes the named volumes declared in the file (not external ones) and anonymous volumes.

Compose 2.38.2 with its default bake builder sometimes kept the old container after up -d --build in our tests. That was the version on the GitHub ubuntu-24.04 runner we used on 12 October 2026. See the last section if docker compose version prints 2.38.2.

Prerequisites

  • Docker Engine with the Compose plugin, so that docker compose version works. The old Python docker-compose (Compose v1) has not had a release since 1.29.2 in May 2021, and everything below uses the plugin.
  • A project with at least one service that has a build: section.
  • curl, to check what a web service serves.

The test project

Two services: web, built from a Dockerfile, and cache, a Redis image with a named volume for its data.

name: shop
services:
  web:
    build: .
    ports:
      - '8000:8000'
    environment:
      GREETING: hello
    depends_on:
      - cache
  cache:
    image: redis:7.2-alpine
    volumes:
      - cache-data:/data
volumes:
  cache-data:
FROM python:3.13-alpine
WORKDIR /app
COPY app.py .
CMD ["python", "-u", "app.py"]

app.py is a small HTTP server that answers with a version string from the code (MSG = "v1") and the value of the GREETING environment variable. One curl shows which code and which config the running container has.

How do I rebuild a container after changing the code or Dockerfile?

Run up with --build and the service name:

docker compose up -d --build web

--build builds the image first, and up replaces the container because its image changed. The other two obvious commands do not work. Here is each one after changing MSG = "v1" to MSG = "v2" in app.py:

  • restart stops and starts the same container (same ID), so it runs the same image and the old code.
  • up -d without --build uses the shop-web image that is already on the machine. Compose does not watch your source files. With default settings it builds only when the image is missing (as on the first start), and --build asks for a build every time. A service can also set pull_policy: build to always build.
  • up -d --build web built a new image and, because the image changed, replaced the container. The container ID changed and the new code answered.

You do not need docker compose down before a rebuild. up --build replaces only the containers whose image or config changed, while down removes every container and the network, which means downtime for all services.

When the build cache hides a change

Docker reuses a cached layer when the instruction and its input files have not changed. A RUN apt-get install or RUN git clone line that would fetch something newer today still hits the cache, because the instruction text is the same. --no-cache runs every build step again without the layer cache. It does not fetch a newer base image, so add --pull for that. Then swap the container:

docker compose build --no-cache --pull web
docker compose up -d web

A build on its own never touches the running container. The next up picks up the new image. The test run left out that up after its --no-cache build, which shows up in the upgrade section below. How to force a clean Docker build covers the cache in more detail.

How do I restart a single container with Docker Compose?

docker compose restart web

In this project, that restarts web and nothing else. A service that lists web under depends_on with restart: true would restart too, and --no-deps prevents that. The container stays the same: same ID, same image, same config. Only the process starts again. That is the right tool for a stuck process, or for a service that reads a config file from a bind mount at startup.

It is the wrong tool for changes in compose.yaml. Docker's compose restart reference says it directly: "If you make changes to your compose.yml configuration, these changes are not reflected after running this command." Here GREETING changes from hello to hi:

restart kept the old value. up -d saw the changed environment, recreated web and left cache running. For one service, docker compose up -d web does the same, and docker compose stop web with docker compose start web behaves like restart. To start only some services in the first place, see docker compose up for certain containers.

Force a new container, or leave dependencies alone

Force a new container when nothing changed

up -d leaves a container alone when its image and config match. --force-recreate replaces it anyway:

That is useful when a fix needs a clean writable layer, for example after someone edited files inside the container by hand. A restart keeps those edits. Docker Compose: always recreate containers shows how to make this the default.

Start one service without its dependencies

up web also starts the services that web lists under depends_on if they are stopped. --no-deps starts web only:

How do I upgrade a container after its image changed?

There are two cases: you changed the tag, or the tag stayed the same and the image behind it moved.

A new tag

Change the tag in compose.yaml and run up -d. Before the upgrade, the run stores a key in Redis:

The key survived because Redis keeps its data in /data, and /data is the named volume cache-data. Compose removed the old container, created a new one from the 7.4 image and mounted the same volume. The named volume is what makes this predictable. Data written outside any volume lives in the container's writable layer and goes away with the old container. Docker data loss when a container exits explains the difference.

web was recreated as well. That was not because of depends_on: the test run had done a --no-cache build of web without the up that should follow it, so the new shop-web image waited for this up.

Rolling back after an upgrade

After 7.4 had written its data, the run switched back to 7.2:

Redis 7.2 cannot read the RDB file that 7.4 wrote, so the container exits at once. Going forward to 7.4 again worked, and the key was still there. Postgres is stricter: a data directory from one major version does not start on another major version in either direction.

Warning

An image rollback is not a data rollback. Before you upgrade a stateful service that you might need to undo, take a backup the database supports, or stop the service and copy its volume.

The same tag with a newer image

Tags such as latest, 8 or stable move when the maintainer pushes a new image. docker compose pull downloads the new image, and up -d replaces the container only if the image is different. To show it without pushing to Docker Hub, the run re-points a local tag, shop/cache:stable, from Redis 7.4 to 8.0. That is what a pull does when it finds a new image:

The first up -d recreated cache because the image name in compose.yaml changed. After the tag moved to 8.0, the running container still ran 7.4 until the next up -d. Back on a real registry tag (one more recreate, because the name changed again), pull and up -d do nothing when the registry has no new image:

So pull alone never restarts anything. With unchanged config and running services, pull followed by up -d recreates nothing when the registry has no new image. When it does find one, the service restarts on the new version, so treat a scheduled pull like any other upgrade.

Without Compose: plain docker run

For a container started with docker run, the same upgrade is pull, stop, remove, and run again with the same volume:

You have to repeat every option from the original docker run: ports, environment, volumes, restart policy. Get one wrong and the new container behaves differently from the old one. That alone is a good reason to put even a single container in a compose.yaml. Old images stay on disk: docker image prune removes only untagged images, so remove an old version with docker image rm redis:7.2-alpine, or use docker image prune -a to remove every image that no container uses.

How do I open an interactive shell in a Compose service?

For a running service:

docker compose exec web sh

The run used -T because a CI job has no terminal. Leave it out when you type the command yourself.

Alpine-based images such as python:3.13-alpine have sh but no bash. If bash is not found, use sh.

When the service is stopped

exec runs a command in an existing, running container, so it fails on a stopped service. run starts a new one-off container from the same service definition (same image, environment and volumes), and --rm removes it when you exit:

When the service crashes on start

A container that exits at startup cannot be reached with exec. Start a one-off container with a shell as its entrypoint, and add --no-deps so that run does not start the dependencies too:

The logs show the cause, and the shell lets you look at the files that the image actually contains. The last two commands fix the code and bring the service back. For a container that runs as a non-root user, docker compose exec -u root web sh opens a root shell. How to access a Docker container's shell covers the plain docker exec options.

What down removes, and what it keeps

docker compose down removes the containers and the network. Volumes stay unless you add -v, and volumes marked external are never removed:

Compose 2.38: when --build does not replace the container

The first run of the script used the Compose that ships on GitHub's ubuntu-24.04 runner, version 2.38.2. Its output did not match this post: up -d --build built a new image and then reported the container as Running, still serving the old code. A focused check ran the same steps four ways: Compose 2.38.2 with its default bake builder (the build output starts with load local bake definitions), the same version with COMPOSE_BAKE=false, and Compose 5.6.0 with and without bake. Both 5.6.0 runs gave the same results.

Step 2.38.2, bake (default) 2.38.2, COMPOSE_BAKE=false 5.6.0
up -d, nothing changed recreated left running left running
edit code, up -d --build kept old container, served v1 recreated, served v2 recreated, served v2
edit again, up -d --build recreated, served v3 recreated, served v3 recreated, served v3
up -d, nothing changed recreated left running left running
edit, build, then up -d recreated, served v4 recreated, served v4 recreated, served v4

Compose decides whether to recreate by comparing the image recorded on the container (the com.docker.compose.image label) with the image the service should run. On 2.38.2 with bake, the labels in the check followed this sequence: the first container had an empty label, so the next up with nothing changed recreated it. The v2 build then left the v1 container running. The v3 build recreated the container, but wrote the v2 image ID into its label, so the next unchanged up recreated it again. Our reading, which the log is consistent with but cannot prove, is that Compose used the image ID from before the build, both for the comparison and for the label. The Compose issue tracker has reports of similar symptoms on releases we did not test: #13944 on 5.3.1 (recreates with nothing changed) and #14014 on 5.4.0 (up --build does not restart containers when the image ID changes).

This can make CI test old code when a job rebuilds an existing service after an edit. A fresh job's first up is fine, because there is no old container to keep. What to do:

  • Run docker compose version. If it prints 2.38.2 and your builds start with load local bake definitions, upgrade the Compose plugin or set COMPOSE_BAKE=false. Both fixed it in our check.
  • Or add --force-recreate to the rebuild: docker compose up -d --build --force-recreate web.
  • After a deploy, check what is running instead of trusting the output. These two commands must print the same image ID:
docker inspect -f '{{.Image}}' "$(docker compose ps -q web)"
docker image inspect -f '{{.Id}}' shop-web

Best practices

  • Pick the command by what changed: code needs --build, config needs up -d, a stuck process needs restart.
  • Keep state in named volumes, never in the container's writable layer.
  • Pin the image tags you upgrade on purpose. A moving tag plus a scheduled pull and up -d means unplanned upgrades.
  • Back up a stateful service before you upgrade it, because older versions often cannot read newer data.
  • Check what runs after a deploy (a version endpoint, or the image ID comparison above), especially after a Compose update.

The script that produced every terminal block above, the Compose version check, and the full logs are here:

Run the commands from this article in the browser. Nothing to install.

Published: 2024-12-05|Last updated: 2026-10-12T09:00:00Z

Found an issue?

Also worth your time on this topic