12 KiB
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 1–9, 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
FROMlines 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 builder—AS buildernames 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 ascd.COPY go.mod ./thenRUN go mod downloadbeforeCOPY . .— this ordering is deliberate and matters for build speed. Docker caches each layer; ifgo.modhasn't changed, Docker reuses the cachedgo mod downloadlayer 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=0disables cgo (Go code calling C code), forcing a fully static binary with no dynamic library dependencies — this is what lets it run on the minimalalpineimage without missing shared libraries.GOOS=linuxensures 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-pondocker 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 theDockerfilein the current directory, instead of pulling a pre-built image.depends_on: [redis]— tells compose to startredisbeforeapp. 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, theappcontainer can reach Redis at the hostnameredis(not127.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 readsREDIS_ADDRfrom config instead of hardcoding127.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*aftergo.summeans "copy it if it exists, don't fail if it doesn't" (useful before you've rungo mod tidythe very first time)../cmd/apiin 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 builtconfig.goto read these from env vars back in Lesson 3/6 instead of hardcoding127.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.envfile sitting next todocker-compose.yml(compose auto-loads a file literally named.envin 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_onstartup-order caveat: MySQL can take a few seconds to become ready even after its container "starts." Ourdatabase.NewMySQLalready callsdb.PingContextwith a timeout and returns an error if it fails — so if you hit a race ondocker compose up, the cleanest fix is either restarting just theappservice, or adding a small retry loop around the ping inNewMySQL. 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.LastInsertId→b.ID). - Interfaces implicitly satisfied —
*chi.Muxand our custom handlers all satisfyhttp.Handlerjust by having the right method, no explicit "implements" keyword. - Closures and the three-layer middleware pattern —
func(deps) func(http.Handler) http.Handler, seen inRequestLoggerandRequireAuth. 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 errors —ErrUserNotFound,errors.Is, giving callers a stable way to distinguish error kinds without string-matching messages. - Dependency injection via structs —
AuthHandler{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 ofCREATE TABLE IF NOT EXISTSon 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.