Ravindra BagaleCourses & study guides मराठी

Chapter 54: Dockerfile Examples for Node.js, Python Flask and Django, Spring Boot, .NET, Go, PHP, nginx, MySQL, Postgres and Redis

54.5 Go Dockerfile with scratch or distroless

Friends, Go is the friendliest language for Docker. It compiles to one single file with no runtime needed. So the final image can be almost empty: scratch, or distroless. Today we build a Go API step by step, from a 900 MB image to a 7 MB image, and we handle the three traps: CGO, certificates and time zones.

1. Start from a story

At Razorpay, a small team wrote a Go service that converts currency amounts and calls an exchange rate API over HTTPS.

The first image used the golang image as the runtime. It was 880 MB and a security scan listed more than 200 packages with known issues, none of which the service used.

They moved to scratch and the image became 8 MB, but production calls failed with x509: certificate signed by unknown authority, and the time showed UTC instead of IST.

Kiran, the platform engineer, fixed it: a static build with CGO turned off, CA certificates and time zone data copied in, a non-root user, and later distroless for an easier life. Today we follow Kiran's steps.

Stage buildgolang:1.23go mod downloadcached layerCGO_ENABLED=0 go buildone static binaryStage finalscratch or distroless/staticCOPY binary + certs + zoneinfonothing elseUSER 65532non-root
Picture 1: Go image pattern

2. Build a static binary

Step 1: The sample Go API

Why: Something to ship.

What: A tiny API with a health route and a route that calls an HTTPS site, so we can see the certificate trap.

Example 1: Init module

Type this:

docker run --rm -v "$PWD":/src -w /src golang:1.23 go mod init example.com/fx && cat go.mod

You will see:

module example.com/fx

go 1.23

What it means: No Go needed on the laptop.

Example 2: Write main.go

Type this:

printf '%s\n' 'package main' 'import ("fmt";"net/http";"time")' 'func main() {' ' http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "ok") })' ' http.HandleFunc("/time", func(w http.ResponseWriter, r *http.Request) { l, _ := time.LoadLocation("Asia/Kolkata"); fmt.Fprint(w, time.Now().In(l).Format(time.Kitchen)) })' ' http.HandleFunc("/ping-out", func(w http.ResponseWriter, r *http.Request) { _, err := http.Get("https://example.com"); fmt.Fprint(w, err) })' ' http.ListenAndServe(":8080", nil)' '}' > main.go && echo ok

You will see:

ok

What it means: Three routes: health, time and an outgoing HTTPS call.

Example 3: Naive image

Type this:

printf '%s\n' 'FROM golang:1.23' 'WORKDIR /src' 'COPY . .' 'RUN go build -o /fx' 'CMD ["/fx"]' > Dockerfile && docker build -q -t fx:naive . && docker images fx:naive --format '{{.Size}}'

You will see:

884MB

What it means: The whole Go toolchain ships with the app.

Step 2: Download modules first

Why: Cache dependencies.

What: Copy go.mod and go.sum, run go mod download, then copy the code. A code change does not re-download modules.

Example 4: Stage 1

Type this:

printf '%s\n' '# syntax=docker/dockerfile:1' 'FROM golang:1.23 AS build' 'WORKDIR /src' 'COPY go.mod go.sum* ./' 'RUN go mod download' 'COPY . .' > Dockerfile && wc -l Dockerfile

You will see:

6 Dockerfile

What it means: go.sum* works even when there are no dependencies yet.

Example 5: Cache mounts

Type this:

printf '%s\n' 'RUN --mount=type=cache,target=/root/.cache/go-build --mount=type=cache,target=/go/pkg/mod CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/fx .' >> Dockerfile && tail -1 Dockerfile | cut -c1-60

You will see:

RUN --mount=type=cache,target=/root/.cache/go-build --mount

What it means: Build cache and module cache stay outside the image.

Example 6: Build stage

Type this:

docker build -q --target build -t fx:build . && docker run --rm fx:build ls -lh /out/fx

You will see:

-rwxr-xr-x 1 root root 5.6M /out/fx

What it means: One 5.6 MB file.

Step 3: CGO_ENABLED=0

Why: Truly static.

