RSS Amplifier

Serge Skoredin — IT Blog · Oct 18, 2025

Docker for Go Developers: From Zero to Production

0
Sign in to vote or save

Serge Skoredin · Serge Skoredin

Key Takeaways

  • Image Size: Achieve 8-12MB production images using multi-stage builds and scratch/distroless base
  • Build Speed: 12-second builds with proper layer caching and dependency management
  • Security: Non-root users, minimal attack surface, and automated vulnerability scanning
  • Best Practices: Static binaries with CGO_ENABLED=0, build-time variables, and health checks

Table of Contents

  1. The Production-Ready Dockerfile
  2. Why This Architecture Works
  3. Common Mistakes to Avoid
  4. Advanced Patterns
  5. Optimization Techniques
  6. Security Scanning
  7. Complete Production Setup
  8. Debugging Docker Issues
  9. CI/CD Integration
  10. Testing Strategy

The Dockerfile That Took 3 Years to Perfect

This Dockerfile has been battle-tested in production across multiple companies. It's fast, secure, and produces tiny images:

# Build stage
FROM golang:1.22-alpine AS builder
# Install certificates for HTTPS calls
RUN apk --no-cache add ca-certificates tzdata
# Create non-root user early
RUN adduser -D -g '' appuser
WORKDIR /build
# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy source
COPY . .
# Build with all optimizations
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags="-w -s -X main.version=$(git describe --tags --always --dirty) \
    -X main.buildTime=$(date -u +%Y%m%d.%H%M%S)" \
    -a -installsuffix cgo \
    -o app ./cmd/server
# Final stage
FROM scratch
# Copy certificates and timezone data
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# Copy user and group files
COPY --from=builder /etc/passwd /etc/passwd
# Copy binary
COPY --from=builder /build/app /app
# Use non-root user
USER appuser
EXPOSE 8080
ENTRYPOINT ["/app"]

Result: 8.2MB image, 12-second build time, passes all security scans.

Why This Works

1. Multi-Stage Build Pattern

First stage (1.2GB) compiles, second stage (8MB) runs. You ship only the binary.

2. Static Binary with CGO_ENABLED=0

No libc dependencies = works on scratch image. This alone saves 100MB.

