Back to BlogDocker Container · Docker compose · docker

Multi-Stage Builds and Auto-Rebuild Workflows in Docker Compose

2025-12-26

Building container images is a fundamental part of containerized development workflows. Docker Compose integrates tightly with Docker's build system, enabling sophisticated multi-stage build processes that optimize image size and security while providing automatic rebuild capabilities that accelerate development cycles. Understanding how to configure build contexts, leverage multi-stage builds, and implement auto-rebuild patterns transforms your Compose files into powerful development and deployment tools.

Understanding Build Configuration Basics

Docker Compose can build images from Dockerfiles before starting services. The simplest build configuration specifies a build context:

version: '3.8'

services:
  web:
    build: ./web
    ports:
      - "8080:80"

This tells Compose to build an image using the Dockerfile in the ./web directory. The entire ./web directory serves as the build context, meaning all files in that directory are available during the build process.

Explicit Dockerfile Specification

When your Dockerfile isn't named Dockerfile or isn't in the root of the build context, specify it explicitly:

version: '3.8'

services:
  api:
    build:
      context: ./backend
      dockerfile: Dockerfile.api

This builds using ./backend/Dockerfile.api with ./backend as the build context.

Build Arguments

Pass build-time variables to Dockerfiles using build arguments:

version: '3.8'

services:
  app:
    build:
      context: ./app
      args:
        NODE_VERSION: 18
        APP_ENV: development

Dockerfile:

ARG NODE_VERSION=16
FROM node:${NODE_VERSION}-alpine

ARG APP_ENV
ENV APP_ENV=${APP_ENV}

WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "start"]

Build arguments allow customizing image builds without modifying Dockerfiles.

Multi-Stage Build Fundamentals

Multi-stage builds use multiple FROM statements in a single Dockerfile, enabling build optimization by separating build dependencies from runtime requirements:

# Build stage
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM node:18-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package*.json ./
EXPOSE 3000
CMD ["node", "dist/index.js"]

The final image only contains the compiled application and runtime dependencies, not build tools or source files.

Targeting Specific Build Stages

Compose can target specific stages in multi-stage builds:

version: '3.8'

services:
  app-dev:
    build:
      context: ./app
      target: builder
    command: npm run dev
    
  app-prod:
    build:
      context: ./app
      target: production

Dockerfile:

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

FROM node:18-alpine AS development
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "run", "dev"]

FROM node:18-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]

This single Dockerfile supports both development and production builds through targeted stages.

Build Arguments in Multi-Stage Builds

Pass arguments to specific build stages:

version: '3.8'

services:
  api:
    build:
      context: ./api
      args:
        BUILD_VERSION: 1.2.3
        ENABLE_DEBUG: "true"

Dockerfile:

FROM golang:1.21-alpine AS builder

ARG BUILD_VERSION
ARG ENABLE_DEBUG=false

WORKDIR /build
COPY . .

RUN if [ "$ENABLE_DEBUG" = "true" ]; then \
      go build -gcflags="all=-N -l" -o app; \
    else \
      go build -ldflags="-s -w" -o app; \
    fi

FROM alpine:latest
COPY --from=builder /build/app /app
CMD ["/app"]

Build arguments control compilation flags and embed version information during the build process.

Image Naming and Tagging

Control the resulting image name and tag:

version: '3.8'

services:
  web:
    image: myapp/web:latest
    build:
      context: ./web

When you run docker-compose build, the image is tagged as myapp/web:latest. Without the image field, Compose generates a name based on the project and service name.

Cache Control with Cache From

Specify images to use as cache sources:

version: '3.8'

services:
  app:
    build:
      context: ./app
      cache_from:
        - myapp/app:latest
        - myapp/app:develop

During builds, Docker checks these images for cached layers, potentially speeding up builds significantly when layers match.

Build Context Exclusion with .dockerignore

Create a .dockerignore file in your build context to exclude files:

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

This reduces build context size, improving build speed and preventing sensitive files from being included in images.

Building Multiple Services

Compose can build multiple services simultaneously:

version: '3.8'

services:
  frontend:
    build: ./frontend
    
  backend:
    build: ./backend
    
  worker:
    build: ./worker

Run docker-compose build to build all services, or docker-compose build frontend to build a specific service.

Parallel and Sequential Building

Control build concurrency:

