Building Images Inside Docker Compose, build vs image
TL;DR —
image:pulls a registry image.build:builds locally. Use both for dev (build:withimage:as the resulting tag). Multi-stagetarget:lets one Dockerfile serve dev and prod. BuildKit cache mounts +--buildflag 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.