Containerization

Docker BuildKit Features and Optimization

Master Docker BuildKit's advanced features including cache mounts, secret handling, SSH forwarding, multi-platform builds, and build performance optimization for Node.js projects.

Docker BuildKit Features and Optimization

BuildKit is Docker's next-generation build engine, and it has been the default since Docker Desktop 4.0. If you are still writing Dockerfiles the old way — without cache mounts, without secret handling, without parallel stage execution — you are leaving significant performance and security gains on the table. This guide covers every BuildKit feature that matters for Node.js development, from cutting npm install times in half to building multi-platform images in a single command.

Prerequisites

  • Docker Desktop v4.0+ (BuildKit enabled by default) or Docker Engine with DOCKER_BUILDKIT=1
  • Docker Buildx plugin
  • Familiarity with Dockerfiles and multi-stage builds
  • Node.js project with package.json

Enabling BuildKit

BuildKit is the default in Docker Desktop. For Docker Engine on Linux, enable it explicitly:

# Per-command
DOCKER_BUILDKIT=1 docker build .

# Permanent (daemon.json)
{
  "features": {
    "buildkit": true
  }
}

Verify BuildKit is active:

docker build --help | grep buildkit
# Or look for BuildKit-style output during builds:
# => [internal] load build definition from Dockerfile
# => => transferring dockerfile: 542B

Legacy builds show Step 1/10 :. BuildKit shows => [stage-name step/total].

Cache Mounts

Cache mounts are the single biggest BuildKit performance win. They persist a directory across builds, surviving even when the layer cache is invalidated.

npm/yarn Cache Mount

# syntax=docker/dockerfile:1

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./

# Cache npm's download cache across builds
RUN --mount=type=cache,target=/root/.npm \
    npm ci --prefer-offline

COPY . .
CMD ["node", "app.js"]

Without cache mount: npm ci downloads every package from the registry on every build. With cache mount: packages already in the npm cache are reused, cutting install time by 50-80%.

# First build (cold cache)
# => RUN npm ci  42.3s

# Second build (warm cache, even with package.json changes)
# => RUN npm ci  8.1s

yarn and pnpm Cache Mounts

# Yarn
RUN --mount=type=cache,target=/root/.yarn \
    yarn install --frozen-lockfile

# pnpm
RUN --mount=type=cache,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile

Multiple Cache Mounts

RUN --mount=type=cache,target=/root/.npm \
    --mount=type=cache,target=/app/.next/cache \
    npm ci && npm run build

This caches both npm downloads and Next.js build cache, dramatically speeding up both dependency installation and application builds.

Cache Mount IDs

When you have multiple stages that install packages, use IDs to share caches:

FROM node:20-alpine AS api-deps
RUN --mount=type=cache,id=npm,target=/root/.npm \
    npm ci

FROM node:20-alpine AS worker-deps
RUN --mount=type=cache,id=npm,target=/root/.npm \
    npm ci

Both stages share the same npm cache because they use id=npm.

Secret Handling

BuildKit secrets let you use sensitive data during builds without baking it into image layers.

# syntax=docker/dockerfile:1

FROM node:20-alpine
WORKDIR /app

# Use secret for private registry auth
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

COPY . .
CMD ["node", "app.js"]

Pass the secret during build:

docker build --secret id=npmrc,src=$HOME/.npmrc -t myapp .

The .npmrc file is available during npm ci but is NOT stored in any image layer. After the build completes, inspecting the image reveals no trace of the secret.

# Verify secret is not in the image
docker run --rm myapp cat /root/.npmrc
# cat: can't open '/root/.npmrc': No such file or directory

# Even checking layer history shows nothing
docker history myapp
# No .npmrc reference in any layer

Environment Variable Secrets

RUN --mount=type=secret,id=api_key \
    API_KEY=$(cat /run/secrets/api_key) npm run build
docker build --secret id=api_key,src=./api-key.txt -t myapp .

Old (Insecure) Way vs BuildKit Secrets

# NEVER do this - secret is permanently stored in image layers
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc
RUN npm ci
RUN rm .npmrc  # Too late - it's in a previous layer

# Correct way with BuildKit
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc npm ci

SSH Forwarding

Access private Git repositories during builds without copying SSH keys into the image.

# syntax=docker/dockerfile:1

FROM node:20-alpine
WORKDIR /app

RUN apk add --no-cache git openssh-client

# Clone private repo using host's SSH agent
RUN --mount=type=ssh \
    git clone [email protected]:myorg/private-lib.git /app/lib

COPY . .
RUN npm ci
CMD ["node", "app.js"]
# Build with SSH agent forwarding
docker build --ssh default -t myapp .

# Or specify a specific SSH key
docker build --ssh default=$HOME/.ssh/id_rsa -t myapp .

For private npm packages hosted in Git:

