Guides · Deployment
Golang Dockerfile: a 15 MB production image
A two-stage Dockerfile that builds a static Go binary and ships it on an image with nothing else in it, and the errors you hit on the way.
By Jonathan Pitter ·
The short answer
Build a static binary in the official Go image, then copy only that binary into a minimal runtime image:
# syntax=docker/dockerfile:1
# 1. Build a static binary
FROM golang:1.27-alpine AS build
WORKDIR /src
# Dependencies first, so this layer stays cached until go.mod or go.sum change
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server .
# 2. Run it on an image with nothing else in it
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
For a small web service using chi, this image came out at 14.7 MB and runs as an unprivileged user (uid 65532). The rest of this guide explains each line, the variants you might need, and the errors that come up most.
What each line does
golang:1.27-alpine AS build is the official Go toolchain on Alpine. Nothing from this stage ships except the binary, so its size doesn't matter. Use the Go version your go.mod asks for, or newer.
COPY go.mod go.sum ./ then go mod download fetches dependencies in their own layer. Docker reuses it on every build until one of those two files changes, so editing a handler doesn't download every module again.
CGO_ENABLED=0 makes the Go linker produce a fully static binary, with no dependency on the C library. That's what lets it run on an image with no libc at all. If your code (or a dependency such as mattn/go-sqlite3) needs cgo, see "When you need cgo" below.
-trimpath -ldflags="-s -w" strips your local file paths and the debug symbol tables from the binary. It's a third smaller, and stack traces still have function names and line numbers.
gcr.io/distroless/static-debian13:nonroot contains CA certificates, time zone data, /etc/passwd with a nonroot user, and nothing else: no shell and no package manager. Fewer files means fewer CVEs to triage, and a compromised process has no tools to work with. The nonroot tag sets the user for you.
Listen on PORT and 0.0.0.0
Most platforms tell your app which port to use through the PORT environment variable, and route traffic to the container's network interface, not to localhost. Read the port from the environment and listen on every interface:
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Fatal(http.ListenAndServe(":"+port, r))
":8080" means every interface. "localhost:8080" or "127.0.0.1:8080" only accepts connections from inside the container, so the app starts and nothing reaches it.
Add a .dockerignore
.git
*.test
bin/
tmp/
.env*
Dockerfile
.dockerignore
This keeps the build context small and stops local .env files from being copied into a layer.
Build and run it
docker build -t my-service .
docker run -p 8080:8080 -e PORT=8080 my-service
Variants
Alpine instead of distroless
If you want a shell in the container for debugging, use Alpine and create the user yourself:
FROM alpine:3.22
RUN apk add --no-cache ca-certificates tzdata && adduser -D -u 1001 app
COPY --from=build /out/server /server
USER app
ENTRYPOINT ["/server"]
It adds about 10 MB (24.7 MB against 14.7 MB for the same service).
scratch
FROM scratch is smaller still, but it has no CA certificates (so outbound HTTPS fails), no time zone database and no users. Copy them from the build stage if you go that way. Distroless static is the same idea with those already in place.
When you need cgo
Build with CGO_ENABLED=1 and run on an image that has the C library your binary links against: gcr.io/distroless/base-debian13 for a binary built on Debian (golang:1.27), or Alpine for one built on golang:1.27-alpine. A binary built against glibc doesn't run on Alpine's musl, and the error is a confusing "no such file or directory" for a file that exists.
Cache mounts
With BuildKit you can keep the module and build caches between builds, which speeds up rebuilds a lot:
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server .
Common problems
"go.sum": not found
A module with no dependencies has no go.sum, so COPY go.mod go.sum ./ fails. Run go mod tidy and commit the file, or write COPY go.mod go.sum* ./, which copies it only if it exists.
go.mod requires go >= 1.27 (running go 1.24; GOTOOLCHAIN=local)
The official golang images set GOTOOLCHAIN=local, so the go command refuses to build a module that asks for a newer Go than the image has. Either use a newer image, or add ENV GOTOOLCHAIN=auto before the first go command, which makes it download the toolchain go.mod names.
x509: certificate signed by unknown authority
The runtime image has no CA certificates. Distroless static and the Alpine variant above include them; scratch doesn't.
The container exits immediately with "no such file or directory"
The binary is dynamically linked and the runtime image doesn't have the libraries. Check that CGO_ENABLED=0 is set in the build, or see "When you need cgo".
exec format error
The image was built for a different CPU architecture than the machine running it, for example on an Apple Silicon laptop for an x86 server. Build with docker build --platform linux/amd64, or set GOARCH in the build stage.
Deploying it on Staxa
If your repository has a Dockerfile, Staxa builds it as it is. If it doesn't, Staxa detects Go from go.mod at the root and generates one. This is the Dockerfile it writes for a service on port 8080 with Go 1.27 selected:
FROM golang:1.27-alpine AS builder
WORKDIR /app
RUN apk add --no-cache git ca-certificates
# The image pins its own Go (GOTOOLCHAIN=local). auto fetches the newer
# toolchain a go.mod asks for instead of failing the build.
ENV GOTOOLCHAIN=auto
# go.sum is optional: a module with no dependencies has none.
COPY go.mod go.sum* ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o app .
FROM alpine:3.22
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/app .
RUN adduser -D -u 1001 app
USER app
ENV PORT=8080
EXPOSE 8080
CMD ["./app"]
It builds the main package at the root of the repository, so a project whose entry point is under cmd/server needs its own Dockerfile. The app must read PORT, which Staxa sets to the service's port. The Golang hosting guide covers the rest: the database variables, health checks and shutdown. The Go and PostgreSQL demo is a complete app with its own Dockerfile.
Frequently asked questions
Distroless or Alpine?
Distroless if you don't need to open a shell in the container, Alpine if you do. Both are small; distroless has fewer packages to keep patched.
Should I use the golang image to run the app?
No. It is more than 300 MB of compiler and tools that the running service never uses. Build in it, run on something small.
How do I add a health check?
Add a route that returns 200 without touching the database, such as /healthz, and point your platform's readiness check at it. Docker's own HEALTHCHECK instruction needs a binary to run the check, which distroless doesn't have, and Kubernetes ignores it anyway.
Why is my image still large?
Check that the final stage starts with its own FROM and copies only the binary. If you see /go or /src in the final image, the runtime stage is copying the whole build.