Creating a Docker image for a Go application

Written 19th of October 2020, updated 11th of May 2021.

The Go compiler produces a nice, single binary that's easy to deploy already. However, sometimes it's convenient to containerize your application, for example if:

  • Your deployment system needs it (e.g. Kubernetes or something like AWS ECS or Google Cloud Run)
  • You have static assets that you want to include with your app, but don't want to use something like go-bindata or go:embed
  • You want to use something like Docker Compose to bring up your app in development

In this blog post, I'll show you a Dockerfile you can use as a template, and give you the reasoning behind each line in it.

Update: most of this content is now part of the course Build Cloud Apps in Go, which I'm writing over at golang.dk.

The Go Dockerfile

I'll start by giving you the file. Have a quick read through it, but if you don't understand everything, that's fine. We'll get to it.

FROM golang:1.16-buster AS builder
WORKDIR /src

COPY go.mod go.sum ./
RUN go mod download -x

COPY . ./
RUN go build -v -o /bin/server cmd/server/*.go

FROM debian:buster-slim
RUN set -x && apt-get update && \
  DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates && \
  rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY production.toml ./
COPY --from=builder /bin/server ./

CMD ["./server", "-config", "production.toml"]

Some things to understand first

There are a few things to understand about this Dockerfile first.

The first has to do with how Docker builds images. This file uses something that's called a multi-stage build in Docker, which is just a fancy way of saying that we can build multiple Docker images defined in the same file. In this case, we use one image to build our Go application, and another for running it. We do it this way so we don't have to include our source code, the Go compiler etc. in our final image.

The second thing has to do with caching. To speed up the build process, Docker caches the result of each line of a Dockerfile. That means that we don't necessarily run all the commands in a Dockerfile on each build, but instead re-use the results of previous lines. Therefore, the order of lines is important, and you should generally do things that don't change very often earlier in the Dockerfile than things that do change more often. One common example is pulling dependencies before building your app from source.

Okay, so with that out of the way, let's go through the file.

The builder image line by line

Let's start with the image that builds your application.

FROM golang:1.16-buster AS builder tells Docker to pull the offical Go image as a base image for our build process. In this case, we use the tag 1.16-buster to ensure that we build with Go 1.16 in Debian Buster. We call the resulting image builder.

WORKDIR /src sets the working directory to /src, so that we don't have to write it on every line. This is where files are now copied to.

COPY go.mod go.sum ./ copies the go module dependency files to the image. go.sum in particular is a lock-file that has all the (transitive) dependencies in it that your application has, so your dependencies have changed only if this file has changed. This also means that the Docker cache is invalidated at this line if these files have changed.

RUN go mod download -x downloads the app dependencies and prints the progress to standard out. Again, because of the caching, this is only done if your dependencies change, which is nice because it can take a while.

COPY . ./ copies all of your code into the image. Note that, if you need to exclude anything from being copied into your image, use a file called .dockerignore.

RUN go build -v -o /bin/server cmd/server/*.go uses go to build your app (in this case located under /cmd/server) into a binary located at /bin/server. Note that the path is outside the /src dir, so we avoid potential name collisions from your source directory (e.g. if you have a directory/package called server).

The runner image line by line

After the builder image comes the image that will be used to actually run your application.

FROM debian:buster-slim starts us out with a specially slimmed-down version of Debian Buster. You could of course use other images here if you prefer. As pointed out by user habarnam on Reddit and @MarkusBlaschke on Twitter, the Distroless base image could also be a good choice. Some also prefer to use FROM scratch, if you don't need anything from the OS at all.

RUN set -x && apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates && rm -rf /var/lib/apt/lists/* is a long line that basically updates the OS dependencies, installs some certificates so your app can connect to TLS-enabled HTTPS endpoints without erroring, and then deletes some intermediary files. This is done in one line to (you guessed it) make caching easier for Docker. If your application has other OS package dependencies, add them here after ca-certificates.

WORKDIR /app sets the working directory to /app, again so that we don't have to repeat it all the time.

COPY production.toml ./ copies a configuration file to the image. You should copy other assets your application needs here. For example, to copy a whole directory called public, do COPY public ./public/.

COPY --from=builder /bin/server ./ copies the compiled binary from the builder image into this image.

Finally, CMD ["./server", "-config", "production.toml"] sets the default command to run, along with some parameters.

Building your image

Building the image is very easy. Once you have Docker running on your machine, just run docker build -t app . to build your image under the name app.

Bonus: Using the image with Docker Compose

Now that you have an image for your application, you can use it with Docker Compose to bring up your application, along with your dependencies like the database. All it takes is a configuration file. I won't go through this one in detail, but have a look at the Docker Compose documentation to get an overview. Put this in a file called docker-compose.yaml to spin up Postgres 12 along with your app:

version: '3.8'
services:
  db:
    image: postgres:12
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: 123
    ports:
      - 5432:5432
    volumes:
      - type: bind
        source: ./data
        target: /var/lib/postgresql/data
  app:
    build:
      context: ./
      dockerfile: Dockerfile
    ports:
      - "8080:8080"
    depends_on:
      - db
    restart: always

Run docker-compose -p app build to (re-)build your image, and docker-compose -p app up -d to run the app and Postgres.

Conclusion

You now know how to create a Dockerfile to properly containerize your application in a lean, production-ready image. Thanks for reading!

About me

I’m Markus, a professional software consultant and developer. 🤓✨ You can reach me at [email protected].

I'm currently building Go courses over at golang.dk.