Week 14: Deploying a Go Service: Docker & CI/CD

A Go service that only runs on your laptop isn't shipped. This week takes the API built over the last four weeks and gets it into a real, deployable container, configured the way a real environment expects, with a CI pipeline that builds, tests, and publishes it automatically.

Module 14 of 16 Week 14 of 16 ~4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Cross-compile a Go binary for a target platform, and package it in a minimal multi-stage Docker image
  • Configure a service through environment variables instead of hardcoded values
  • Add a health-check endpoint a real orchestrator can poll
  • Write a CI pipeline that builds, tests, and publishes a container image on every push

1. Cross-Compiling & Multi-Stage Docker Builds

Go compiles to a single, statically-linked binary with no runtime dependency — which means a Docker image for a Go service doesn't need Go installed in it at all, only the compiled binary. A multi-stage build takes advantage of exactly that.

Dockerfile
# --- build stage ---
FROM golang:1.22 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/api

# --- final stage ---
FROM gcr.io/distroless/static-debian12
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

The build stage has the full Go toolchain and every source file; the final stage has nothing but the compiled binary, on top of a minimal (or, here, distroless — no shell, no package manager, nothing beyond what's needed to run the binary) base image. The result is often under 20MB, with a dramatically smaller attack surface than shipping a full OS alongside the binary. CGO_ENABLED=0 is what guarantees a fully static binary with no dynamic-linking surprises against the minimal base image.

2. Environment Configuration

A container's configuration — database URL, port, log level — should come from its environment, never be hardcoded, per the widely-followed twelve-factor app principle: the same image should run correctly in every environment purely by changing what's injected around it.

config.go
type Config struct {
    Port        string
    DatabaseURL string
    LogLevel    string
}

func LoadConfig() (*Config, error) {
    cfg := &Config{
        Port:        getEnv("PORT", "8080"),
        DatabaseURL: os.Getenv("DATABASE_URL"),
        LogLevel:    getEnv("LOG_LEVEL", "info"),
    }
    if cfg.DatabaseURL == "" {
        return nil, errors.New("DATABASE_URL is required")
    }
    return cfg, nil
}

func getEnv(key, fallback string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return fallback
}

Failing fast (with a clear error) on a missing required variable at startup — rather than limping along and failing confusingly on the first request that needed it — is the same "fail fast at startup" instinct as Week 12's db.Ping().

3. Health-Check Endpoints

An orchestrator (Kubernetes, ECS, anything else) needs a way to ask "is this container actually ready to receive traffic" — a plain 200 OK on a well-known path is the simplest version, and a real one checks that the service's own dependencies (like the database) are actually reachable.

a real health check
func healthHandler(db *sql.DB) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        if err := db.PingContext(r.Context()); err != nil {
            writeError(w, http.StatusServiceUnavailable, "database unreachable")
            return
        }
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("ok"))
    }
}

Distinguishing liveness ("is the process still running") from readiness ("is it ready to serve real traffic") as two separate endpoints matters once deployed for real — a service that's alive but whose database connection just dropped should fail readiness (so traffic stops routing to it) without necessarily being killed and restarted, which failing liveness would trigger.

4. A CI Pipeline

.github/workflows/ci.yml
name: CI
on:
  push:
    branches: [main]

jobs:
  build-test-publish:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.22' }

      - run: go build ./...
      - run: go vet ./...
      - run: go test -race -cover ./...

      - name: Build and push image
        uses: docker/build-push-action@v6
        with:
          push: true
          tags: ghcr.io/yourname/my-service:${{ github.sha }}

-race enables Go's race detector during tests — genuinely worth running in CI given everything from Weeks 6–7, since a data race (two goroutines touching shared memory without a mutex) can pass every functional test and still be a real bug the race detector catches directly. Every push to main now builds, vets, tests, and — only if all of that passes — publishes a container image tagged with the exact commit that produced it.

5. Hands-on Exercise

Hands-on

Containerize and automate the task API

Package Weeks 10–13's API into a real, minimal container with a working CI pipeline.

Requirements:

  1. A multi-stage Dockerfile producing a minimal final image, with CGO_ENABLED=0 and a distroless or scratch-based final stage.
  2. All configuration (database URL, port, log level) read from environment variables, with a clear startup error if a required one is missing.
  3. A /health endpoint that actually checks database connectivity, returning 503 if unreachable rather than an unconditional 200.
  4. A CI workflow that runs go vet and go test -race on every push, failing the pipeline if either fails.
  5. Confirm the built image runs correctly with docker run, connecting to a real (or containerized) database purely through environment variables passed at run time.
Hint

If docker build succeeds but the container immediately exits or fails to bind to its port, check that your Dockerfile's EXPOSE and your app's actual listen address agree, and that you're passing -p host:container correctly on docker run — a service listening on localhost:8080 inside the container (rather than 0.0.0.0:8080) is a common mistake that makes it unreachable from outside the container even though it's running fine.

6. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why can a Go service's Docker image skip installing Go (or any language runtime) entirely in its final stage?

Go compiles to a single, statically-linked binary with no external runtime dependency — unlike an interpreted or VM-based language, there's nothing left to interpret or execute the source at container run time. The final image only needs the already-compiled binary plus whatever minimal OS-level pieces it directly depends on (often nothing, with CGO_ENABLED=0 and a distroless base).

Q2

Why load configuration from environment variables instead of a hardcoded config file baked into the image?

The same built image then works correctly across every environment — local, staging, production — purely by changing what's injected around it at run time, with zero rebuild required to change a database URL or log level. A hardcoded config would require a separate image build per environment, which defeats the point of having one tested, promotable artifact.

Q3

Why might a real deployment want separate liveness and readiness checks, rather than one combined health endpoint?

Liveness ("is the process still running") and readiness ("can it actually serve traffic right now") answer different questions with different consequences — failing liveness typically triggers a restart, while failing readiness just routes traffic elsewhere. A service whose database connection temporarily dropped should stop receiving traffic (fail readiness) without necessarily being killed and restarted (which failing liveness would trigger), since the process itself might still be healthy and about to reconnect.

Q4

Why does running tests with -race in CI matter specifically for a service using goroutines and channels?

A data race — two goroutines touching the same memory without proper synchronization — can easily pass every functional test, since the bug's symptom depends on unlucky timing that a given test run might never happen to hit. Go's race detector instruments the actual memory accesses during a test run and catches the race directly, regardless of whether it happened to produce a visibly wrong result that run, which is exactly the class of bug Weeks 6–7's concurrency work can introduce.