DEV Community

Ramer Labs
Ramer Labs

Posted on

7 Tips for Optimizing Docker Images and Build Times on CI/CD Pipelines

Introduction

If you’re a DevOps lead managing a fleet of containers, image bloat and slow builds are the silent productivity killers that creep into any CI/CD pipeline. In this tutorial‑style guide we’ll walk through seven concrete techniques to shrink Docker images, accelerate builds, and keep your runners happy. The focus is on practical, reproducible steps you can drop into an existing workflow without a full rewrite.


1. Adopt Multi‑Stage Builds

Multi‑stage builds let you separate the build environment from the runtime image. This eliminates compilers, package managers, and temporary files from the final artifact.

# ---- Build Stage ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build

# ---- Runtime Stage ----
FROM node:20-alpine
WORKDIR /app
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
RUN npm ci --only=production
CMD ["node", "dist/index.js"]
Enter fullscreen mode Exit fullscreen mode

The resulting image contains only the compiled output and runtime dependencies, often cutting size by 60‑80%.


2. Leverage Docker Build Cache Effectively

Docker caches each layer based on the exact command and its context. To maximize cache hits:

  • Order instructions from least to most frequently changing (e.g., install OS packages before copying source code).
  • Use .dockerignore aggressively to prevent unnecessary files from invalidating the cache.
  • Pin versions of base images and packages to avoid silent cache busts.
# .dockerignore
node_modules
.git
Dockerfile
*.log
Enter fullscreen mode Exit fullscreen mode

When only source files change, Docker reuses the earlier layers, slashing build times on subsequent runs.


3. Choose Minimal Base Images

Alpine Linux is a popular lightweight base (≈5 MB). However, be aware of glibc‑related compatibility issues. If you need a full‑featured OS, consider scratch for Go binaries or distroless images for Java and Python.

Base Image Approx. Size Typical Use‑Case
alpine 5 MB General purpose, Node, Python
scratch 0 B Statically linked binaries
distroless 20‑30 MB Java, .NET, Go (glibc)

4. Remove Build‑Time Files in a Single Layer

Instead of scattering rm -rf commands across multiple RUN statements (which each creates a new layer), combine them:

RUN apk add --no-cache build-base && \
    make && \
    make install && \
    rm -rf /var/cache/apk/* /tmp/*
Enter fullscreen mode Exit fullscreen mode

This keeps the final image lean and reduces layer count, which improves both pull speed and storage efficiency.


5. Use BuildKit for Parallelism and Caching

Docker’s BuildKit engine (available in Docker 18.09+) introduces advanced caching, inline secret handling, and parallel execution. Enable it by setting the environment variable:

export DOCKER_BUILDKIT=1

docker build -t myapp:latest .
Enter fullscreen mode Exit fullscreen mode

BuildKit can dramatically cut build times for large projects, especially when combined with the --cache-from flag to reuse layers from previous builds stored in a registry.


6. Cache Dependencies Separately

For languages with heavy dependency trees (Node, Python, Ruby), cache the dependency directory in its own layer. Example for Node:

COPY package*.json ./
RUN npm ci --only=production
COPY . .
Enter fullscreen mode Exit fullscreen mode

When only application code changes, Docker reuses the cached npm ci layer, avoiding a full reinstall of all packages.


7. Scan and Prune Images Regularly

Even with best practices, stray intermediate images can accumulate on CI runners. Schedule a nightly cleanup:

# Remove dangling images
docker image prune -f
# Remove images older than 30 days
docker image prune -a --filter "until=720h" -f
Enter fullscreen mode Exit fullscreen mode

Automating this step prevents disk‑space exhaustion and keeps the runner’s performance consistent.


Putting It All Together in a CI Pipeline

Below is a minimal GitHub Actions workflow that demonstrates these tips:

name: Docker Build & Push
on: [push]
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - name: Set up Docker BuildKit
        run: echo "DOCKER_BUILDKIT=1" >> $GITHUB_ENV
      - name: Log in to Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKER_USER }}
          password: ${{ secrets.DOCKER_PASS }}
      - name: Build and push
        run: |
          docker build -t myorg/myapp:${{ github.sha }} .
          docker push myorg/myapp:${{ github.sha }}
Enter fullscreen mode Exit fullscreen mode

The workflow respects caching, uses BuildKit, and pushes a lean image to the registry.


Conclusion

Optimizing Docker images is less about magic and more about disciplined layering, caching, and cleanup. By applying these seven tips you’ll see faster CI builds, smaller registries, and smoother deployments. For deeper dives into Docker best practices and a curated list of tools, check out https://lacidaweb.com – it’s a handy resource for teams looking to tighten their container workflows.

Top comments (0)