Our Docker builds in CI took 15 minutes. Developers were waiting, and we were burning CI minutes. I spent a day optimizing, and here is what actually made a difference.
The Before
FROM node:20
WORKDIR /app
COPY . . -- Copy everything
RUN npm install -- Install dependencies (14 minutes)
RUN npm run build -- Build (1 minute)
CMD ["npm", "start"]
Problem 1: node_modules was being copied every time
Every commit changed files, triggering full COPY and npm install.
FROM node:20-alpine
WORKDIR /app
# Copy package files FIRST (this is key!)
COPY package*.json ./
RUN npm ci --production
# Then copy the rest
COPY . .
RUN npm run build
CMD ["npm", "start"]
Problem 2: Using full node image
We switched to alpine - much smaller, faster to pull.
FROM node:20-alpine -- 150MB instead of 1GB
Problem 3: Not using BuildKit
# Enable BuildKit
export DOCKER_BUILDKIT=1
# In docker-compose
build:
context: .
dockerfile: Dockerfile
cache_from: -- Use GitHub Actions cache
- ghcr.io/yourorg/app:builder
Problem 4: Multi-stage builds
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
COPY . .
RUN npm ci && npm run build
# Production stage
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
CMD ["npm", "start"]
The Results
- First build: 3 minutes (was 15)
- Subsequent builds: ~30 seconds (with cache)
- Image size: 200MB (was 1.2GB)
Summary
Order matters. Put things that change less frequently (like package.json) earlier in the Dockerfile. Use BuildKit. Use alpine images. Multi-stage builds are your friend.