ZetCode

Go Docker

last modified October 30, 2026

In this article we show how to build and run Go applications with Docker. A Docker image contains the program and everything it needs to run, and a container is a running instance of an image.

For a compiled language the image can be very small: the Go compiler produces a static executable, and the final image only has to contain that one file. Go programs also build quickly inside a container, which makes Docker a convenient way to produce reproducible binaries and to deploy them.

The examples were run with Docker 29.1.3 on Linux. The golang:1.27-alpine image contained Go 1.27.3 at the time of writing, so binaries built inside the container report that version.

The program

The first examples use a small console program with no dependencies beyond the standard library.

main.go
package main

import (
    "fmt"
    "runtime"
)

func main() {

    fmt.Println("Hello from Go")
    fmt.Printf("%s on %s/%s\n", runtime.Version(), runtime.GOOS, runtime.GOARCH)
}

The program prints the Go version and the target platform. The module is created with go mod init, which writes the module path and the language version.

$ go mod init com.zetcode/hello
go: creating new go.mod: module com.zetcode/hello
$ cat go.mod
module com.zetcode/hello

go 1.27.0

The single-stage image

A Dockerfile is a list of instructions for building an image. The first version uses the Go image as the final image, so the compiler and the whole toolchain stay in the result.

Dockerfile
FROM golang:1.27-alpine

WORKDIR /app

COPY go.mod ./
COPY main.go ./

RUN go build -o /hello .

CMD ["/hello"]

FROM selects the base image, WORKDIR sets the working directory for the following instructions, and COPY adds the source files. RUN executes the build inside the image, and CMD is the default command of the container.

The image is large because it contains the Alpine base system, the Go toolchain and the build cache. None of that is needed to run the compiled program.

$ docker build -t hello:single .
...
Successfully built 85e0d28ec544
Successfully tagged hello:single
$ docker run --rm hello:single
Hello from Go
go1.27.3 on linux/amd64
$ docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' hello
hello:single 287MB

A multi-stage build

A multi-stage build uses one image to compile the program and a second, much smaller image to run it. Only the files copied with COPY --from=build survive into the final image.

Dockerfile
FROM golang:1.27-alpine AS build

WORKDIR /app

COPY go.mod ./
COPY main.go ./

RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /hello .

FROM alpine:3.22

COPY --from=build /hello /usr/local/bin/hello

CMD ["hello"]

The AS build clause names the first stage. The second stage starts from Alpine, copies the executable and runs it. The executable is built with cgo disabled, without local paths and without debug information, so it does not depend on the Go toolchain or on the build directory.

The image is now dominated by the Alpine base system. The Go compiler is gone.

$ docker build -t hello:alpine .
...
Successfully tagged hello:alpine
$ docker run --rm hello:alpine
Hello from Go
go1.27.3 on linux/amd64
$ docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' hello
hello:alpine 9.82MB
hello:single 287MB

A scratch image

A static Go binary needs no operating system files at all. The special scratch image is empty, and the result contains exactly one file.

Dockerfile
FROM golang:1.27-alpine AS build

WORKDIR /app

COPY go.mod ./
COPY main.go ./

RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /hello .

FROM scratch

COPY --from=build /hello /hello

USER 65532:65532

ENTRYPOINT ["/hello"]

The USER instruction switches to a numeric user and group, so the program does not run as root. The numbers do not need entries in /etc/passwd, which is useful because the scratch image has no such file.

A scratch container has no shell and no tools, so docker exec cannot be used to inspect it. It also has no CA certificates; a program that makes HTTPS requests must copy /etc/ssl/certs/ca-certificates.crt from the build stage.

$ docker build -t hello:scratch .
...
Successfully tagged hello:scratch
$ docker run --rm hello:scratch
Hello from Go
go1.27.3 on linux/amd64
$ docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}' hello
hello:scratch 1.52MB
hello:alpine 9.82MB
hello:single 287MB

A web application

The same pattern works for a server. This program reads the port from an environment variable and exposes a health endpoint.

main.go
package main

import (
    "fmt"
    "log"
    "net/http"
    "os"
)

func main() {

    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintln(w, "Hello from a Go container")
    })

    http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        fmt.Fprintln(w, "ok")
    })

    log.Printf("listening on :%s", port)
    log.Fatal(http.ListenAndServe(":"+port, nil))
}

The image sets PORT with ENV and documents the port with EXPOSE. EXPOSE does not publish anything by itself; the port still has to be mapped with -p when the container is started.

