Case Study: Golang Application

Case Study: Golang Application

Bitnesia Sep 13, 2026 12 ID

Go was designed from the beginning as a language that produces a single compiled binary, unlike Node.js or Python which require a runtime and have all dependencies copied into the image. This characteristic makes Go applications the most ideal candidates for truly minimal container images, capable of running without a complete operating system inside them. This chapter covers a full case study on containerizing Go applications: constructing a multi-stage Dockerfile that produces a static binary, choosing the most minimal base image such as scratch or distroless, reducing image size through several optimization techniques, and setting up live reload for daily development needs using Air or CompileDaemon.

31.1 Multi-Stage Build for Minimal Go Binaries

Go applications are compiled into a single binary file that already includes all of its dependencies, so the final image does not actually need the Go toolchain, source code, or go.mod at all to run. A multi-stage build is the most suitable pattern to leverage here: the first stage uses a full Go image to compile the source code, while the second stage simply copies the binary result to a much smaller image.

31.1.1 Go Project Structure for Containers

A Go project commonly used for web services usually has a simple structure with go.mod at the project root as a module indicator, plus a main.go file as the entry point.

myapp/
├── go.mod
├── go.sum
├── main.go
├── internal/
│   ├── handler/
│   └── config/
├── .dockerignore
└── Dockerfile

The go.sum file stores checksums for each dependency to ensure the version downloaded during build is exactly the same as the one used during development, so this file must be copied into the image along with go.mod. The internal folder is used by Go to restrict packages inside it so they can only be imported from within the same module, a common practice to separate application code from the public API if the project is also exposed as a library.

31.1.2 Basic Multi-Stage Dockerfile

The builder stage uses the official golang image which includes the compiler and full toolchain, while the final stage simply uses scratch, an empty image without any content provided by Docker.

FROM golang:1.23 AS builder
WORKDIR /app

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

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./main.go

FROM scratch
COPY --from=builder /app/server /server

EXPOSE 8080
ENTRYPOINT ["/server"]

The instruction COPY --from=builder only copies a single binary file /app/server to the final image, leaving behind all source code, the Go toolchain, and temporary build files in the builder stage without carrying them over to the production image. As a result, the final image can be just a few megabytes in size, much smaller than Node.js or Python application images that still require a full runtime in the production stage.

31.1.3 Caching go.mod and go.sum for Faster Builds

The order of COPY instructions in the Dockerfile above is intentionally separated: go.mod and go.sum are copied and their dependencies are downloaded first, before the entire source code is copied via COPY . .. This pattern leverages Docker layer caching so that go mod download is only re-executed when there are changes to the dependencies, rather than on every application code change.

In practice, projects that copy all source code before running go mod download often complain that Docker builds feel slow, even though the cause is merely an incorrect instruction order, not network issues or dependency sizes themselves. Verify that the cache ordering works by running the build twice consecutively without changing dependencies.

docker build -t myapp .
docker build -t myapp .

The second build should display CACHED on the RUN go mod download step if go.mod and go.sum have not changed since the previous build.

31.2 Static Compilation and Distroless/Scratch Base Images

The Dockerfile in the previous sub-chapter already used CGO_ENABLED=0 so that the resulting binary is completely static, a strict prerequisite for running in an image as empty as scratch. This section dives deeper into why this option is important, as well as the minimal base image options available besides scratch.

31.2.1 CGO_ENABLED and Static Linking

By default, the Go toolchain enables cgo, a mechanism that allows Go code to call C libraries, primarily used implicitly by the net and os/user packages for DNS resolution and user lookups on the system via the standard C library. If cgo is enabled, the binary produced by go build becomes dynamically linked against the system C library like glibc, meaning the binary can only run on systems with the exact same library, and will definitely fail to run on a scratch image which lacks libraries entirely.

Setting CGO_ENABLED=0 forces the Go toolchain to use pure Go implementations for DNS resolution and related functions, producing a binary that is statically linked and does not depend on any system libraries.

CGO_ENABLED=0 GOOS=linux go build -o /app/server ./main.go

The GOOS=linux variable ensures the binary is compiled for Linux systems, which is especially important if the build process is run from macOS or Windows machines while the target container image is Linux-based. Verify that the binary is completely static using the file command after the build completes.

file /app/server

An output displaying statically linked indicates the binary is ready to run on minimal base images like scratch. If the output still shows dynamically linked, double-check whether CGO_ENABLED=0 was properly applied during the build process, or if a third-party dependency is forcing cgo to remain enabled.

