arrow_backBACK TO GO FOR BACKEND ENGINEERS — CONCURRENCY, SIMPLICITY AND REAL NUMBERS
Lesson 11Go for Backend Engineers — Concurrency, Simplicity and Real Numbers5 min read

Production Go

October 29, 2026

TL;DR

Four things turn a working Go service into a production-ready one. GRACEFUL SHUTDOWN: catch SIGTERM/SIGINT (signal.NotifyContext), call server.Shutdown(ctx) to stop accepting new requests while in-flight ones finish, then close the DB pool and exit — so a deploy or a pod restart drops no requests. CONFIG from the environment (12-factor): read from env vars, fail fast at startup if a required one is missing, keep secrets out of the binary and repo — same binary runs in every environment. STRUCTURED LOGGING with log/slog (standard library since 1.21): log JSON key/values not prose, attach request_id/trace_id, use levels — so logs are greppable and feed into observability. ONE SMALL STATIC CONTAINER: build with CGO_ENABLED=0, ship FROM scratch or distroless plus the binary — a ~10-20 MB image that starts in milliseconds with a tiny attack surface and fast autoscaling. A Go service in prod is boring in the best way: one binary, env config, JSON logs, clean shutdown — fewer moving parts, calm to operate.

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

Taking a Go service to production: graceful shutdown, config, structured logs, and one tiny container. Graceful shutdown: catch SIGTERM/SIGINT (signal.Notify); server.Shutdown(ctx) stop accepting, let in-flight requests finish, then close the DB pool and exit — no dropped requests on deploy. Config from the environment: read config from env vars (12-factor), fail fast at startup if required config is missing or invalid, no secrets in the binary or repo — same binary, every environment. Structured logging log/slog: slog ships in the std lib (1.21 plus), log JSON key/values not prose, attach request_id / trace_id, levels Debug/Info/Warn/Error — greppable, ties into observability. One small static container: build CGO_ENABLED=0 go build, FROM scratch or distroless plus binary, image is about 10-20 MB starts in ms, tiny attack surface fast autoscale — the Lesson 1 simplicity in prod. The through-line: a Go service in prod is boring in the best way — one binary, env config, JSON logs, clean shutdown; fewer moving parts than most stacks, which is exactly why it is calm to operate at 2 AM.

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 resources

http.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 --from=build /app /app
COPY --from=build /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

  1. No graceful shutdown. Dying immediately on SIGTERM drops every in-flight request on every deploy. Catch the signal and call srv.Shutdown(ctx) to drain, then clean up. Users should never see errors from a routine deploy.
  2. Shutdown with no timeout, or forgetting to close resources. Shutdown can 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.
  3. 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.
  4. Unstructured (prose) logs. log.Printf with interpolated values can’t be filtered or aggregated at scale. Use log/slog JSON with fields, attach request_id/trace_id, log to stdout.
  5. Fat container images. Shipping on a full ubuntu/golang base gives a hundreds-of-MB image with a shell and packages an attacker can use. Build CGO_ENABLED=0 and ship FROM scratch or distroless — ~10–20 MB, no shell.
  6. Forgetting CA certs / timezone data on scratch. FROM scratch has nothing, so outbound HTTPS fails (no root certs) and time-zone lookups break. Copy /etc/ssl/certs (and tzdata if 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, attach request_id/trace_id, use levels, log to stdout — greppable and observable.
  • One small static container: CGO_ENABLED=0 go build, ship FROM scratch/distroless + the binary — ~10–20 MB, millisecond start, tiny attack surface, fast autoscale. Copy CA certs on scratch.
  • 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.