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=gha2cache-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 build2 uses: actions/cache@v43 with:4 path: .next/cache5 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_modules2.git3.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 container2 run: |3 CONTAINER_ID="$(docker create ${{ env.DOCKER_IMAGE_REPO }}:${{ env.DOCKER_IMAGE_TAG }})"4 mkdir -p .next5 docker cp "${CONTAINER_ID}:/app/.next/cache" .next/cache6 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 build2 uses: actions/cache@v43 with:4 path: .next/cache5 key: ${{ runner.os }}-nextjs-docker-${{ hashFiles('**/package-lock.json') }}6 restore-keys: |7 ${{ runner.os }}-nextjs-docker-89- name: Set up Docker Buildx10 uses: docker/setup-buildx-action@v31112- name: Build image13 uses: docker/build-push-action@v614 with:15 context: .16 file: dockerfiles/nextjs/Dockerfile17 load: true18 tags: my-app:latest19 outputs: type=docker20 cache-from: type=gha21 cache-to: type=gha,mode=max2223- name: Extract Next.js cache from container24 run: |25 CONTAINER_ID="$(docker create my-app:latest)"26 mkdir -p .next27 docker cp "${CONTAINER_ID}:/app/.next/cache" .next/cache28 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.