What: With CGO on, Go may link to the C library of the build image. On scratch there is no C library, so the binary fails to start. CGO_ENABLED=0 makes it fully static.

Example 7: Check static

Type this:

docker run --rm fx:build sh -c 'apt-get -qq update >/dev/null; file /out/fx' | cut -d, -f1-5

You will see:

/out/fx: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked

What it means: statically linked is what we want.

Example 8: The failure without it

Type this:

printf 'With CGO on, a scratch container prints: exec /fx: no such file or directory\n'

You will see:

With CGO on, a scratch container prints: exec /fx: no such file or directory

What it means: Confusing message: the missing file is the C loader, not your binary.

Example 9: ldflags

Type this:

printf '%s\n' '-s removes the symbol table' '-w removes debug info' '-trimpath removes local paths'

You will see:

-s removes the symbol table
-w removes debug info
-trimpath removes local paths

What it means: Smaller binary, no laptop paths inside.

Step 4: Build for another CPU

Why: Graviton and Apple silicon.

What: Go cross-compiles easily. With buildx, TARGETOS and TARGETARCH arrive as build arguments and we pass them to go build.

Example 10: Use the args

Type this:

sed -i 's#^FROM golang:1.23 AS build#FROM --platform=$BUILDPLATFORM golang:1.23 AS build\nARG TARGETOS TARGETARCH#' Dockerfile && sed -i 's#CGO_ENABLED=0 go build#CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build#' Dockerfile && grep -c TARGET Dockerfile

You will see:

2

What it means: The compiler runs natively and outputs for the target.

Example 11: Build two platforms

Type this:

docker buildx build --platform linux/amd64,linux/arm64 --target build -t fx:multi . 2>&1 | grep -c 'go build'

You will see:

2

What it means: One build per platform, both fast.

Example 12: Why it matters

Type this:

printf 'AWS Graviton (arm64) instances cost about 20 percent less for the same work.\n'

You will see:

AWS Graviton (arm64) instances cost about 20 percent less for the same work.

What it means: Real savings for Go services.

Step 5: Stamp the version into the binary

Why: Know what is running.

What: -ldflags -X sets a string variable at build time. We pass the Git commit as a build argument, so every image can tell us its version.

Example 13: Variable in code

Type this:

printf '%s\n' 'var version = "dev"' 'http.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, version) })'

You will see:

var version = "dev"
http.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, version) })

What it means: dev is the default for local runs.

Example 14: Pass it in

Type this:

sed -i 's#-ldflags="-s -w"#-ldflags="-s -w -X main.version=$VERSION"#' Dockerfile && sed -i 's#^ARG TARGETOS TARGETARCH#ARG TARGETOS TARGETARCH VERSION=dev#' Dockerfile && grep -c VERSION Dockerfile

You will see:

2

What it means: ARG plus the ldflags use.

Example 15: Build with the commit

Type this:

docker build -q --build-arg VERSION=$(git rev-parse --short HEAD) -t fx:v . && docker run -d -p 8085:8080 --name fxv fx:v && curl -s localhost:8085/version

You will see:

a1b2c3d

What it means: Support teams can now see exactly which commit is live.

3. scratch and distroless runtimes

golang:1.23about 880 MB, never shipalpine:3.20about 13 MB, has a shelldistroless/staticabout 7 MB, certs, tzdata, nonroot userscratchabout 6 MB, truly emptyYou addcerts and zoneinfo when using scratch
Picture 2: Go runtime choices

Step 6: A scratch runtime

Why: Nothing but our file.

What: scratch is an empty image. We copy only the binary.

Example 16: Final stage

Type this:

printf '%s\n' 'FROM scratch' 'COPY --from=build /out/fx /fx' 'EXPOSE 8080' 'ENTRYPOINT ["/fx"]' >> Dockerfile && docker build -q -t fx:scratch . && docker images fx:scratch --format '{{.Size}}'

You will see:

5.6MB

What it means: From 884 MB to 5.6 MB.

Example 17: Run it

Type this:

docker run -d -p 8080:8080 --name fx fx:scratch && curl -s localhost:8080/healthz

You will see:

ok

What it means: It works.

Example 18: No shell

Type this:

docker exec fx sh 2>&1 | head -1

You will see:

