[Docker] Speeding Up Next.js Docker Builds with GitHub Actions Cache

β˜• 4 min read
GHADockernextjs

Table of Contents

The Problem

Every push triggered a full Next.js rebuild in our Docker-based CI/CD. Webpack recompiled everything, pages were regenerated, and builds took forever πŸ˜‡

Meanwhile, our native npm run build on GitHub Actions was blazing fast thanks to Next.js's built-in caching.

Docker was killing our velocity.

How Next.js Build Cache Works

Next.js stores build artifacts in .next/cache to speed up subsequent builds:

  • Webpack compilation cache - Compiled modules and chunks
  • TypeScript cache - Type checking results (.tsbuildinfo)
  • SWC cache - Transformed JavaScript/TypeScript files
  • Image optimization cache - Processed images

When you run npm run build again, Next.js checks this cache. If source files haven't changed, it skips recompiling them. This can cut build times from minutes to seconds.

In a native environment (like directly on a GitHub Actions runner), this just works. The .next/cache directory persists between builds.

But in Docker? Each build starts fresh. The cache gets created, used during that single build, then thrown away when the container exits.

Why Docker Wasn't Caching

I tried BuildKit cache mounts:

1RUN --mount=type=cache,target=/app/.next/cache \
2 npm run build

And GitHub Actions cache:

1cache-from: type=gha
2cache-to: type=gha,mode=max

But builds stayed slow. Every. Single. Time.

Turns out GitHub Actions cache backend doesn't properly export BuildKit cache mounts. The cache was created but never persisted.

The Solution

The key: explicitly move the Next.js cache in and out of Docker containers.

1. Restore Cache Before Docker Build

1- name: Cache Next.js build
2 uses: actions/cache@v4
3 with:
4 path: .next/cache
5 key: ${{ runner.os }}-nextjs-docker-${{ hashFiles('**/package-lock.json') }}
6 restore-keys: |
7 ${{ runner.os }}-nextjs-docker-

This restores .next/cache to the runner before building the Docker image.

2. Include Cache in Docker

Create .dockerignore that excludes heavy stuff but NOT .next/:

1node_modules
2.git
3.github

Now COPY . . includes the restored cache.

3. Extract Cache After Build

After Docker build, extract the updated cache:

1- name: Extract Next.js cache from container
2 run: |
3 CONTAINER_ID="$(docker create ${{ env.DOCKER_IMAGE_REPO }}:${{ env.DOCKER_IMAGE_TAG }})"
4 mkdir -p .next
5 docker cp "${CONTAINER_ID}:/app/.next/cache" .next/cache
6 docker rm "${CONTAINER_ID}"

GitHub Actions auto-saves it at job end.

Don't Make This Mistake

I initially used ${{ github.sha }} in the cache key:

1key: ${{ runner.os }}-nextjs-docker-${{ github.sha }} # ❌ Wrong!

Every commit got a unique cache key. Useless.

Fix: base it on package-lock.json:

1key: ${{ runner.os }}-nextjs-docker-${{ hashFiles('**/package-lock.json') }} # βœ… Right!

Cache persists across commits, only invalidates when dependencies change.

The Flow

1β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
2β”‚ 1. GitHub Actions restores cache β”‚
3β”‚ .next/cache β†’ runner filesystem β”‚
4β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
5 ↓
6β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
7β”‚ 2. Docker build copies cache β”‚
8β”‚ COPY . . β†’ includes cache β”‚
9β”‚ npm run build β†’ reuses cache β”‚
10β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
11 ↓
12β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
13β”‚ 3. Extract updated cache β”‚
14β”‚ docker cp β†’ pull cache from β”‚
15β”‚ container to runner β”‚
16β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
17 ↓
18β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
19β”‚ 4. GHA auto-saves for next build β”‚
20β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Complete Workflow

1- name: Cache Next.js build
2 uses: actions/cache@v4
3 with:
4 path: .next/cache
5 key: ${{ runner.os }}-nextjs-docker-${{ hashFiles('**/package-lock.json') }}
6 restore-keys: |
7 ${{ runner.os }}-nextjs-docker-
8
9- name: Set up Docker Buildx
10 uses: docker/setup-buildx-action@v3
11
12- name: Build image
13 uses: docker/build-push-action@v6
14 with:
15 context: .
16 file: dockerfiles/nextjs/Dockerfile
17 load: true
18 tags: my-app:latest
19 outputs: type=docker
20 cache-from: type=gha
21 cache-to: type=gha,mode=max
22
23- name: Extract Next.js cache from container
24 run: |
25 CONTAINER_ID="$(docker create my-app:latest)"
26 mkdir -p .next
27 docker cp "${CONTAINER_ID}:/app/.next/cache" .next/cache
28 docker rm "${CONTAINER_ID}"

Results

  • Before: Full rebuild every time
  • After: ~2GB of cached build artifacts reused
  • Impact: Way faster CI/CD

Lesson

BuildKit cache mounts don't integrate well with GitHub Actions cache. You need to explicitly manage cache persistence.

Sometimes the best solution isn't elegantβ€”it's the one that works. Don't be afraid to use docker cp if that's what it takes.

Related Articles