{
  "dependencies": {
    "my-private-lib": "git+ssh://[email protected]:myorg/my-private-lib.git#v1.0.0"
  }
}
RUN --mount=type=ssh \
    --mount=type=cache,target=/root/.npm \
    npm ci

Parallel Stage Execution

BuildKit automatically parallelizes independent build stages. This is a free performance win.

# These stages run in PARALLEL
FROM node:20-alpine AS api-deps
WORKDIR /app/api
COPY api/package*.json ./
RUN npm ci --only=production

FROM node:20-alpine AS worker-deps
WORKDIR /app/worker
COPY worker/package*.json ./
RUN npm ci --only=production

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

# This stage depends on all three above
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=api-deps /app/api/node_modules ./api/node_modules
COPY --from=worker-deps /app/worker/node_modules ./worker/node_modules
COPY --from=client-build /app/client/dist ./client/dist
COPY . .
CMD ["node", "api/app.js"]

The three dependency stages run simultaneously, reducing total build time from the sum of all three to the duration of the slowest one.

# Sequential (old builder): 45s + 30s + 60s = 135s
# Parallel (BuildKit):      max(45s, 30s, 60s) = 60s

Multi-Platform Builds

Build images for multiple architectures from a single command. Essential for ARM-based servers (AWS Graviton, Apple Silicon).

# Create a multi-platform builder
docker buildx create --name multiplatform --driver docker-container --use

# Build for multiple architectures
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  -t myregistry.com/myapp:latest \
  --push \
  .

The resulting manifest list contains images for both architectures. When you docker pull, Docker automatically selects the correct image for your platform.

Platform-Specific Optimization

FROM --platform=$BUILDPLATFORM node:20-alpine AS build
ARG TARGETPLATFORM
ARG BUILDPLATFORM

RUN echo "Building on $BUILDPLATFORM for $TARGETPLATFORM"

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

FROM node:20-alpine
WORKDIR /app
COPY --from=build /app .
CMD ["node", "app.js"]

$BUILDPLATFORM is the host architecture. $TARGETPLATFORM is what you are building for. Using FROM --platform=$BUILDPLATFORM for the build stage runs the build natively (fast), then the final stage uses the target platform.

Cross-Platform Native Modules

Native modules like sharp need special handling for cross-platform builds:

FROM --platform=$BUILDPLATFORM node:20-bookworm AS build
ARG TARGETPLATFORM

WORKDIR /app
COPY package*.json ./

