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
# 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"].git
bin/
*.test
.env
Dockerfile
.dockerignoreBefore you build
- Put your main package in ./cmd/server, or change the path at the end of the go build line.
- Avoid cgo dependencies, because the binary must be fully static for an empty base image.
- Make the server listen on :8080 or read the port from an environment variable.
- Commit go.mod and go.sum so the module download layer can be cached.
How this Dockerfile works
FROM golang:1.25-alpine AS buildThe build stage holds the Go toolchain. It's discarded after compiling, so its size never reaches your deployed image.
RUN apk add --no-cache ca-certificates tzdataInstalls the certificate bundle and time zone database in the build stage, purely so they can be copied into the empty runtime image later.
COPY go.mod go.sum ./ RUN go mod downloadDownloading modules before the source is copied caches dependencies, so editing code doesn't trigger a fresh download.
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/serverCGO_ENABLED=0 is what makes scratch possible: the binary links nothing dynamically. -s -w strip debug symbols, and -trimpath removes local file paths.
FROM scratchscratch 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.
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfoWithout 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.
USER 65532:65532scratch has no user database, so a numeric UID and GID are used. 65532 is the conventional nonroot ID used by distroless images.
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
docker build -t go-app .
docker run --rm -p 8080:8080 go-appBuild 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.
| Variant | Runtime image | Image size | CI |
|---|---|---|---|
| Alpine runtime (shell available) | alpine:3.22 | 14.8 MB | Passing |
| Distroless static (nonroot) | gcr.io/distroless/static-debian12:nonroot | 7.7 MB | Passing |
| Scratch (static, nonroot UID) | scratch | 6.2 MB | Passing |
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.