31.2.2 Scratch Base Image: Advantages and Limitations

scratch is the most minimal image provided by Docker, completely empty without a shell, package manager, certificate authority, or timezone data. It is suitable for static Go binaries that need nothing besides themselves, but its limitations need to be considered by developers before using it in production.

Applications that make outbound HTTPS requests, such as calling third-party APIs, require root CA certificates to verify the destination server's certificate. Because scratch does not include any certificates, they must be copied manually from the builder stage.

FROM golang:1.23 AS builder
WORKDIR /app

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

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./main.go

FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=builder /app/server /server

EXPOSE 8080
ENTRYPOINT ["/server"]

Without this certificate COPY line, HTTPS calls from inside the container will fail with errors such as x509: certificate signed by unknown authority, even if the application binary itself runs normally. If the application also relies on local time zones via the time/tzdata package, timezone data must be copied similarly from /usr/share/zoneinfo in the builder image, or more simply by importing the time/tzdata package directly in the Go code so the timezone data gets embedded into the binary itself.

Another limitation that often traps Sysadmins/DevOps Engineers during troubleshooting: scratch does not have a shell at all, so the command docker exec -it <container> sh commonly used to enter a container will fail with the message exec: "sh": executable file not found. Debugging scratch-based containers relies on docker logs and external tools such as docker debug or ephemeral containers via kubectl debug in Kubernetes environments, rather than entering the problematic container directly.

31.2.3 Distroless as a Scratch Alternative

Distroless is a collection of minimal images maintained by the gcr.io/distroless project, providing a thin layer above scratch that includes root CA certificates, timezone data, and basic user/group configurations, without incorporating a shell, package manager, or other system tools unnecessary for the application at runtime.

FROM golang:1.23 AS builder
WORKDIR /app

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

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/server ./main.go

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server

EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]

The static-debian12 variant is used for fully static binaries such as those produced with CGO_ENABLED=0, already including root CA certificates and timezone data without needing manual copying like in scratch. The nonroot tag provides a nonroot user with UID 65532 preconfigured from the base image, so the USER nonroot:nonroot instruction simply references that user without needing to create a new user via useradd, which is not available anyway since this image lacks a shell or package manager.

If the application still requires cgo to be active due to certain dependencies, such as database drivers requiring C libraries, distroless provides the gcr.io/distroless/base-debian12 variant which includes glibc and basic C libraries, unlike the static variant which is purely for static binaries without C library dependencies.

31.3 Image Size Optimization for Go Apps

Minimal base images like scratch or distroless form the foundation of a small image, but the size of the Go binary itself can also be reduced further using compiler options and build strategies, while simultaneously speeding up the build process through better cache utilization.

31.3.1 Stripping Debug Symbols with ldflags

By default, Go binaries include debug symbol information and source code paths useful for profiling and debugging, but this increases binary size without benefit if not used in production. The -ldflags="-w -s" option removes this information during the linking process.

RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/server ./main.go

The -w flag strips DWARF debugging information used by debuggers like delve to map code lines to binary instructions, while the -s flag strips the symbol table used for more descriptive stack traces. Consequently, binaries built with both flags are harder to debug directly via delve if issues arise in production, leading some teams to retain debug symbols in staging or debugging builds, and only apply -w -s for images destined for production.

31.3.2 Size Comparison Across Base Images

The difference in final image size across base image options is quite significant for the exact same Go application. The following table summarizes the characteristics of each option discussed.

Base ImageShellRoot CASuitable for
golang:1.23PresentPresentBuilder stage, not production
alpinePresent (ash)Requires ca-certificatesRequires debugging shell or cgo with musl
gcr.io/distroless/staticNonePre-installedStatic binaries (CGO_ENABLED=0)
scratchNoneNeeds manual copyStatic binaries, full control over image content

The alpine base image is sometimes a popular choice because of its small size while still providing a shell for debugging purposes, but it is important to note that alpine uses musl libc instead of glibc. If a Go binary is compiled with cgo enabled in a glibc environment and then executed on top of alpine which is based on musl, the binary may fail to run due to differences in C library implementations, a common pitfall encountered by developers transitioning from debian or ubuntu base images to alpine without adjusting their cgo build process.

31.3.3 BuildKit Cache Mount for Go Builds

