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.

Article illustration 1

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