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.
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
- Why —build sometimes changes nothing
- Volumes, which override the image entirely
- The watch mode that replaces most of this
- Making builds fast enough to stop mattering
- How this fits the rest of the stack
- FAQ
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
--buildis 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:
- n8n Docker Compose Example: A Setup That Survives Restarts
- Docker Compose Environment Variables: env_file, .env, and Substitution
- Docker Compose env File: .env, env_file, and the Precedence That Trips Everyone
- Docker services on RunxBuild
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.