Skip to main content
Cloud DevOps Solutions
Get a quote Talk to an engineer

Docker & Containerization

.dockerignore and Layer Caching: Faster Docker Builds in Dev and CI

Docker rebuilds a layer only when its inputs change. We write a tight .dockerignore, order Dockerfile instructions from least to most frequently changing, use BuildKit cache mounts for package managers, and configure a shared cache backend so CI builds reuse work between runs.

Key takeaways

  • A missing .dockerignore sends the whole repository to the builder and invalidates caches unnecessarily.
  • Dependency manifests should be copied and installed before source code.
  • BuildKit cache mounts speed up package installs without bloating image layers.
  • CI runners start empty, so a remote cache is what makes pipeline builds fast.
  • Caching has failure modes: stale results, huge caches and secrets leaking into layers.

The hidden cost of slow image builds

A ten-minute image build looks like a minor irritation. Multiply it by every commit, every engineer and every pipeline retry and it becomes a tax on your whole delivery process. Developers context-switch while they wait. Hotfixes take longer than they should. CI minutes grow. And the build often rebuilds things that have not changed at all, because a cache is being invalidated for no good reason.

Caching is a mechanical topic, and it rewards care. It is one of the highest-return pieces of our Docker containerisation service, and it feeds straight into your CI/CD pipeline.

How Docker caching actually works

Each instruction in a Dockerfile creates a layer. For each instruction, the builder computes a cache key from the instruction itself and, for COPY and ADD, the content of the files involved. If the key matches a cached layer, it is reused. If any layer changes, every layer after it is rebuilt.

Two consequences follow. First, order matters: put things that rarely change at the top. Second, the files you send to the builder matter: anything in the build context can alter a COPY key.

Step one: the .dockerignore

The build context is everything sent to the builder. Without a .dockerignore, that includes .git, node_modules from the host, build output and any local environment file. A typical file looks like this:

.git
.gitignore
node_modules
dist
build
coverage
*.log
.env
.env.*
.vscode
.idea
Dockerfile
docker-compose*.yml
README.md

This reduces upload time, stops host-side changes from busting caches, and prevents secrets in a local .env from being copied into an image. Be careful with patterns: a rule that excludes too much causes confusing COPY failures, so we test the build after changing it. For monorepos we ignore everything and explicitly allow only what each service needs.

Step two: instruction order

The classic fix is copying dependency manifests first:

# syntax=docker/dockerfile:1
FROM python:3.12-slim AS build
WORKDIR /app

# 1. Rarely changes: dependency manifests
COPY requirements.txt ./
RUN --mount=type=cache,target=/root/.cache/pip \
    pip install -r requirements.txt

# 2. Changes constantly: application source
COPY src ./src

With this order, changing application code reuses the dependency layer. Changing requirements.txt rebuilds only from that point. The same idea applies to package.json with npm ci, go.mod with go mod download, or pom.xml with a dependency resolve step. The wider structure of stages is covered in Dockerfile multi-stage builds.

Step three: cache mounts

A cache mount keeps a directory such as the pip or npm cache on the builder between runs, without writing it into the image. Even when a lockfile changes and the install layer must rerun, packages are not downloaded again. This requires BuildKit, which is the default builder in current Docker releases.

Step four: caching in CI

Here is the part many teams miss. A CI runner is typically a fresh machine, so a local cache does nothing. You need a cache that survives between jobs.

- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
  with:
    context: .
    push: false
    tags: myapp:test
    cache-from: type=gha
    cache-to: type=gha,mode=max

For GitLab CI or self-hosted runners, a registry cache (type=registry,ref=...) stored in your private repository works well, as described in private registry setup. Persistent runners with a local BuildKit daemon are another option and are fastest when you control the infrastructure.

Reading the build output

You cannot tune what you cannot see. BuildKit’s plain progress output shows each step with a CACHED marker or its duration:

docker build --progress=plain -t myapp:test . 2>&1 | tee build.log

Change one source file, rebuild, and read the log from the top. The first step that is not CACHED is where invalidation starts. If it is the dependency install, something earlier than it changed: usually a COPY that includes more than the manifest, or a file in the context that changes on each run. This simple loop finds the cause of most slow builds in minutes.

Language specifics worth knowing

The pattern is the same everywhere, but the details differ:

  • Node.js: copy package.json and the lockfile, run npm ci, then copy source. Mount the npm cache.
  • Go: copy go.mod and go.sum, run go mod download, then copy source. Mount the module and build caches.
  • Java with Maven or Gradle: resolve dependencies in a step that depends only on the build files, and mount the local repository cache.
  • Monorepos: copy only the manifests for the one service being built, so that a change in another service does not invalidate this one.

