GoDeployment

Deploying Go Apps with Docker and CI/CD Pipelines

TT
TopicTrick Team
Deploying Go Apps with Docker and CI/CD Pipelines

Go Deployment & Docker: The Artifact Mirror

A Go application compiles to a single static binary with no runtime dependencies. A multi-stage Docker build uses a golang:alpine builder image to compile the binary, then copies only that binary into a minimal alpine or scratch base image — producing a production container as small as 15MB. A GitHub Actions CI/CD pipeline then automates running tests, building the image, and deploying on every push to main.

In this module, we will explore how to "Dockerize" your Go application and deploy it with confidence using CI/CD.


1. The Artifact Mirror: Static Linking Physics

Go's primary deployment advantage is its ability to produce Sovereign Binaries.

The Compilation Physics

  • The Dependency Mirror: Unlike interpreted languages (Python, JS) that require an external interpreter mirror on the target machine, Go's compiler bundles all code, libraries, and the runtime into a single file.
  • The CGO Mirror: By disabling CGO (CGO_ENABLED=0), we ensure the binary uses Go's internal network and OS implementations rather than the host's dynamic C libraries.
  • The Result: A direct "Hardware Mirror" that runs on any Linux kernel of the same architecture, regardless of what's installed on the system.

2. Deploying Go Apps: Docker & CI/CD

Go's Deployment Advantage

A typical Node.js Docker image can easily reach 800MB - 1GB. A Go Docker image, using a "multi-stage build," can be as small as 15MB. This makes deployments faster, more secure (fewer tools for attackers to exploit), and significantly cheaper in terms of storage and bandwidth.

This is the secret to Go's tiny images. We use a heavy "builder" image to compile the code, and then copy the resulting binary into a clean, minimal "scratch" or "alpine" image for production.


3. The Multi-Stage Geometry: Build vs. Production

Multi-stage builds allow us to separate the Compilation Mirror from the Execution Mirror.

The Structural Physics

  • The Build Mirror: This stage contains the 800MB+ Go toolchain, caches, and source code. It is the "Heavy Mirror" needed for engineering but not for execution.
  • The Production Mirror: We use FROM scratch or FROM alpine to create a near-empty shell. We copy only the 15MB binary across the stage boundary.
  • The Security Mirror: Because the production image lacks a shell, a package manager, or even basic tools like ls, it presents a near-zero attack surface for hackers.

4. Multi-Stage Docker Builds

dockerfile
# Stage 1: Build the binary
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY . .
RUN go mod download
RUN CGO_ENABLED=0 GOOS=linux go build -o main ./cmd/api

# Stage 2: Create the minimal production image
FROM alpine:latest
WORKDIR /root/
COPY --from=builder /app/main .
EXPOSE 8080
CMD ["./main"]

Automating with CI/CD

Continuous Integration and Continuous Deployment (CI/CD) ensure that every time you push code, it is automatically tested, built into a Docker image, and deployed to your servers.

CI/CD Pipeline Stages

No data available

Environment Secrets

Never hardcode your database passwords or API keys in your code or Dockerfile. Use environment variables. In a production environment, use a secret manager like AWS Secrets Manager or GitHub Secrets.

go
func main() {
    dbURL := os.Getenv("DATABASE_URL")
    if dbURL == "" {
        log.Fatal("DATABASE_URL not set!")
    }
}
Task / FeatureStandard DeployGo Docker Deploy
No comparison data available

A Complete GitHub Actions CI/CD Workflow

Here is a production-ready GitHub Actions workflow that tests, builds, and pushes a Docker image on every push to main:

yaml
# .github/workflows/deploy.yml
name: Build and Deploy

on:
  push:
    branches: [ main ]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: '1.22'
      - name: Run tests
        run: go test -race ./...
      - name: Run linter
        uses: golangci/golangci-lint-action@v4

  build-and-push:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Log in to Docker Hub
        uses: docker/login-action@v3
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: myorg/myapp:latest,myorg/myapp:${{ github.sha }}

The needs: test directive ensures the Docker image is only built and pushed if all tests pass. The image is tagged with both latest and the Git commit SHA — the SHA tag gives you a precise record of which code version is in production.


Docker Compose for Local Development

While the multi-stage Dockerfile is for production, use Docker Compose to run your entire local stack (application + database + Redis) with a single command:

yaml
# docker-compose.yml
version: '3.8'
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - DATABASE_URL=postgres://user:password@db:5432/myapp
      - REDIS_ADDR=redis:6379
    depends_on:
      - db
      - redis

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: myapp
    volumes:
      - pgdata:/var/lib/postgresql/data

  redis:
    image: redis:7-alpine

volumes:
  pgdata:

Run docker compose up to start everything. Developers on any OS get an identical environment, eliminating "works on my machine" issues.


Health Checks and Graceful Shutdown

Production containers should implement a health check endpoint and graceful shutdown to handle zero-downtime deployments:

go
func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("ok"))
    })

    server := &http.Server{
        Addr:    ":8080",
        Handler: mux,
    }

    // Start server in background
    go server.ListenAndServe()

    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit

    // Graceful shutdown: finish in-flight requests, max 30s
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    server.Shutdown(ctx)
}

Your Docker Compose and Kubernetes health check configuration should poll /health. Kubernetes only routes traffic to pods that pass the health check, ensuring zero-downtime rolling deployments.


External Resources

