The factory floor vs the shipping box
You build your Go service, run docker build, and the image comes out at 800 megabytes. You push it to production, and the deployment takes forever. A security scan flags three hundred packages you don't even use. The problem isn't your code. Go compiles to a single binary. You don't need the compiler, the standard library source, or the build tools in the final container. You just need the binary.
Multi-stage builds let you separate the build environment from the runtime environment. Think of a factory floor. The factory has heavy machinery, raw materials, and assembly robots. The final product is a toaster. You don't ship the factory inside the box with the toaster. You build the toaster on the factory floor, then pack just the toaster for delivery. In Docker, the first stage is the factory. It has the Go toolchain, your source code, and all dependencies. The second stage is the shipping box. It starts empty, grabs the binary from the factory, and ignores everything else.
Minimal working example
Start with a simple HTTP server. The code is straightforward, but the Dockerfile does the heavy lifting.
// main.go
package main
import (
"fmt"
"net/http"
)
// HandleRoot prints a greeting to the HTTP response.
func HandleRoot(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "Hello from a tiny image")
}
func main() {
// Register the handler on the default mux.
http.HandleFunc("/", HandleRoot)
// Listen on port 8080. The server blocks until stopped.
http.ListenAndServe(":8080", nil)
}
The Dockerfile uses two stages. The first stage builds the binary. The second stage copies it into a minimal image.
# Use the official Go image with Alpine Linux for the build stage.
# Alpine is small, which keeps the builder image lean.
FROM golang:1.22-alpine AS builder
# Set the working directory inside the container.
WORKDIR /app
# Copy dependency definitions first to leverage Docker layer caching.
# If go.mod hasn't changed, Docker skips downloading modules.
COPY go.mod go.sum ./
RUN go mod download
# Copy the rest of the source code.
COPY . .
# Build the binary.
# CGO_ENABLED=0 ensures a static binary with no C dependencies.
# GOOS=linux targets the Linux kernel used in the runtime stage.
# -a forces rebuilding all packages, ensuring a clean build.
RUN CGO_ENABLED=0 GOOS=linux go build -o /server .
# Start the runtime stage with a minimal base image.
# distroless images are even smaller but harder to debug.
# Alpine is a good balance of size and usability.
FROM alpine:3.19
# Install CA certificates so the app can make HTTPS requests.
# Many Go apps need to call external APIs over TLS.
RUN apk --no-cache add ca-certificates
# Copy the binary from the builder stage.
# This is the only artifact that survives into the final image.
COPY --from=builder /server /server
# Run the binary.
# Using the full path avoids PATH issues.
CMD ["/server"]
What happens during the build
When Docker processes this file, it creates two distinct images. The first image downloads the Go toolchain, fetches your modules, and compiles the code. The resulting binary sits in the filesystem of that first image. The second image starts fresh with Alpine Linux. It installs certificates, then copies the binary from the first image. The final image contains only the binary, the certificates, and the minimal Alpine OS. The Go toolchain, source code, and build cache are gone. The result is an image around 15 megabytes instead of 800.
Layer caching makes the build fast on subsequent runs. Docker caches each layer. If you change main.go but not go.mod, Docker reuses the cached layer for go mod download. It only rebuilds the code. If you copy all files before downloading modules, every code change invalidates the module cache. Docker downloads modules again even if dependencies haven't changed. Always copy go.mod and go.sum before the rest of the source.
Realistic application pattern
Real apps read environment variables, handle errors, and log structured output. The Dockerfile pattern stays the same, but you need to handle configuration and security.
// main.go
package main
import (
"fmt"
"log"
"net/http"
"os"
)
// GetPort reads the PORT environment variable or defaults to 8080.
func GetPort() string {
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
return port
}
// HandleStatus returns a JSON status response.
func HandleStatus(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"status": "ok"}`)
}
func main() {
port := GetPort()
http.HandleFunc("/status", HandleStatus)
// Log the startup message.
// In production, you might use a structured logger.
log.Printf("Listening on :%s", port)
// Start the server.
// If ListenAndServe returns an error, log it and exit.
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatalf("Server failed: %v", err)
}
}
The Dockerfile adds a non-root user and strips debug information. Running as root is a security risk. If an attacker escapes the container, they get root access to the host. Stripping debug symbols reduces the binary size and removes information useful to attackers.
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build with ldflags to strip debug info.
# -s removes the symbol table.
# -w removes DWARF debugging information.
# This reduces binary size by 10-20%.
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /server .
FROM alpine:3.19
RUN apk --no-cache add ca-certificates
# Create a non-root user for security.
# adduser is the Alpine tool for user management.
RUN adduser -D -u 1000 appuser
# Copy the binary and change ownership.
COPY --from=builder /server /server
RUN chown appuser:appuser /server
# Switch to the non-root user.
USER appuser
CMD ["/server"]
Add a .dockerignore file to keep the build context clean. If you don't have one, Docker might copy your .git directory, local build artifacts, or sensitive files into the image. This slows down the build and increases image size.
# .dockerignore
.git
*.md
vendor/
bin/
*.log
gofmt is mandatory. Don't argue about indentation; let the tool decide. Most editors run it on save. The Go community values consistent formatting over style debates. Run gofmt -w . before committing code.
Static binaries and CGO
Go can produce static binaries that contain everything they need to run. CGO_ENABLED=0 forces the compiler to avoid C code. The resulting binary links no external libraries. You can run it on a scratch image, which has no OS at all. This gives you the smallest possible image.
Some packages require CGO. Database drivers like lib/pq for PostgreSQL or go-sqlite3 use C libraries. If you use these packages, you must enable CGO. The compiler rejects the program with undefined: ... or a linker error if CGO is disabled and the code calls C functions.
When CGO is enabled, the binary links against the C standard library. Alpine uses musl libc. Debian uses glibc. These are incompatible. If you build on Debian with CGO, the binary expects glibc. If you run it on Alpine, it crashes with error while loading shared libraries: libc.so.6: not found. You must match the C library between the builder and the runtime. Build on Alpine and run on Alpine. Build on Debian and run on Debian.
if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Error handling is explicit. You can't accidentally swallow an error. When writing Dockerfiles, check for errors in your build steps. If go build fails, the build stops. Docker doesn't continue to the next stage. This is safe. You won't ship a broken binary.
Common pitfalls
Layer caching order matters. If you copy source code before go mod download, every code change invalidates the module cache. Docker downloads modules again. This slows down builds. Keep dependency resolution separate from source code.
Timezone data is missing in Alpine. If your app logs timestamps, they might show UTC even when you expect local time. Add tzdata to the runtime stage if you need timezone support. RUN apk --no-cache add tzdata. Set the TZ environment variable to configure the timezone.
The CMD instruction defines the default command. Use the JSON array form ["/server"]. This avoids shell interpretation. The shell form CMD /server runs the command inside /bin/sh. This can cause issues with signal handling. The JSON form passes the signal directly to the binary. Go handles SIGTERM gracefully. The shell might not forward it correctly.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. In a Docker container, the process receives SIGTERM when the container stops. Your app should listen for the signal and shut down cleanly. Use context.Context to propagate cancellation. context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines.
The receiver name is usually one or two letters matching the type: (b *Buffer) Write(...), NOT (this *Buffer) or (self *Buffer). This is a Go convention. Keep receiver names short.
Public names start with a capital letter. Private start lowercase. No keywords like public or private. Exported names are visible outside the package. Unexported names are private to the package.
Interfaces are accepted, structs are returned. "Accept interfaces, return structs" is the most common Go style mantra. This keeps code flexible. Callers can pass any type that implements the interface. The function returns a concrete type.
Don't pass a *string. Strings are already cheap to pass by value. They are immutable. Passing a pointer adds indirection without benefit.
_ (underscore) discards a value intentionally. result, _ := ... says "I considered the second return value and chose to drop it". Use it sparingly with errors. Discarding errors hides bugs.
Decision matrix
Use a multi-stage build with Alpine when you want a balance of small size and debugging tools.
Use a multi-stage build with distroless when security is the top priority and you don't need a shell in the container.
Use a single-stage build when you are prototyping locally and don't care about image size.
Use a scratch image when you have a fully static binary and want the absolute minimum footprint.
Use a custom base image when your organization mandates specific system libraries or agents.
The smallest image is the one that runs. Security scans love tiny attack surfaces.