3. Security First

  • Non-root user (CVE compliance)
  • No shell in final image (can't exec into it)
  • Minimal attack surface (literally just your binary)

Common Mistakes I See Daily

Docker layer caching is the difference between 2-second and 2-minute builds. Understanding how Docker determines when to invalidate cache saves hours of waiting for builds.

Mistake 1: Not Caching Dependencies

Go modules rarely change but take longest to download. Copying go.mod/go.sum before source code creates a cacheable dependency layer. When you modify code but not dependencies, Docker reuses the cached layer, skipping module downloads.

This optimization becomes critical with private modules requiring authentication. Rebuilding the dependency layer means re-authenticating, re-downloading, and re-verifying checksums. In CI/CD pipelines, this adds minutes to every build.

# GOOD - deps cached separately
COPY go.mod go.sum ./
RUN go mod download
COPY . .

Mistake 2: Using Ubuntu/Debian Base

Ubuntu base images include thousands of packages you'll never use. Each package increases attack surface and CVE exposure. Security scanners flag vulnerabilities in packages like Perl interpreters that your Go binary doesn't even link against.

Alpine provides essential tools in 5MB. Distroless or scratch images eliminate everything except your binary. This isn't premature optimization - it's defense in depth.

# BAD - 124MB base with unnecessary packages
FROM ubuntu:22.04
# GOOD - 5MB base image
FROM alpine:3.19
# BEST - 0MB base image
FROM scratch

Mistake 3: Running as Root

Container breakouts are rare but catastrophic. Running as root inside a container means a successful escape grants root on the host. Compliance frameworks like SOC2 and PCI-DSS explicitly require non-root container execution.

Creating a non-root user adds one line but prevents entire classes of attacks. The principle of least privilege isn't just theory - it's saved us from vulnerabilities multiple times when dependencies had security issues.

# Create and use non-root user
USER nobody
# BETTER - create specific user
RUN adduser -D -g '' appuser
USER appuser

Advanced Patterns

Pattern 1: Build-Time Variables

# Inject version info at build time
ARG VERSION=dev
ARG COMMIT=unknown
RUN go build -ldflags="-X main.Version=${VERSION} -X main.Commit=${COMMIT}" -o app
# Build with:
# docker build --build-arg VERSION=v1.2.3 --build-arg COMMIT=$(git rev-parse HEAD) .

Pattern 2: Private Dependencies

# For private GitHub repos
ARG GITHUB_TOKEN
RUN git config --global url."https://${GITHUB_TOKEN}:[email protected]/".insteadOf "https://github.com/"
# Copy dependencies
COPY go.mod go.sum ./
RUN --mount=type=secret,id=github_token \
    GITHUB_TOKEN=$(cat /run/secrets/github_token) \
    go mod download
# Build with:
# docker build --secret id=github_token,src=$HOME/.github-token .

Pattern 3: Development vs Production

# Dockerfile.dev - with hot reload
FROM golang:1.22-alpine
RUN go install github.com/cosmtrek/air@latest
WORKDIR /app
CMD ["air", "-c", ".air.toml"]
# docker-compose.yml
services:
  app:
    build:
      dockerfile: ${DOCKERFILE:-Dockerfile}
    volumes:
      - .:/app  # Mount source for hot reload in dev

Optimization Techniques

1. Reduce Binary Size

# Standard build: 15MB
go build -o app
# Optimized build: 10MB
go build -ldflags="-w -s" -o app
# Ultimate optimization: 8MB
go build -ldflags="-w -s" -a -installsuffix cgo -o app
# With UPX compression: 3MB (but slower startup)
upx --brute app

2. Layer Caching Strategy

# Order matters! Most stable → most volatile
# 1. System dependencies (rarely change)
RUN apk add --no-cache git ca-certificates
# 2. Go dependencies (change occasionally)
COPY go.mod go.sum ./
RUN go mod download
# 3. Source code (changes frequently)
COPY . .
# 4. Build
RUN go build -o app

3. Multi-Architecture Builds

# Build for multiple platforms
docker buildx create --use
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag myapp:latest \
  --push .

Security Scanning

Every image should pass these checks:

# Scan for vulnerabilities
docker scout cves myapp:latest
# Check for secrets
docker run --rm -v /var/run/docker.sock:/var/run/docker.sock \
  aquasec/trivy image myapp:latest
# Verify non-root user
docker run --rm myapp:latest id
# Should output: uid=1000(appuser) gid=1000(appuser)

Production Setup

The Complete Dockerfile

# syntax=docker/dockerfile:1.4
FROM golang:1.22-alpine AS builder
# Build arguments
ARG VERSION=dev
ARG COMMIT=unknown
ARG BUILD_TIME
# Install build dependencies
RUN apk add --no-cache git ca-certificates tzdata
WORKDIR /build
# Cache dependencies
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod \
    go mod download
# Copy source
COPY . .
# Build with cache mount
RUN --mount=type=cache,target=/go/pkg/mod \
    --mount=type=cache,target=/root/.cache/go-build \
    CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build \
    -ldflags="-w -s \
    -X main.version=${VERSION} \
    -X main.commit=${COMMIT} \
    -X main.buildTime=${BUILD_TIME}" \
    -o app ./cmd/server
# Create minimal runtime image
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /build/app /app
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD ["/app", "health"]
EXPOSE 8080
ENTRYPOINT ["/app"]

Docker Compose for Development

version: '3.8'
services:
  app:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "8080:8080"
    volumes:
      - .:/app
      - /app/vendor  # Don't sync vendor
    environment:
      - DB_HOST=postgres
      - REDIS_HOST=redis
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_started
  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_PASSWORD: secret
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5
  redis:
    image: redis:7-alpine
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data
volumes:
  postgres_data:
  redis_data:

Debugging Docker Issues

Problem: "exec format error"

# You built for wrong architecture
# Check your binary
file app
# Should show: ELF 64-bit LSB executable, x86-64
# Fix: specify platform
docker build --platform linux/amd64 .

Problem: Can't Connect to Database

# Check network
docker network ls
docker inspect <container> | grep NetworkMode
# Debug with busybox
docker run --rm -it --network container:<app-container> busybox sh
# Test connection
nc -zv postgres 5432

Problem: Image Too Large

# Analyze layers
docker history myapp:latest
# Use dive for visual analysis
docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock \
  wagoodman/dive myapp:latest

CI/CD Integration

GitHub Actions

name: Build and Push
on:
  push:
    branches: [main]
    tags: ['v*']
jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
      - name: Login to DockerHub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKER_USERNAME }}
          password: ${{ secrets.DOCKER_TOKEN }}
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          platforms: linux/amd64,linux/arm64  # Support both x86 and ARM
          push: true
          tags: |
            user/app:latest
            user/app:${{ github.sha }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
          build-args: |
            VERSION=${{ github.ref_name }}
            COMMIT=${{ github.sha }}
            BUILD_TIME=${{ github.event.head_commit.timestamp }}

Multi-Platform Considerations

Modern deployment targets include ARM instances (AWS Graviton, Apple Silicon). Multi-platform builds ensure compatibility:

# Use BuildKit for cross-compilation
# syntax=docker/dockerfile:1
FROM --platform=$BUILDPLATFORM golang:1.22-alpine AS builder
# These args are provided by BuildKit
ARG TARGETOS
ARG TARGETARCH
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# Build for target platform
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build \
    -ldflags="-w -s" \
    -o app ./cmd/server
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /build/app /app
ENTRYPOINT ["/app"]

Benefits of multi-platform builds:

  • ARM deployment: 40% cost savings on AWS Graviton instances
  • Local development: Native performance on Apple Silicon Macs
  • Edge computing: Support for ARM-based edge devices
  • Future-proofing: Ready for architecture shifts

Performance Metrics

Approach Build Time Image Size Push Time Security Score
Basic Dockerfile 45s 850MB 120s C (many CVEs)
Multi-stage 35s 15MB 3s B (few CVEs)
Optimized + Cache 12s 8MB 2s A (minimal surface)
Distroless 15s 12MB 2.5s A+ (no shell)

*Measured on M1 MacBook Pro with Docker Desktop 4.x

Testing Strategy

1. Build Testing

# Test multi-platform builds
#!/bin/bash
PLATFORMS="linux/amd64 linux/arm64 linux/arm/v7"
for platform in $PLATFORMS; do
    echo "Building for $platform..."
    docker buildx build --platform $platform --load -t test:$platform .
    docker run --rm test:$platform --version
done

2. Security Testing

# Automated security checks
make security-scan
# Makefile
security-scan:
	@echo "Scanning for vulnerabilities..."
	@docker scout cves $(IMAGE):$(TAG)
	@trivy image --severity HIGH,CRITICAL $(IMAGE):$(TAG)
	@echo "Checking for secrets..."
	@docker run --rm -v "$PWD:/path" trufflesecurity/trufflehog:latest filesystem /path
	@echo "Verifying non-root user..."
	@docker run --rm $(IMAGE):$(TAG) id | grep -q "uid=.*([^r][^o][^o][^t])" || exit 1

3. Container Structure Tests

# container-structure-test.yaml
schemaVersion: 2.0.0
fileExistenceTests:
  - name: 'App binary exists'
    path: '/app'
    shouldExist: true
    permissions: '-rwxr-xr-x'
fileContentTests:
  - name: 'CA certificates present'
    path: '/etc/ssl/certs/ca-certificates.crt'
    expectedContents: ['.*BEGIN CERTIFICATE.*']
metadataTest:
  user: 'appuser'
  exposedPorts: ['8080']
  entrypoint: ['/app']
commandTests:
  - name: 'Health check works'
    command: '/app'
    args: ['health']
    exitCode: 0

4. Performance Testing

// docker_test.go
package main
import (
    "context"
    "testing"
    "time"
    "github.com/docker/docker/client"
)
func TestContainerStartupTime(t *testing.T) {
    cli, _ := client.NewClientWithOpts()
    start := time.Now()
    // Start container
    resp, _ := cli.ContainerCreate(context.Background(), ...)
    cli.ContainerStart(context.Background(), resp.ID, ...)
    // Wait for health check
    for {
        inspect, _ := cli.ContainerInspect(context.Background(), resp.ID)
        if inspect.State.Health.Status == "healthy" {
            break
        }
        time.Sleep(100 * time.Millisecond)
    }
    duration := time.Since(start)
    if duration > 5*time.Second {
        t.Errorf("Container took too long to start: %v", duration)
    }
}

5. Integration Testing

# docker-compose.test.yml
version: '3.8'
services:
  app:
    build: .
    depends_on:
      db:
        condition: service_healthy
  db:
    image: postgres:15-alpine
    healthcheck:
      test: ["CMD", "pg_isready"]
  test:
    build:
      context: .
      target: builder
    command: go test -v ./...
    depends_on:
      - app
      - db
# Run with:
# docker-compose -f docker-compose.test.yml up --abort-on-container-exit

Security Considerations

Critical Security Practices

  • Never run as root: Always create and use a non-root user
  • Avoid secrets in layers: Use BuildKit secrets or multi-stage builds
  • Scan regularly: Integrate vulnerability scanning in CI/CD
  • Minimize attack surface: Use scratch or distroless images

Security Checklist

# .docker-security.yml
security_requirements:
  image:
    - no_root_user: true
    - no_sudo: true
    - readonly_filesystem: recommended
    - no_new_privileges: true
  build:
    - no_secrets_in_args: critical
    - use_buildkit_secrets: true
    - verify_base_image: true
  runtime:
    - drop_capabilities: ["ALL"]
    - add_capabilities: ["NET_BIND_SERVICE"]
    - security_opt: ["no-new-privileges:true"]
    - read_only_rootfs: true
  scanning:
    - trivy: "HIGH,CRITICAL"
    - snyk: true
    - docker_scout: true

Hardened Dockerfile Example

# Security-first Dockerfile
FROM golang:1.22-alpine AS builder
# Security: Don't run as root even during build
RUN adduser -D -g '' -u 10001 builduser
USER builduser
# ... build steps ...
# Final stage with maximum security
FROM gcr.io/distroless/static:nonroot
# Security labels
LABEL security.scan="true" \
      security.nonroot="true" \
      security.updates="auto"
# Copy with specific permissions
COPY --from=builder --chown=nonroot:nonroot /app /app
# Security: Drop all capabilities
USER nonroot:nonroot
# Security: Read-only filesystem
# (app must write to /tmp if needed)
EXPOSE 8080
ENTRYPOINT ["/app"]

The Bottom Line

After dockerizing 50+ Go services in production, here's what actually matters:

  • Size matters - 8MB images deploy significantly faster than 800MB (2s vs 120s push time)
  • Security matters - Non-root users and minimal attack surface prevent most CVEs
  • Build time matters - Proper caching reduces builds from 45s to 12s
  • Debugging matters - Keep dev and prod similar but not identical

✅ Ready for Production

The Dockerfile patterns in this article have been tested across:

  • 50+ microservices in production
  • Handling 100K+ requests per second
  • Passing SOC2 and ISO27001 audits
  • Running on Kubernetes, ECS, and Cloud Run

Start with the optimized Dockerfile at the top of this article. It's production-ready and will pass security audits.

Read the original on skoredin.pro

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.