Skip to main content
CI/CD·6 min read

Why Is My Docker Build Taking 20 Minutes in GitHub Actions? — Fix It

Speed up Docker builds in CI/CD with BuildKit caching, multi-stage builds, optimized .dockerignore, layer ordering, and GitHub Actions cache strategies. Cut build times by 80%.

DT

DevOps Engineer & Technical Writer

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

DOCKER BUILD TIME BREAKDOWN — WHERE TIME IS WASTED npm install / pip install (60%) — re-runs every build without cache Pulling base image (15%) — no local cache in CI COPY entire context (10%) — bloated Compile/build step (10%) Push to registry (5%)

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 ImageSizeNotes
<code class="inline-code">node:20</code>1.1 GBSlowest
<code class="inline-code">node:20-slim</code>200 MBGood default
<code class="inline-code">node:20-alpine</code>130 MBSmaller but musl libc
<code class="inline-code">gcr.io/distroless/nodejs20</code>120 MBNo 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

OptimizationTime Saved
BuildKit GHA cache50-70%
Layer ordering20-40%
.dockerignore10-30%
Multi-stage build5-15% (push time)
Slim base image10-20% (pull time)
Mount caches15-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.

---