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 KarunaratneIshan Karunaratne⏱️ 6 min readUpdated
Share thisCopied

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

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years building software, Linux systems, and DevOps infrastructure, and lately working AI into the stack. Currently Chief Technology Officer at a healthcare tech startup, which is where most of these field notes come from.

Keep reading

Related posts

H

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.