Docker optimization doesn't have to be a headache. Learn how to use multi-stage builds and GitHub Actions remote caching to slash your deployment times.
My team’s CI pipeline was dragging. What started as a snappy three-minute build had ballooned into a twelve-minute slog, mostly because we were rebuilding our entire node_modules directory every single time a developer pushed a line of CSS. If you're tired of watching your deployment pipeline stall while Docker re-downloads half the internet, it’s time to take control of your build process.
When you build a Docker image, the engine processes your Dockerfile line by line. Each RUN, COPY, or ADD instruction creates a new layer. If you change a file early in the process—like your package.json or go.mod—Docker invalidates that layer and every layer that follows.
We initially tried to "fix" this by just throwing more compute at our GitHub Actions runners. It worked for about two weeks. Then our images grew, our dependencies multiplied, and we were back to square one, only now we were paying more for the privilege of waiting. Real Docker optimization isn't about faster hardware; it's about smarter layer management.
The first step in any serious CI/CD performance strategy is the multi-stage build. Instead of shipping your build tools, compilers, and source code into your production image, you split the process.
Think of it like a construction site: you bring in the cranes and scaffolding to build the skyscraper, but you remove them before the tenants move in.
Dockerfile# Stage 1: Build FROM node:20-alpine AS builder WORKDIR /app COPY package*.json ./ RUN npm install COPY . . RUN npm run build # Stage 2: Production FROM nginx:alpine COPY --from=builder /app/dist /usr/share/nginx/html
By separating the build environment from the runtime environment, you drastically reduce the attack surface and the image size. More importantly, it keeps your production image clean and lightweight.
Even with multi-stage builds, GitHub Actions runners are ephemeral. They start from scratch every time, meaning your local Docker cache is gone the moment the job finishes. You’re essentially cold-starting your build on every PR.
To fix this, you need to implement a container build cache that persists between runs. The docker/build-push-action in GitHub Actions supports this natively using the gha (GitHub Actions) cache backend.
Here is how we configure it in our workflow files:
YAML- name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: my-app:latest cache-from: type=gha cache-to: type=gha,mode=max
The mode=max flag is the secret sauce. By default, Docker only caches the final image layers. Setting this to max tells Docker to cache intermediate layers from every stage of your build. This turned our average build time from 12 minutes down to roughly 2 minutes and 30 seconds.
Don't fall into the trap of blindly caching everything. If your build process relies on environment variables that change frequently, you might end up with stale cache hits that cause hard-to-debug runtime errors.
package.json, go.mod, etc.) before your source code. This ensures that changing a line of business logic doesn't trigger a full dependency re-install.RUN commands. Every RUN creates a layer. Use && to chain commands and clean up temporary files in the same layer.mode=max tax: While caching every intermediate layer speeds up builds, it does consume more storage space on GitHub’s backend. Keep an eye on your repository's cache usage.Honestly, this setup covers about 90% of the performance issues I see in production environments. If you’re still seeing slow builds after implementing multi-stage builds and remote caching, you might need to look at your base image selection. Switching from a full Ubuntu image to an Alpine or Distroless image can shave off another 100MB+ from your image size, which speeds up the push/pull part of your deployment cycle.
I’m still experimenting with BuildKit’s advanced features, like inline cache metadata, to see if we can get our deployment times even lower. It’s an ongoing process. Don’t strive for perfection; strive for a build pipeline that doesn't make you want to grab a coffee every time you push a hotfix.
Achieve a zero-downtime deploy with GitHub Actions using blue-green strategies. Learn how to keep your services running seamlessly during every release.