Caching and security

A cache is a place where old content lives. A secret that was ever copied into a cached layer can persist in the cache backend long after the Dockerfile changed. That is another reason to use secret mounts rather than ARG or COPY for credentials, and to restrict who can read a registry cache. We also keep the cache repository separate from release repositories, so that a cache image is never mistaken for something deployable.

Decisions and trade-offs

GitHub Actions cache vs registry cache. The Actions cache is easy but has size limits and eviction. A registry cache has no such limit, works across CI systems and developer machines, and costs storage. We choose based on image count and team size.

mode=max or mode=min. Max caches intermediate stages, so multi-stage builds benefit more, at the cost of a larger cache. For stages that do heavy compilation, it is worth it.

Cache vs reproducibility. Aggressive caching can hide breakage that a clean build would reveal. We run a scheduled uncached build to catch it.

When not to bother. If your builds take thirty seconds and run a few times a day, leave them alone.

Common failure modes

  • Cache never hits because the key includes a changing value like a build timestamp or git SHA written into an early layer.
  • COPY . . before dependencies, which invalidates the install layer constantly.
  • Secrets in layers. Never pass credentials through ARG or copy them in. Use RUN --mount=type=secret.
  • Stale apt-get update layers cached for months. Combine update and install in one RUN, and rebuild periodically.
  • Cache growth that slows import. Expire it.

Delivery and handover

You receive a reviewed .dockerignore, restructured Dockerfiles, CI cache configuration and a one-page explanation of how to read build logs for cache hits. We pair this with image size reduction because the same changes help both.

How to tell it is working

Measure wall-clock time for three cases: a no-change rebuild, a one-line code change and a dependency update. A healthy setup shows seconds for the first, a short build for the second, and a moderate rebuild for the third. CI logs show CACHED against the early layers. To have your builds profiled, contact us.

References

How we deliver it

  1. 01

    Profile the build

    We time each step with BuildKit progress output and record what is rebuilt after typical code changes.

  2. 02

    Write the .dockerignore

    Version control data, local dependencies, build output, secrets and editor files are excluded from the build context.

  3. 03

    Reorder the Dockerfile

    Stable inputs such as manifests and lockfiles come first, source code last.

  4. 04

    Add cache mounts

    Package manager and compiler caches persist across builds without ending up in the image.

  5. 05

    Configure CI caching

    Buildx exports and imports a cache using the GitHub Actions cache or a registry.

  6. 06

    Verify and monitor

    We confirm cache hits in CI logs and track build times so regressions are visible.

Tools and technologies

Docker BuildKitDocker Buildx.dockerignoredocker/build-push-actionGitHub Actions cacheAmazon ECRGitLab CIdivehadolintRenovate

Frequently asked questions

What should go in a .dockerignore file?
Anything the image does not need: .git, node_modules or virtual environments from the host, build output, logs, test artefacts, local environment files, editor settings and the Dockerfile itself. Excluding them reduces build context size, avoids needless cache invalidation and prevents accidentally copying secrets into an image.
Why is my Docker build slow even when little has changed?
Usually an early layer is being invalidated. Common causes are copying the full source before installing dependencies, files in the build context that change on every run (such as logs or timestamps), or using a fresh CI runner with no cache. Fixing instruction order and ignore rules normally resolves it.
How do we cache Docker builds in GitHub Actions?
Use docker/setup-buildx-action with docker/build-push-action and set cache-from and cache-to to type=gha, or use a registry cache. The cache is stored between workflow runs, so unchanged layers are reused. Mode max also caches intermediate stages in multi-stage builds.
What is a BuildKit cache mount?
It is a directory, declared with RUN --mount=type=cache, that persists between builds on the same builder but is not part of the image. Package managers such as npm, pip and Go modules use it to avoid re-downloading dependencies when a lockfile changes slightly.
Can caching cause wrong or stale builds?
Yes, if inputs are not captured by the cache key. A RUN step that downloads the latest of something looks unchanged to Docker, so it never refreshes. We pin versions, use explicit cache busting arguments for deliberate refreshes, and run scheduled uncached builds to catch drift.

Talk to an engineer about docker layer caching

Book a free 30-minute call. We review your current setup and tell you honestly whether and how we can help.

Written by Saddique Khan, DevOps / SRE / Cloud Consultant Engineer. Last updated 2026-10-08.