Friday, July 11, 2025

From 11 Minutes to 39 Seconds: Optimizing Docker Builds in CI/CD Pipelines

Oscar Blanco Castan
Oscar Blanco Castan

Long build times are the silent killer of developer productivity β€” especially when working with compiled languages like Go. If every commit triggers a 10+ minute container build, your team is burning hours waiting instead of shipping.

In this post, we'll walk through how we reduced build times in a Go microservice pipeline from over 11 minutes to just 39 seconds, using Docker BuildKit, buildx, mount-based caching, and some strategic GitHub Actions enhancements β€” all without compromising on security or reproducibility.

🧨 Before Optimization: Full Rebuild Every Time

Our original setup was straightforward β€” but inefficient:

Before optimization: docker build took 11m 34s

Each CI run rebuilt our Go service image from scratch. With no caching enabled, Docker:

  • Redownloaded Go modules
  • Recompiled everything
  • Pushed a fresh image

That led to a consistent 11+ minute wait on every commit.

Here's what our old Dockerfile looked like:

FROM golang:1.24 AS build
WORKDIR /go/src/app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o dist/server cmd/main.go
 
FROM gcr.io/distroless/static
COPY --from=build /go/src/app/dist/server /server
EXPOSE 8000
CMD ["/server"]

✨ After Optimization: Same Build, 94% Faster

With a few changes to our pipeline and Dockerfile, we brought the same job down to 39 seconds:

After optimization: docker build took 39s

That's a 94% improvement, with no loss in reproducibility or security.

πŸ”§ What We Did

Go projects have a unique structure that makes them highly cacheable β€” but only if your tooling takes advantage of it.

We followed Docker's official guidance on caching strategies and implemented the following improvements:

  • Enabled BuildKit and buildx β€” required for advanced caching and --mount support.
  • Used --mount=type=cache in the Dockerfile to cache the Go module download and build output using GOMODCACHE.
  • Leveraged GitHub's native cache backend (type=gha) to restore layers across CI runs.
  • Integrated buildkit-cache-dance to persist mount-based caches (like GOMODCACHE) that Docker doesn't preserve by default.

Here's our optimized Dockerfile:

FROM golang:1.24 AS build
WORKDIR /go/src/app
RUN go env -w GOMODCACHE=/root/.cache/go-build
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/root/.cache/go-build go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build CGO_ENABLED=0 go build -o dist/server cmd/main.go
 
FROM gcr.io/distroless/static
COPY --from=build /go/src/app/dist/server /server
EXPOSE 8000
CMD ["/server"]

This pipeline sets up BuildKit, restores cached layers and Go modules, and builds your image in under a minute β€” ready for deployment.

name: Release
 
on:
  push:
 
permissions:
  contents: read
  id-token: write
 
jobs:
  build-push-server:
    runs-on: ubuntu-latest
    name: Docker - Publish server OCI to registry
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
      - uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
      - uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
        id: setup-buildx
      - uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
        id: cache
        with:
          path: cache-mount
          key: cache-mount-${{ hashFiles('**/go.sum') }}
          restore-keys: |
            cache-mount-
      - uses: reproducible-containers/buildkit-cache-dance@5b81f4d29dc8397a7d341dba3aeecc7ec54d6361 # v3.3.0
        with:
          builder: ${{ steps.setup-buildx.outputs.name }}
          cache-dir: cache-mount
          skip-extraction: ${{ steps.cache.outputs.cache-hit }}
      - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
        with:
          context: .
          platforms: linux/arm64
          tags: server:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max

βœ… Why This Works β€” and Why It's Worth It

This pipeline is built on top of Docker's official caching strategies for CI, specifically tailored for GitHub Actions. Here's why each part matters:

  • BuildKit via setup-buildx-action: Unlocks --mount=type=cache, parallel layers, and multi-platform builds.
  • actions/cache + buildkit-cache-dance: Keeps Go modules cached across CI runs. Without cache-dance, mount caches get lost.
  • cache-from/cache-to with type=gha: Leverages GitHub’s caching layer for Docker layers β€” no external registry needed.
  • Go-specific module caching: By setting GOMODCACHE and caching it with --mount=type=cache, we ensure Go dependencies are reused across builds β€” avoiding redundant downloads and dramatically speeding up the build.

πŸ”­ What's Next

With caching solved, we're now looking at:

  • Generating and signing SBOMs using docker sbom and Cosign
  • Enforcing image policy with Sigstore
  • Validating builds with provenance attestations

We'll cover those in an upcoming post. If you're optimizing your own CI/CD pipelines and want to go from "slow and safe" to "fast and secure" stay tuned.

πŸ“š Reference:Docker CI Cache Management Docs

Back to Blog