# Build all services in parallel (default)
docker-compose build

# Build services sequentially
docker-compose build --parallel 1

# Build with specific parallelism
docker-compose build --parallel 4

Sequential building is useful when services have interdependencies or when system resources are limited.

Auto-Rebuild on File Changes

Compose doesn't automatically rebuild on file changes by default, but you can implement watch patterns. First, ensure your Dockerfile is optimized for layer caching:

Dockerfile:

FROM node:18-alpine

WORKDIR /app

# Dependencies change less frequently
COPY package*.json ./
RUN npm ci

# Source code changes frequently
COPY . .

CMD ["npm", "start"]

This structure ensures dependency installations are cached unless package files change.

Force Rebuild

Force rebuilding without using cache:

docker-compose build --no-cache

Or for specific services:

docker-compose build --no-cache backend

This ensures a completely fresh build, useful when debugging build issues or when dependencies have updated.

Pull-Based Builds

Attempt to pull newer versions of base images before building:

version: '3.8'

services:
  app:
    build:
      context: ./app
      pull: true

Or via command line:

docker-compose build --pull

This ensures your builds use the latest base images, incorporating security updates and bug fixes.

Build with Compose Up

Rebuild images during up command:

# Build if image doesn't exist
docker-compose up

# Always build before starting
docker-compose up --build

# Build without starting
docker-compose up --no-start

The --build flag ensures images are rebuilt before containers start, incorporating recent code changes.

Selective Service Building

Build only changed services:

# Build specific services
docker-compose build frontend backend

# Then start all services
docker-compose up

This optimizes workflows when you know which services have changed.

Build Logging and Output

Control build output verbosity:

# Default output
docker-compose build

# Quiet build (minimal output)
docker-compose build --quiet

# Show build progress
docker-compose build --progress=plain

The --progress=plain option displays detailed layer-by-layer build output, useful for debugging build issues.

Multi-Stage Builds for Different Languages

Python multi-stage build:

FROM python:3.11-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --user --no-cache-dir -r requirements.txt
COPY . .

FROM python:3.11-slim
WORKDIR /app
COPY --from=builder /root/.local /root/.local
COPY --from=builder /app .
ENV PATH=/root/.local/bin:$PATH
CMD ["python", "app.py"]

Go multi-stage build:

FROM golang:1.21 AS builder
WORKDIR /src
COPY go.* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app

FROM scratch
COPY --from=builder /app /app
ENTRYPOINT ["/app"]

Java multi-stage build:

FROM maven:3.9-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
RUN mvn dependency:go-offline
COPY src ./src
RUN mvn package -DskipTests

