The ideal Dockerfile for a minimal Go container image

In this post I want to share my template for building container images for Go applications. My criteria are:

  • minimal
  • rootless
  • fully functional ;-)

A minimal (as small as possible) image has the advantages that it is faster to deploy because we are using less I/O (pushing image into registry, pulling image from registry, unpacking image, mounting overlay filesystem …) and that it is minimizes the attack surface for exploits (no shell, curl or other tools available).

Continuing with security, running the application inside the container as non-root acts as an additional security measure to protect the host system. In theory, the kernel should take care of this of course, but in practice nothing is perfect, thus it’s good practice to use multiple layers of defense.

Combined together, these two measures create a third layer: a read-only filesystem (at least for the process running inside the container). This means attackers can not download any other tools on the fly (such as a reverse shell). At this point, all that’s left for them is extracting secrets from memory (a so called info dump) or ROP, both of which are pretty hard to do in memory-safe languages. But also you need to keep the read-only filesystem in mind when developing and containerizing your application. Don’t just write files here or there, but make sure you properly document the locations so that whoever is running the container knows they need to mount a volume there.

Now that we set the stage, here is the Dockerfile (detailed explanations below):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
FROM golang:1.16 AS builder

WORKDIR /myapp

# Enable Go's DNS resolver to read from /etc/hosts
RUN echo "hosts: files dns" > /etc/nsswitch.conf.min

# Create a minimal passwd so we can run as non-root in the container
RUN echo "nobody:x:65534:65534:Nobody:/:" > /etc/passwd.min

# Fetch latest CA certificates
RUN apt-get update && \
    apt-get install -y ca-certificates

# Only download Go modules (improves build caching)
COPY go.mod go.sum ./
RUN go mod download

# Copy our source code over and build the binary
COPY . .
RUN CGO_ENABLED=0 \
    go build -ldflags '-s -w' -tags 'osusergo netgo' myapp

FROM scratch AS final

# Copy over the binary artifact
COPY --from=builder /myapp/myapp /

# Add any other assets you need, e.g.:
# COPY --from=builder /myapp/static/ /static
# COPY templates/ /templates

# Copy configuration from builder
COPY --from=builder /etc/nsswitch.conf.min /etc/nsswitch.conf
COPY --from=builder /etc/passwd.min /etc/passwd
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/

USER nobody

ENTRYPOINT ["/myapp"]

In line 1 we start with the Golang image, which is based on Debian and contains all tools required for building Go applications. If necessary, we can also install other libraries and applications that we might need to compile our application myapp.

Since there are no tools available in the final container (not even echo!), we need to create them beforehand.

In line 6 we ensure that Go’s DNS resolver will read hostnames from /etc/hosts, which is where Docker, Kubernetes and a few other container tools are putting static hosts (i.e. those not covered by DNS resolution). By default, Go will just use DNS resolution when nothing is specified in /etc/nsswitch.conf (just like Glibc). And since the filesystem of our FROM scratch container will be empty, we need to set the correct value in nsswitch.conf ourselves.

In line 9 we create a barebones passwd file. This will allow us to run our application as non-root in the final container. In this case I decided to go for the “nobody” user, since my application is not supposed to write any data locally. Liz Rice has described a similar technique, but she’s just copying over the entire passwd from the builder container. However, if you are writing data (for example to a persistent volume or host mount), you need to make sure that the UID is consistent and does not infer with any other applications (at least if you don’t want them to be able to read your data).

In line 13 we fetch the up-to-date list of trusted CA vendors. This step will generate the /etc/ssl/ca-certificates.crt file, which we later copy over into the final container. This allows our application to verify the validity of any publicly trusted certificate. If your application does not need to talk to external service via TLS, you can also skip this. Alternatively, you can inject only the root certificate of your organization, if that option is available to you.

In lines 16 & 17 we copy over just the go.mod and go.sum – not the entire source directory – and download the specified Go modules. This drastically improves the effectiveness of the build cache, since this step will only need to be executed when the go.mod or go.sum files change. However, when you are just modifying your application source code (for example adding a little printf for debugging), the cached layer can be reused. A similar approach can be used for package managers that use a Lockfile, such as NPM and Pipenv.

Afterwards, we copy over the rest of the source directory. In this case, I’m just copying everything, but you can of course also be more selective, for example by specifying particular directories (./src/) or files (main.go).

In line 22 we build a static, standalone binary of our Go application. Setting CGO_ENABLED=0 ensures there is no dynamic linking involved. Of course, when you are linking to external libraries, you will need to omit this. The Go build tags osusergo and netgo make sure the application is also using Go’s internal user/group facilities and DNS resolver, respectively. For more details, check out this excellent post from Martin Tournoij.

There has been some discussion around these build tags and a proposal to have a -static argument for go build, but there doesn’t seem to be any progress towards that (see Go Issue #26492).

The linker flags -s and -w tell Go to omit debugging symbols from the binary, which reduces its file size quite significantly. This is equivalent to running strip on the binary. You will still get proper stack traces with annotations, though. For more details, check out this excellent post from Filippo Valsorda.

Okay, we are done with hard part, from here on it gets straightforward because now we have prepared everything we need and only need to copy it into place.

Illustration of Multi-stage Container Build Pattern

In line 24 we launch into a new, empty container. I’m calling the second stage final here, but that’s completely optional. If you are not familiar with this pattern, read up on multi-stage builds.

We copy over the binary we compile previously and any other assets we might need (HTML templates, static Javascript files etc.)

Notably, we also copy over the configuration files (nsswitch.conf, passwd, ca-certificates.crt) we have created in the previous stage (line 34-36).

Finally, we switch the user from root (the default) to nobody. Make sure this is one of the last steps in the Dockerfile. After this instruction the container builder won’t be able to write to the filesystem anymore (e.g. COPY commands won’t work).

At runtime, if you need to write data inside the container (for example temporary files in /tmp), you can either mount a volume there or set appropriate permissions when copying the directory to the scratch container:

1
COPY --chown=nobody --from=builder /tmp /tmp

Yay, we made it all the way through the Dockerfile! Now you can build the image with favorite image builder (yes, there are a few):

1
2
3
4
5
6
docker image build -t myapp .
podman image build -t myapp .
buildah build-using-dockerfile -t myapp
kaniko --destination myapp --context dir:///myapp
img build -t myapp .
kim image build -t myapp

After you are done with all of this, you can use dive to verify your container image only contains the necessary files and directories:

Screenshot of 'dive' in action

Happy building!