DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

Container Image Layer Caching in GitHub Actions

---
title: "Container Image Caching in GitHub Actions: 12 Min to 90 Sec"
published: true
description: "BuildKit cache mounts, registry-backed layers, and multi-stage builds cut Docker build times from 12 minutes to 90 seconds in GitHub Actions."
tags: devops, docker, cloud, cicd
canonical_url: https://blog.mvpfactory.co/container-image-caching-github-actions
---

## What We Will Build

By the end of this tutorial, you will have a three-layer caching strategy that takes your Docker builds in GitHub Actions from 12 minutes down to 90 seconds. We will wire up BuildKit cache mounts for your package manager, registry-backed layer caching with `--cache-to`/`--cache-from`, and multi-stage builds that separate dependency layers from source code.

Let me show you a pattern I use in every project that runs Docker in CI.

## Prerequisites

- A GitHub Actions workflow that builds Docker images
- A container registry (GHCR, Docker Hub, or ECR)
- Basic familiarity with Dockerfiles and multi-stage builds

## The Problem: Ephemeral Runners Kill Your Cache

GitHub Actions runners are ephemeral. Every job starts with a cold Docker daemon — no layers, no build cache, nothing. That `RUN npm install` layer you cached locally? Rebuilt from scratch. Every single time.

Here is what ours looked like before and after applying all three techniques:

| Build phase | Without caching | With full strategy |
|---|---|---|
| Base image pull | ~45s | ~5s (cached) |
| Dependency install | ~6min | ~15s (cache mount hit) |
| Compilation/build | ~4min | ~50s (layer cache hit) |
| Final image assembly | ~1min | ~20s |
| **Total** | **~12min** | **~90s** |

## Step 1: Add BuildKit Cache Mounts

BuildKit's `--mount=type=cache` is wildly underused in CI. Unlike layer caching, cache mounts persist package manager directories across builds without baking them into image layers.

Enter fullscreen mode Exit fullscreen mode


dockerfile

syntax=docker/dockerfile:1

FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --prefer-offline
COPY . .
RUN npm run build


For JVM projects, target `~/.gradle/caches` or `~/.m2/repository` instead. Here is the gotcha that will save you hours: these mounts survive layer invalidation. Even when `package.json` changes, the npm cache directory still has most of your packages warm.

## Step 2: Wire Up Registry-Backed Layer Caching

The `docker/build-push-action` supports multiple cache backends. The registry backend stores cache layers alongside your images, so no local storage is needed.

Enter fullscreen mode Exit fullscreen mode


yaml

  • uses: docker/build-push-action@v5 with: context: . push: true tags: ghcr.io/org/app:latest cache-from: type=registry,ref=ghcr.io/org/app:buildcache cache-to: type=registry,ref=ghcr.io/org/app:buildcache,mode=max

`mode=max` matters. It exports cache for all stages, not just the final one. Without it, intermediate build stages lose their cache on the next run.

The docs do not mention this, but GitHub's built-in cache has a hard 10GB limit per repository. In a monorepo with multiple services, you will hit eviction within days. The registry backend does not have that problem.

| Cache backend | Size limit | Cross-branch | Monorepo-friendly |
|---|---|---|---|
| GitHub Actions cache | 10GB total | Limited | No, shared eviction |
| Registry (`type=registry`) | Registry limit | Yes | Yes, per-image refs |
| Local (`type=local`) | Runner disk | No | N/A, ephemeral |

## Step 3: Split Dependencies With Multi-Stage Builds

Structure your Dockerfile so dependency installation and source code compilation live in separate stages with distinct cache keys.

Enter fullscreen mode Exit fullscreen mode


dockerfile

Stage 1: Dependencies (changes rarely)

FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci

Stage 2: Build (changes on every commit)

FROM deps AS builder
COPY . .
RUN npm run build

Stage 3: Runtime (minimal image)

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


When only source code changes, Stage 1 is a full cache hit. For monorepos, extract shared dependencies into a common base image:

Enter fullscreen mode Exit fullscreen mode


yaml

  • name: Build base
    uses: docker/build-push-action@v5
    with:
    file: docker/base.Dockerfile
    cache-from: type=registry,ref=ghcr.io/org/base:buildcache
    cache-to: type=registry,ref=ghcr.io/org/base:buildcache,mode=max

  • name: Build service-a
    uses: docker/build-push-action@v5
    with:
    build-args: BASE_IMAGE=ghcr.io/org/base:latest
    cache-from: type=registry,ref=ghcr.io/org/service-a:buildcache


One warm cache serves the entire monorepo.

## Gotchas

- **Forgetting `mode=max`** — without it, only the final stage gets cached. Intermediate stages rebuild from scratch every time.
- **Hitting the 10GB GitHub Actions cache limit** — this is the silent killer in monorepos. Switch to registry-backed caching before eviction starts wiping your builds randomly.
- **Not using `# syntax=docker/dockerfile:1`** — BuildKit cache mounts require the BuildKit frontend directive at the top of your Dockerfile. Miss it, and your `--mount` flags silently fail.
- **Coupling dependencies and source in one stage** — every commit invalidates your dependency layer. Split them. Dependency layers should only change when lockfiles change.

## Wrapping Up

Here is the minimal setup to get this working: start by adding `--mount=type=cache` to your package manager `RUN` lines today — one line change, immediate wins. Then wire up registry-backed caching for monorepo setups. Finally, split dependency and build stages as aggressively as you can.

No single one of these gets you from 12 minutes to 90 seconds. But stack all three and Docker stops being the thing you wait on.

- [BuildKit cache mount docs](https://docs.docker.com/build/cache/optimize/#use-cache-mounts)
- [docker/build-push-action](https://github.com/docker/build-push-action)
- [GitHub Actions cache limits](https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)