331 lines
12 KiB
Markdown
331 lines
12 KiB
Markdown
# 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.
|
||
|
||
```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.
|