OCI runtime exec failed: exec: "sh": executable file not found in $PATH

What it means: Nothing to attack, nothing to log into.

Step 7: Trap 1: HTTPS certificates

Why: x509 errors.

What: scratch has no CA certificate bundle, so outgoing HTTPS calls fail. Copy the bundle from the build stage.

Example 19: See the error

Type this:

curl -s localhost:8080/ping-out

You will see:

Get "https://example.com": tls: failed to verify certificate: x509: certificate signed by unknown authority

What it means: Exactly Kiran's production error.

Example 20: Copy certs

Type this:

sed -i 's#^COPY --from=build /out/fx /fx#COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/\nCOPY --from=build /out/fx /fx#' Dockerfile && grep ssl Dockerfile

You will see:

COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

What it means: The golang image already has the bundle.

Example 21: Test again

Type this:

docker build -q -t fx:scratch2 . && docker rm -f fx && docker run -d -p 8080:8080 --name fx fx:scratch2 && curl -s localhost:8080/ping-out

You will see:

<nil>

What it means: No error now. Go prints nil when err is empty.

Step 8: Trap 2: time zones

Why: UTC instead of IST.

What: time.LoadLocation needs zoneinfo files. scratch has none. Copy them, or embed them with the timetzdata package.

Example 22: See the problem

Type this:

curl -s localhost:8080/time

You will see:

5:12AM

What it means: Looks like UTC: the location failed to load.

Example 23: Copy zoneinfo

Type this:

sed -i 's#^COPY --from=build /out/fx /fx#COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo\nCOPY --from=build /out/fx /fx#' Dockerfile && grep zoneinfo Dockerfile

You will see:

COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo

What it means: About 450 KB of data.

Example 24: Or embed

Type this:

printf '%s\n' 'import _ "time/tzdata"' '// adds about 450 KB to the binary, no files needed'

You will see:

import _ "time/tzdata"
// adds about 450 KB to the binary, no files needed

What it means: Either way the time is now 10:42AM IST.

Step 9: Trap 3: non-root on scratch

Why: No useradd on an empty image.

What: scratch has no useradd and no /etc/passwd. Just set a numeric USER. 65532 is the common nonroot ID.

Example 25: Set USER

Type this:

sed -i 's#^ENTRYPOINT \["/fx"\]#USER 65532:65532\nENTRYPOINT ["/fx"]#' Dockerfile && grep USER Dockerfile

You will see:

USER 65532:65532

What it means: A number works without any passwd file.

Example 26: Check

Type this:

docker build -q -t fx:scratch3 . && docker inspect fx:scratch3 --format '{{.Config.User}}'

You will see:

65532:65532

What it means: Recorded in the image config.

Example 27: Port rule

Type this:

printf 'Non-root users cannot bind ports below 1024, so listen on 8080.\n'

You will see:

Non-root users cannot bind ports below 1024, so listen on 8080.

What it means: Our app already uses 8080.

Step 10: distroless/static

Why: All three traps solved for you.

What: Google's distroless static image already has certificates, time zones and a nonroot user. The nonroot tag sets USER 65532.

Example 28: Swap the base

Type this:

sed -i '/^FROM scratch/,$d' Dockerfile && printf '%s\n' 'FROM gcr.io/distroless/static-debian12:nonroot' 'COPY --from=build /out/fx /fx' 'EXPOSE 8080' 'ENTRYPOINT ["/fx"]' >> Dockerfile && tail -4 Dockerfile | head -1

You will see:

FROM gcr.io/distroless/static-debian12:nonroot

What it means: Four short lines.

Example 29: Size and user

Type this:

docker build -q -t fx:dl . && docker images fx:dl --format '{{.Size}}' && docker inspect fx:dl --format '{{.Config.User}}'

You will see:

7.6MB
65532

What it means: Just 2 MB more than scratch.

Example 30: Everything works

Type this:

docker rm -f fx && docker run -d -p 8080:8080 --name fx fx:dl && curl -s localhost:8080/ping-out; echo; curl -s localhost:8080/time

You will see:

<nil>
10:44AM

What it means: HTTPS and IST both fine.

Step 11: Healthcheck without curl

Why: Use the binary itself.

