Go Dockerfile FROM scratch: Static Binary, Non-Root UID

This Go Dockerfile compiles a static, CGO-free binary on golang:1.25-alpine and ships it on an empty scratch image. Only CA certificates and time zone data are copied in next to it. Nothing else exists in the runtime image: no shell, no libc, no package manager. The process runs as UID 65532 and listens on port 8080.

CI status
Build passing
Image size (uncompressed)
6.2 MB
Build time
28 s
Runtime image
scratch
Port
8080
Last built
2026-10-15
Dockerfile
# syntax=docker/dockerfile:1
FROM golang:1.25-alpine AS build
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/server

FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /out/app /app
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/app"]
.dockerignore
.git
bin/
*.test
.env
Dockerfile
.dockerignore

Before you build

How this Dockerfile works

  1. FROM golang:1.25-alpine AS build

    The build stage holds the Go toolchain. It's discarded after compiling, so its size never reaches your deployed image.

  2. RUN apk add --no-cache ca-certificates tzdata

    Installs the certificate bundle and time zone database in the build stage, purely so they can be copied into the empty runtime image later.

  3. COPY go.mod go.sum ./ RUN go mod download

    Downloading modules before the source is copied caches dependencies, so editing code doesn't trigger a fresh download.

  4. RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/server

    CGO_ENABLED=0 is what makes scratch possible: the binary links nothing dynamically. -s -w strip debug symbols, and -trimpath removes local file paths.

  5. FROM scratch

    scratch is Docker's empty base image. Whatever you copy in is all that exists, which gives the smallest possible image and almost no attack surface.

  6. COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo

    Without the certificate bundle, outgoing HTTPS calls fail. Without zoneinfo, time.LoadLocation can't resolve time zones. Both are plain files the binary reads at runtime.

  7. USER 65532:65532

    scratch has no user database, so a numeric UID and GID are used. 65532 is the conventional nonroot ID used by distroless images.

  8. ENTRYPOINT ["/app"]

    Exec form is required because there is no shell to parse a command string. The binary runs as the main process and receives stop signals directly.

Build and run

terminal
docker build -t go-app .
docker run --rm -p 8080:8080 go-app

Build from the module root where go.mod lives, then send a request to http://localhost:8080. With no shell inside the image, use logs and HTTP probes to see what the service is doing.

Other Go Dockerfile variants

scratch gives the smallest Go image, but you assemble the runtime files yourself. Distroless static bundles the same files and a nonroot user for you. The Alpine runtime adds a shell, which makes debugging inside the container easier. Compare their measured sizes in the table below.

VariantRuntime imageImage sizeCI
Alpine runtime (shell available)alpine:3.2214.8 MBPassing
Distroless static (nonroot)gcr.io/distroless/static-debian12:nonroot7.7 MBPassing
Scratch (static, nonroot UID)scratch6.2 MBPassing

Troubleshooting

x509: certificate signed by unknown authority
The certificate bundle didn't reach the runtime image. Keep the COPY line for ca-certificates.crt and make sure the build stage installs the ca-certificates package.
exec /app: no such file or directory
The binary is dynamically linked, usually because cgo was enabled by a dependency. Build with CGO_ENABLED=0, or switch to a base image that includes libc.
unknown time zone Europe/Berlin
The zoneinfo directory is missing. Keep the COPY of /usr/share/zoneinfo, or embed the database in your binary by importing time/tzdata.

FAQ

Is scratch more secure than distroless?
Both have no shell or package manager, so the attack surface is similar. Distroless saves you from copying certificates and defining a user. scratch gives full control over every file in the image.
How do I debug a scratch container?
You can't exec into a shell. Attach an ephemeral debug container that shares the process namespace, or temporarily build the same binary on the Alpine runtime variant.
Why use a numeric USER instead of a name?
User names resolve through /etc/passwd, which doesn't exist in scratch. A numeric UID and GID work without that file, and orchestrators can still verify the container isn't root.