Dockerfile
FROM golang:1.27-alpine AS build

WORKDIR /app

COPY go.mod ./
COPY main.go ./

RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /server .

FROM scratch

COPY --from=build /server /server

ENV PORT=8080
EXPOSE 8080

USER 65532:65532

ENTRYPOINT ["/server"]

The container runs in the background, and the host port 8080 is forwarded to the container port 8080. The health endpoint is useful for orchestrators and load balancers.

docker stop sends SIGTERM and then SIGKILL after a grace period. A server that needs to drain connections should handle SIGTERM and call http.Server.Shutdown.

$ docker build -t hello:web .
...
Successfully tagged hello:web
$ docker run -d --name hello-web -p 8080:8080 hello:web
09036b41bb583049c3debbaeb216b10b7642e039eeaa9f79ed0224af68e346e3
$ curl localhost:8080/
Hello from a Go container
$ curl localhost:8080/healthz
ok
$ docker logs hello-web
2026/10/30 19:16:05 listening on :8080
$ docker stop hello-web
hello-web
$ docker rm hello-web
hello-web

Layer caching

Docker builds an image in layers and reuses a layer when its instruction and its inputs have not changed. Copying go.mod and go.sum before the source code keeps the dependency download in a separate layer, so editing a .go file does not download the modules again.

Dockerfile
FROM golang:1.27-alpine AS build

WORKDIR /app

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

COPY main.go ./
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /hello .

FROM scratch

COPY --from=build /hello /hello
ENTRYPOINT ["/hello"]

The program in this example imports github.com/google/uuid, so go mod download has real work to do. After changing a string in main.go, the download step is taken from the cache and only the last stages are rebuilt.

The UUID differs on every run. The important line is Using cache on the download step: the modules were not fetched again.

$ docker build -t cache:demo .
...
Successfully tagged cache:demo
$ sed -i 's/Hello from Go/Hello again/' main.go
$ docker build -t cache:demo .
Step 2/9 : WORKDIR /app
 ---> Using cache
Step 3/9 : COPY go.mod go.sum ./
 ---> Using cache
Step 4/9 : RUN go mod download
 ---> Using cache
Step 5/9 : COPY main.go ./
Step 6/9 : RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /hello .
...
Successfully tagged cache:demo
$ docker run --rm cache:demo
Hello again
3da1fd66-3e12-47ce-8020-a33a86271001

The build context and .dockerignore

The build context is the set of files sent to the Docker daemon before the build starts. A large context slows every build, even when the files are not copied into the image.

Dockerfile
FROM golang:1.27-alpine AS build
WORKDIR /app
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /hello .
FROM scratch
COPY --from=build /hello /hello
ENTRYPOINT ["/hello"]

With a 10 MB file in the directory, the context contains that file. The .dockerignore file excludes it from the context, and the size drops from megabytes to a few kilobytes. The size line comes from the classic builder; BuildKit reports the transferred context differently.

A typical .dockerignore also excludes .git, editor files, test data and local binaries. Since COPY . . copies everything that is not ignored, the file also protects the image from accidental content.

$ head -c 10485760 /dev/zero > junk.bin
$ docker build -t ctx:demo .
Sending build context to Docker daemon  10.49MB
...
Successfully tagged ctx:demo
$ printf 'junk.bin\nDockerfile\n' > .dockerignore
$ docker build -t ctx:demo .
Sending build context to Docker daemon   5.12kB
...
Successfully tagged ctx:demo

Image sizes and best practices

The three variants of the same console program differ only in what the final image contains.

ImageContentsSize
hello:singleAlpine, Go toolchain, build cache287MB
hello:alpineAlpine, one static binary9.82MB
hello:scratchOne static binary1.52MB

A few rules apply to most Go images: use a named build stage, build with CGO_ENABLED=0 for a static binary, copy go.mod and go.sum before the source, strip and trim the binary for release, run as a non-root user, and pin the base image tag instead of using latest.

Source

Go Docker image
Docker build best practices

In this article we have built Go applications with Docker. We started with a single-stage image, moved to a multi-stage build with Alpine and to a scratch image running as a non-root user, containerized a web server with a port mapping, cached module downloads, reduced the build context with .dockerignore and compared the resulting image sizes.

Author

My name is Jan Bodnar, and I am a passionate programmer with extensive programming experience. I have been writing programming articles since 2007. To date, I have authored over 1,400 articles and 8 e-books. I possess more than ten years of experience in teaching programming.

List all Go tutorials.