In the previous tutorial, you built a CLI tool with Cobra. Now let’s package your Go applications with Docker.

Go and Docker are a perfect combination. Go compiles to a single static binary. No runtime, no dependencies, no virtual machine. You can run a Go binary in a Docker image that contains nothing else. The result? Production images under 15MB.

A Simple Dockerfile

Start with a basic Dockerfile for a Go web server:

// main.go
package main

import (
    "fmt"
    "net/http"
    "os"
)

func main() {
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    http.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
        fmt.Fprintf(w, "Hello from Go in Docker!")
    })

    http.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        fmt.Fprintf(w, `{"status":"ok"}`)
    })

    fmt.Printf("Server starting on :%s\n", port)
    http.ListenAndServe(":"+port, nil)
}

A naive Dockerfile:

FROM golang:1.26

WORKDIR /app
COPY . .
RUN go build -o server .

EXPOSE 8080
CMD ["./server"]

This works, but the image is over 1GB. It includes the entire Go toolchain, source code, and build artifacts. You do not need any of that in production.

Multi-Stage Builds

Multi-stage builds solve this. Use one stage to build, another to run:

# Stage 1: Build
FROM golang:1.26-alpine AS builder

WORKDIR /app

# Copy go.mod and go.sum first for better caching
COPY go.mod go.sum ./
RUN go mod download

# Copy source code and build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o server .

# Stage 2: Run
FROM alpine:3.20

WORKDIR /app

# Copy only the binary from the build stage
COPY --from=builder /app/server .

EXPOSE 8080
CMD ["./server"]

This image is about 15MB. Alpine Linux adds a small OS layer with useful tools like sh and wget.

Key settings:

  • CGO_ENABLED=0 — disables C library linking. The binary is fully static.
  • GOOS=linux — builds for Linux (even if you are on macOS or Windows).
  • go mod download — cached separately from source code. Dependencies only re-download when go.mod changes.

Scratch Images — The Smallest Possible

For the absolute smallest image, use scratch — an empty image with nothing in it:

# Stage 1: Build
FROM golang:1.26-alpine AS builder

WORKDIR /app

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

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

# Stage 2: Run on empty image
FROM scratch

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

EXPOSE 8080
ENTRYPOINT ["/server"]

The -ldflags="-s -w" flags strip debug information, making the binary smaller. This image is about 8-10MB — just the Go binary and nothing else.

When to use scratch:

  • You do not need a shell (no sh, bash)
  • You do not need package managers
  • You do not need CA certificates (or you copy them from the build stage)

When to use Alpine instead:

  • You need to debug inside the container
  • You need CA certificates for HTTPS calls
  • You need timezone data

Adding CA Certificates to Scratch

If your app makes HTTPS requests, copy CA certificates from the build stage:

FROM golang:1.26-alpine AS builder

RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .

FROM scratch

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

EXPOSE 8080
ENTRYPOINT ["/server"]

.dockerignore

Keep your Docker build context clean. Create a .dockerignore file:

.git
.gitignore
.env
*.md
docs/
tmp/
*.test
coverage.out

This speeds up builds and prevents sensitive files from ending up in the image.

Docker Compose with PostgreSQL

Most Go services need a database. Docker Compose runs your app and PostgreSQL together:

# docker-compose.yml
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - PORT=8080
      - DATABASE_URL=postgres://appuser:apppassword@db:5432/appdb?sslmode=disable
    depends_on:
      db:
        condition: service_healthy

  db:
    image: postgres:16-alpine
    environment:
      - POSTGRES_USER=appuser
      - POSTGRES_PASSWORD=apppassword
      - POSTGRES_DB=appdb
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U appuser -d appdb"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  pgdata:

Run it:

docker compose up --build

The depends_on with service_healthy ensures the app starts only after PostgreSQL is ready to accept connections.

Reading Configuration from Environment

Update your Go app to read the database URL from the environment:

package main

import (
    "fmt"
    "net/http"
    "os"
)

