A Docker build that takes 10 minutes on every commit slows deployment pipelines, costs CI/CD minutes, and discourages frequent deployments. Most of that time is usually spent reinstalling dependencies that have not changed. Docker's layer cache eliminates repeated work — but only when the Dockerfile is written to take advantage of it. BuildKit provides additional tools: parallel stage execution, bind mounts for dependency installation, and external cache storage for CI environments.
How Docker Build Cache Works
Docker caches each layer (Dockerfile instruction) and reuses the cached layer on subsequent builds when the instruction and its inputs have not changed. Cache invalidation is sequential and cascading: once a layer’s cache is invalidated, all subsequent layers are rebuilt from scratch, even if their instructions did not change.
The critical implication: instruction order in a Dockerfile determines which layers benefit from cache. Dependencies that change rarely (package.json, requirements.txt, go.mod) should be copied and installed before application source code. Application source code changes on every commit; if source code is copied before dependency installation, every build reinstalls all dependencies regardless of whether they changed.
Cache invalidation triggers: a FROM base image has been updated (new digest); a COPY or ADD instruction source files have changed (Docker checksums all copied files); a RUN instruction text has changed; or any parent layer cache has been invalidated (cascading).
BuildKit (enabled by default since Docker 23.0) adds two important optimisation tools: ‘–mount=type=cache’ for mounting a persistent cache directory during RUN instructions (eliminates the need to copy package caches into the image), and ‘–mount=type=bind’ for reading source files during RUN without adding them as a layer.
Build Cache Optimization Patterns
# Enable BuildKit (default since Docker 23.0, or set env var)
export DOCKER_BUILDKIT=1
# WRONG layer ordering: source code before dependencies
# (source change invalidates npm install cache)
FROM node:20-alpine
WORKDIR /app
COPY . . # invalidated on every source change
RUN npm install # reinstalls every time
# CORRECT layer ordering: dependencies before source code
# (npm install cache survives source-only changes)
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./ # copied first
RUN npm ci # only reinstalls when package*.json changes
COPY . . # source copied after
RUN npm run build
# BuildKit RUN --mount=type=cache (persistent cache directory)
# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --cache /root/.npm
# Python with pip cache
FROM python:3.11-alpine
COPY requirements.txt .
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# Build with explicit cache from a registry
docker buildx build \
--cache-from=type=registry,ref=myregistry/myapp:cache \
--cache-to=type=registry,ref=myregistry/myapp:cache,mode=max \
--tag myapp:latest .
# Build and immediately tag for deployment
docker build \
--build-arg APP_VERSION=1.2.3 \
--tag myapp:1.2.3 \
--tag myapp:latest \
.The ‘–mount=type=cache’ directive in BuildKit preserves a directory between builds without including its content in image layers. npm’s download cache stored in /root/.npm persists across builds, so only packages not in the cache need to be downloaded. This can reduce a 3-minute dependency installation to under 10 seconds for builds where only a few packages changed.
Build Optimisation Techniques
| Flag / Parameter | Description | Security Note |
|---|---|---|
Layer cache ordering | The highest-impact optimisation with no tooling requirements. Copy dependency manifests (package.json, requirements.txt, go.mod, Gemfile) and install dependencies before copying application source code. Application source changes on every commit; dependency manifests change far less often. With correct ordering, the dependency installation layer is cache-hit on every source-only change. | A cache hit on the dependency installation layer means you are using the same package versions as the previous build. This is usually correct but means new security patches in dependencies are not pulled automatically. Regularly force a cache bust on the dependency layer or rebuild with '–no-cache' on a schedule to pick up security updates. |
BuildKit --mount=type=cache | Mounts a persistent cache directory during a RUN instruction. The cache persists between builds on the same host but is not included in the image layer. Ideal for package manager caches (npm, pip, apk, apt), build tool caches (Maven .m2, Gradle .gradle), and compiler caches (ccache). The 'mode=max' option for registry caches includes all intermediate layers, not just the final image layers. | BuildKit cache mounts are shared across all builds on the host by default. A malicious Dockerfile could read from another build's cache mount. Use 'id=unique-id' to namespace caches: '–mount=type=cache,id=myapp-npm,target=/root/.npm'. In CI environments where untrusted builds run, use per-job cache isolation. |
.dockerignore for build context | The build context is sent to the Docker daemon before any Dockerfile instructions run. Without a .dockerignore file, node_modules/ (potentially hundreds of MB), .git/ (can be large for long-lived repos), test fixtures, and documentation are sent on every build. A well-configured .dockerignore reduces context transfer time and prevents irrelevant file changes from invalidating cache. | Without .dockerignore, 'COPY . .' in a Dockerfile copies everything including .env files, certificates, and local credentials. These are baked into image layers and visible in 'docker history'. Always add .env, .env.*, *.pem, *.key, and credentials files to .dockerignore. |
docker buildx with registry cache | BuildKit extended mode (buildx) supports remote build cache stored in a container registry. Registry cache is the solution for CI pipelines where each build runs on a fresh runner with no local cache. '–cache-from=type=registry' pulls the previous build's cache; '–cache-to=type=registry,mode=max' stores the new cache. The 'mode=max' setting stores intermediate layer caches, not just the final layer. | Registry-based build caches should be stored in a private registry. A public registry cache exposes information about your application's dependencies and build process. The cache image does not contain the application binary, but its layer metadata reveals package versions and build tool versions used in the build. |
CI/CD Build Optimisation
GitHub Actions Build Cache
GitHub Actions provides a native cache mechanism for Docker BuildKit layers. This avoids reinstalling dependencies on every pull request build without requiring a separate registry.
# .github/workflows/build.yml
name: Build Docker Image
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: false
tags: myregistry/myapp:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
# On first run: full build, cache written
# On subsequent runs: dependency layers restored from cache
# Only layers invalidated by actual changes are rebuiltMeasuring and Profiling Build Time
Before optimising, measure where build time is actually spent. Docker BuildKit provides timing information in its output; enabling it reveals which instructions are slow.
# Enable BuildKit progress display (shows timing per instruction)
DOCKER_BUILDKIT=1 docker build --progress=plain -t myapp:latest . 2>&1 | \
grep -E 'DONE|CACHED|ERROR'
# CACHED lines show cache hits (no rebuild cost)
# DONE lines show rebuild time in seconds
# Force rebuild without cache (to measure cold build time)
DOCKER_BUILDKIT=1 docker build --no-cache -t myapp:latest .
# View build cache storage
docker buildx du
docker system df # shows build cache size
# Prune build cache (keeps disk usage in check)
docker builder prune --keep-storage 5GBBuild Security: ARGs, Secrets, and Cache Poisoning
Docker build processes have distinct security concerns from running containers. The build process fetches packages from external sources, runs arbitrary code in RUN instructions, and may have access to credentials needed during the build. Handling build-time secrets incorrectly exposes them in image layers, which persist in registries and on all hosts that pull the image.
BuildKit’s ‘–mount=type=secret’ provides a secure mechanism for build-time secrets: the secret is available during the RUN instruction but not stored in any image layer. This is the correct pattern for private registry credentials, SSH keys for private repo clones, and API keys needed only during package installation. Additional considerations apply when running Docker in production: monitoring container health metrics, setting up centralised log aggregation, implementing image signing and verification workflows, and auditing container runtime configuration against CIS Docker benchmarks. Each of these layers adds defence in depth beyond the controls described above. Review the full security posture regularly as Docker releases update default security settings and new CVEs are discovered in container runtime components.
- Never use ARG to pass credentials into a build. ARG values appear in 'docker history' output and are visible to anyone who can pull the image. Use 'RUN –mount=type=secret' for build-time credentials.
- Build cache poisoning is a supply chain attack where a malicious cached layer is substituted for a legitimate one. In CI environments that pull cache from registries, verify that the cache registry is private and access is controlled. Do not use public registry cache from untrusted sources.
- Packages fetched during 'docker build' are not verified for integrity unless the package manager performs checksum verification and the lockfile is committed. Use lockfiles (package-lock.json, requirements.txt with pinned versions, go.sum) in all builds to ensure reproducible package installation.
- Adding '–no-cache' to every build forces all packages to be re-fetched but may download a newer (potentially vulnerable) version. Pin package versions in dependency files and update them deliberately through your dependency management process.
Practical Examples
Optimised Multi-Stage Dockerfile with BuildKit Cache
# syntax=docker/dockerfile:1
# Requires BuildKit (DOCKER_BUILDKIT=1 or Docker 23.0+)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
npm ci --cache /root/.npm
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY src/ ./src/
COPY tsconfig.json ./
RUN npm run build && npm prune --production
FROM node:20-alpine AS production
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
USER app
EXPOSE 3000
CMD ["node", "dist/server.js"]This Dockerfile achieves maximum cache reuse: the npm install step is only rerun when package.json or package-lock.json changes. The BuildKit cache mount means npm does not re-download packages even when the lockfile changes — it serves them from the persistent cache. Source code changes (the most frequent change) only rebuild the TypeScript compilation step and the production stage, not the dependency installation.
Quick Reference: Key Commands and Options
# Inspect current configuration
docker inspect <container> --format='{{json .HostConfig}}' | python3 -m json.tool
# View container resource usage
docker stats <container> --no-stream
# Check container logs
docker logs <container> --tail 50 --timestamps
# Verify running processes inside container
docker exec <container> ps aux
# Check container environment variables
docker exec <container> env | sortThese diagnostic commands work across all container scenarios: ‘docker inspect’ shows the full HostConfig including all runtime parameters, ‘docker stats’ shows real-time resource consumption, and ‘docker logs’ shows output from the main process. Use these as the starting point for any container investigation before diving into more specific tooling.
Common Build Optimisation Issues
Problem: Dependency installation runs on every build even though package files did not change
Solution: Verify the layer ordering in the Dockerfile: ‘COPY . .’ must not appear before ‘COPY package.json package-lock.json ./’ and the dependency install. Any file that changes frequently must come after the dependency installation. Also verify .dockerignore excludes files that should not trigger cache invalidation (node_modules/, .git/, test output).
Problem: BuildKit –mount=type=cache not working as expected in CI
Solution: BuildKit cache mounts store data in the local build daemon cache, which is ephemeral in most CI environments (each job runs in a fresh VM). For CI cache persistence, use registry cache: ‘–cache-from=type=registry,ref=image:cache –cache-to=type=registry,ref=image:cache,mode=max’. This stores the cache in the container registry and restores it at the start of each build.
Problem: docker build is slow in CI even with BuildKit and cache configured
Solution: Check the build context size: the first line of ‘docker build’ output shows ‘Sending build context to Docker daemon X.XXkB’. If this is large (over a few MB), .dockerignore is not set up correctly. Also verify cache hits in build output — look for ‘CACHED’ lines. If all layers show rebuild times, the cache is not being hit, likely because the cache-from source is not accessible or the cache key has changed.
Layer Ordering Is the Foundation; BuildKit Adds the Speed
The majority of Docker build time in typical applications is spent on dependency installation. Correct layer ordering (dependency manifests first, source code last) ensures the dependency installation layer is a cache hit on every source-only change. BuildKit’s cache mounts eliminate redundant package downloads even when dependencies change. Registry caching brings these benefits to CI environments. Together, these techniques can reduce typical build times from minutes to seconds for the common case.
- Copy dependency manifest files (package.json, requirements.txt, go.mod) before application source in the Dockerfile. This single change makes the dependency installation layer cache-stable across source code changes.
- Use BuildKit ‘–mount=type=cache’ for package manager caches. This avoids both re-downloading packages between builds and baking package caches into image layers.
- For CI/CD environments, use registry-backed build cache (‘–cache-from type=registry’ and ‘–cache-to type=registry’). Local cache mounts do not persist between ephemeral CI runners.
Related Docker Topics
reducing Docker image size with multi-stage builds and Alpine • understanding how Docker images are built and layered • how Docker build system and layer storage work internally • production Docker configuration including build practices
Docker Builds Taking Too Long in CI?
INTRAM optimises Docker build pipelines — layer ordering, BuildKit configuration, and registry cache setup to dramatically reduce build times.
Get Build Optimization Help