TL;DR — Quick Fix
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: my-app:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
This alone can cut build times by 50-70% on subsequent runs.
---
Why Docker Builds Are Slow in CI
CI runners start fresh every build — no local Docker cache. Every layer rebuilds from scratch unless you explicitly configure remote caching.
Fix 1: Enable BuildKit Cache in GitHub Actions
name: Build
on: [push]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
type=gha stores layer cache in GitHub Actions cache storage (10 GB free).
Registry-Based Cache (For Non-GitHub CI)
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:cache
cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:cache,mode=max
Fix 2: Optimize Layer Ordering
Docker caches layers sequentially. If layer 3 changes, layers 4+ rebuild. Order from least-changed to most-changed:
# BAD — COPY . invalidates npm install cache on every code change
FROM node:20-slim
WORKDIR /app
COPY . .
RUN npm ci
CMD ["node", "server.js"]
# GOOD — package files change rarely, code changes often
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
COPY . .
CMD ["node", "server.js"]
For Python
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
For Go
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /server
FROM gcr.io/distroless/static-debian12
COPY --from=builder /server /server
CMD ["/server"]
Fix 3: Use .dockerignore
Without .dockerignore, Docker sends your entire project directory as build context:
# .dockerignore
node_modules
.git
.github
*.md
docs
tests
coverage
.env*
.DS_Store
dist
build
tmp
.next
Impact: Context transfer drops from minutes to seconds.
Fix 4: Multi-Stage Builds
# Stage 1: Build
FROM node:20 AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 2: Production
FROM node:20-slim AS production
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY package.json ./
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Result: 1.2 GB build image becomes 200 MB production image.
Fix 5: BuildKit Mount Caches
Mount caches persist package manager caches across builds:
# syntax=docker/dockerfile:1
FROM node:20-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
COPY . .
CMD ["node", "server.js"]
For pip
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
For apt
RUN --mount=type=cache,target=/var/cache/apt \
--mount=type=cache,target=/var/lib/apt \
apt-get update && apt-get install -y curl
Fix 6: Use Smaller Base Images
| Base Image | Size | Notes |
|---|---|---|
| <code class="inline-code">node:20</code> | 1.1 GB | Slowest |
| <code class="inline-code">node:20-slim</code> | 200 MB | Good default |
| <code class="inline-code">node:20-alpine</code> | 130 MB | Smaller but musl libc |
| <code class="inline-code">gcr.io/distroless/nodejs20</code> | 120 MB | No shell, most secure |
Complete Optimized Workflow
name: Optimized Docker Build
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=sha
type=ref,event=branch
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
cache-from: type=gha
cache-to: type=gha,mode=max
Results: Before vs After
| Optimization | Time Saved |
|---|---|
| BuildKit GHA cache | 50-70% |
| Layer ordering | 20-40% |
| .dockerignore | 10-30% |
| Multi-stage build | 5-15% (push time) |
| Slim base image | 10-20% (pull time) |
| Mount caches | 15-30% |
Typical result: 20-minute build drops to 2-4 minutes.
---
Frequently Asked Questions
Why is my Docker build slow only in CI but fast locally?
Your local machine has a Docker layer cache from previous builds. CI runners start fresh — no cache exists unless you configure remote caching. Enable cache-from/cache-to in your build step.
What's the difference between GHA cache and registry cache?
GHA cache (type=gha) stores layers in GitHub's cache storage (10 GB per repo). Registry cache (type=registry) stores a cache manifest in your container registry. GHA cache is faster for GitHub Actions; registry cache works with any CI system.
Should I use Alpine or Slim base images?
Slim (Debian-based) is generally more compatible — uses glibc. Alpine uses musl libc which can cause bugs with native modules. Start with slim; switch to Alpine only after verifying compatibility.
How do I debug cache misses?
Add --progress=plain to see which layers are cached vs rebuilt. Check that COPY instructions aren't invalidating the cache unnecessarily.
---
Related Resources
- Docker Multi-Stage Build Guide — Advanced multi-stage patterns
- Docker Production Best Practices — Security and optimization
- GitHub Actions CI/CD Complete Guide — Workflow configuration
- GitHub Actions Cache Optimization — Speed up all workflows