SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Docker Compose build vs image on a VPS

The image key pulls a published tag. The build key makes one locally. What each means on a VPS, why compose up ignores your Dockerfile change, and the fix.

Docker Compose build vs image: the short answer

In a Docker Compose file, image: names an image to pull from a registry, and build: tells Compose to build one on this machine from a Dockerfile. Set only image: and Compose pulls that tag and runs it. Set only build: and Compose builds the image here, giving it a name derived from the project name and the service name. Set both and Compose builds locally, then tags the result with the name in image:, which is how you build an image and push it under a name you chose.

That is the whole distinction. Everything below is what it means in operation on a server. This assumes Docker Engine and the Compose plugin are already installed; running Docker on a VPS covers that part.

The three forms in full

Pull a published tag and run it. No Dockerfile is involved at any point.

services:
  web:
    image: nginx:1.27
    restart: unless-stopped
    ports:
      - "80:80"

Build from a Dockerfile in the current directory. Nothing is pulled except the base image named in FROM.

services:
  web:
    build: .
    restart: unless-stopped
    ports:
      - "80:80"

Build locally and tag the result. docker compose push can then send that exact tag to a registry.

services:
  web:
    build:
      context: .
      dockerfile: Dockerfile
    image: registry.example.com/acme/web:1.4.2
    restart: unless-stopped
    ports:
      - "80:80"

context is the directory sent to the builder. dockerfile is resolved relative to that context, so context: . with dockerfile: docker/prod.Dockerfile is normal and correct. Run docker compose images to see the image name and image ID behind each service container, which is the fastest way to confirm which of these three forms you actually wrote.

Why does docker compose up not rebuild after I change the Dockerfile?

Because up checks whether the image exists, not whether it is current.

When Compose starts a service that has a build: section, it looks for the image in the local image store. If an image with that name is already there, Compose uses it. It does not read your Dockerfile, compare your source files, or look at any timestamp. The Compose specification states the rule as the pull_policy attribute, and the default behaviour builds an image only when it is missing. Present is treated as good enough.

So you edit app.py, run docker compose up -d, watch Compose report the container as running, and serve the old code. Nothing failed, so nothing warned you. This is the most common "my change did not take effect" report with Compose. The giveaway is the status word Compose prints next to the container name: a container Compose replaced reports as recreated or started, and a container Compose decided to leave alone reports as running.

Two checks settle it. docker compose images prints the image ID each container is using, so note it before the deploy and compare after. docker image ls has a CREATED column, and an image created before your last commit is a stale image no matter what the deploy script printed.

Which flags force a rebuild

  • docker compose up -d --build builds first, then recreates any container whose image changed. This is the flag most people are looking for.
  • docker compose build web builds one service and starts nothing. Follow it with docker compose up --no-deps -d web to replace only that container and leave the rest of the stack running.
  • docker compose build --no-cache web discards every cached layer and rebuilds from the first instruction.
  • docker compose build --pull attempts to pull a newer version of the base image in FROM, so a moving tag such as node:22 picks up its current contents instead of the copy you downloaded in March.
  • docker compose up -d --force-recreate recreates containers from the image they already use. It never builds. Reaching for this when you meant --build is a common dead end.

You can also move the decision into the file. In the words of the Compose specification, pull_policy: build means Compose builds the image, and rebuilds it if it is already present. Every up then pays for a build, which is what you want on a laptop and rarely what you want on a server.

services:
  web:
    build: .
    image: registry.example.com/acme/web:dev
    pull_policy: build

One more interaction is worth knowing. docker compose pull tries to pull images for services that have a build section as well, and if that pull fails it tells you the image must be built instead. Pass --ignore-buildable to skip those services quietly.

How the build cache decides your deploy time

Each instruction in a Dockerfile produces a layer, and the builder reuses a cached layer when that instruction and its inputs are unchanged. For COPY, the inputs are the contents of the files being copied. Once one layer misses the cache, every layer after it is rebuilt, because each layer is built on the filesystem the previous one produced.

That one rule decides whether your deploy takes seconds or minutes. Order the Dockerfile from what changes rarely down to what changes on every commit.

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

npm ci sits above COPY . ., so editing a source file leaves the install layer cached and the build resumes at the copy step. Swap those two lines and a one-character change reinstalls every dependency, because COPY . . invalidates the layer that npm ci is built on. The same shape applies to pip install -r requirements.txt and to go mod download.

--no-cache is the right tool when you suspect a stale layer is hiding your fix. It is a bad default, because it throws away the reuse that the Dockerfile ordering exists to earn.

One thing the image sets and Compose can override: the Dockerfile's CMD is what the image runs by default, and a command: key in the service replaces it. How command and entrypoint interact matters here, because a Compose override can make a freshly built image behave exactly like the old one.

Build context and .dockerignore

context: . means Compose packages that directory and sends it to the builder before the first instruction runs. Everything under it goes, including .git and any data directory you happen to keep beside your source. A build that pauses on the transferring-context step of an otherwise unchanged project is telling you the context is too large.

A .dockerignore file at the root of the context excludes paths from that transfer. The syntax is close to .gitignore.

.git
node_modules
*.log
data/
.env

There are two payoffs. The transfer shrinks, so every build starts faster. And COPY . . can no longer copy .env into the image, where anyone who pulls that image can read it back out.