FROM eclipse-temurin:17-jre-alpine
COPY --from=builder /app/target/*.jar app.jar
CMD ["java", "-jar", "app.jar"]

Complex Multi-Stage Workflows

Implement sophisticated build pipelines with multiple stages:

# Stage 1: Download dependencies
FROM node:18-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN npm ci

# Stage 2: Build application
FROM node:18-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# Stage 3: Run tests
FROM node:18-alpine AS tester
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm test

# Stage 4: Production image
FROM node:18-alpine AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=deps /app/node_modules ./node_modules
COPY package*.json ./
USER node
CMD ["node", "dist/index.js"]
version: '3.8'

services:
  app:
    build:
      context: ./app
      target: production

Build-Time Secret Handling

Use build arguments for build-time secrets (note: these are not secure for sensitive data as they're visible in image history):

version: '3.8'

services:
  app:
    build:
      context: ./app
      args:
        NPM_TOKEN: ${NPM_TOKEN}

Dockerfile:

FROM node:18-alpine
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
COPY package*.json ./
RUN npm install && rm -f .npmrc
COPY . .
CMD ["npm", "start"]

The token is used during build but not stored in the final image.

Conditional Build Steps

Implement conditional logic in Dockerfiles:

FROM node:18-alpine

ARG BUILD_ENV=production
ARG ENABLE_FEATURES=false

WORKDIR /app
COPY package*.json ./

RUN if [ "$BUILD_ENV" = "development" ]; then \
      npm install; \
    else \
      npm ci --only=production; \
    fi

COPY . .

RUN if [ "$ENABLE_FEATURES" = "true" ]; then \
      npm run build:with-features; \
    else \
      npm run build; \
    fi

CMD ["npm", "start"]
version: '3.8'

services:
  app-dev:
    build:
      context: ./app
      args:
        BUILD_ENV: development
        ENABLE_FEATURES: "true"
    
  app-prod:
    build:
      context: ./app
      args:
        BUILD_ENV: production
        ENABLE_FEATURES: "false"

Build Context from Git Repositories

Build directly from Git repositories:

version: '3.8'

services:
  app:
    build:
      context: https://github.com/user/repo.git#branch
      dockerfile: Dockerfile

This pulls the repository at build time and uses it as the build context.

Build Context from Tarball

Use a tarball as build context:

version: '3.8'

services:
  app:
    build:
      context: ./app.tar.gz

Useful when distributing build contexts as archives.

Labels in Built Images

Add metadata to built images:

version: '3.8'

services:
  app:
    build:
      context: ./app
      labels:
        com.example.version: "1.2.3"
        com.example.build-date: "2024-01-15"
        com.example.vcs-ref: "${GIT_COMMIT}"

Labels help track image provenance and version information.

Build Platform Specification

Build for specific platforms:

version: '3.8'

services:
  app:
    build:
      context: ./app
      platforms:
        - linux/amd64
        - linux/arm64

This creates multi-platform images supporting both x86_64 and ARM64 architectures.

Shared Build Stages

Share build stages across multiple services:

Dockerfile:

FROM node:18-alpine AS base
WORKDIR /app
COPY package*.json ./
RUN npm ci

FROM base AS api
COPY api/ ./
CMD ["node", "api/server.js"]

FROM base AS worker
COPY worker/ ./
CMD ["node", "worker/processor.js"]

FROM base AS frontend
COPY frontend/ ./
RUN npm run build
CMD ["npm", "run", "serve"]
version: '3.8'

services:
  api:
    build:
      context: .
      dockerfile: Dockerfile
      target: api
  
  worker:
    build:
      context: .
      dockerfile: Dockerfile
      target: worker
  
  frontend:
    build:
      context: .
      dockerfile: Dockerfile
      target: frontend

This shares the base stage across all services, reducing redundant dependency installations.

Build-Time Variable Substitution

Use environment variables in build configuration:

version: '3.8'

services:
  app:
    build:
      context: ./app
      args:
        VERSION: ${APP_VERSION:-latest}
        BUILD_NUMBER: ${CI_BUILD_NUMBER:-0}

Environment variables from the host or .env files are substituted during build configuration.

Incremental Builds with Layer Caching

Optimize Dockerfiles for maximum layer reuse:

Poor caching:

FROM node:18-alpine
WORKDIR /app
COPY . .
RUN npm install
CMD ["npm", "start"]

Every code change invalidates the npm install layer.

Good caching:

FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
CMD ["npm", "start"]

Code changes don't invalidate the dependency installation layer.

Optimal caching:

FROM node:18-alpine
WORKDIR /app

# Copy lock files first
COPY package-lock.json ./
RUN npm ci --only=production

# Copy package.json (might trigger different installs)
COPY package.json ./

# Copy source code last
COPY src ./src

CMD ["npm", "start"]

Build Hooks and Scripts

Execute scripts during build:

Dockerfile:

FROM node:18-alpine
WORKDIR /app

COPY package*.json ./
RUN npm ci

COPY . .

# Run build hook
RUN if [ -f ./scripts/post-install.sh ]; then \
      chmod +x ./scripts/post-install.sh && \
      ./scripts/post-install.sh; \
    fi

CMD ["npm", "start"]

This pattern allows injecting custom build logic without modifying the Dockerfile.

Building with External Dockerfiles

Reference Dockerfiles outside the build context:

version: '3.8'

services:
  app:
    build:
      context: ./app
      dockerfile: ../dockerfiles/Dockerfile.app

Useful when centralizing Dockerfiles for multiple related services.

Automated Rebuild Triggers

Implement file watching for automatic rebuilds (requires external tools):

version: '3.8'

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

Combined with tools like nodemon, watchman, or entr, this enables automatic application restart when files change. The build itself still requires manual triggering.

Build Verification

Verify built images before use:

# Build image
docker-compose build app

# Inspect built image
docker inspect $(docker-compose config --services | head -n1)

# Run verification container
docker run --rm myapp/app:latest /app/verify.sh

Verification scripts in images can test that builds succeeded correctly.

Build Composition Patterns

Compose services that depend on built images:

version: '3.8'

services:
  base-service:
    build:
      context: ./base
    image: myapp/base:latest
  
  derived-service:
    build:
      context: ./derived
      args:
        BASE_IMAGE: myapp/base:latest
    depends_on:
      - base-service

derived/Dockerfile:

ARG BASE_IMAGE
FROM ${BASE_IMAGE}

COPY additional-files ./
RUN additional-setup.sh

CMD ["derived-app"]

Handling Build Failures

Implement robust error handling:

# Build with error checking
docker-compose build || exit 1

# Continue building other services on failure
docker-compose build --continue-on-error

# Build with timeout
timeout 600 docker-compose build

Build Output Management

Control where build output is stored:

# Export build context
docker-compose build --build-arg BUILDKIT_INLINE_CACHE=1

# Save build logs
docker-compose build 2>&1 | tee build.log

Best Practices for Multi-Stage Builds

Minimize layer count: Each RUN, COPY, and ADD creates a layer. Combine commands where logical:

# Poor: Multiple layers
RUN apt-get update
RUN apt-get install -y curl
RUN apt-get install -y git

# Better: Single layer
RUN apt-get update && \
    apt-get install -y curl git && \
    rm -rf /var/lib/apt/lists/*

Use specific base image tags: Avoid latest tags for reproducible builds:

# Avoid
FROM node:latest

# Prefer
FROM node:18.17.0-alpine3.18

Copy dependencies before source code: Maximize cache hit rates by copying dependency manifests first:

COPY package*.json ./
RUN npm ci
COPY . .

Clean up in the same layer: Remove temporary files in the same RUN command that creates them:

RUN wget -O /tmp/file.tar.gz http://example.com/file.tar.gz && \
    tar xzf /tmp/file.tar.gz && \
    rm /tmp/file.tar.gz

Use multi-stage builds for size reduction: Build tools often aren't needed at runtime:

FROM node:18 AS builder
# ... build steps ...

FROM node:18-alpine
COPY --from=builder /app/dist ./

Order stages by change frequency: Stages that change less frequently should come first.

Name stages descriptively: Use meaningful stage names:

FROM node:18 AS dependency-installer
FROM node:18 AS application-builder  
FROM node:18-alpine AS production-runtime

Leverage build arguments for flexibility: Make builds configurable:

ARG NODE_ENV=production
ARG OPTIMIZATION_LEVEL=2

Document build arguments: Comment what each argument does:

# Set to 'development' for debug builds
ARG NODE_ENV=production

# Optimization level: 0 (none), 1 (basic), 2 (full)
ARG OPTIMIZATION_LEVEL=2

Best Practices for Auto-Rebuild Workflows

Use .dockerignore effectively: Prevent unnecessary rebuilds by excluding files that don't affect the build:

.git/
node_modules/
*.log
.env*
coverage/

Structure Dockerfiles for caching: Put frequently changing files at the end:

# Rarely changes
FROM base:latest

# Changes occasionally  
COPY dependencies.txt ./
RUN install-dependencies.sh

# Changes frequently
COPY source-code ./

Tag images consistently: Use meaningful tags for built images:

services:
  app:
    image: myapp/service:${VERSION:-dev}
    build: ./app

Implement build health checks: Verify builds produce working images:

RUN npm run test && npm run build

Cache external dependencies: Download and cache dependencies separately:

RUN go mod download
COPY . .
RUN go build

Use BuildKit features: Enable BuildKit for better caching and parallelization:

DOCKER_BUILDKIT=1 docker-compose build

Monitor build times: Track build duration to identify optimization opportunities:

time docker-compose build

Parallelize builds when possible: Build independent services simultaneously:

docker-compose build --parallel

Version control Dockerfiles: Keep Dockerfiles in version control alongside application code.

Test builds locally before CI: Ensure builds work locally before pushing to CI systems.

Docker Compose's build capabilities, combined with multi-stage builds and intelligent caching strategies, create powerful development workflows. By structuring Dockerfiles for optimal layer caching, using multi-stage builds to minimize image size, and implementing consistent rebuild patterns, you can build efficient, reproducible container images that accelerate both development and deployment processes.

We use cookies to improve your experience and analyse site traffic. See our Privacy Policy.