What: There is no curl in scratch or distroless. Add a small health flag to the program, or let Kubernetes probes call the HTTP route.

Example 31: Add a flag

Type this:

printf '%s\n' 'if len(os.Args) > 1 && os.Args[1] == "health" {' '  r, err := http.Get("http://127.0.0.1:8080/healthz")' '  if err != nil || r.StatusCode != 200 { os.Exit(1) }' '  os.Exit(0)' '}'

You will see:

if len(os.Args) > 1 && os.Args[1] == "health" {
  r, err := http.Get("http://127.0.0.1:8080/healthz")
  if err != nil || r.StatusCode != 200 { os.Exit(1) }
  os.Exit(0)
}

What it means: Put this at the top of main and import os.

Example 32: HEALTHCHECK

Type this:

sed -i 's#^ENTRYPOINT#HEALTHCHECK --interval=10s CMD ["/fx","health"]\nENTRYPOINT#' Dockerfile && grep HEALTH Dockerfile

You will see:

HEALTHCHECK --interval=10s CMD ["/fx","health"]

What it means: Exec form, no shell needed.

Example 33: Status

Type this:

docker build -q -t fx:dl2 . && docker rm -f fx && docker run -d -p 8080:8080 --name fx fx:dl2 && sleep 12 && docker ps --filter name=fx --format '{{.Status}}'

You will see:

Up 12 seconds (healthy)

What it means: Healthy with zero extra tools.

Step 12: Debugging a tiny image

Why: When there is no shell.

What: Use the debug tag of distroless, docker debug, or a temporary container sharing the same namespaces.

Example 34: Debug tag

Type this:

docker run --rm --entrypoint sh gcr.io/distroless/static-debian12:debug -c 'ls /etc/ssl/certs | head -1'

You will see:

ca-certificates.crt

What it means: The debug tag adds busybox for testing only.

Example 35: Share namespaces

Type this:

docker run --rm -it --pid container:fx --network container:fx busybox sh -c 'ps; wget -qO- localhost:8080/healthz'

You will see:

PID   USER     COMMAND
    1 65532    /fx
ok

What it means: busybox sees the fx process and network.

Example 36: Copy files out

Type this:

docker cp fx:/fx ./fx-from-container && ls -lh fx-from-container

You will see:

-rwxr-xr-x 1 user user 5.6M fx-from-container

What it means: docker cp works without a shell.

4. Common mistakes and how Kiran avoids them

Mistake one: shipping the golang image. Kiran ships scratch or distroless.

Mistake two: forgetting CGO_ENABLED=0. The binary then fails on scratch with no such file or directory.

Mistake three: no CA certificates. HTTPS calls fail with x509 errors.

Mistake four: no zoneinfo. Times are silently wrong.

Mistake five: running as root. Kiran sets USER 65532 or uses the nonroot tag.

Check yourself

Q1. Why can Go use scratch?

It builds one static binary that needs no runtime.

Q2. What error appears without CA certs?

x509: certificate signed by unknown authority.

Q3. How do you set non-root on scratch?

USER 65532:65532, a numeric ID.

Q4. What does distroless/static add over scratch?

Certificates, time zone data and a nonroot user.

Friends, go mod download first, CGO off, strip the binary, then scratch with certs and zoneinfo, or simply distroless nonroot. Did you understand? Let us try it once more. Build the naive image and the distroless image and write both sizes in your notebook.

Ravindra Bagale's Tip

For most Go services choose distroless static nonroot. You get almost the size of scratch without the certificate and time zone surprises.

Lab: try at home

  1. Write the fx API and build the naive image.
  2. Add a build stage with go mod download and cache mounts.
  3. Ship it on scratch and hit /ping-out to see the x509 error.
  4. Copy certs and zoneinfo, then set USER 65532.
  5. Switch to distroless nonroot and compare sizes.
  6. Add the health flag and a HEALTHCHECK.

Recap

  • Copy go.mod first and download modules.
  • CGO_ENABLED=0 for a static binary.
  • -trimpath and -ldflags -s -w for a smaller file.
  • scratch needs certs, zoneinfo and a numeric USER.
  • distroless static nonroot has them already.
  • Health check with the binary itself.

Ravi Sir's Cards for this lesson

मराठी cards