BuildKit, Docker's default builder in recent versions, supports cache mounts via the --mount=type=cache option, allowing Go module download directories and build caches to persist across builds even if the Docker layer itself is invalidated.

# syntax=docker/dockerfile:1
FROM golang:1.23 AS builder
WORKDIR /app

COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download

COPY . .
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/server ./main.go

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server

EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]

The /go/pkg/mod directory stores downloaded Go modules, while /root/.cache/go-build stores compiled package outputs that can be reused by go build in subsequent builds. The comment line # syntax=docker/dockerfile:1 on the first line must be included so Docker uses the latest Dockerfile syntax supporting the --mount option on RUN instructions, according to official BuildKit documentation.

Unlike standard layer caching which depends on COPY ordering, cache mounts are reused even if all source code changes completely, because the cache is stored separately from image layers and does not become part of the final image. In practice, this technique shows its benefit in large Go projects with many dependencies, where builds without cache mounts can spend significant time just recompiling packages that have not changed at all.

31.4 Live Reload for Development (Air/CompileDaemon)

Go's nature as a compiled language means every code change requires a recompilation step before it can run, unlike Node.js or Python which can immediately rerun their interpreter. Without additional tools, the development workflow inside a container becomes tedious because developers must stop the container, rerun the build, and then start the container again every time a line of code changes.

31.4.1 Setting Up Air for Live Reload

Air is a popular live reload tool in the Go ecosystem that monitors source code file changes and automatically re-executes go build and runs the resulting binary as soon as changes are detected. Create a development-specific Dockerfile that installs Air inside the image, separate from the production Dockerfile discussed earlier.

FROM golang:1.23
WORKDIR /app

RUN go install github.com/air-verse/air@latest

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

COPY . .

EXPOSE 8080
CMD ["air", "-c", ".air.toml"]

Air requires a .air.toml configuration file at the project root to define build commands and file patterns to monitor.

root = "."
tmp_dir = "tmp"

[build]
  cmd = "go build -o ./tmp/main ./main.go"
  bin = "./tmp/main"
  include_ext = ["go"]
  exclude_dir = ["tmp", "vendor"]
  delay = 1000

[log]
  time = false

Run air init inside the project to generate a .air.toml template automatically, then adjust the cmd and bin sections according to your project structure. To ensure code changes on the host are immediately detected by Air inside the container, define the development service in compose.yaml with a bind mount to the source code.

services:
  app-dev:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "8080:8080"
    volumes:
      - .:/app
      - go-mod-cache:/go/pkg/mod

volumes:
  go-mod-cache:

The bind mount .:/app ensures file changes on the host are immediately reflected inside the container without needing an image rebuild, while the named volume go-mod-cache keeps the Go module cache persistent across container recreations, preventing go mod download from needing to redownload all dependencies every time the development container is started from scratch.

31.4.2 CompileDaemon Alternative

CompileDaemon is a simpler alternative compared to Air, requiring no separate configuration file because all options are specified via flags at runtime.

RUN go install github.com/githubnemo/CompileDaemon@latest

CMD CompileDaemon -polling -log-prefix=false \
    -build="go build -o /tmp/server ./main.go" \
    -command="/tmp/server"

The -polling option is essential when running CompileDaemon inside a Linux container with source code mounted from a macOS or Windows host via Docker Desktop, as native filesystem event mechanisms are sometimes not passed through properly across the Docker Desktop virtualization layer, requiring CompileDaemon to periodically poll for file changes instead of waiting for event notifications. Air offers a similar option via poll = true inside the [build] block of .air.toml if facing the same issue.

31.4.3 Troubleshooting Live Reload Inside Containers

If Air or CompileDaemon appears to run but does not react to file changes, first check whether the bind mount in compose.yaml actually points to the project folder being edited, rather than being copied from a COPY instruction in the image which leads to desynchronization with host changes.

docker compose exec app-dev ls -la /app

The command above verifies that the contents of the /app folder inside the container match the project folder contents on the host. If the contents match but reloading still does not occur, enable polling as explained previously, as this is the most common cause encountered by developers running Docker Desktop on macOS or Windows with WSL2.

One thing to keep in mind regarding the trustworthiness of this workflow: a development Dockerfile with Air or CompileDaemon should never be used for production images, because both include the full Go toolchain and extra tools that increase image size and attack surface without benefit in production. Always separate the development Dockerfile and production Dockerfile following the pattern discussed in the multi-stage build and distroless sub-chapters, so that the deployed image remains minimal and secure.