How to Use Docker BuildKit with Go

Enable Docker BuildKit for Go by setting DOCKER_BUILDKIT=1 before running docker build to get faster, more efficient builds.

The build that never ends

You change a single line in main.go. You run docker build. Docker downloads every dependency again. The build takes forty seconds. You change another line. Forty seconds again. The problem isn't Go. Go compiles fast. The problem is the Docker builder. The legacy builder treats every RUN command as an opaque step. It doesn't understand that your module cache hasn't changed. It doesn't know how to parallelize stages. It forces you to bake SSH keys into image layers just to download private modules.

BuildKit fixes this. It's the new build engine for Docker. It parses your Dockerfile into a dependency graph. It runs independent steps in parallel. It supports cache mounts that persist across builds. It handles secrets without leaving traces. It makes Go builds fast, secure, and incremental.

BuildKit is the engine under the hood

Docker started with a simple builder that executed Dockerfile instructions sequentially. Each instruction created a new layer. If you changed a file early in the Dockerfile, every layer after that point invalidated. The builder had no concept of what actually changed. It just replayed steps.

BuildKit replaces that engine. It analyzes the Dockerfile and constructs a Directed Acyclic Graph (DAG) of operations. Each node in the graph represents a step. Edges represent dependencies. BuildKit evaluates the graph to find the critical path. It runs independent branches in parallel. It tracks inputs and outputs with high precision. If you modify a file that doesn't affect the build output, BuildKit skips the work entirely.

The legacy builder works like a checklist. BuildKit works like a compiler.

The minimal switch

Here's the simplest way to enable BuildKit. Set the environment variable before running the build command.

export DOCKER_BUILDKIT=1
docker build -t my-go-app .

The export command sets the variable for the current shell session. The docker build command detects the variable and activates the BuildKit engine. Modern Docker versions enable BuildKit by default. Run docker version to check. If the client output shows BuildKit: true, you're already using it.

BuildKit is the default. If it's not, update Docker.

Go build cache meets Docker cache

Go has its own build cache. When you run go build, the compiler stores intermediate results in a cache directory. If you rebuild without changes, Go reuses the cached objects. This makes local development instant. Docker's layer cache doesn't know about Go's cache. Every time you rebuild the image, the container starts fresh. Go has to recompile everything.

BuildKit bridges this gap with cache mounts. A cache mount attaches a persistent directory to a RUN step. The directory survives across builds. You can mount Go's module cache and build cache into the container. The first build populates the cache. Subsequent builds reuse it.

Here's a Dockerfile that mounts both caches.

# syntax=docker/dockerfile:1
FROM golang:1.21-alpine AS builder

WORKDIR /app
# Copy dependency files first to isolate module resolution from source changes
COPY go.mod go.sum ./

# Mount module cache and build cache to persist across builds
RUN --mount=type=cache,target=/go/pkg/mod/cache \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download

COPY . .

# Build the binary. CGO_ENABLED=0 produces a static binary.
# ldflags strips debug symbols to reduce image size.
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w" -o /out/server .

FROM alpine:3.18 AS runtime
# Install ca-certificates for HTTPS calls in the runtime image
RUN apk add --no-cache ca-certificates

COPY --from=builder /out/server /server
# Run as non-root user for security
USER 65532:65532

ENTRYPOINT ["/server"]

The # syntax directive tells BuildKit to use the latest parser. The RUN command uses --mount=type=cache to attach the module cache and build cache. The target specifies the path inside the container. Go writes to these paths during the build. BuildKit saves the contents and restores them on the next build. The COPY go.mod step separates dependency resolution from source code. If you change main.go but not go.mod, the module download step is cached. The build skips downloading dependencies.

Go convention: disable CGO for Docker images unless you absolutely need C libraries. Setting CGO_ENABLED=0 produces a static binary that runs on any Linux distribution. It also removes the need for glibc in the runtime image. Strip debug symbols with ldflags="-s -w" to save space. Production images don't need debug info.

Cache mounts make Go builds feel instant.

Secrets that vanish after the build

