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.
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.
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.
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.
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.
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.
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.
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.
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.
| Image | Contents | Size |
|---|---|---|
hello:single | Alpine, Go toolchain, build cache | 287MB |
hello:alpine | Alpine, one static binary | 9.82MB |
hello:scratch | One static binary | 1.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
List all Go tutorials.