Your service works (Lessons 8–10). Taking it to production — where it is deployed many times a day, restarted by the orchestrator, observed, and expected not to drop traffic — needs four more things. None is hard, and all of them play to Go’s strengths. The theme, continuing from Lesson 1, is that a well-built Go service in production is boring: one binary, a handful of env vars, JSON logs, and a clean shutdown.
The Four Production Essentials
1. Graceful shutdown
When you deploy, your orchestrator (Kubernetes, say) sends your process a SIGTERM and gives it a few seconds before SIGKILL. If your process just dies on SIGTERM, every request it was mid-way through is dropped — users see errors on every deploy. Graceful shutdown fixes this: on the signal, stop accepting new connections but let the in-flight requests finish, then clean up and exit.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %v", err)
}
}()
<-ctx.Done() // wait for SIGTERM/SIGINT
stop()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil { // drain in-flight requests
log.Printf("graceful shutdown failed: %v", err)
}
db.Close() // then release resourceshttp.Server.Shutdown(ctx) does exactly the right thing — stops listening, waits for active requests to complete (up to the timeout), then returns. With this, a rolling deploy drops zero requests. This ties the whole concurrency story together: the context (Lesson 6) carries the shutdown deadline, and the server’s per-request goroutines (Lesson 8) are allowed to finish.
2. Config from the environment
Following the 12-factor approach, read configuration — port, database DSN, log level, feature flags, secrets — from environment variables, not from files baked into the image or constants in the code. This means the same binary runs in dev, staging and prod, configured differently by its environment. Two rules: fail fast at startup if a required variable is missing or invalid (don’t boot a half-configured service that fails on the first request), and keep secrets out of the binary and the repo (they come from the environment / a secrets manager, never from source control). A small config struct populated and validated once at startup, then passed down, keeps this clean.
3. Structured logging with log/slog
Prose logs (log.Printf("user %d did %s", ...)) are fine for a toy and useless at scale — you cannot filter or aggregate them. Production logging should be structured: JSON with key/value fields. Since Go 1.21 this is in the standard library as log/slog, so you need no third-party logger:
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
logger.Info("order created",
"order_id", o.ID,
"user_id", o.UserID,
"amount", o.Amount,
"request_id", reqID,
)That emits a JSON line with named fields you can filter, aggregate and alert on. Attach a request_id (and a trace_id if you trace) to every log line for a request, so you can pull the whole story of one request across services — this is precisely the correlation ID from the Observability course, and slog’s context-aware handlers make threading it through natural. Use levels (Debug/Info/Warn/Error) sensibly, and log to stdout (the orchestrator collects it). Structured logs are what make a Go service observable rather than just noisy.
4. One small static container
This is where Go’s single-binary story (Lesson 1) pays its final dividend. Build a static binary and ship it in a minimal image:
# build stage
FROM golang:1.24 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/server
# final stage — nothing but the binary
FROM scratch
COPY /app /app
COPY /etc/ssl/certs /etc/ssl/certs # for outbound TLS
ENTRYPOINT ["/app"]CGO_ENABLED=0 forces a fully static binary (no libc dependency), so it runs on FROM scratch — an image containing literally nothing but your binary (or distroless if you want a CA bundle and a non-root user ready-made). The result is a ~10–20 MB image (versus hundreds of MB for a typical interpreted-language image with its runtime and OS), that starts in milliseconds, has a tiny attack surface (no shell, no package manager, nothing for an attacker to pivot to), and autoscales fast (small images pull quickly). The simplicity you had while coding follows the program all the way into the cluster.
Boring Is the Point
Step back and notice what a production Go service is: a single static binary, configured by a few environment variables, logging JSON to stdout, shutting down cleanly on a signal, running in a tiny container. There is no application server to tune, no runtime version to match, no sprawling dependency tree in the image, no interpreter warm-up. Fewer moving parts than almost any other stack — which is exactly why Go services are calm to operate. When you get paged at 2 AM, “boring” is the highest praise a system can earn, and Go’s whole design — from the language in Lesson 1 to the deployment here — is oriented toward it. (For what to do after the page — the observability and debugging workflow — the Observability & Production Debugging course picks up exactly where this leaves off.)
Pitfalls to Avoid
- No graceful shutdown. Dying immediately on
SIGTERMdrops every in-flight request on every deploy. Catch the signal and callsrv.Shutdown(ctx)to drain, then clean up. Users should never see errors from a routine deploy. - Shutdown with no timeout, or forgetting to close resources.
Shutdowncan hang if a request won’t finish — give it a bounded context. And after it returns, close the DB pool and other resources, in order. - Config from files baked into the image, or secrets in the repo. That couples the binary to an environment and leaks secrets. Read config from env vars, validate at startup (fail fast), keep secrets in the environment/secrets manager.
- Unstructured (prose) logs.
log.Printfwith interpolated values can’t be filtered or aggregated at scale. Uselog/slogJSON with fields, attachrequest_id/trace_id, log to stdout. - Fat container images. Shipping on a full
ubuntu/golangbase gives a hundreds-of-MB image with a shell and packages an attacker can use. BuildCGO_ENABLED=0and shipFROM scratchor distroless — ~10–20 MB, no shell. - Forgetting CA certs / timezone data on
scratch.FROM scratchhas nothing, so outbound HTTPS fails (no root certs) and time-zone lookups break. Copy/etc/ssl/certs(andtzdataif needed), or use distroless which includes them.
Quick Recap
- Graceful shutdown: catch
SIGTERM/SIGINT(signal.NotifyContext),srv.Shutdown(ctx)to stop accepting and drain in-flight requests, then close the DB pool — zero dropped requests on deploy. Ties together context (Lesson 6) and per-request goroutines (Lesson 8). - Config from the environment (12-factor): env vars, fail fast on missing/invalid, secrets never in the binary or repo — the same binary in every environment.
- Structured logging with
log/slog(stdlib since 1.21): JSON key/values, attachrequest_id/trace_id, use levels, log to stdout — greppable and observable. - One small static container:
CGO_ENABLED=0 go build, shipFROM scratch/distroless + the binary — ~10–20 MB, millisecond start, tiny attack surface, fast autoscale. Copy CA certs onscratch. - A production Go service is boring in the best way — few moving parts, calm to operate.
Next lesson, the last: squeezing the last nanosecond — performance and memory, with escape analysis, the GC, and allocation measurements.