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

331 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```bash
mkdir ~/go-playground/docker-demo && cd ~/go-playground/docker-demo
go mod init docker-demo
```
**`main.go`**
```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`**
```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:
```bash
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 builder``AS 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`**
```yaml
services:
app:
build: .
ports:
- "8080:8080"
depends_on:
- redis
environment:
REDIS_ADDR: redis:6379
redis:
image: redis:8
ports:
- "6379:6379"
```
```bash
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):
```dockerfile
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:
```yaml
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:**
```bash
docker compose up --build
```
```bash
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:
```bash
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.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 pattern** —
`func(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 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 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.