Migrate to RunxBuild and earn up to $50 in hosting credit on your first deposit.

Calculate your savings
unxBuild
Back to Blog Explainer

docker compose up --build: When You Need It and Why It Sometimes Changes Nothing

Sean

Platform Writer

Aug 26, 2026
7 min read

docker compose up starts containers from images that already exist. If your source changed, the image did not, and you are running old code. --build rebuilds first — and when it still appears to change nothing, a cached layer is being reused because the instruction that copies your source has not been invalidated.

docker compose up --build: When You Need It and Why It Sometimes Changes Nothing

This is the source of a specific and very common confusion: you edit a file, restart with docker compose up, and the old behaviour persists. Nothing is broken. Compose simply does not watch your source, and building is not part of up unless you ask for it.

The second half — when --build runs and the result is still stale — is about the layer cache, and it is worth understanding rather than working around with --no-cache.

Table of contents

What each command does

docker compose up             # start from existing images, build only if none exists
docker compose up --build     # always rebuild, then start
docker compose build          # rebuild only, do not start
docker compose up --force-recreate   # recreate containers, do NOT rebuild images

The distinction that matters: up builds an image only if one does not exist yet. That is why the first run appears to work and every run after an edit does not.

--force-recreate is frequently reached for by mistake here. It destroys and recreates the containers from the same image, so it helps with container state and does nothing at all for changed source code.

The everyday command for a Compose project you are actively developing:

docker compose up --build -d
docker compose logs -f

Or combined, watching the output directly:

docker compose up --build

Note that services using image: rather than build: are never rebuilt by this — they are pulled. --build only applies to services with a build context.

Why —build sometimes changes nothing

Docker caches each instruction in the Dockerfile. If an instruction’s inputs have not changed, the cached layer is reused, and every layer after a cache hit can also be reused.

The classic Dockerfile is ordered like this, and the ordering is deliberate:

FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["node", "server.js"]

COPY . . is invalidated whenever any file in the context changes, so your source does get picked up. Dependencies are installed above it, so npm ci is only re-run when package.json changes. That is the correct arrangement and it makes builds fast.

It goes wrong when the copy is too early:

COPY . .
RUN npm ci        # re-runs on EVERY source change -- slow but correct

Or when a .dockerignore excludes the thing you changed, which is the genuinely puzzling case:

# .dockerignore
node_modules
*.log
src/config/     <- your change is here and will never be copied

If --build runs and the behaviour is unchanged, check .dockerignore before anything else. An overly broad pattern silently excludes files from the build context, and no error is produced.

To force a genuinely clean build:

docker compose build --no-cache
docker compose up

That is slow and worth reserving for when you actually suspect the cache, rather than using it as a habit.

Volumes, which override the image entirely

This is the other half of the confusion, and it works in the opposite direction: a bind mount means rebuilding does nothing, because the container is reading your host files rather than the image’s.

services:
  app:
    build: .
    volumes:
      - .:/app
      - /app/node_modules

With .:/app, the image’s /app is shadowed by your working directory. Code changes are live without any rebuild, which is exactly what you want in development.

The second line — an anonymous volume on node_modules — exists to stop your host’s node_modules from shadowing the one installed inside the image. Without it, a host directory built for a different platform overwrites the container’s dependencies, which produces confusing native-module errors.

The consequence worth internalising:

  • Bind mount in use — source changes are live; rebuild only when dependencies or the Dockerfile change.
  • No bind mount — every source change needs --build.
  • Production — no bind mounts, the image is the artefact, and --build is how it gets made.

A lot of “my changes are not appearing” is really “this Compose file has a mount in one environment and not the other”.

The watch mode that replaces most of this

Compose has a watch mode that handles the rebuild-versus-sync decision per path, which is better than choosing one strategy for the whole project:

services:
  app:
    build: .
    develop:
      watch:
        - action: sync
          path: ./src
          target: /app/src
        - action: rebuild
          path: package.json
docker compose watch

Source edits are synced into the running container without a rebuild; a change to package.json triggers a full rebuild. That is the correct behaviour for both cases and removes the manual judgement.

It also avoids the node_modules shadowing problem, because it syncs specific paths rather than mounting the whole directory over the image.

This requires a reasonably recent Compose version. docker compose version will tell you; the feature landed in v2.22.

Making builds fast enough to stop mattering

Most frustration with --build is really frustration with build time. A few changes usually fix it.

Order the Dockerfile from least to most frequently changed. Dependencies above source, always.

Use a real .dockerignore. Without one, node_modules, .git and build output are all sent to the daemon on every build, which can be hundreds of megabytes of context for nothing:

node_modules
.git
dist
*.log
.env

Excluding .env is a security point as much as a speed one — it stops secrets being baked into an image layer.

Multi-stage builds keep the final image small by leaving build tooling behind:

FROM node:22 AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/server.js"]

Cache mounts with BuildKit persist a package cache across builds, so a dependency change re-resolves rather than re-downloading everything:

RUN --mount=type=cache,target=/root/.npm npm ci

How this fits the rest of the stack

The --build confusion is a small instance of a general problem: the thing you edited and the thing that is running are separated by a step you have to remember to perform. In development that costs a few minutes of puzzlement. In deployment, the same gap is how a fix that was verified locally does not reach production.

Closing it means making the build automatic and its output inspectable. RunxBuild builds services from a connected GitHub repository on push, including Docker services, with a build log per deploy showing exactly what was produced and rollback to a previous one if it was wrong — so there is no manual rebuild step to forget. The RunxBuild hosting calculator shows what the service costs alongside a managed database and storage.

Useful related references:

FAQ

Why do my code changes not appear after docker compose up?

Because up starts containers from existing images and only builds when no image exists. Your source changed, the image did not. Use docker compose up --build, or add a bind mount so the container reads your working directory directly.

What is the difference between —build and —force-recreate?

--build rebuilds the images before starting. --force-recreate destroys and recreates the containers from the same image, which resets container state but picks up no code changes. For changed source you want --build.

Why does —build still use old code?

Either a cached layer is being reused because the instruction copying your source was not invalidated, or .dockerignore is excluding the files you changed. Check .dockerignore first — an overly broad pattern silently drops files from the build context with no error.

Do I need —build if I use bind mounts?

Not for source changes — the container reads your host files directly, so edits are live. You still need it when dependencies change, when the Dockerfile changes, or when anything baked into the image at build time changes.

What does docker compose watch do?

It syncs specific paths into running containers and rebuilds on others, configured per path in the develop.watch section. That gives you live source updates without a rebuild while still rebuilding when package.json changes, and it avoids the node_modules shadowing problem that whole-directory bind mounts cause.

#docker compose up --build#docker#docker compose#containers#build cache