Dockerising a Go Service
A multi-stage Dockerfile compiles the service in a Go image, then ships only the binary in a tiny one.
COPY --from=<stage> <path in that stage> <path in the new image>A container image is a filesystem plus the command to run inside it, packaged as one unit. A Go service fits this well, because a build produces a single executable file. The image needs that file and very little else.
Two stages
A multi-stage build is one Dockerfile with more than one FROM line. The first stage starts from golang:1.27, which carries the whole Go toolchain, and compiles the program. The second starts from a small base image and copies only the finished binary across. The toolchain, the module cache and the source never reach the shipped image, so it is a few megabytes rather than several hundred. FROM golang:1.27 AS build names the stage, and COPY --from=build reads from it. COPY --from=0 means the same thing by stage number; the name is the same instruction, easier to read.
Order the lines for caching
Docker caches each instruction and reuses it while nothing it depends on has changed. Copy go.mod and go.sum first, run go mod download, then copy the rest of the source. After that, editing a .go file reuses the download layer instead of fetching every dependency again.
Details that bite
Build with CGO_ENABLED=0, because a small base image has no C library. Add a .dockerignore file so local build output and .git stay out of the build context. EXPOSE 8080 is documentation only; the port is published by docker run -p 8080:8080. Read the port from the environment so one image runs anywhere. Since Go 1.25 the runtime also reads the container's CPU limit on Linux and sets GOMAXPROCS from it, so a CPU-limited container no longer starts far more threads than it can use.
Example
// Command app is the service that goes into the image.
// Once it is running: curl http://localhost:8080/healthz
package main
import (
"fmt"
"log"
"net/http"
"os"
)
func main() {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
// The port comes from the environment, so moving it needs no rebuild.
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
addr := ":" + port
fmt.Println("listening on " + addr) // printed once, before the server blocks
log.Fatal(http.ListenAndServe(addr, mux))
}
// Output:
// listening on :8080When to use it
- Ship a service as one image that runs the same on a laptop and in a cluster.
- Cut a 900 MB image to a few megabytes by leaving the toolchain in the build stage.
- Pin the Go version used to build a release in the Dockerfile rather than on each machine.
More examples
The whole Dockerfile
Two stages, one binary copied between them. The nonroot tag already runs as user 65532, and repeating USER makes that explicit for anyone reading the file or scanning it. This is the shape to start from for any Go service.
# syntax=docker/dockerfile:1
FROM golang:1.27 AS build
WORKDIR /src
# Dependencies first: this layer survives every change to your own code.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# No C library in the final image, so no cgo. -trimpath keeps build paths out.
RUN CGO_ENABLED=0 go build -trimpath -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/app /app
EXPOSE 8080
USER 65532:65532
ENTRYPOINT ["/app"]Build it and talk to it
-p publishes the container port on your machine; without it nothing is reachable, whatever EXPOSE says. -e sets an environment variable for the container, which is how the same image runs on a different port in another environment.
# Keep local build output and git history out of the build context.
printf 'bin/\n.git/\n*.md\n' > .dockerignore
# --platform matters on an Apple-silicon machine building for an amd64 cluster.
docker build --platform linux/amd64 -t app:1.4.0 .
docker run --rm -p 8080:8080 -e PORT=8080 app:1.4.0
# listening on :8080
curl localhost:8080/healthz
# ok
docker images app:1.4.0 --format '{{.Size}}'Reuse the compile cache between builds
The module cache and the compile cache are kept outside the layers, so a rebuild after a one-line change recompiles only what changed, and the shipped image gains nothing. Use it when build time is the pain; the syntax line at the top is required for mount options.
# syntax=docker/dockerfile:1
FROM golang:1.27 AS build
WORKDIR /src
COPY . .
# Cache mounts persist across builds but are not part of any image layer.
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 go build -trimpath -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian13:nonroot
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]
Discussion