Smaller and more secure Docker images for Golang
Preface
In this article, I want to show you how to build smaller and more secure Docker images for Golang. I’ll not only show how to do this, but more importantly why. I want to emphasize one obvious and crucial thing. This article won’t make your images secure. Security isn’t a one-time thing. It’s your responsibility to handle vulnerabilities in your code and its dependenciecs. It will help by reducing potential vulnerabilities in the base image and applying some best practices for building Docker images for Golang.
Code
I used github.com/google/uuid
library to have some dependency. We’ll need it later.
main.go
package main
import (
"fmt"
"github.com/google/uuid"
)
func main() {
fmt.Println(uuid.NewString())
}
go.mod
module github.com/zeraye/tiny-go-docker
go 1.24.3
require github.com/google/uuid v1.6.0
go.sum
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
Step 1
When you build your first Golang image, you will end up with something like this:
FROM golang:1.24.3-alpine3.21
WORKDIR /app
COPY . .
RUN go mod download
RUN go build -o /main .
CMD ["/main"]
It weighs 316MB, that’s a lot. There are also a lot of things we can improve in terms of security and image size. Let’s get to work:
1. Verify downloaded dependencies.
You can verify that the dependencies of the current module, which are stored in a local downloaded source cache, have not been modified since being downloaded:
RUN go mod download && go mod verify
Let’s assume someone tampers with the source of github.com/google/uuid v1.6.0
. Next time you try to download this package, verification process will return a non-zero exit code with something like this:
github.com/google/uuid v1.6.0: dir has been modified
2. First download dependencies then build the project.
You don’t want to download dependencies every time you change something in the source code. Let’s download dependencies and then build project. Now after you change some code, the layer that downloads dependencies would be cached:
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /main .
Note: go build
will download dependecies by itself, so the example from the beginning where go mod download
is directly over go build
would work without RUN go mod download
line.
3. Run as non-root user.
This is well known security practice. I don’t have anything more to add here. See Docker best practices about USER or search it.
FROM golang:1.24.3-alpine3.21
# rest of the Dockerfile...
USER nobody:nobody
CMD ["/main"]
Note: User and group nobody
are built into the Alpine image.
Step 2
Let’s apply all suggestions from step 1 and see what we get:
FROM golang:1.24.3-alpine3.21
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
RUN go build -o /main .
USER nobody:nobody
CMD ["/main"]
It still weighs 316MB, but it’s much more secure and bulding is faster, because we handle dependencies caching better. Now let’s make the image as small as possible:
1. Use scratch
image.
We can utilize Docker scratch
image. It’s quite light because it weighs 0 bytes. Let’s build our binary using golang:1.24.3-alpine3.21
, and then run binary in the scratch
image:
FROM golang:1.24.3-alpine3.21 AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download && go mod verify
COPY . .
RUN go build -o /main .
FROM scratch
COPY --from=build /main /main
USER nobody:nobody
CMD ["/main"]
After we build it we get:
docker: Error response from daemon: unable to find user nobody: no matching entries in passwd file
Oh, scratch
doesn’t have nobody
user built-in (or any user). We need to copy /etc/passwd
and /etc/group
from our build
image:
FROM golang:1.24.3-alpine3.21 AS build
# same as above...
FROM scratch
COPY --from=build /etc/passwd /etc/passwd
COPY --from=build /etc/group /etc/group
COPY --from=build /main /main
USER nobody:nobody
CMD ["/main"]
It works, and our final image has 2.47MB. Is that it? Can we grab a drink and chill now? Not really. Our Docker image is missing 3 key components: CA certificates, timezones and build a static binary. Let’s work on examples:
package main
import (
"log/slog"
"net/http"
)
func main() {
resp, err := http.Get("https://example.com")
if err != nil {
slog.Error("failed to make request", slog.Any("error", err))
return
}
defer resp.Body.Close()
slog.Info("response from request", slog.String("status", resp.Status))
}
After we build and run we get:
2025/05/11 12:37:25 ERROR failed to make request error="Get \"https://example.com\": tls: failed to verify certificate: x509: certificate signed by unknown authority"
We need CA certificates to make HTTPS requests. We can easily add them, by just adding one line:
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
Now let’s take a look at timezones:
package main
import (
"log/slog"
"time"
)
func main() {
loc, err := time.LoadLocation("Europe/Warsaw")
if err != nil {
slog.Error("failed to load timezone:", slog.Any("error", err))
return
}
now := time.Now().In(loc)
slog.Info("current time in Europe/Warsaw", slog.String("time", now.Format(time.RFC3339)))
}
After we run it, we get:
2025/05/11 12:43:27 ERROR failed to load timezone: error="unknown time zone Europe/Warsaw"
We are missing timezones! Let’s add them to our build
image and then copy to scratch
image:
FROM golang:1.24.3-alpine3.21 AS build
RUN apk add --no-cache tzdata
# some other stuff...
FROM scratch
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
# rest of the Dockerfile...
The last thing is static binary. Golang programs are statically linked by default, but there is exception. When we use cgo
(C bindings), the binary becomes dynamically linked. We can disable it by setting environmental variable CGO_ENABLED
to 0:
RUN CGO_ENABLED=0 go build -o /main .
2. Optimize build flags.
We can reduce binary size by stripping debug information -ldflags="-s -w"
and removing local file system paths from the compiled binary -trimpath
, so we end up with:
RUN go build ldflags="-s -w" -trimpath -o /main .
Step 3
After we update our Dockerfile from step 2 with suggestions we get:
FROM golang:1.24.3-alpine3.21 AS build
RUN apk add --no-cache tzdata
WORKDIR /app
COPY go.sum go.mod ./
RUN go mod download && go mod verify
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -trimpath -o /main .
FROM scratch
COPY --from=build /usr/share/zoneinfo /usr/share/zoneinfo
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /etc/passwd /etc/passwd
COPY --from=build /etc/group /etc/group
COPY --from=build /main /main
USER nobody:nobody
CMD ["/main"]
Our Docker image for Golang weighs only 2.48MB. We reduced it from 316MB (99.2%). Great job!
Afterword
I intentionally didn’t include pinning base image version to digest, because it may introduce new vulnerabilities instead of reducing it. It’s up to you. Learn more about pin base image versions here. If you spot any mistake or want to improve this blog post contact me at jakub@rudnik.io. Source code is available here.