Dockerize a Go API: small binaries, clean images and simple deployment
Multi-stage Dockerfile for Go, static binaries, environment variables, healthcheck and deployment. Minimal images with no surprises.

A Go API compiles to a single binary. A Docker image of that binary can weigh less than 10 MB. Compare that with the typical image of a Java application (300-500 MB with the JVM), Python (200-400 MB with dependencies) or Node (200-300 MB with node_modules). That difference is not cosmetic: it affects deployment time, image storage cost, cold start and the attack surface of your container.
I come from deploying services in Kotlin with Spring Boot. Every Docker image started at 400 MB at a minimum. The Dockerfile was an engineering artifact: multi-stage with Maven, dependency caching, layer caching, and even then the result was heavy and slow to start. When I built my first Docker image for Go, the Dockerfile had 12 lines and the final image weighed 8 MB. Not because I was smarter, but because Go eliminates most of the complexity that Docker has to manage in other languages.
Why Go and Docker fit together so well
Docker solves a fundamental problem: packaging an application with all its dependencies so that it works the same in any environment. The more dependencies your application has, the more work Docker has. And the more things can break.
Go minimizes that problem by design:
- Static binary: no runtime, no interpreter, no virtual machine. The binary is the complete application.
- No system dependencies: with
CGO_ENABLED=0, the binary doesn’t link against libc or any shared library. It works on any Linux. - Trivial cross-compilation: compiling for
linux/amd64from macOS is an environment variable, not a pipeline. - Instant startup: no JVM warm-up, no module loading, no JIT. The process starts serving requests in milliseconds.
- Predictable memory consumption: no heavy garbage collector or runtime overhead. A basic HTTP service starts with 5-10 MB of RAM.
All of this means that the Docker image of your Go API can be extremely small, fast to build and fast to deploy. And you don’t need tricks to achieve it.
Basic Dockerfile: a single stage
Let’s start with the simplest thing. A single-stage Dockerfile that compiles and runs your API:
FROM golang:1.23-alpine
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server ./cmd/api
EXPOSE 8080
CMD ["./server"]This works. Your API compiles and runs inside the container. But there’s an obvious problem: the final image includes the entire Go toolchain (compiler, tools, sources), all downloaded dependencies and your source code. The result is an image of 300-400 MB.
docker build -t my-api:single .
docker images my-api:single
# REPOSITORY TAG SIZE
# my-api single 387MBThis is unacceptable for production. You’re deploying the compiler along with your application. It’s like sending the mechanic’s workshop along with the car.
Multi-stage build: the standard approach
The solution in Docker is to use multi-stage builds. You compile in one stage with all the necessary tools and copy only the resulting binary to a minimal final image.
# === Stage 1: build ===
FROM golang:1.23-alpine AS builder
WORKDIR /app
# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Compile
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o server ./cmd/api
# === Stage 2: final image ===
FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
USER nobody:nobody
CMD ["./server"]Let’s analyze the key decisions:
CGO_ENABLED=0: generates a static binary without C dependencies. Essential for it to work in minimal images.GOOS=linux GOARCH=amd64: explicit cross-compilation. Even if you’re compiling on Linux, it’s good practice to declare it.-ldflags="-s -w": removes the symbol table and debug information. Reduces binary size by 20-30%.ca-certificates: required if your API makes HTTPS calls to external services. Without this, TLS certificates are not validated.tzdata: required if you work with time zones. Without this,time.LoadLocation("Europe/Madrid")fails.USER nobody:nobody: the container doesn’t run as root. Basic security that too many people forget.
The result:
docker build -t my-api:multi .
docker images my-api:multi
# REPOSITORY TAG SIZE
# my-api multi 18MBFrom 387 MB to 18 MB. And the final image only contains your binary, the certificates and the time zone data. Nothing more.
Scratch vs distroless vs Alpine
The base image for the final stage is an important decision. There are three common options, each with different trade-offs.
scratch
FROM scratch
COPY --from=builder /app/server /server
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
CMD ["/server"]scratch is an empty image. It literally has nothing: no shell, no tools, no libc, no /tmp, no /etc. Your binary is the only thing that exists inside the container.
Advantages: minimal image (only your binary, ~8-12 MB), zero attack surface.
Disadvantages: you can’t do docker exec -it container sh to debug. There’s no /etc/passwd, so USER doesn’t work in the usual way. If your application needs temporary files, you have to create /tmp explicitly.
gcr.io/distroless/static-debian12
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
CMD ["/server"]Google’s Distroless includes CA certificates, tzdata and a configured nonroot user, but without shell or package manager. It’s a good middle ground between scratch and Alpine.
Advantages: certificates and time zones included, pre-configured non-root user, ~2 MB base.
Disadvantages: no shell for debugging, dependency on Google’s images.
alpine
FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /app/server /server
CMD ["/server"]Alpine is a minimal Linux distribution (~5 MB) with shell, package manager and basic tools.
Advantages: you can enter the container and debug, install tools if needed, small image (~8 MB base).
Disadvantages: slightly larger than scratch/distroless, uses musl libc instead of glibc (irrelevant if you compile with CGO_ENABLED=0).
My recommendation
For most teams and projects, Alpine is the pragmatic option. The size difference from scratch is 5-8 MB, and in return you have a shell for when something fails in production at 3 in the morning. If your organization has strict security requirements and doesn’t need interactive debugging, distroless is a better option than scratch because it includes the minimum necessary without you having to copy it manually.
Static binaries: CGO_ENABLED=0 and what it implies
CGO_ENABLED=0 is the key piece that makes all this work. By default, Go can dynamically link against libc for certain standard library packages (net, os/user). With CGO_ENABLED=0, Go uses pure Go implementations for everything.
# With CGO enabled (default in some cases)
CGO_ENABLED=1 go build -o server ./cmd/api
ldd server
# linux-vdso.so.1 => ...
# libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0
# libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
# With CGO disabled
CGO_ENABLED=0 go build -o server ./cmd/api
ldd server
# not a dynamic executableWith CGO_ENABLED=0, the binary is completely self-contained. You can copy it to any Linux system with the correct architecture and it will work. No shared libraries, no dependencies.
When you CANNOT use CGO_ENABLED=0:
- If you use SQLite through
mattn/go-sqlite3(requires CGO). - If you depend on native C libraries that have no pure Go equivalent.
- If you use packages that wrap C code (certain database drivers, specialized cryptography libraries).
In those cases you need a base image that includes libc (Alpine with musl or Debian), and your Dockerfile gets more complex. If you can avoid CGO, avoid it. For a standard REST API with Go with PostgreSQL (using pgx, which is pure Go), you don’t need CGO.
The ldflags flags also matter:
# Without ldflags
go build -o server ./cmd/api
ls -lh server # 12.4 MB
# With -s -w (without symbols or debug info)
go build -ldflags="-s -w" -o server ./cmd/api
ls -lh server # 8.7 MB
# Injecting version at compile time
go build -ldflags="-s -w -X main.version=1.2.3 -X main.commitHash=$(git rev-parse --short HEAD)" \
-o server ./cmd/apiInjecting the version and commit hash into the binary is standard practice. It lets you know exactly which version is running in production without relying on Docker tags.
package main
var (
version = "dev"
commitHash = "unknown"
)
func main() {
log.Printf("Starting server version=%s commit=%s", version, commitHash)
// ...
}Environment variables and configuration
An API in a Docker container receives its configuration through environment variables. It’s the standard of 12-Factor Apps and it’s what all orchestrators expect (Kubernetes, ECS, Docker Compose).
In Go, reading environment variables is trivial with the standard library:
package config
import (
"fmt"
"os"
"strconv"
)
type Config struct {
Port int
DatabaseURL string
LogLevel string
Environment string
}
func Load() (*Config, error) {
port, err := strconv.Atoi(getEnv("PORT", "8080"))
if err != nil {
return nil, fmt.Errorf("invalid PORT: %w", err)
}
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
return nil, fmt.Errorf("DATABASE_URL is required")
}
return &Config{
Port: port,
DatabaseURL: dbURL,
LogLevel: getEnv("LOG_LEVEL", "info"),
Environment: getEnv("ENVIRONMENT", "development"),
}, nil
}
func getEnv(key, fallback string) string {
if value, ok := os.LookupEnv(key); ok {
return value
}
return fallback
}In the Dockerfile, you can declare default values with ENV:
FROM alpine:3.20
ENV PORT=8080
ENV LOG_LEVEL=info
ENV ENVIRONMENT=production
COPY --from=builder /app/server /app/server
CMD ["/app/server"]But don’t put secrets in the Dockerfile. Ever. Not the database URL, not API keys, not tokens. Those values are passed at runtime:
docker run -d \
-e DATABASE_URL="postgres://user:pass@db:5432/mydb?sslmode=disable" \
-e PORT=8080 \
-p 8080:8080 \
my-api:latestIf your configuration becomes more complex, there are libraries like caarlos0/env, kelseyhightower/envconfig or koanf that parse environment variables in Go directly into structs with validation included. But for most services, os.Getenv with a helper function covers it easily.
Health checks
A container that starts is not a container that works. Docker and orchestrators need to know if your application is alive and ready to receive traffic.
First, expose a /health endpoint in your API:
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, `{"status":"ok"}`)
})
// If you want a more complete check that verifies the DB
mux.HandleFunc("GET /ready", func(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(r.Context()); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
fmt.Fprintf(w, `{"status":"error","detail":"%s"}`, err.Error())
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprintln(w, `{"status":"ok"}`)
})
// ... rest of routes
}The distinction between liveness (/health) and readiness (/ready) matters:
- Liveness: “The process is alive and not hung.” If it fails, Docker/Kubernetes restarts the container.
- Readiness: “The application is ready to receive traffic.” If it fails, the orchestrator stops sending it requests but doesn’t restart it.
In the Dockerfile, configure the health check:
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD ["/app/server", "-health"]A simpler alternative that doesn’t require modifying your binary:
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1If you use Alpine, wget is available. If you use scratch or distroless, you need to compile a health check binary or use the first option (a flag in your own binary):
func main() {
if len(os.Args) > 1 && os.Args[1] == "-health" {
resp, err := http.Get("http://localhost:8080/health")
if err != nil || resp.StatusCode != 200 {
os.Exit(1)
}
os.Exit(0)
}
// Normal server startup...
}This pattern is elegant: your binary can function as both server and health checker. No external dependencies.
Docker Compose for local development
In development you need more than your API: database, maybe Redis, maybe a messaging service. Docker Compose is the standard for orchestrating this locally.
services:
api:
build:
context: .
dockerfile: Dockerfile
ports:
- "8080:8080"
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/myapp?sslmode=disable
- PORT=8080
- LOG_LEVEL=debug
- ENVIRONMENT=development
depends_on:
db:
condition: service_healthy
restart: unless-stopped
db:
image: postgres:16-alpine
ports:
- "5432:5432"
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: myapp
volumes:
- pgdata:/var/lib/postgresql/data
- ./migrations/init.sql:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 3s
retries: 5
volumes:
pgdata:Key points:
depends_onwithcondition: service_healthy: the API doesn’t start until PostgreSQL is ready. Without this, your API will try to connect to the database before it exists and will fail.- Named volume (
pgdata): PostgreSQL data persists between Docker Compose restarts. Without this, you lose data every time you rundocker compose down. - Mounting
init.sql: PostgreSQL automatically runs scripts in/docker-entrypoint-initdb.d/the first time it starts. Ideal for creating tables and initial data.
For development with hot-reload, you can mount your source code and use Air:
services:
api:
build:
context: .
dockerfile: Dockerfile.dev
ports:
- "8080:8080"
volumes:
- .:/app
environment:
- DATABASE_URL=postgres://postgres:postgres@db:5432/myapp?sslmode=disableWith a Dockerfile.dev that installs Air:
FROM golang:1.23-alpine
RUN go install github.com/air-verse/air@latest
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
CMD ["air", "-c", ".air.toml"]This way every change in your code recompiles and restarts the server automatically inside the container.
Image size comparison: Go vs Java vs Python vs Node
The numbers speak for themselves. All examples are minimal HTTP APIs with a /health endpoint:
| Stack | Base image | Final size |
|---|---|---|
| Go (multi-stage + scratch) | scratch | ~8 MB |
| Go (multi-stage + alpine) | alpine:3.20 | ~15 MB |
| Go (multi-stage + distroless) | distroless/static | ~10 MB |
| Java (Spring Boot + JRE) | eclipse-temurin:21-jre-alpine | ~250 MB |
| Python (FastAPI + uvicorn) | python:3.12-slim | ~180 MB |
| Node (Express) | node:20-alpine | ~180 MB |
| Rust (actix-web + scratch) | scratch | ~6 MB |
Go is not the smallest (Rust wins there), but the difference from JVM, Python and Node ecosystems is an order of magnitude. This has real consequences:
- Pull time: downloading 10 MB takes <1 second. Downloading 250 MB takes 15-30 seconds on a typical CI network.
- Registry storage: if you maintain 50 images with 20 tags each, the difference between 10 MB and 250 MB is 10 GB vs 250 GB.
- Cold start in serverless: Google Cloud Run, AWS Lambda with containers. Starting a Go container takes 100-200 ms. Starting a Java container can take 5-15 seconds.
- Attack surface: less software in the image = fewer possible CVEs. A Trivy scan on a Go+scratch image returns zero vulnerabilities. A Debian-slim based image can return dozens.
For cloud-native services, where you can have tens or hundreds of containers, these differences accumulate quickly.
CI/CD considerations
A well-configured multi-stage Dockerfile integrates directly into any CI/CD pipeline. But there are details that make the difference between a 30-second build and a 5-minute one.
Layer caching
The order of instructions in your Dockerfile matters. Layers are cached from top to bottom, and any change invalidates all subsequent layers.
# GOOD: dependencies change less often than code
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o server ./cmd/api
# BAD: any code change invalidates the dependency cache
COPY . .
RUN go mod download
RUN go build -o server ./cmd/apiThe first version only re-downloads dependencies when go.mod or go.sum change. The second re-downloads on every code change.
Build cache in CI
In GitHub Actions, you can cache Docker layers and Go modules:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/my-org/my-api:${{ github.sha }}
cache-from: type=gha
cache-to: type=gha,mode=maxtype=gha uses GitHub Actions’ native cache. It’s the simplest way to have incremental builds in CI without setting up additional infrastructure.
Vulnerability scanning
Including an image scanning step in your pipeline is standard practice:
- name: Scan image
uses: aquasecurity/trivy-action@master
with:
image-ref: ghcr.io/my-org/my-api:${{ github.sha }}
format: table
exit-code: 1
severity: CRITICAL,HIGHWith Go images based on Alpine or scratch, Trivy results are usually clean. If you use a heavier base image, be prepared to manage CVEs that have nothing to do with your code.
Multi-architecture
If you deploy on ARM (AWS Graviton, Apple Silicon), you need multi-architecture builds:
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/my-org/my-api:latest \
--push .Go makes this trivial because cross-compilation is native. You don’t need emulators or architecture-specific builders. Just GOARCH=arm64 and you’re done.
Final reference Dockerfile
With all of the above, this is the Dockerfile I use as a starting point for any Go API:
# === Build ===
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
ARG VERSION=dev
ARG COMMIT=unknown
RUN CGO_ENABLED=0 GOOS=linux \
go build \
-ldflags="-s -w -X main.version=${VERSION} -X main.commitHash=${COMMIT}" \
-o server ./cmd/api
# === Runtime ===
FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata \
&& addgroup -S appgroup \
&& adduser -S appuser -G appgroup
WORKDIR /app
COPY --from=builder /app/server .
EXPOSE 8080
USER appuser:appgroup
HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1
CMD ["./server"]It’s built with:
docker build \
--build-arg VERSION=1.0.0 \
--build-arg COMMIT=$(git rev-parse --short HEAD) \
-t my-api:1.0.0 .The result: an image of ~15 MB with health check, non-root user, TLS certificates, time zone, injected version and nothing else. No surprises, no hidden dependencies, no runtime to maintain.
One binary, one image, zero surprises
Go and Docker fit together naturally. Where other languages need elaborate multi-stage builds, aggressive dependency caching and layer optimization just to reach 200 MB images, Go gives you 10-15 MB images with a straightforward Dockerfile. CGO_ENABLED=0 for static binaries, multi-stage builds to separate compilation from runtime, Alpine or distroless as base, -ldflags="-s -w" to trim the binary, integrated health checks and layer caching with go.mod/go.sum separated from the code. Everything fits without forcing anything.
You don’t need sophisticated deployment architectures to start. A multi-stage Dockerfile, a docker compose up and your API is running with PostgreSQL locally. When CI/CD time comes, the same Dockerfile works without changes.
The simplicity of deployment is one of the most pragmatic reasons to choose Go for backend. It’s not the most expressive language nor the one with the most features. But when it comes to getting code to production, less complexity is exactly what you want.


