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:1at 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=secretfor 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=plainin 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.