Exercises — Module 18: Build, Tooling, and Deployment¶
Work through exercises in order — they're designed to build on each other. Attempt each problem genuinely before looking at the solution. Seeing the solution first might feel like progress, but it isn't.
Instructions¶
- Attempt first. Spend at least the estimated time on each problem before checking hints or solutions.
- Write your work. Don't just read — actually run the commands and observe the output.
- Check your answer against the acceptance criteria, not just the solution code.
- Score yourself honestly in the Scoring Log at the bottom.
- If you're stuck after a genuine effort, use the hints one at a time — not all at once.
Difficulty Legend¶
| Symbol | Difficulty | Expected Time | Points |
|---|---|---|---|
| 🟢 Easy | Recall and basic application | 5–15 min | 1 pt |
| 🟡 Medium | Requires combining 2+ concepts | 20–35 min | 2 pts |
| 🔴 Hard | Multi-step, requires real problem-solving | 40–75 min | 3 pts |
| ⭐ Expert | Open-ended; more than one good answer | 90+ min | 5 pts |
Exercise 1: Version Flag with ldflags [🟢 Easy] [1 pt]¶
Context¶
Stamping version information into a binary at link time is the canonical Go approach — no generated files, no runtime lookups, no configuration. This exercise establishes the -ldflags -X pattern that every other exercise and the CI workflow will build on.
Task¶
Write a Go program (main.go) that:
1. Declares three package-level string variables: version, commit, and buildDate with default values "dev", "none", and "unknown"
2. Exposes a --version flag that prints all three values and exits
3. Has a normal code path that prints "Hello from myapp"
Build the binary twice: once with plain go build (showing the defaults), and once with -ldflags "-X ..." stamping real values.
Requirements¶
- Three package-level variables with sensible defaults
-
--versionflag implemented with theflagpackage -
go buildwithout ldflags produces"myapp dev (commit: none, built: unknown)" -
go build -ldflags "-X 'main.version=v1.0.0' -X 'main.commit=abc1234' -X 'main.buildDate=2026-06-09'"produces the stamped values
Hints¶
Hint 1 (try without this first)
Declare the variables at the package level (outside `main`), not inside `main`. They must be package-level `var` declarations for the linker's `-X` flag to target them.Hint 2 (only if Hint 1 wasn't enough)
The `-X` flag syntax is: `-X 'importpath.VariableName=value'`. For variables in `package main`, the import path is just `main`. Example: `-X 'main.version=v1.0.0'`.Expected Output / Acceptance Criteria¶
# Without ldflags:
$ ./myapp --version
myapp dev (commit: none, built: unknown)
# With ldflags:
$ ./myapp --version
myapp v1.0.0 (commit: abc1234, built: 2026-06-09)
Solution¶
Show Solution (attempt first!)
// main.go
package main
import (
"flag"
"fmt"
"os"
)
// Stamped by the linker via -ldflags "-X main.version=..." at build time.
// Default values are used for development builds.
var (
version = "dev"
commit = "none"
buildDate = "unknown"
)
func main() {
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()
if *showVersion {
fmt.Printf("myapp %s (commit: %s, built: %s)\n", version, commit, buildDate)
os.Exit(0)
}
fmt.Println("Hello from myapp")
}
# Build without stamping — shows defaults
go build -o myapp .
./myapp --version
# myapp dev (commit: none, built: unknown)
# Build with stamped values
go build \
-ldflags "-X 'main.version=v1.0.0' -X 'main.commit=abc1234' -X 'main.buildDate=2026-06-09'" \
-o myapp .
./myapp --version
# myapp v1.0.0 (commit: abc1234, built: 2026-06-09)
Exercise 2: go vet and Build Tag [🟢 Easy] [1 pt]¶
Context¶
go vet catches real bugs that the compiler doesn't. Build constraints restrict compilation to specific platforms. Both tools are the first line of defense before code reaches a CI system.
Task¶
- Write a Go file with a deliberate
go veterror: afmt.Printfcall with a mismatched format verb (e.g.,fmt.Printf("%s\n", 42)— using%sfor an integer). - Run
go vet ./...and observe the error. - Fix the error and run
go vet ./...again to confirm it passes. - Add a second file with
//go:build integrationat the top. Add a function in it. Demonstrate thatgo build .ignores it, butgo build -tags integration .includes it.
Requirements¶
-
go vetreports the format mismatch before the fix -
go vetpasses cleanly after the fix - The
//go:build integrationfile is excluded fromgo build .and included withgo build -tags integration .
Hints¶
Hint 1
For the build tag demonstration, the simplest approach is to have the integration file define a function or variable that causes a compile error if included twice — like a duplicate `main` function — so you can clearly see when it's included vs excluded.Expected Output / Acceptance Criteria¶
# Before fix:
$ go vet ./...
./main.go:8:2: fmt.Printf format %s has arg 42 of wrong type int
# After fix:
$ go vet ./...
(no output — clean)
# Without the tag:
$ go build .
(succeeds, integration file excluded)
# With the tag:
$ go build -tags integration .
(succeeds, integration file included)
Solution¶
Show Solution
// main.go
package main
import "fmt"
func main() {
n := 42
// BUG: %s expects a string, but n is an int — go vet will catch this
fmt.Printf("%s\n", n) // ← change to %d to fix
}
Exercise 3: Cross-Compile a Binary [🟡 Medium] [2 pts]¶
Context¶
Cross-compilation is one of Go's most powerful features for distribution. This exercise builds the same binary for Linux and macOS from whatever platform you're on, verifies the output is a static binary, and confirms the file type.
Task¶
Using the program from Exercise 1 (or any main package):
1. Cross-compile for linux/amd64 with CGO_ENABLED=0, outputting to bin/myapp-linux-amd64
2. Cross-compile for darwin/arm64 with CGO_ENABLED=0, outputting to bin/myapp-darwin-arm64
3. Run file bin/myapp-linux-amd64 to confirm it's an ELF 64-bit binary
4. Run ldd bin/myapp-linux-amd64 (if on Linux) or explain what the expected output would be
Requirements¶
- Both binaries are produced without error
- The Linux binary is confirmed as ELF 64-bit (via
file) - You can explain why
CGO_ENABLED=0is necessary for container deployment - Version ldflags are included in the build commands
Hints¶
Hint 1
Set `GOOS`, `GOARCH`, and `CGO_ENABLED` as environment variable prefixes before `go build`: `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build ...`. This is equivalent to `export`ing them but only applies to that single command.Hint 2
`mkdir -p bin` before running the build commands, since Go won't create the output directory for you.Expected Output / Acceptance Criteria¶
$ file bin/myapp-linux-amd64
bin/myapp-linux-amd64: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, ...
$ ldd bin/myapp-linux-amd64
not a dynamic executable
Solution¶
Show Solution
mkdir -p bin
# Linux/amd64 — static binary for containers
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build \
-ldflags "-s -w -X 'main.version=v1.0.0' -X 'main.commit=abc1234'" \
-o bin/myapp-linux-amd64 .
# macOS/arm64 — Apple Silicon
CGO_ENABLED=0 GOOS=darwin GOARCH=arm64 \
go build \
-ldflags "-s -w -X 'main.version=v1.0.0' -X 'main.commit=abc1234'" \
-o bin/myapp-darwin-arm64 .
# Verify:
file bin/myapp-linux-amd64
# bin/myapp-linux-amd64: ELF 64-bit LSB executable, x86-64, statically linked, ...
ldd bin/myapp-linux-amd64
# not a dynamic executable ← correct for scratch/distroless containers
Exercise 4: Makefile with Build Targets [🟡 Medium] [2 pts]¶
Context¶
A Makefile consolidates all project commands into a consistent interface. This is the single document a new contributor reads to understand how to build, test, and release the project. Writing a good Makefile is a professional skill.
Task¶
Write a Makefile for the project from the previous exercises with the following targets:
- build — builds a binary with version stamping (using git describe --tags --always)
- build-linux — cross-compiles for linux/amd64 with CGO_ENABLED=0
- test — runs go test -race ./...
- lint — runs go vet ./... (substitute golangci-lint run ./... if installed)
- clean — removes bin/ and runs go clean -cache
- help — prints a list of targets with descriptions
Requirements¶
- Variables
BINARY,VERSION,COMMIT, andLDFLAGSare defined at the top - All targets are declared
.PHONY -
helptarget prints target descriptions extracted from##comments - Running
make(no target) runsbuildby default - Running
make helpoutputs readable target descriptions
Hints¶
Hint 1
Set `VERSION` using `$(shell git describe --tags --always --dirty)` — this produces `v1.0.0-dirty` if there are uncommitted changes, or `v1.0.0` for a clean tag. If no tags exist, it falls back to the commit hash.Hint 2 (help target)
The `help` target uses `grep` to extract lines with `##` comments: `grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}'`Expected Output / Acceptance Criteria¶
$ make help
build Build binary for current platform
build-linux Cross-compile for linux/amd64
test Run tests with race detector
lint Run go vet
clean Remove build artifacts
$ make build
go build -ldflags "..." -o bin/myapp .
$ make test
go test -race ./...
ok github.com/acme/myapp 0.042s
Solution¶
Show Solution
# Makefile
BINARY := myapp
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "none")
BUILD_DATE := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -s -w \
-X 'main.version=$(VERSION)' \
-X 'main.commit=$(COMMIT)' \
-X 'main.buildDate=$(BUILD_DATE)'
.DEFAULT_GOAL := build
.PHONY: build
build: ## Build binary for current platform
mkdir -p bin
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY) .
.PHONY: build-linux
build-linux: ## Cross-compile for linux/amd64
mkdir -p bin
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags "$(LDFLAGS)" -o bin/$(BINARY)-linux-amd64 .
.PHONY: test
test: ## Run tests with race detector
go test -race ./...
.PHONY: lint
lint: ## Run go vet (or golangci-lint if available)
go vet ./...
.PHONY: clean
clean: ## Remove build artifacts and build cache
rm -rf bin/
go clean -cache
.PHONY: help
help: ## Print this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | \
awk 'BEGIN {FS = ":.*?## "}; {printf " %-20s %s\n", $$1, $$2}'
Exercise 5: Multi-Stage Dockerfile [🔴 Hard] [3 pts]¶
Context¶
Multi-stage Docker builds are the standard approach for Go containers. This exercise produces a production-quality Dockerfile that creates a minimal final image using scratch or distroless, with correct layer caching.
Task¶
Write a complete Dockerfile for the program from Exercise 1 that:
1. Uses golang:1.22-alpine as the builder stage
2. Copies go.mod and go.sum first and runs go mod download (for layer caching)
3. Copies the source and builds a static binary with CGO_ENABLED=0 and stripped symbols (-s -w)
4. Creates a second stage from gcr.io/distroless/static-debian12:nonroot (or scratch with CA certs)
5. Copies only the binary into the final image
6. Sets a non-root user and exposes port 8080
Build the image and verify its size.
Requirements¶
- Two stages:
builderand a minimal final image -
go.mod go.sumare copied before source for cache efficiency -
CGO_ENABLED=0in the build command - Final image contains only the binary (and CA certs if using scratch)
- Final image runs as non-root
-
docker imagesshows the final image is under 25 MB
Hints¶
Hint 1
`gcr.io/distroless/static-debian12:nonroot` already runs as non-root and includes CA certificates and a minimal filesystem. It's simpler than `scratch` for services that need HTTPS. Use `scratch` if you want the absolute minimum and don't need HTTPS calls from inside the container.Hint 2 (scratch with CA certs)
To use `FROM scratch` with HTTPS, copy the CA bundle from the builder: Alpine includes this file at `/etc/ssl/certs/ca-certificates.crt`.Hint 3 (non-root on scratch)
`scratch` has no `/etc/passwd`, so you cannot use `USER nobody`. Use the numeric UID: `USER 65534:65534` (nobody/nogroup on most Linux systems).Expected Output / Acceptance Criteria¶
$ docker build -t myapp:latest .
[+] Building 12.3s
$ docker images myapp
REPOSITORY TAG IMAGE ID CREATED SIZE
myapp latest a1b2c3d4e5f6 5 seconds ago 9.2 MB
$ docker run --rm myapp:latest --version
myapp dev (commit: none, built: unknown)
Solution¶
Show Solution
# syntax=docker/dockerfile:1
# ── Stage 1: Builder ──────────────────────────────────────────────────────────
FROM golang:1.22-alpine AS builder
# Install CA certificates (needed to copy into scratch later)
RUN apk add --no-cache ca-certificates
WORKDIR /app
# Copy module files first — this layer is cached until deps change
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build static binary
COPY . .
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build \
-ldflags "-s -w" \
-o /app/myapp .
# ── Stage 2: Minimal final image ─────────────────────────────────────────────
FROM scratch
# CA certificates for outbound HTTPS
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# Only the binary crosses into the final image
COPY --from=builder /app/myapp /myapp
# Run as non-root (65534 = nobody — numeric because scratch has no /etc/passwd)
USER 65534:65534
EXPOSE 8080
ENTRYPOINT ["/myapp"]
Exercise 6: GitHub Actions CI Workflow [🔴 Hard] [3 pts]¶
Context¶
A CI workflow that runs on every pull request is the minimum viable quality gate for a professional Go project. This exercise writes the real workflow YAML, with separate jobs for tests, linting, and a two-platform build.
Task¶
Write a .github/workflows/ci.yml GitHub Actions workflow that:
1. Triggers on push to main and on pull_request
2. Has a test job that: checks out the code, sets up Go 1.22, runs go mod download, and runs go test -race -coverprofile=coverage.out ./...
3. Has a lint job that: runs golangci/golangci-lint-action@v6
4. Has a build job that: depends on test and lint, runs a matrix for linux/amd64 and linux/arm64, cross-compiles with CGO_ENABLED=0, and uploads each binary as an artifact
Requirements¶
- Jobs are independent (
testandlintrun in parallel) -
buildonly runs after bothtestandlintpass (needs: [test, lint]) - The build matrix produces two binaries with distinct names
- Go version is pinned (not
latest) - Module cache is enabled (
cache: trueinsetup-go)
Hints¶
Hint 1
The `matrix` strategy uses `include:` with explicit objects when you want both `goos` and `goarch` to vary together: Reference them in steps with `${{ matrix.goos }}` and `${{ matrix.goarch }}`.Hint 2
Pass `GOOS` and `GOARCH` as step-level environment variables using the `env:` key on the step:Expected Output / Acceptance Criteria¶
- The workflow file is valid YAML with correct indentation
- The three jobs are visible in the GitHub Actions UI:
Test,Lint, andBuild (linux/amd64)/Build (linux/arm64) - A pull request shows all three jobs passing (or failing independently with useful error messages)
Solution¶
Show Solution
# .github/workflows/ci.yml
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
# ── Tests with race detector ─────────────────────────────────────────────────
test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.22" # pin to a minor version; update deliberately
cache: true # caches $GOCACHE and $GOMODCACHE
- name: Download dependencies
run: go mod download
- name: Run tests with race detector
run: go test -race -coverprofile=coverage.out ./...
- name: Upload coverage report
uses: actions/upload-artifact@v4
with:
name: coverage
path: coverage.out
# ── Lint ─────────────────────────────────────────────────────────────────────
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
cache: true
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
with:
version: latest
# ── Build matrix ─────────────────────────────────────────────────────────────
build:
name: Build (${{ matrix.goos }}/${{ matrix.goarch }})
runs-on: ubuntu-latest
needs: [test, lint] # only build if tests and lint pass
strategy:
matrix:
include:
- goos: linux
goarch: amd64
- goos: linux
goarch: arm64
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.22"
cache: true
- name: Build binary
env:
GOOS: ${{ matrix.goos }}
GOARCH: ${{ matrix.goarch }}
CGO_ENABLED: "0"
run: |
go build \
-ldflags "-s -w -X 'main.version=${{ github.ref_name }}'" \
-o myapp-${{ matrix.goos }}-${{ matrix.goarch }} .
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: myapp-${{ matrix.goos }}-${{ matrix.goarch }}
path: myapp-${{ matrix.goos }}-${{ matrix.goarch }}
Exercise 7: govulncheck Audit [🟡 Medium] [2 pts]¶
Context¶
govulncheck is the Go team's official tool for scanning a module's dependencies against the Go Vulnerability Database. This exercise runs a real scan on a module with a known-old dependency to observe what a vulnerability finding looks like.
Task¶
- Create a new Go module with
go mod init example.com/vulntest - Add a dependency on an older version of a package known to have had a vulnerability — use
github.com/tidwall/gjson v1.9.0(which had a ReDoS vulnerability fixed in v1.9.3) - Add a minimal
main.gothat imports and callsgjson.Get - Run
govulncheck ./...and observe the output - Run
go get github.com/tidwall/gjson@latestto update to the fixed version - Run
govulncheck ./...again and confirm the vulnerability is gone
Requirements¶
- The initial scan with v1.9.0 reports at least one vulnerability
- The finding includes the CVE/GO identifier, the fixed version, and which call in your code is affected
- After updating,
govulncheck ./...reports no vulnerabilities
Hints¶
Hint 1
Install `govulncheck` first: `go install golang.org/x/vuln/cmd/govulncheck@latest`. Then run `go get github.com/tidwall/gjson@v1.9.0` to pin the vulnerable version.Hint 2 (minimal main.go)
Expected Output / Acceptance Criteria¶
$ govulncheck ./...
Scanning your code and N packages across M dependent modules for known vulnerabilities...
Vulnerability #1: GO-2021-0085
...gjson: ReDoS vulnerability in Get and GetMany functions
More info: https://pkg.go.dev/vuln/GO-2021-0085
Module: github.com/tidwall/gjson
Found in: github.com/tidwall/gjson@v1.9.0
Fixed in: github.com/tidwall/gjson@v1.9.3
Call stacks in your code:
main.go:9:28: main.main calls github.com/tidwall/gjson.Get
1 vulnerability found.
# After `go get github.com/tidwall/gjson@latest`:
$ govulncheck ./...
No vulnerabilities found.
Solution¶
Show Solution
# Set up the vulnerable module
mkdir vulntest && cd vulntest
go mod init example.com/vulntest
go get github.com/tidwall/gjson@v1.9.0
// main.go
package main
import (
"fmt"
"github.com/tidwall/gjson"
)
func main() {
result := gjson.Get(`{"name":"Alice"}`, "name")
fmt.Println(result.String())
}
# Run the vulnerability scan
govulncheck ./...
# Vulnerability #1: GO-2021-0085 — ReDoS in gjson.Get
# Found in: github.com/tidwall/gjson@v1.9.0
# Fixed in: github.com/tidwall/gjson@v1.9.3
# Update to fixed version
go get github.com/tidwall/gjson@latest
go mod tidy
# Confirm clean
govulncheck ./...
# No vulnerabilities found.
Scoring Log¶
Record your performance honestly. Include the date and whether you used hints.
| Exercise | Date | Score | Used Hints? | Notes |
|---|---|---|---|---|
| Exercise 1 — Version Flag with ldflags | — | —/1 | — | — |
| Exercise 2 — go vet and Build Tag | — | —/1 | — | — |
| Exercise 3 — Cross-Compile a Binary | — | —/2 | — | — |
| Exercise 4 — Makefile with Build Targets | — | —/2 | — | — |
| Exercise 5 — Multi-Stage Dockerfile | — | —/3 | — | — |
| Exercise 6 — GitHub Actions CI Workflow | — | —/3 | — | — |
| Exercise 7 — govulncheck Audit | — | —/2 | — | — |
| Total | —/14 |
Passing threshold: 9/14 (65%). Aim for 12/14 (86%) before taking the test.