Files
go-simple-api/lessons/lesson-10-docker-wrapup.md
T
2026-07-16 10:13:46 +03:30

12 KiB
Raw Blame History

Lesson 10 — Docker, docker-compose & Course Wrap-up

New Go concepts in this lesson: none — this lesson is entirely about Docker/containerization, which is language-agnostic. If you've followed the Go Basics lessons and Lessons 19, you already know everything Go needs for this course.

This is the last lesson — we'll containerize the whole app (API + MySQL + Redis) so it runs with one command, then do a full review of everything you've built.

Part A — Docker basics playground

A minimal example first, so the concepts aren't tangled up with our full project.

mkdir ~/go-playground/docker-demo && cd ~/go-playground/docker-demo
go mod init docker-demo

main.go

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintln(w, "hello from inside docker")
	})
	http.ListenAndServe(":8080", nil)
}

Dockerfile

# ---- Stage 1: build ----
FROM golang:1.26 AS builder

WORKDIR /app

COPY go.mod ./
RUN go mod download

COPY . .

# CGO_ENABLED=0 produces a statically-linked binary - no C libraries
# needed, which lets us run it on a tiny base image in stage 2.
RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/server .

# ---- Stage 2: run ----
FROM alpine:3.20

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

EXPOSE 8080
CMD ["./server"]

Build and run it:

docker build -t docker-demo .
docker run -p 8080:8080 docker-demo
curl http://localhost:8080

Line by line:

  • Multi-stage build — two FROM lines means two separate images are involved. The first (builder) has the full Go toolchain (~800MB+) and compiles your binary. The second (alpine) is a tiny (~7MB) Linux image that only receives the finished binary, not the compiler, source code, or build tools. Your final shipped image ends up small with a much smaller attack surface — no compiler sitting around in production.
  • FROM golang:1.26 AS builderAS builder names this stage so we can reference it later with --from=builder.
  • WORKDIR /app — sets the working directory inside the image for all subsequent commands, same idea as cd.
  • COPY go.mod ./ then RUN go mod download before COPY . . — this ordering is deliberate and matters for build speed. Docker caches each layer; if go.mod hasn't changed, Docker reuses the cached go mod download layer instead of re-downloading every dependency on every code change. If we copied all the source first, any code edit would invalidate the cache and force a full re-download every build.
  • CGO_ENABLED=0 GOOS=linux go build -o /app/bin/server .CGO_ENABLED=0 disables cgo (Go code calling C code), forcing a fully static binary with no dynamic library dependencies — this is what lets it run on the minimal alpine image without missing shared libraries. GOOS=linux ensures we cross-compile for Linux even if you're building this on macOS/Windows.
  • COPY --from=builder /app/bin/server . — the actual multi-stage magic: pull just one file out of the first image into the second, discarding everything else from the builder stage.
  • EXPOSE 8080 — documentation for humans/tools about which port the container listens on; doesn't actually publish the port by itself (that's -p on docker run).
  • CMD ["./server"] — the command that runs when the container starts.

Now let's connect it to something else via docker-compose, so you see multi-container orchestration before we do it for real:

docker-compose.yml

services:
  app:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      - redis
    environment:
      REDIS_ADDR: redis:6379

  redis:
    image: redis:8
    ports:
      - "6379:6379"
docker compose up --build
  • build: . — build the image from the Dockerfile in the current directory, instead of pulling a pre-built image.
  • depends_on: [redis] — tells compose to start redis before app. Note: this only controls startup order, not "wait until Redis is actually ready to accept connections" — a fast-starting app can still race ahead of a slow-starting dependency.
  • environment: REDIS_ADDR: redis:6379 — the key insight for compose networking: service names become hostnames. Inside the compose network, the app container can reach Redis at the hostname redis (not 127.0.0.1!), because compose sets up internal DNS that resolves service names to the right container's IP. This is exactly why our app reads REDIS_ADDR from config instead of hardcoding 127.0.0.1:6379 — it needs to be different in Docker vs. local dev.

Part B — containerize the full project

Dockerfile at the project root (same multi-stage pattern, adjusted for our module path):

FROM golang:1.26 AS builder

WORKDIR /app

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

COPY . .

RUN CGO_ENABLED=0 GOOS=linux go build -o /app/bin/server ./cmd/api

FROM alpine:3.20

# ca-certificates is needed for outbound HTTPS calls - our Google OAuth
# token exchange and userinfo requests both need this to verify certs.
RUN apk add --no-cache ca-certificates

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

EXPOSE 8080
CMD ["./server"]
  • COPY go.mod go.sum* ./ — the * after go.sum means "copy it if it exists, don't fail if it doesn't" (useful before you've run go mod tidy the very first time).
  • ./cmd/api in the build command — points at our actual entrypoint package from Lesson 1's project layout, not the project root.
  • RUN apk add --no-cache ca-certificates — Alpine's minimal base doesn't include root CA certificates by default. Without this, any outbound HTTPS call our app makes (Google's token/userinfo endpoints) would fail with a certificate verification error.

docker-compose.yml — the full stack: our app, MySQL, and Redis:

services:
  app:
    build: .
    ports:
      - "8080:8080"
    depends_on:
      - mysql
      - redis
    environment:
      PORT: 8080
      ENV: development
      DB_HOST: mysql
      DB_PORT: 3306
      DB_USER: root
      DB_PASSWORD: devpass
      DB_NAME: go_simple_api
      REDIS_ADDR: redis:6379
      GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID}
      GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET}
      GOOGLE_REDIRECT_URL: http://localhost:8080/auth/google/callback
      ALLOWED_ORIGINS: http://localhost:3000

  mysql:
    image: mysql:9
    environment:
      MYSQL_ROOT_PASSWORD: devpass
      MYSQL_DATABASE: go_simple_api
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql

  redis:
    image: redis:8
    ports:
      - "6379:6379"

