Stage 13 - Ship It
Package the whole system as one binary with three roles, containerize it, and run it end to end.
Prereqs: stage 12 complete · Time: ~30 min
By the end: docker build, migrate, and one binary that serves or consumes depending on its argument.
Subcommands
Goal: turn the three entry points into one binary that picks its role from the command line.
Learn
You have three entry functions - StartServer, StartWorker, RunMigrate - but no front door that chooses between them. One binary with three roles is the standard deployment shape: the same image runs as an HTTP server, a background worker, or a one-shot migration, and the platform picks which by the command argument.
main does nothing but hand off. A small dispatcher in cmd reads os.Args[1] and calls the matching function. No framework - a switch.
Build
1. main.go shrinks to a single call:
package main
import "minimart/cmd"
// The whole program is three subcommands (server, worker, migrate) behind one
// binary. main just hands off to cmd.Execute, which dispatches on os.Args.
func main() {
cmd.Execute()
}2. cmd/root.go is the dispatcher:
// Package cmd holds the three entry points of the single MiniMart binary.
// One binary, three roles - the deployment platform chooses which by the command
// argument (server, worker, or migrate). A plain switch keeps the course free of
// an additional command-framework dependency.
package cmd
import (
"fmt"
"os"
)
func Execute() {
if len(os.Args) < 2 {
fmt.Fprintln(os.Stderr, "usage: minimart <server|worker|migrate>")
os.Exit(2)
}
var err error
switch os.Args[1] {
case "server":
err = StartServer()
case "worker":
err = StartWorker()
case "migrate":
err = RunMigrate()
default:
fmt.Fprintf(os.Stderr, "unknown command %q; want server|worker|migrate\n", os.Args[1])
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "fatal:", err)
os.Exit(1)
}
}A missing or unknown command exits 2 (usage error); a failure from a role exits 1. Those exit codes are what an orchestrator reads to decide a container is unhealthy.
In production: a command framework such as cobra can add flags, help text, and nested commands. The basic shape stays the same:maincallsExecute, andExecutedispatches. A plain switch is enough for this course.
Checkpoint
go build -o minimart .
./minimart
# usage: minimart <server|worker|migrate>
./minimart nope
# unknown command "nope"; want server|worker|migrateBoth error paths exit non-zero (echo $? shows 2). With the stack up (next section), ./minimart migrate, ./minimart server, and ./minimart worker each run their role.
Exercise
Why use one binary with subcommands instead of separate server, worker, and migrate binaries?
Answer
One artifact reduces deployment drift. The same image contains the code and migrations for every role, and the platform chooses the role with an argument. That keeps build once, run many.
The Dockerfile
Goal: build a small production image with a multi-stage Dockerfile.
Learn
A Go binary is self-contained, so the runtime image needs almost nothing - no compiler, no source, no module cache. A multi-stage build gets you there: one stage compiles with the full Go toolchain, the next copies only the binary (and the migrations) into a tiny base. The result is a few megabytes instead of hundreds.
Two details matter:
- The build needs
go.sum. It is produced bygo mod tidy, which must run beforedocker buildso the file exists to copy in. - The migrations ship inside the image, so
minimart migrateworks in any environment without mounting files.
Build
Create Dockerfile at the project root:
# syntax=docker/dockerfile:1
# --- Build stage: compile a static binary ---
FROM golang:1.22-alpine AS build
WORKDIR /app
# Cache dependencies first. go.sum is produced by `go mod tidy` - run it before
# building the image.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /minimart .
# --- Run stage: a tiny image with just the binary and the migrations ---
FROM alpine:3.20
WORKDIR /app
COPY --from=build /minimart /app/minimart
# The migrations ship in the image so `minimart migrate` works in any environment.
COPY --from=build /app/db /app/db
EXPOSE 8080
# One binary, three roles. The platform picks the role via the command argument;
# CMD defaults to the HTTP server.
ENTRYPOINT ["/app/minimart"]
CMD ["server"]COPY go.mod go.sum ./ before the rest is a layer-caching trick: dependencies re-download only when those files change, not on every source edit. CGO_ENABLED=0 makes a static binary that runs on the bare alpine image with no C library. The ENTRYPOINT + CMD split means docker run minimart defaults to server, while docker run minimart worker overrides the argument - the same three roles from the Subcommands section.
Checkpoint
go mod tidy # ensure go.sum exists and is current
docker build -t minimart .
docker images minimartThe build completes and docker images shows a minimart image in the tens of megabytes, not hundreds. Sanity-check the entry point:
docker run --rm minimart nope
# unknown command "nope"; want server|worker|migrateThe container ran the binary and hit the dispatcher from the Subcommands section. (Running server or migrate needs the database reachable - that is the next section.)
Exercise
Why copy the migration files into the runtime image when the server command does not use them?
Answer
The image also runs the migrate subcommand. Shipping migrations with the binary means the exact schema changes needed by this version deploy with this version, without depending on source files mounted beside the container.
Run the stack
Goal: run the whole system end to end - database, broker, migrations, server, worker.
Learn
Everything exists; this section runs it in order. The sequence is always the same: infrastructure up, schema applied, then the processes. A Makefile captures the common commands so nobody memorizes flags.
Build
Create Makefile at the project root:
.PHONY: build server worker migrate test compose compose-down sqlc new-migration tidy
# Build the single binary.
build:
go build -o minimart .
# Run each role locally (one binary, three subcommands).
server:
go run . server
worker:
go run . worker
migrate:
go run . migrate
test:
go test ./...
# Bring the local Postgres + Redpanda stack up / down.
compose:
docker compose up -d
compose-down:
docker compose down
# Regenerate the type-safe query code from queries.sql (needs the sqlc CLI).
sqlc:
sqlc generate
# Create a new timestamped migration: make new-migration name=create_widgets
new-migration:
dbmate new $(name)
tidy:
go mod tidyGo note: Makefile recipes must be indented with a tab, not spaces - a spaces-indented recipe fails with missing separator.
Checkpoint
Bring the stack up and apply migrations:
make compose # or: docker compose up -d
make migrate # or: go run . migrateRun the two processes (separate terminals):
make server # terminal 1 - HTTP API on :8080
make worker # terminal 2 - background consumersNow exercise it end to end - health, create a product, place an order priced from the catalog:
curl localhost:8080/health
# {"status":"ok"}
PID=$(curl -s -XPOST localhost:8080/v1/products \
-d '{"name":"Widget","price_cents":500,"stock":10}' \
| sed -n 's/.*"id":"\([a-f0-9]*\)".*/\1/p')
curl -s -XPOST localhost:8080/v1/orders \
-d "{\"user_id\":\"u1\",\"items\":[{\"product_id\":\"$PID\",\"quantity\":2}]}"
# total_cents is 1000 = 2 × 500, computed from the catalog price, not the client.The worker terminal logs both reactions to the one order - the order-side confirmation and the notification email. That is the whole system: HTTP in, event out, two consumers reacting, all backed by Postgres.
Treat this as an end-to-end smoke check: it proves the deployed pieces can talk to each other. It is not where you want to prove every business rule. Those stay in fast unit tests and focused integration tests, which stage 14 adds.
Exercise
Why is this end-to-end run a smoke check rather than the place to test every rule?
Answer
It proves the deployed pieces can talk: database, broker, migrations, server, worker, HTTP, and messages. Business edge cases are cheaper and clearer in unit tests, with integration tests reserved for adapter wiring.
Deploy
Goal: map what you built to a real deployment. No new code.
Learn
The image from the Dockerfile section and the one-binary-three-roles shape from the Subcommands section are already deploy-ready. The rest is operational discipline, and it all falls out of decisions you already made.
Deployment style is still not the architecture. The same hexagon can run on a VM, Kubernetes, Cloud Run, or a laptop. Managed platforms solve scaling and process supervision; they do not rescue tangled domain code.
One image, pushed to a registry. Build once, tag, push; every environment pulls the same artifact.
TAG=registry.example.com/minimart:$(git rev-parse --short HEAD)
docker build -t "$TAG" .
docker push "$TAG"Migrate as a one-shot release step. Run minimart migrate before rolling the new server/worker, as its own job that must succeed first. It is idempotent and safe to re-run (the Migrations section), so a retried deploy is harmless. Schema changes ship with the code that needs them, in the same image.
Config from the environment. Every knob is an env var read in conf, with a compose-friendly default (the Postgres in Docker section). Production sets DATABASE_URL, DATABASE_REPLICA_URL, KAFKA_BROKERS, HTTP_ADDR, ENV - no code change between staging and prod, only values. This is the twelve-factor rule; it is why nothing is a string literal.
Health checks. GET /health returns {"status":"ok"} with no dependencies, so a load balancer or orchestrator can ask "is this process up?" cheaply and keep only live instances in rotation. It lives in the composition root, not a domain - an operational question, not a business one (stage 01).
Independent horizontal scaling. server and worker are the same binary, different argument - so they scale separately. A traffic spike adds server replicas; a backlog of events adds worker replicas. Consumer groups (the worker section) coordinate the workers: Kafka distributes a topic's partitions across the replicas in a group, so adding a worker adds throughput without double-processing.
A simple pipeline. The CI shape mirrors your local commands: run unit tests, bring up compose dependencies, migrate, run the integration tests stage 14 adds, build the image, push it, run the migration job, then roll server and worker. The provider can change; the order of confidence should not.
In production: the same deployment can use one image, a migration job that gates the rollout, environment-only config, /health for the load balancer, and server and worker deployments scaled on different signals (request latency and consumer lag).
Exercise
Why run migrate as a separate step instead of at the start of StartServer?
Answer
Because many server replicas start at once. Migrating inside StartServer means N processes racing to alter the schema, and a slow migration blocks every replica's boot. A single one-shot job runs the schema change exactly once, to completion, before any server or worker starts - decoupling "change the schema" from "serve traffic."