Private modules require SSH keys or tokens. The old way was to copy the key into the image, run go mod download, and hope you removed the key in a later layer. That approach fails. Docker layers are additive. The key exists in the image history. Anyone who pulls the image can extract the key.

BuildKit mounts secrets at build time only. The secret appears as a file inside a RUN step. It never gets committed to a layer. When the step finishes, the secret disappears.

Here's how to mount an SSH key for private modules.

# syntax=docker/dockerfile:1
FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./

# Mount SSH key and module cache. The key is available only during this step.
RUN --mount=type=secret,id=ssh_key,target=/root/.ssh/id_rsa \
    --mount=type=cache,target=/go/pkg/mod/cache \
    go mod download

COPY . .
RUN CGO_ENABLED=0 go build -o /out/server .

FROM scratch
COPY --from=builder /out/server /server
ENTRYPOINT ["/server"]

The --mount=type=secret option mounts the secret. The id matches the secret passed to the build command. The target is the path inside the container. You pass the secret from the host using the --ssh flag.

docker build --ssh default -t my-go-app .

The --ssh default flag forwards the host's SSH agent to the build. BuildKit mounts the agent credentials as the secret. The key never touches the disk on the build host. It never appears in the image.

Secrets stay in the host. They never touch the image layers.

A production-ready Dockerfile

Real Go applications need more than cache mounts. They need multi-stage builds to keep the final image small. They need to handle architecture differences. They need to follow security best practices.

Here's a complete Dockerfile for a production Go service.

# syntax=docker/dockerfile:1
FROM golang:1.21-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./

RUN --mount=type=cache,target=/go/pkg/mod/cache \
    --mount=type=cache,target=/root/.cache/go-build \
    go mod download

COPY . .

# Build with cross-compilation flags.
# GOARCH and GOOS match the target platform.
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
    go build -ldflags="-s -w -X main.version=1.0.0" \
    -o /out/server .

FROM alpine:3.18 AS runtime

# Install ca-certificates and tzdata for time zones
RUN apk add --no-cache ca-certificates tzdata

# Create a non-root user
RUN adduser -D -u 10001 appuser

COPY --from=builder /out/server /server

USER appuser
ENTRYPOINT ["/server"]

The # syntax directive enables BuildKit features. The builder stage downloads modules with cache mounts. It copies source code and builds the binary. The ldflags inject a version string at compile time. This is a common Go pattern for embedding metadata. The runtime stage uses Alpine with ca-certificates for HTTPS. It creates a non-root user. It copies the binary and switches to the user.

Go convention: use scratch or distroless for the final image if you don't need a shell. Alpine is fine for debugging, but scratch is smaller and has no attack surface. If you use scratch, you don't need ca-certificates because Go bundles them. The binary runs directly on the kernel.

Dockerfiles don't have gofmt, but hadolint catches common mistakes. Run hadolint in CI to enforce best practices. It checks for missing labels, unused stages, and security issues.

Trust the linter. Fix the warnings.

Pitfalls and gotchas

BuildKit changes how caching works. The --mount=type=cache is persistent. If you update a dependency, the cache might hold stale data. Go's module cache is keyed by version. It usually handles updates correctly. The build cache can get confused if you change build flags or compiler versions.

Force a clean rebuild with --no-cache.

docker build --no-cache -t my-go-app .

The --no-cache flag tells BuildKit to ignore all caches. It rebuilds everything from scratch. Use this when you suspect cache corruption.

BuildKit errors are verbose. If you forget to pass a secret, the build fails with failed to solve: secret not found. If you mount a cache to a read-only path, you get failed to solve: permission denied. If your Docker version is too old, you see docker: error: --ssh is not supported on this platform.

Check your Docker version. BuildKit requires Docker 18.09 or later. Most systems run newer versions now.

The worst cache bug is the one that silently serves stale code. Clear the cache when in doubt.

When to use BuildKit

Use BuildKit when you need fast iterative builds with Go. Use --mount=type=cache when go mod download slows down your workflow. Use --mount=type=secret when you access private modules or need build-time credentials. Use docker buildx when you need multi-arch builds or advanced build options. Use the legacy builder only when you are stuck on Docker versions older than 18.09.

BuildKit is the standard. The legacy builder is history.

Where to go next