volumes:
  mysql_data:
  • DB_HOST: mysql / REDIS_ADDR: redis:6379 — using compose service names as hostnames, exactly as explained in Part A. This is why we built config.go to read these from env vars back in Lesson 3/6 instead of hardcoding 127.0.0.1 — the same code now works both locally and inside compose, just by changing environment variables.
  • ${GOOGLE_CLIENT_ID} / ${GOOGLE_CLIENT_SECRET} — compose substitutes these from your shell environment or a .env file sitting next to docker-compose.yml (compose auto-loads a file literally named .env in the same directory).
  • volumes: mysql_data:/var/lib/mysql — without this, MySQL's data directory lives inside the container's writable layer, destroyed when the container is removed (docker compose down). A named volume persists that data on the host, independent of the container's lifecycle.
  • About the depends_on startup-order caveat: MySQL can take a few seconds to become ready even after its container "starts." Our database.NewMySQL already calls db.PingContext with a timeout and returns an error if it fails — so if you hit a race on docker compose up, the cleanest fix is either restarting just the app service, or adding a small retry loop around the ping in NewMySQL. Treat that as an optional improvement rather than something required for this course.

Try the whole stack:

docker compose up --build
curl -X POST http://localhost:8080/register \
  -H "Content-Type: application/json" \
  -d '{"email":"hamid@example.com","password":"secret123"}'

curl -c cookies.txt -X POST http://localhost:8080/login \
  -H "Content-Type: application/json" \
  -d '{"email":"hamid@example.com","password":"secret123"}'

curl -b cookies.txt http://localhost:8080/me

Stop everything cleanly:

docker compose down          # stops and removes containers, keeps the volume
docker compose down -v       # also wipes the mysql_data volume

Course review — what you actually built

Concept Where you learned it Where it lives now
chi routing, graceful shutdown Lesson 1 router/, cmd/api/main.go
Structured JSON logging (slog) Lesson 2 logging/, middleware/request_logger.go
MySQL connection pooling Lesson 3 database/mysql.go
Repository pattern, pointers Lesson 4 models/user_repository.go
bcrypt, JSON request handling Lesson 5 handlers/auth.go
Server-side sessions (scs + Redis) Lesson 6 session/, login/logout/me
OAuth2 (Google login) Lesson 7 oauth/, handlers/oauth_google.go
Context values, auth middleware Lesson 8 middleware/require_auth.go
Rate limiting, CORS, cookie security Lesson 9 router.go, session.go, config.go
Docker & docker-compose Lesson 10 Dockerfile, docker-compose.yml

Core Go ideas that came up repeatedly — make sure these are solid

  • Pointers (*/&) — sharing state (*sql.DB, *scs.SessionManager) vs. copying values; writing into caller variables (rows.Scan, res.LastInsertIdb.ID).
  • Interfaces implicitly satisfied*chi.Mux and our custom handlers all satisfy http.Handler just by having the right method, no explicit "implements" keyword.
  • Closures and the three-layer middleware patternfunc(deps) func(http.Handler) http.Handler, seen in RequestLogger and RequireAuth.
  • context.Context — carrying request-scoped values (request ID, current user) and deadlines (timeouts) through a call chain without threading extra parameters everywhere.
  • Error wrapping (%w) and sentinel errorsErrUserNotFound, errors.Is, giving callers a stable way to distinguish error kinds without string-matching messages.
  • Dependency injection via structsAuthHandler{userRepo, sessions, logger} instead of global variables, making every handler's dependencies explicit and testable.

Reasonable next steps, if you want to keep going

  • Testing — table-driven tests, httptest (you touched this in Lesson 5's Part A) for handlers, and mocking the repository via an interface instead of a concrete *sql.DB-backed struct.
  • A real migration tool (e.g. golang-migrate) instead of CREATE TABLE IF NOT EXISTS on every boot — versioned, reversible schema changes.
  • CSRF tokens, if you ever add a same-origin HTML form frontend, as flagged in Lesson 9.
  • Refresh tokens / remember-me, since right now a session simply expires after 24 hours with no renewal path.
  • Structured error responses with error codes, so a frontend can branch on "error_code": "invalid_credentials" instead of parsing message strings.
  • Observability: running Grafana Alloy to tail this container's JSON stdout logs and ship them to Loki is a natural next step, since Lesson 2 already gives you the right log shape for it.

That's the course. You went from an empty folder to a real, containerized Go API with password auth, Google OAuth, Redis-backed sessions, rate limiting, and structured logging — and along the way, picked up the core Go idioms (pointers, interfaces, closures, contexts, error handling) that show up in essentially every real-world Go codebase.