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.