For the full context of this module in the Go series, see Go caching with Redis (the step before deployment) and Go security best practices (the step after). For AI-assisted code review that fits into this CI/CD pipeline, see building a GitHub PR review agent with Claude.


Common Mistakes and How to Avoid Them

Deploying Go applications with Docker and CI/CD introduces a specific set of failure modes. Here are five mistakes to avoid.

1. Running containers as root. By default, Docker containers run as root. If an attacker escapes the container, they have root on the host. Add a non-root user to your Dockerfile: RUN adduser -D appuser && USER appuser. Most production security scanners flag root containers as a critical vulnerability.

2. Including secrets in Docker image layers. Environment variables and RUN commands used to COPY secrets at build time leave those secrets baked into the image layers, even if you delete them later in the same stage. Always inject secrets at runtime via environment variables or a secrets manager — never at build time.

3. Using latest as the image tag in production. The latest tag is mutable — a new push can silently change what version you are running. Tag every production image with the Git commit SHA (myapp:${{ github.sha }}). This gives you a precise audit trail and enables rollback by deploying a specific SHA tag.

4. Missing the CGO_ENABLED=0 flag. Without CGO_ENABLED=0, Go may produce a dynamically linked binary that depends on libc. When you copy this binary into a scratch or alpine image that lacks libc, the container crashes at startup with exec format error. Always build with CGO_ENABLED=0 GOOS=linux in your Dockerfile.

5. Not testing the health check endpoint. A health check endpoint that always returns 200 provides no value. Wire the handler to a real dependency check: confirm the database connection pool is alive and Redis is reachable. Kubernetes and load balancers use this signal to stop routing traffic to a degraded pod. The Go net/http package makes it straightforward to implement lightweight dependency checks inline.

FAQ

Q: Should I use scratch or alpine as my production base image?

scratch produces the smallest possible image (just your binary) but offers no shell for debugging. alpine adds ~5MB and a minimal shell, making it easier to exec into a running container during an incident. For security-critical production services, scratch or gcr.io/distroless/static is preferred. For most applications, alpine offers the right balance of size and debuggability.

Q: How do I handle database migrations in a CI/CD pipeline?

Run migrations as a separate Job in your pipeline before deploying the new application version, using a tool like golang-migrate. The migration job should run migrate up against the production database connection, and the deploy job should depend on (needs: migrate) so the new schema is in place before the new binary starts.

Use a .env file loaded by godotenv locally (never committed to version control), and GitHub Secrets or AWS Secrets Manager in production. The key principle — consistent with Go security best practices — is that the same environment variable name is used in all environments, so no code changes are needed when promoting from staging to production.


Phase 23: Deployment & Docker Mastery Checklist

  • Verify CGO Disablement: Ensure CGO_ENABLED=0 is set in your build command. This prevents the binary from having hidden dependencies on the host OS's shared libraries.
  • Audit Image Base: Switch from alpine to gcr.io/distroless/static or scratch to minimize the "Vulnerability Mirror" in production.
  • Implement Multi-Stage Geometry: Confirm that your Dockerfile separates the build toolchain from the final execution layer to protect your source code mirror.
  • Test Graceful Shutdown: Verify that your application catches SIGTERM signals from Docker/Kubernetes and finishes the request mirror before exiting.
  • Use SHA-Based Tagging: Never deploy the latest tag. Always mirror your Git history by tagging images with the specific commit SHA.

Read next: Go Security: The Hardening Mirror →


Common Go Deployment Mistakes

1. Not using a multi-stage Docker build Building Go inside a single Docker image leaves the compiler, source code, and build cache in the final image — bloating it to 800MB+. Use a multi-stage build: compile in golang:1.22 and copy only the binary into scratch or alpine. Final image size drops to under 20MB.

2. Running as root inside the container Go binaries do not need root. Add USER nonroot:nonroot in your Dockerfile after copying the binary. Running as root unnecessarily increases the blast radius if the container is compromised.

3. Not setting CGO_ENABLED=0 for static builds CGO_ENABLED=0 GOOS=linux go build produces a fully static binary with no shared library dependencies. Without this, your binary may fail to run in a scratch or minimal Alpine container that lacks the required libc.

4. Hardcoding configuration instead of using environment variables Port numbers, database URLs, and feature flags hardcoded in the binary require a rebuild for every environment change. Use os.Getenv for all environment-specific configuration and document required variables in a .env.example file.

5. Missing health check endpoints Kubernetes and other orchestrators use liveness and readiness probes to determine if a pod should receive traffic. Always implement /healthz (liveness) and /readyz (readiness) endpoints that return 200 when the service is healthy. See the Kubernetes health check documentation.

Frequently Asked Questions

What is the recommended base image for a Go Docker container? scratch (empty image) produces the smallest possible image but requires a fully static binary and no file system access. gcr.io/distroless/static-debian12 adds CA certificates and timezone data without a shell — the best balance of security and functionality for most Go services. alpine is also popular but adds a package manager attack surface.

How do I handle graceful shutdown in a Go HTTP server?

go
srv := &http.Server{Addr: ":8080", Handler: router}
go srv.ListenAndServe()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
srv.Shutdown(ctx)

This waits up to 30 seconds for in-flight requests to complete before exiting.

What CI/CD tools work well with Go? GitHub Actions with the official Go setup action is the most common choice. GitLab CI and CircleCI also have excellent Go support. For container builds, Docker Buildx with BuildKit caching significantly reduces build times by caching the Go module download layer.