The slow-build case that grows over time is a bind mount. A named volume lives outside your project directory, but a bind mount such as ./data:/var/lib/postgresql/data sits inside the build context, so your builds get slower every week as the database grows. One line in .dockerignore fixes it. Bind mounts against named volumes covers the wider trade.

Build arguments carry a smaller version of the same risk. Values passed through args: are visible in the image history to anyone holding the image, so put a version number there and never a token. Env files and secrets in Compose covers where credentials belong instead.

Should you build on the VPS or build elsewhere and pull?

Building on the box that serves your traffic is the default because it is the shortest path: git pull, then docker compose up -d --build. That is fine on a small server nobody depends on yet. It stops being fine for two reasons you can measure and one that only shows up on a bad day.

Memory. A build runs compilers and bundlers next to your live application, and those are the memory-hungry part of most stacks. On a 1 GB VPS a JavaScript bundler or a Rust compile is routinely the largest process on the box. When the kernel runs out of memory it kills the largest process: either the build stops with Killed and exit status 137, or your database is killed instead and the site goes down in the middle of a deploy. dmesg -T | grep -i oom prints the kill line with the process name, so you can tell which of the two happened rather than guessing.

Disk. Every build leaves layers behind, and the builder keeps its own cache separately from your images. docker system df shows both, and the build cache row only grows. Reclaim with docker image prune for dangling images and docker builder prune for cached layers. A full disk stops more than the build. The database stops writing too, and that failure costs far more than a slow deploy.

Reproducibility. An image built on the server exists only on that server. Rolling back means checking out the old commit and building again, and that build is not guaranteed to produce what you had, because the base tag moved and the package mirrors moved with it. Building elsewhere and pushing a tag turns a rollback into an edit: point image: at the previous tag and run docker compose up -d.

The arrangement that holds up is plain. Your continuous integration runs the build and pushes registry.example.com/acme/web:<git-sha>, and the Compose file on the VPS carries image: with no build: key at all. Deployment is then two commands that need almost no memory.

docker compose pull
docker compose up -d

Run docker login registry.example.com once on the server and Compose can pull private tags from then on.

Keep the build section for development rather than deleting it, in a file you name yourself.

# compose.dev.yaml
services:
  web:
    build:
      context: .
    pull_policy: build
docker compose -f compose.yaml -f compose.dev.yaml up -d --build

Name that file compose.dev.yaml and not compose.override.yaml. Compose loads an override file automatically whenever it is present, so a stray override copied to the server would quietly start building there again. Layering several Compose files explains how the merge resolves each key.

The architecture trap when you build elsewhere

An image carries the CPU architecture it was built for. Build on an Apple Silicon laptop, push, then pull that tag onto an x86_64 VPS, and Docker warns that the requested image platform does not match the detected host platform. The process then dies with exec format error, which reads like a corrupt binary and is not one. Build for the target explicitly:

docker buildx build --platform linux/amd64 \
  -t registry.example.com/acme/web:1.4.2 --push .

The same mismatch happens in reverse if your laptop is x86 and you run an ARM VPS rather than an x86 one. Letting CI build on the architecture you deploy to removes the question.

What to check after a deploy

  • docker compose images prints the image and tag behind every running container. A changed image ID is your proof that the new build is in service.
  • docker compose config prints the merged file after variable substitution, so you can read the final image name Compose will use before you run anything.
  • docker compose logs -f web for the first half minute after the swap. A container that starts and exits restarts in a loop instead of staying up, and the loop is quiet unless you look.
  • docker image ls shows a CREATED column. An image older than your last commit was never rebuilt.

If you are still assembling the file these checks run against, the basics of a Compose file on a VPS covers the surrounding keys, and the Compose command cheat sheet lists the rest of the subcommands.

FAQ

Can I use build and image in the same service?

Yes, and it is the normal setup for a project you build yourself. Compose builds from the build: section and tags the result with the value of image:. That tag is what docker compose push sends to a registry and what another machine pulls. Without an image: key Compose still builds, but it names the image after the project and the service, and it warns that the missing attribute prevents the image being pushed.

Why does docker compose up not pick up my Dockerfile change?

Because up only checks whether an image with that name exists. When one is present, Compose starts it and never compares it against your Dockerfile or your source files. Run docker compose up -d --build, or run docker compose build web followed by docker compose up --no-deps -d web to replace a single service. Setting pull_policy: build on the service makes every up rebuild, which suits a development machine.

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

--build builds the image again, then recreates the containers whose image changed. --force-recreate recreates containers from the image they already have, so it can never pick up a code change. If your change is in the source or in the Dockerfile, --build is the flag you want. --force-recreate is for resetting the container itself, for example to clear its writable layer while keeping the same image.

Should I build my Docker images on the VPS or somewhere else?

Build elsewhere and pull a tag once the box also serves traffic. A build competes with your application for memory, and a small VPS resolves that competition by letting the kernel kill the largest process, which may be the build or may be your database. Builds also leave cache on the disk that nothing reclaims for you. Building on the server stays fine for a small project with no users, and moving later costs little if you keep the build: section in a development-only Compose file.

How do I stop the Docker build cache from filling my disk?

Run docker system df to see how much space your images and your build cache each hold. docker builder prune removes cached layers and docker image prune removes the dangling images left by earlier builds. Adding -a to either one is more aggressive and forces your next build to start cold. Do not schedule docker system prune -af --volumes on a server, because --volumes deletes any volume no container is currently using, and a stack you stopped for maintenance holds your database in exactly such a volume.