Mastering Minimalist Docker Images: Go, Nix, and Optimization Tricks for Faster Builds
Share this article
Mastering Minimalist Docker Images: Go, Nix, and Optimization Tricks for Faster Builds
In an environment traditionally anchored in Python, introducing a new language like Go can feel like a bold pivot—especially for a project as sensitive as remote code execution (RCE) as a service. The choice of Go stems from its simplicity, speed, and robust ecosystem, backed by a major tech giant, making it a safer bet than alternatives like Rust or Nix for a team new to systems-level performance. This shift not only addresses Python's runtime limitations but also opens doors to optimized containerization strategies that can transform build pipelines across any tech stack.
The project in question demanded a language comfortable for remote execution on servers, and Go's static binaries fit the bill perfectly. However, the real innovation lies in the containerization approach. Initially, the developer explored Nix for building OCI images, appreciating its declarative power to create minimal layers without bloat. A simple Nix expression, for instance, can produce a 45.8MB image containing just the service and glibc—no shell, no extras.
{ pkgs ? import <nixpkgs> {} }:
pkgs.dockerTools.streamLayeredImage {
name = "someimage";
tag = "latest";
config.Cmd = [
"${pkgs.hello}/bin/hello"
];
}
Building with nix-build and loading into Docker yields a lean artifact, but the team defaulted to familiar tools: Docker and Docker Compose. This pivot highlighted a key tension in modern devops—balancing innovation with practicality. Yet, even within Docker's ecosystem, it's possible to rival Nix's efficiency using savvy Dockerfile techniques, particularly for statically linked Go executables (built with CGO_ENABLED=0).
Crafting a Barebones Image for Database Migrations
Consider the case of integrating Goose, a Go-based tool for database migrations, into a Docker Compose setup. The configuration pulls the Goose repo directly via GitHub URL in the build context—a lesser-known Docker feature that streamlines external dependencies without local cloning.
services:
migrate:
image: migrate:latest
pull_policy: build
build:
context: https://github.com/pressly/goose.git#v3.26.0
dockerfile: $PWD/Dockerfile.migrate
environment:
GOOSE_DBSTRING: postgresql://AzureDiamond:hunter2@db:5432/bobby
GOOSE_MIGRATION_DIR: /migrations
GOOSE_DRIVER: postgres
depends_on:
db:
condition: service_started
volumes:
- ./migrations:/migrations
The accompanying Dockerfile employs a multi-stage build: first, an Alpine-based Go stage optimizes caching and bind mounts to compile a stripped-down binary, excluding unnecessary drivers.
FROM golang:1.25-alpine3.23 AS builder
WORKDIR /build
ARG CGO_ENABLED=0
ARG GOCACHE=/root/.cache/go-build
ARG GOMODCACHE=/root/.cache/go-mod
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/root/.cache/go-mod \
--mount=type=bind,source=.,target=/build \
go build -tags='no_clickhouse no_libsql no_sqlite3 no_mssql no_vertica no_mysql no_ydb' -o /goose ./cmd/goose
FROM scratch
COPY --from=builder /goose /goose
CMD ["/goose", "up"]
Key optimizations include disabling CGO for static linking, explicit cache directories for persistent builds, and bind mounts to avoid unnecessary file copies. The final scratch stage discards build artifacts, resulting in a mere 15.9MB image that boots instantly. For teams handling sensitive operations like RCE, this minimalism enhances security by reducing attack surfaces—no libc dependencies, no shell.
As the alt text notes, tools like Dive can visualize these layers, confirming the image's sparsity. This approach proves that even without Nix, Docker can deliver production-grade efficiency.
Taming the Build Context and Layering for Speed
A common pitfall in Docker builds is the 'build context'—the entire directory tree sent to the builder, including irrelevant files like .git. Without a .dockerignore, this bloats transfer times and obscures focus.
.*
Dockerfile*
docker-compose.yml
# ...
Excluding such items accelerates uploads, though it doesn't shrink the final image. For the main service Dockerfile, layering further refines this: install stable tools like DataDog's Orchestrion first, then download modules, and finally build the source. Bind mounts for go.mod, go.sum, and source directories ensure only changed files trigger rebuilds.
FROM golang:1.25-alpine3.23 AS builder
WORKDIR /build
ARG CGO_ENABLED=0
ARG GOCACHE=/root/.cache/go-build
ARG GOMODCACHE=/root/.cache/go-mod
RUN --mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/root/.cache/go-mod \
go install github.com/DataDog/orchestrion@latest
RUN --mount=type=bind,source=go.mod,target=go.mod \
--mount=type=bind,source=go.sum,target=go.sum \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/root/.cache/go-mod \
go mod download
RUN --mount=type=bind,source=go.mod,target=go.mod \
--mount=type=bind,source=go.sum,target=go.sum \
--mount=type=cache,target=/root/.cache/go-build \
--mount=type=cache,target=/root/.cache/go-mod \
--mount=type=bind,source=internal,target=internal \
--mount=type=bind,source=cmd,target=cmd \
--mount=type=bind,source=orchestrion.tool.go,target=orchestrion.tool.go \
orchestrion go build -o server ./cmd/server
FROM alpine:3.23 AS prod
WORKDIR /app
COPY --from=builder /build/server .
ENTRYPOINT ["/app/server"]
This granularity—caching dependencies separately from code—slashes iteration times, especially in CI/CD pipelines where every second counts. For Go developers, it's a reminder that static binaries pair beautifully with Alpine or Scratch bases, minimizing vulnerabilities while maximizing deploy speed.
Broader Implications for DevOps Teams
These techniques extend beyond a single RCE service. In an era of resource-constrained cloud environments, lean images reduce storage costs, network transfer times, and runtime overhead. Nix offers declarative purity, but Docker's ubiquity makes these optimizations immediately actionable. As teams experiment with Go's performance edge over Python, integrating such practices can future-proof workflows against scaling challenges.
Ultimately, the developer's journey underscores a core truth: true efficiency in containerization isn't about choosing the fanciest tool, but mastering the fundamentals—caching wisely, layering thoughtfully, and stripping away the unnecessary. In doing so, even a Python shop can harness Go's potential to build services that are not just fast, but resilient.
Source: Adapted from a blog post by a developer at sgt.hootr.club