background-shape
Building Images Inside Docker Compose, build vs image
July 20, 2022 · 5 min read · by Muhammad Amal programming

TL;DRimage: pulls a registry image. build: builds locally. Use both for dev (build: with image: as the resulting tag). Multi-stage target: lets one Dockerfile serve dev and prod. BuildKit cache mounts + --build flag keep rebuilds fast.

After networking, the build vs pull decision. Most stacks mix: pull infrastructure (Postgres, Redis), build your own services. The directives are simple; the nuances matter.

The basics

services:
  postgres:
    image: postgres:14-alpine          # pull from registry

  api:
    build: ./services/api              # build locally
    # OR
    build:
      context: ./services/api
      dockerfile: Dockerfile

  worker:
    build: ./services/worker
    image: my-worker:dev               # build locally, tag as my-worker:dev

Three patterns:

  • image: only — pull, never build. For third-party images.
  • build: only — build, don’t tag (Compose generates a name). For app-local services.
  • build: + image: — build, tag with the given name. For services where you want a known tag (for inspection, push, etc.).

I use the third for app services: build: with image: <service>:dev so I can docker pull / docker tag reliably.

Multi-stage with target:

A single Dockerfile with multiple stages:

# Dockerfile
FROM node:18-alpine AS dev
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
CMD ["npm", "run", "dev"]

FROM node:18-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

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

In Compose, pick the stage:

services:
  api:
    build:
      context: ./services/api
      target: dev          # dev stage for local

In CI / prod, same Dockerfile, different target:

docker build --target prod -t my-api:1.2.3 ./services/api

One Dockerfile; multiple consumers. The dev image has dev tools (nodemon, debug); prod is the minimal runtime.

Build args

services:
  api:
    build:
      context: ./services/api
      args:
        NODE_VERSION: "18.7"
        VITE_API_URL: "${API_URL:-http://localhost:8080}"
ARG NODE_VERSION
FROM node:${NODE_VERSION}-alpine
ARG VITE_API_URL
ENV VITE_API_URL=${VITE_API_URL}

Build args are passed at build time; they’re different from runtime env vars. Two use cases:

  • Version pinning (NODE_VERSION above)
  • Build-time configuration (VITE_API_URL must be baked into the bundle)

Don’t put secrets in build args; they’re visible in docker history. For secrets, use BuildKit’s --mount=type=secret.

When does up rebuild?

By default, docker compose up doesn’t rebuild if the image already exists. You have to ask:

docker compose build api          # build, don't start
docker compose up -d              # start with whatever image exists

docker compose up -d --build      # rebuild then start
docker compose build --no-cache   # ignore cache, full rebuild

The --build flag is the common case. After a Dockerfile or source change:

docker compose up -d --build

Builds affected services, recreates containers.

BuildKit cache mounts (for fast rebuilds)

For dev builds that re-run on every dep change, BuildKit cache mounts are essential:

# syntax=docker/dockerfile:1.4
FROM node:18-alpine AS dev
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci
COPY . .
CMD ["npm", "run", "dev"]

The --mount=type=cache,target=/root/.npm is what makes npm ci fast on repeat builds. The .npm cache persists across builds.

Same pattern for Go (/go/pkg/mod), Python (/root/.cache/pip), Composer (/root/.composer/cache).

Make sure the # syntax=docker/dockerfile:1.4 line is at the top — opts into BuildKit features.

When NOT to use build inside Compose

If a service is built in a separate CI pipeline (which it should be for prod), don’t have Compose build it for dev too — version drift is confusing.

Two patterns:

Pattern A — build everywhere. Compose builds for dev; CI builds for prod. Same Dockerfile. Compose uses dev target; CI uses prod target.

Pattern B — pull built images for “prod-like” dev. For staging-rehearsal stacks, pull from the registry instead of building:

api:
  image: ghcr.io/yourorg/api:latest    # pull instead of build

Skip the build: directive. Used for “run the actual prod image locally to debug.”

Both fine; pick per use case.

.dockerignore matters

A .dockerignore in the build context affects build time and image size:

.git
node_modules
.next
.env
*.log
README.md
tests/

Without it, the entire context (potentially gigabytes) gets uploaded to the Docker daemon. With it, only what’s relevant.

For monorepos, scope tighter:

**/.git
**/node_modules
**/.env*
**/dist
**/coverage

Image tagging in Compose

When build: and image: are both set, Compose tags the built image with the image: value:

api:
  build: ./services/api
  image: my-org/api:dev

After build, docker images shows my-org/api:dev. You can docker push it. Useful for testing image push/pull flows locally.

Common Pitfalls

Forgetting --build after source changes. docker compose up -d doesn’t rebuild. Always --build after Dockerfile changes.

Cache busted by trivial line changes. Reordering lines invalidates cache. Put stable lines (deps install) above frequently-changing ones (source copy).

build: without an image: tag, then trying to push. Compose-generated image names aren’t pushable. Always set image: for builds you want to publish.

Build args holding secrets. Visible in docker history. Use BuildKit secrets instead.

# syntax=docker/dockerfile:1.4 missing. No BuildKit features available. Always include for cache mounts and other 1.4 features.

Production with build: directive. Production should pull built images, not build at startup. Build in CI; push; pull in compose for staging or pull in K8s for prod.

Wrapping Up

image: for pulls, build: for builds, both for tagged-builds, target: for stage selection. Multi-stage Dockerfiles + BuildKit cache mounts keep rebuilds fast. Friday: resource limits and memory tuning.