TechEarl

How to Dockerize a Go App (Multi-Stage + Scratch for ~10 MB Images)

Go's static binaries are perfect for tiny Docker images. Build in a Go image, copy the single binary into FROM scratch (or distroless), and ship under 15 MB total — no shell, no runtime libraries, nothing else.

Ishan Karunaratne⏱️ 7 min readUpdated
Share thisCopied
A Go Dockerfile that produces a 10-15 MB image: static binary with CGO_ENABLED=0, multi-stage build, FROM scratch final, ca-certificates and tzdata included, distroless alternative.

Go produces statically-linked binaries by default if you disable CGO. That makes Go uniquely well-suited to the absolute-smallest Docker image: build the binary in a Go image, copy just the binary into FROM scratch, and ship a 10-15 MB image with literally nothing else in it. No shell, no glibc, no userland — just the binary and its environment.

A working Dockerfile

dockerfile
# Build stage
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app ./cmd/app

# Runtime stage
FROM scratch
COPY --from=build /out/app /app
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/app"]

Build:

bash
docker build -t my-go-app .
docker images my-go-app
# REPOSITORY    TAG       SIZE
# my-go-app     latest    11.3MB

That's the whole thing. Run it:

bash
docker run -d --name my-go-app -p 8080:8080 my-go-app

Why CGO_ENABLED=0 matters

Go's default build dynamically links against the C library (glibc on Debian/Alpine). That means the resulting binary needs a libc inside the container, which means you can't use FROM scratch.

CGO_ENABLED=0 disables CGO entirely. Go uses its own implementations of DNS resolution, file operations, etc., and the resulting binary has no C-library dependencies. That binary runs in a fully empty image.

The exception: if your app imports a package that uses CGO (like older versions of mattn/go-sqlite3), you can't disable CGO and you need a glibc-bearing base image like distroless/base or gcr.io/distroless/static-debian12.

The three things to copy in (besides the binary)

FROM scratch is completely empty. Even basic things you take for granted aren't there. The three you usually need:

  1. ca-certificates.crt — for HTTPS calls. Without it, every http.Client request to an HTTPS URL fails with "x509: certificate signed by unknown authority." Copy from the build image.
  2. /usr/share/zoneinfo — for time-zone-aware code. Without it, time.LoadLocation("America/New_York") returns an error.
  3. A non-root user. USER 65534:65534 (the well-known nobody UID) works in scratch since there's no /etc/passwd to consult — Docker just runs the process as UID 65534. The kernel doesn't care that there's no user record.

The -ldflags="-s -w" flag strips symbol tables and debug info. Shaves a few MB off the binary.

Alternative: distroless

If FROM scratch is too sparse (you really want ca-certificates and timezone data without copying them manually, or you have CGO dependencies), Google's distroless images are the next tier up.

dockerfile
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/app ./cmd/app

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
EXPOSE 8080
ENTRYPOINT ["/app"]

distroless/static-debian12:nonroot ships:

  • A non-root user already set up.
  • ca-certificates, tzdata, and a few other essentials.
  • No shell, no package manager, no useless binaries.
  • About 2 MB on top of your Go binary.

distroless/static is for pure static binaries (CGO_ENABLED=0). distroless/base-debian12 includes glibc for CGO-using binaries.

I default to scratch + the manual file copies above for "absolutely smallest"; distroless for "I want some niceties without giving up much size."

Layer-cache pattern

Same idea as Node/Python: copy go.mod and go.sum first, run go mod download, then copy the source. Source edits don't bust the dependency-download layer.

dockerfile
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build ...

Without this, every source change re-downloads every module. With it, the download layer caches until go.mod or go.sum changes.

Building for multiple architectures

Go cross-compiles trivially. To build for both amd64 and arm64 in one go:

bash
docker buildx build --platform linux/amd64,linux/arm64 -t my-go-app --push .

The Dockerfile doesn't change — Go picks up the target architecture from the build environment. See docker buildx: Multi-Architecture Builds.

Practical usage: a small HTTP server

main.go:

go
package main

import (
    "fmt"
    "net/http"
    "os"
)

func main() {
    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, `{"ok":true}`)
    })
    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprint(w, "Hello from Go in Docker")
    })
    port := os.Getenv("PORT")
    if port == "" { port = "8080" }
    http.ListenAndServe(":"+port, nil)
}

Build and run with the Dockerfile above:

bash
docker build -t my-go-app .
docker run -d --name my-go-app -p 8080:8080 my-go-app
curl http://localhost:8080/healthz
# {"ok":true}

Image size: about 11 MB. The Go binary itself is about 8 MB after -ldflags="-s -w"; the rest is the ca-certificates file and the timezone data.

Compose

yaml
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      DATABASE_URL: postgres://postgres:hello@db:5432/myapp
    depends_on:
      db:
        condition: service_healthy
    restart: unless-stopped

  db:
    image: postgres:17
    environment:
      POSTGRES_PASSWORD: hello
    volumes:
      - pg-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 3s
      retries: 5

volumes:
  pg-data:

Common pitfalls

  • HTTPS calls fail with "certificate signed by unknown authority." You forgot to copy ca-certificates.crt into the scratch image.
  • time.LoadLocation("America/New_York") returns an error. Missing zoneinfo. Copy /usr/share/zoneinfo from the build stage, or import _ "time/tzdata" in your Go code (embeds the data into the binary at ~500 KB cost).
  • CGO surprise. A dependency uses CGO and your CGO_ENABLED=0 build fails. Either find a pure-Go alternative, or switch to distroless/base and don't disable CGO.
  • Building on Apple Silicon without setting GOOS/GOARCH. The host's Docker build can produce arm64 images by default; if your target is amd64 servers, set the platform explicitly with --platform linux/amd64.
  • No USER in scratch image. Running as root inside scratch isn't a security gain — there's nothing to attack. But setting USER 65534:65534 is one line and good hygiene.

What to do next

FAQ

Sources

Authoritative references this article was fact-checked against.

TagsDockerGoGolangDockerfilescratchMulti-stageDevOps

Found this useful? Pass it on.

Copied

Ishan Karunaratne

Software Systems Architect · Senior Software Engineer · Engineering Leadership

Software systems architect and senior software engineer with more than two decades designing, building, and running production software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Now a CTO, though what I write here is drawn from the full arc of that work, across architecture, engineering, and operations, not any single job.

Keep reading

Related posts

A working Dockerfile for PHP and Laravel: multi-stage with Composer, php-fpm + Nginx via Compose, OPcache config, and the Laravel storage / bootstrap-cache permission fix.

How to Dockerize a PHP / Laravel App

A Dockerfile for PHP-FPM + Nginx, the Composer build step, OPcache config, and the Laravel storage/cache permission flip that catches every PHP developer once.