How to Write a Dockerfile for a Go Application

Write a multi-stage Dockerfile to build your Go app in a builder image and copy the binary to a minimal runtime image.

The kitchen and the dining table

You finish your Go API. It works locally. You run docker build and push the image. The image is 750 megabytes. Your CI/CD pipeline takes twenty minutes to pull it on every deploy. The security scanner flags three hundred vulnerabilities in the Go compiler libraries you don't need at runtime.

This happens when you bake the entire development environment into the container. Go compiles to a single binary. The container only needs that binary and the OS libraries it links against. Everything else is dead weight.

Multi-stage builds let you separate the build environment from the runtime environment. Think of a multi-stage build like a restaurant kitchen. The prep station has every knife, every spice, the industrial oven, and the raw ingredients. The dining table has only the plated dish. You don't serve the customer the oven. You cook the food in the kitchen, plate it, and send only the plate to the table.

In Docker, the builder stage is the kitchen. The runtime stage is the dining table. You compile the code in the builder, then copy just the executable to a fresh, empty runtime image. The final image contains the binary and nothing else.

The anatomy of a Go binary

Go binaries are self-contained. When you run go build, the compiler links the standard library, the garbage collector, and the runtime directly into the executable. This is different from Python or Node.js, where the container must include the interpreter.

A Go binary is a portable executable file. If you compile it for Linux, it runs on any Linux system. You don't need Go installed on the target machine. You don't need the Go runtime installed. The binary carries its own runtime.

This property makes Go ideal for containers. The runtime image can be tiny. It doesn't need the Go toolchain. It doesn't need source code. It just needs the OS libraries the binary depends on. If you disable CGO, the binary depends on nothing. It can run on an empty filesystem.

Multi-stage builds exploit this. The builder stage provides the toolchain. The runtime stage provides the OS. The binary bridges the gap.

Minimal multi-stage build

Here's the baseline multi-stage Dockerfile. It separates compilation from runtime and optimizes layer caching by copying module files before source code.

# syntax=docker/dockerfile:1
# Enable BuildKit for better performance and syntax validation
FROM golang:1.22 AS builder
# Use a pinned Go version for reproducible builds
WORKDIR /src
# Set working directory; creates /src if it doesn't exist
COPY go.mod go.sum ./
# Copy module files first to leverage layer caching for dependency downloads
RUN go mod download
# Download dependencies into the module cache before copying source
COPY . .
# Copy application source code; this layer invalidates on every file change
RUN CGO_ENABLED=0 GOOS=linux go build -o /bin/server .
# Build static binary; CGO_ENABLED=0 prevents linking C libraries
FROM alpine:3.19
# Start runtime stage with a minimal OS image
WORKDIR /app
# Set working directory for the runtime container
COPY --from=builder /bin/server .
# Copy the compiled binary from the builder stage
USER nonroot:nonroot
# Run as non-root user to reduce privilege escalation risk
CMD ["./server"]
# Execute the binary; use exec form to avoid shell wrapper

The first FROM starts the builder stage. Docker pulls the golang image, which contains the compiler, standard library, and build tools. It creates a working directory, copies the module files, and downloads dependencies. The RUN go mod download step populates the module cache. This step is fast if the dependencies haven't changed. Then it copies the source code and runs go build. The compiler reads the source, resolves types, generates machine code, and writes the executable to /bin/server.

The second FROM starts a new stage. This stage begins with a fresh filesystem based on alpine. It has no memory of the builder stage except for artifacts you explicitly copy. The COPY --from=builder instruction reaches back into the builder stage and extracts the binary. The final image contains only the Alpine OS and your binary. The Go toolchain, source code, and intermediate build artifacts are discarded.

Multi-stage builds separate concerns. Build in the kitchen, serve on the plate.

Production hardening

Production builds need more than a basic copy. You want to optimize layer caching so dependency changes don't force a full rebuild. You also need to handle secrets, set environment variables, and ensure the binary runs as a non-root user.

Docker caches layers. If a line in the Dockerfile changes, all subsequent layers rebuild. Order matters. go.mod changes rarely. Source changes often. Put go.mod first. If you copy the source before downloading modules, every file change invalidates the dependency download layer. The build slows down.

Here's a production-ready Dockerfile. It strips debug symbols, installs certificates for TLS, creates a dedicated user, and documents the port.

# syntax=docker/dockerfile:1
# Enable BuildKit for advanced features like cache mounts
FROM golang:1.22 AS builder
# Builder stage contains the Go toolchain and build tools
WORKDIR /src
# Create working directory for build artifacts
COPY go.mod go.sum ./
# Copy module definition files to populate the dependency cache
RUN go mod download
# Fetch modules; this layer caches until go.mod changes
COPY . .
# Copy source code; changes here trigger a full rebuild
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /bin/server .
# Build static binary and strip debug symbols to reduce size
FROM alpine:3.19
# Runtime stage starts with a fresh, minimal Alpine image
RUN apk add --no-cache ca-certificates
# Install certificates for HTTPS; --no-cache avoids storing package index
WORKDIR /app
# Set runtime working directory
COPY --from=builder /bin/server .
# Import the binary from the builder stage
RUN adduser -D -g '' appuser
# Create a dedicated non-root user for the application
USER appuser
# Switch to non-root user before running the app
EXPOSE 8080
# Document the port; does not publish the port automatically
CMD ["./server"]
# Run the binary directly without a shell

The -ldflags="-s -w" flag strips the symbol table and DWARF debug info. This shrinks the binary by 20 to 30 percent. The ca-certificates package provides root certificates for HTTPS requests. Without it, the binary cannot verify TLS connections. The adduser command creates a non-root user. Running as root is a security risk. If an attacker compromises the container, they get root access to the host kernel namespace.

The EXPOSE instruction documents the port. It does not publish the port. You still need -p in docker run or a port mapping in Kubernetes. It's metadata for humans and tooling.

Cache the dependencies. Copy the source last.

Pitfalls and errors

Alpine uses musl libc. Debian and Ubuntu use glibc. If you enable CGO, the binary links against the C library in the builder image. If the builder uses golang (which is Debian-based), the binary links against glibc. If you copy that binary to Alpine, it fails at runtime.

The error is exec format error or not found even though the file exists. The dynamic linker cannot find libc.so.6. The solution is CGO_ENABLED=0. This forces the Go compiler to link everything statically. The binary contains no external C dependencies. It runs on Alpine, Debian, or scratch.

If you forget CGO_ENABLED=0 and try to run a CGO binary on Alpine without the right libraries, the runtime panics with error while loading shared libraries: libstdc++.so.6: cannot open shared object file.

Another pitfall is COPY . . before go mod download. This invalidates the module cache on every build. The build downloads dependencies every time, even if only a comment changed in the source. The build takes minutes instead of seconds.

A third pitfall is using latest tags. FROM golang:latest pulls whatever version is current. The build works today and breaks tomorrow when the major version bumps. Pin the version. FROM golang:1.22 is reproducible. FROM golang:1.22.5 is even better.

The convention in the Go community is to use CGO_ENABLED=0 for container builds unless you specifically need CGO. Static binaries are portable. Dynamic binaries are fragile.

Decision matrix

Use a multi-stage build when you ship to production and want a small, secure image. Use a single-stage build for local development where you need the Go toolchain inside the container for debugging. Use scratch as the runtime base when the binary is fully static and requires no OS libraries. Use alpine when you need a shell or basic utilities for troubleshooting inside the container. Use distroless when you want Debian libraries without the package manager overhead. Use golang as the runtime base only when the application requires the Go toolchain at runtime, which is rare.

Trust the binary. Drop the toolchain.

Where to go next