func main() {
    port := os.Getenv("PORT")
    if port == "" {
        port = "8080"
    }

    dbURL := os.Getenv("DATABASE_URL")
    if dbURL == "" {
        fmt.Println("Warning: DATABASE_URL not set")
    } else {
        fmt.Println("Database URL configured")
        // Connect to database here
    }

    http.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        fmt.Fprintf(w, `{"status":"ok","port":"%s"}`, port)
    })

    fmt.Printf("Server starting on :%s\n", port)
    http.ListenAndServe(":"+port, nil)
}

Health Checks in Dockerfile

Add a health check to your Dockerfile so Docker knows if your app is healthy:

FROM golang:1.26-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o server .

FROM alpine:3.20

WORKDIR /app
COPY --from=builder /app/server .

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1

CMD ["./server"]

Docker checks the /health endpoint every 30 seconds. If it fails 3 times, the container is marked as unhealthy.

Building for Multiple Platforms

Go makes cross-compilation simple. Build for any OS and architecture:

# Linux AMD64 (most servers)
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o server-linux-amd64 .

# Linux ARM64 (AWS Graviton, Raspberry Pi)
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -o server-linux-arm64 .

# macOS Apple Silicon
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 go build -o server-darwin-arm64 .

# Windows
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -o server.exe .

Multi-Platform Docker Images

Build Docker images for multiple architectures with docker buildx:

# Create a builder
docker buildx create --name multiplatform --use

# Build for both AMD64 and ARM64
docker buildx build --platform linux/amd64,linux/arm64 \
  -t myapp:latest --push .

Your Dockerfile already supports this because CGO_ENABLED=0 produces a static binary. Docker automatically picks the right platform.

Image Size Comparison

Here is how different base images compare:

Base ImageSize with Go BinaryUse Case
golang:1.26~1.2 GBDevelopment only
ubuntu:24.04~100 MBWhen you need many OS tools
alpine:3.20~15 MBProduction (good default)
distroless~12 MBProduction (no shell)
scratch~8-10 MBProduction (nothing)

For most projects, Alpine is the best choice. It gives you a shell for debugging and CA certificates for HTTPS, with minimal size overhead.

Production Dockerfile Template

Here is a production-ready Dockerfile you can use for any Go project:

# Build stage
FROM golang:1.26-alpine AS builder

# Install CA certificates for HTTPS
RUN apk add --no-cache ca-certificates

WORKDIR /app

# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download

# Build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
    -ldflags="-s -w -X main.version=1.0.0" \
    -o /app/server .

# Run stage
FROM alpine:3.20

# Add non-root user
RUN adduser -D -g '' appuser

WORKDIR /app

# Copy binary and CA certs
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server .

# Run as non-root user
USER appuser

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health || exit 1

CMD ["./server"]

Key production practices:

  • Non-root user — never run as root in production
  • CA certificates — for HTTPS connections
  • Health check — Docker and orchestrators know if the app is alive
  • Stripped binary-ldflags="-s -w" reduces binary size
  • Cached dependenciesgo mod download runs only when go.mod changes

Common Mistakes

1. Not using multi-stage builds.

Single-stage builds include the Go toolchain (1GB+) in your production image. Always use multi-stage builds.

2. Running as root.

The default Docker user is root. If your app has a vulnerability, the attacker has full control. Always add a non-root user.

3. Not caching go mod download.

If you copy all files before go mod download, dependencies re-download on every code change. Copy go.mod and go.sum first.

Source Code

You can find the complete source code for this tutorial on GitHub:

GO-24 Source Code on GitHub

What’s Next?

In the next tutorial, Go Tutorial #25: Building a Microservice, you will learn:

  • Building a complete notes microservice with Gin
  • PostgreSQL with sqlx and clean architecture
  • JWT authentication and input validation
  • Docker Compose deployment with integration tests

This is part 24 of the Go Tutorial series. Follow along to learn Go from scratch.