# Install for target platform
RUN --mount=type=cache,target=/root/.npm \
    npm ci --only=production --platform=${TARGETPLATFORM##linux/}

COPY . .

FROM node:20-bookworm-slim
WORKDIR /app
COPY --from=build /app .
CMD ["node", "app.js"]

Build Arguments and Metadata

OCI Image Labels

# syntax=docker/dockerfile:1

ARG BUILD_DATE
ARG GIT_SHA
ARG VERSION

FROM node:20-alpine
LABEL org.opencontainers.image.created="${BUILD_DATE}" \
      org.opencontainers.image.revision="${GIT_SHA}" \
      org.opencontainers.image.version="${VERSION}" \
      org.opencontainers.image.source="https://github.com/myorg/myapp"

WORKDIR /app
COPY . .
RUN npm ci --only=production
CMD ["node", "app.js"]
docker build \
  --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
  --build-arg GIT_SHA=$(git rev-parse HEAD) \
  --build-arg VERSION=1.2.3 \
  -t myapp:1.2.3 .

Build-Time Feature Flags

ARG ENABLE_PROFILING=false

FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

RUN if [ "$ENABLE_PROFILING" = "true" ]; then \
      npm install --save clinic; \
    fi

CMD ["node", "app.js"]
# Standard build
docker build -t myapp .

# Profiling build
docker build --build-arg ENABLE_PROFILING=true -t myapp:profile .

Heredoc Support

BuildKit supports heredocs for inline files, eliminating the need for external scripts:

# syntax=docker/dockerfile:1

FROM node:20-alpine
WORKDIR /app

# Inline configuration file
COPY <<EOF /app/config.json
{
  "port": 3000,
  "logLevel": "info",
  "cache": {
    "ttl": 300,
    "maxSize": 1000
  }
}
EOF

# Inline shell script
RUN <<EOF
  npm ci --only=production
  npm cache clean --force
  rm -rf /tmp/*
EOF

COPY . .
CMD ["node", "app.js"]

Heredocs keep everything in one file and eliminate the need for separate build scripts.

Build Output Customization

Exporting Build Artifacts

# Export build output to local directory
docker build --output type=local,dest=./dist --target=build .

# Export as tar archive
docker build --output type=tar,dest=myapp.tar .

# Build without creating an image (just run tests)
docker build --target test --output type=local,dest=/dev/null .

Progress Output Formats

# Detailed output (default)
docker build --progress=plain .

# TTY-friendly (default with TTY)
docker build --progress=auto .

# No output except errors
docker build --progress=quiet .

Use --progress=plain in CI for readable logs. Use --progress=auto locally for the compact, updating display.

Performance Optimization Checklist

Layer Ordering

Place rarely-changing instructions first:

FROM node:20-alpine
WORKDIR /app

# 1. System dependencies (rarely change)
RUN apk add --no-cache dumb-init

# 2. Package files (change when dependencies change)
COPY package*.json ./

# 3. Install dependencies (cached when package files unchanged)
RUN --mount=type=cache,target=/root/.npm npm ci --only=production

# 4. Application code (changes frequently)
COPY . .

CMD ["dumb-init", "node", "app.js"]

.dockerignore

node_modules
.git
.env*
*.md
test
coverage
.nyc_output
.vscode
.idea
docker-compose*.yml
Dockerfile*

Smaller build context = faster builds. Check your context size:

# Create a test build to see context size
docker build --no-cache --progress=plain . 2>&1 | head -5
# => transferring context: 2.3MB
# Target: under 10MB for fast transfers

npm ci vs npm install

Always use npm ci in Dockerfiles:

# npm ci: deterministic, faster, removes existing node_modules
RUN npm ci --only=production

# npm install: non-deterministic, slower, may modify package-lock.json
# RUN npm install  # DON'T use this in Dockerfiles

Complete Working Example

# syntax=docker/dockerfile:1

# Build stage
FROM node:20-alpine AS build
WORKDIR /app

COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci

COPY . .
RUN npm run build

# Test stage
FROM build AS test
RUN npm run lint
RUN npm test

# Production deps
FROM node:20-alpine AS production-deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci --only=production

# Production image
FROM node:20-alpine AS production

ARG BUILD_DATE
ARG GIT_SHA

LABEL org.opencontainers.image.created="${BUILD_DATE}" \
      org.opencontainers.image.revision="${GIT_SHA}"

RUN apk add --no-cache dumb-init

WORKDIR /app
COPY --from=production-deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY --from=build /app/app.js ./
COPY --from=build /app/views ./views

USER node
EXPOSE 3000

CMD ["dumb-init", "node", "app.js"]
# Build with all features
DOCKER_BUILDKIT=1 docker build \
  --secret id=npmrc,src=$HOME/.npmrc \
  --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
  --build-arg GIT_SHA=$(git rev-parse HEAD) \
  --target production \
  -t myapp:latest .

# Multi-platform build and push
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --secret id=npmrc,src=$HOME/.npmrc \
  --build-arg BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) \
  --build-arg GIT_SHA=$(git rev-parse HEAD) \
  --target production \
  -t myregistry.com/myapp:latest \
  --push .

Common Issues and Troubleshooting

1. Cache Mount Not Working

RUN --mount=type=cache,target=/root/.npm npm ci
# Error: dockerfile parse error... unknown flag: mount

BuildKit is not enabled. Add the syntax directive at the top of your Dockerfile:

# syntax=docker/dockerfile:1

And ensure BuildKit is active:

DOCKER_BUILDKIT=1 docker build .

2. Secret Not Found During Build

RUN --mount=type=secret,id=npmrc...
# Error: could not find "npmrc" in build secrets

You forgot to pass the secret on the command line:

docker build --secret id=npmrc,src=$HOME/.npmrc .

3. Multi-Platform Build Fails for ARM

error: failed to solve: process "/bin/sh -c npm ci" did not complete successfully: exit code: 1

Native modules fail to compile for ARM in emulation. Use --platform=$BUILDPLATFORM for the build stage and cross-compile, or pre-build native modules for each platform.

4. Cache Not Shared Between CI Runs

# Every CI run starts with cold cache
RUN npm ci  # 45s every time

CI runners are ephemeral. Use registry-based caching:

docker build \
  --cache-from type=registry,ref=myregistry.com/myapp:buildcache \
  --cache-to type=registry,ref=myregistry.com/myapp:buildcache,mode=max \
  .

Best Practices

  • Always add # syntax=docker/dockerfile:1 at the top. This enables the latest Dockerfile features regardless of your Docker version.
  • Use cache mounts for package managers. npm, yarn, pnpm all benefit from cached downloads. This is the single easiest optimization.
  • Never put secrets in build args or COPY commands. Use --mount=type=secret for private registry tokens, API keys, and certificates.
  • Order layers from least to most frequently changing. System deps, then package files, then npm install, then source code.
  • Use --progress=plain in CI. It produces readable, sequential logs instead of TTY escape codes.
  • Parallelize independent stages. BuildKit runs them automatically — structure your Dockerfile to maximize independent work.
  • Use multi-platform builds for ARM deployments. AWS Graviton and Apple Silicon are increasingly common. Build once, deploy anywhere.
  • Audit build context with .dockerignore. Target under 10MB for fast context transfers.

References

Powered by Contentful