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.

Cover for Dockerize a Go API: small binaries, clean images and simple deployment

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/amd64 from 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    387MB

This 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     18MB

From 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 executable

With 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/api

Injecting 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:latest

If 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 1

If 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_on with condition: 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 run docker 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=disable

With 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:

StackBase imageFinal 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/api

The 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=max

type=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,HIGH

With 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.

OshyTech

Backend and data engineering focused on scalable systems, automation, and AI.

Navigation

Copyright 2026 OshyTech. All Rights Reserved