Skip to content

Day 111 — Project: Dockerfile & CI

Month 4 · Week 4 · ⬅ Day 110 · Day 112 ➡ · Journal index

🎯 Learning Objective

Package the capstone as a small, secure container with a multi-stage Dockerfile, and run a CI pipeline (build, vet, test, lint) on every push so regressions are caught automatically.

📚 Topics

  • Multi-stage Docker builds: compile in a Go image, ship a tiny runtime image
  • Static binaries (CGO_ENABLED=0), distroless/scratch, non-root user
  • GitHub Actions: matrix builds, caching, go vet / go test -race / golangci-lint
  • Reproducibility: pinned base images, go.sum integrity

📖 Reading / Sources

📝 Notes

  • A multi-stage build uses a fat golang image to compile, then COPY --from=build only the binary into a minimal final image. The toolchain never ships to prod → smaller, smaller attack surface → [[multi-stage-build]].
  • Build a static binary with CGO_ENABLED=0 go build so it runs on scratch/distroless with no libc. Pure-Go (database/sql + pgx) needs no CGO; the pq/sqlite cgo drivers would → [[static-binary]].
  • Run as non-root (USER nonroot) and prefer distroless (no shell, no package manager) to shrink what an attacker can do inside the container.
  • Use a .dockerignore (drop .git, tests, local env) so the build context is small and secrets don't leak into layers.
  • Layer caching: COPY go.mod go.sum and go mod download before copying source, so dependency layers cache across code changes and rebuilds are fast → [[build-cache]].
  • CI gates on every push/PR: go build ./..., go vet ./..., go test -race ./..., and golangci-lint run. The -race flag catches data races that unit tests alone miss → [[race-detector]].
  • Cache the Go build/module cache in CI (actions/setup-go does this) so pipelines are fast. Pin action and image versions (by tag or digest) for reproducible builds.
  • Tag/pin base images by digest in production; latest is not reproducible and can change under you.

💻 Code Examples

# ---- build stage: full toolchain ----
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download                       # cached unless deps change
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /app ./cmd/api

# ---- runtime stage: tiny, non-root ----
FROM gcr.io/distroless/static:nonroot
COPY --from=build /app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
# .github/workflows/ci.yml — runs on every push/PR
name: ci
on: [push, pull_request]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with: { go-version: '1.23', cache: true }
      - run: go vet ./...
      - run: go test -race -count=1 ./...

No runnable example: Docker/Actions are out-of-band tooling, not Go code. These are the actual files committed to the project repo.

🏋️ Exercises / Practice

Exercise Status Link
(Concept) write the multi-stage Dockerfile + CI workflow snippets above
Re-run week-4 packages under go test -race exercises/month-04/week-4

🐛 Mistakes Made

  • First image was 900 MB (shipped the whole golang image). Multi-stage + distroless dropped it to ~15 MB.
  • Built with default CGO and got "no such file or directory" on scratch (dynamic libc). Set CGO_ENABLED=0 for a static binary.

❓ Open Questions

  • distroless static:nonroot vs alpine for debuggability — alpine has a shell but a bigger surface. Use distroless for prod, alpine only when I need to exec in?

🧠 Active Recall (answer without looking)

  1. Q: Why copy go.mod/go.sum and run go mod download before copying the source in a Dockerfile?
A To cache the dependency-download layer. Source changes far more often than deps, so this layer is reused and rebuilds only re-download when go.mod/go.sum change.
  1. Q: Why run go test -race in CI even if all tests pass locally without it?
A The race detector instruments memory access to catch data races that ordinary test runs miss (they're timing-dependent and may pass by luck). CI is where you afford the slower, instrumented run.

🪶 Feynman Reflection

A multi-stage build is like baking in a fully-equipped kitchen, then selling only the finished cake in a plain box — the ovens and flour (the Go toolchain) never go to the customer. CI is the quality-control line: every cake is weighed, vetted, and stress-tested (race detector) before it can leave the factory, so a bad batch never reaches a release.

🕳️ Knowledge Gaps

  • Supply-chain hardening: SBOMs, image signing (cosign), and govulncheck in CI — add next iteration.

✅ Summary

I can containerise the service as a tiny, non-root, static image via multi-stage build, and gate every push on build/vet/race-test/lint in GitHub Actions.

⏭️ Next Steps / Prep for Tomorrow

  • Day 112: Month 4 review, polish the project README, and tag the v0.4.0 release.

Time spent Difficulty Confidence
90 min 🟦🟦⬜⬜⬜ 🟦🟦🟦⬜⬜

Suggested commit: build(project): multi-stage Dockerfile and CI pipeline (day 111)