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 whengo.modchanges.
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 Image | Size with Go Binary | Use Case |
|---|---|---|
golang:1.26 | ~1.2 GB | Development only |
ubuntu:24.04 | ~100 MB | When you need many OS tools |
alpine:3.20 | ~15 MB | Production (good default) |
distroless | ~12 MB | Production (no shell) |
scratch | ~8-10 MB | Production (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 dependencies —
go mod downloadruns only whengo.modchanges
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:
Related Articles
- Go Tutorial #23: Building CLI Tools with Cobra — Build CLI tools
- Go Tutorial #21: API Best Practices — Health checks, graceful shutdown
- Docker Cheat Sheet — Quick Docker reference
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.