Deploying Go Network Applications: Best Practices
"Building a boat in the workshop and sailing it across open water are two very different challenges. Everything you've built in this part of the book — servers, proxies, clients — has been tested at the dock. Deployment is what happens when it has to keep running unattended, survive restarts, and handle traffic without you standing next to it."
Why Go Deploys Well
A lot of deployment complexity in other ecosystems comes from shipping a language runtime and a pile of dynamic dependencies alongside the application. Go sidesteps most of that: go build produces a single statically linked binary (by default, with no cgo dependencies) containing your compiled code and the Go runtime together. There's no separate interpreter or VM to install on the target machine, no dependency resolution step at deploy time — copy the binary over and run it.
Cross-compilation is built into the toolchain: building a Linux binary from a Windows or macOS development machine is a matter of setting two environment variables:
GOOS=linux GOARCH=amd64 go build -o server ./cmd/server
A binary that "just runs" everywhere isn't an accident — it's CGO_ENABLED=0 doing its job.
Graceful Shutdown
Every server in this book so far has run in an infinite for loop with no clean way to stop. In production, a process gets asked to stop constantly — deploys, autoscaling, container restarts — and a server that just dies mid-request drops whatever it was doing. The fix combines two things already introduced in the context and proxy chapters: signal.NotifyContext to observe the shutdown signal, and http.Server.Shutdown to stop accepting new work while letting in-flight requests finish.
package main
import (
"context"
"errors"
"log"
"net/http"
"os/signal"
"syscall"
"time"
)
func main() {
srv := &http.Server{Addr: ":8080", Handler: buildHandler()}
ctx, stop := signal.NotifyContext(
context.Background(), syscall.SIGINT, syscall.SIGTERM,
)
defer stop()
go func() {
err := srv.ListenAndServe()
if err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}()
log.Println("server started on :8080")
<-ctx.Done() // blocks until SIGINT or SIGTERM arrives
log.Println("shutdown signal received, draining connections...")
shutdownCtx, cancel := context.WithTimeout(
context.Background(), 15*time.Second,
)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
log.Printf("forced shutdown: %v", err)
}
log.Println("server stopped cleanly")
}
srv.Shutdown stops the listener immediately, then waits (up to the deadline on shutdownCtx) for active handlers to finish before returning — exactly the "finish what's in flight, refuse anything new" behavior a load balancer expects when it stops routing traffic to an instance and waits for it to drain.
SIGTERM and then wait a configurable grace period (commonly 30 seconds) before sending SIGKILL, which cannot be caught or delayed. Your shutdown timeout must be comfortably shorter than that grace period, or in-flight requests get killed anyway.srv.Shutdown only knows about the HTTP server's own listener and connections. A handler-owned database pool, a background worker draining a queue, or an open log file each need their own explicit Close or cancellation after srv.Shutdown returns — otherwise the process can exit mid-write on a worker, or leave a pool cleaned up only when the OS reclaims the process. Fold cleanup into the same shutdown path instead of assuming draining the HTTP server drains everything else too.A load balancer typically stops routing to an instance only once its
readiness probe fails, and that probe has its own polling interval — so
new traffic can still arrive after the shutdown signal fires but before
srv.Shutdown starts draining. Failing readiness the instant the signal is
observed gives the load balancer a head start on rerouting away first:
var ready atomic.Bool // read by the /ready handler
// during startup, once the server is accepting connections:
ready.Store(true)
// as the very first line after <-ctx.Done():
ready.Store(false) // fail /ready immediately, then drain in-flight work
The /ready handler itself just checks ready.Load() and returns 200 or
503 — the same liveness/readiness distinction the health checks section
covers below, wired directly into the shutdown path rather than only
reflecting whether the process is alive.
Configuration for Different Environments
Hardcoded ports and addresses (used throughout this book for clarity) don't survive contact with real deployment environments, where the port, backend URLs, and secrets differ between local, staging, and production. Environment variables are the most portable way to inject configuration into a container or process without rebuilding it:
func getEnvOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
addr := getEnvOrDefault("SERVER_ADDR", ":8080")
For anything beyond a couple of values, the standard library's flag package or a small typed configuration struct populated from environment variables keeps configuration centralized and self-documenting, rather than scattered os.Getenv calls throughout the codebase.
/proc/<pid>/environ on the host, and they get swept into crash dumps or logged accidentally by a library printing its own startup configuration. For passwords, API keys, and TLS private keys, prefer a dedicated secrets manager (Kubernetes Secrets mounted as files, Vault, a cloud provider's secrets service) or a file read once at startup with restrictive permissions.Containerizing a Go Server
A multi-stage Dockerfile keeps the final image small by compiling in one stage and copying only the resulting binary into a minimal runtime image:
FROM golang:1.22 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /server ./cmd/server
FROM gcr.io/distroless/static-debian12
COPY --from=builder /server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
CGO_ENABLED=0 guarantees a fully static binary with no dependency on the target system's C libraries, which is what makes it possible to run on a distroless base image containing nothing but the binary itself — no shell, no package manager, a dramatically reduced attack surface compared to a full OS image.
:nonroot tag variant; adding USER nonroot (or an explicit numeric UID) means a vulnerability in handler code can't casually attempt privilege escalation inside the container, even if it doesn't eliminate every container-escape risk on its own.Health Checks for Orchestrators
Deployment platforms need a way to know an instance is actually ready to serve traffic — not just that the process started. The /health endpoint sketched in the logging and monitoring chapter is exactly what a container orchestrator's liveness and readiness probes poll:
- Liveness probe: "Is the process still responsive?" A failing probe triggers a restart.
- Readiness probe: "Should traffic be routed to this instance right now?" Useful for taking an instance temporarily out of rotation (e.g., while it's still warming up a cache) without restarting it.
The two probes should not share one handler. A liveness check that also verifies a downstream database is a common way to trigger a restart loop that never actually fixes anything:
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) // process is up; nothing more to prove
})
mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
/health (liveness) pings the database and the database has a brief outage, every replica fails liveness at once and the orchestrator restarts all of them — fixing nothing and adding a thundering herd of reconnects on top of an already struggling dependency. Liveness should only answer "is this process itself stuck," independent of other services. Readiness is the right place to check dependencies, since a failing readiness probe only removes the instance from rotation — it doesn't restart anything.Logs and Metrics in Production
Once deployed, a process typically can't be attached to interactively. Structured logs written to stdout/stderr (as covered in the logging chapter) are collected by the container runtime or orchestrator and shipped to a central log store — write to standard streams rather than local files that may not survive a container restart or be accessible at all.
slog, not after.Try It Yourself: Zero-Downtime Reload
Take the graceful shutdown example, add the /ready handler from the
health checks section, and wire the readiness flag to flip false the
instant ctx.Done() fires. Send SIGTERM from another terminal and
confirm the order: /ready returns 503 immediately, while a slow in-flight
request (add time.Sleep(5 * time.Second) to one handler to simulate one)
still completes before the process exits. Fail readiness first, drain
second — that ordering is the difference between a deploy that briefly
serves errors and one that doesn't.
Frequently Asked Questions
My binary runs fine on my dev machine but crashes on startup in a minimal Alpine container — why?
This is almost always the cgo asterisk on Go's "static binary" claim. As soon as any package in the build — commonly net's hostname resolver — pulls in cgo, go build links dynamically against the host's glibc unless CGO_ENABLED=0 is set explicitly. A binary built against glibc on Ubuntu simply has nothing to link against on Alpine, which ships musl instead. Setting CGO_ENABLED=0 forces the pure-Go DNS resolver and produces a binary that genuinely runs anywhere, which is also why the Dockerfile in this chapter sets it explicitly rather than relying on the default.
I call srv.Shutdown and the process still gets killed by Kubernetes — what's happening?
Check your shutdown timeout against the orchestrator's grace period. Kubernetes sends SIGTERM and then waits a configurable grace period, commonly 30 seconds, before sending an unstoppable SIGKILL. If shutdownCtx's timeout is set equal to or longer than that grace period, SIGKILL arrives before your in-flight requests finish draining, killing them anyway. Keep your shutdown timeout comfortably shorter than the orchestrator's grace period, not equal to it.
Is calling srv.Shutdown enough to guarantee a clean exit?
No — srv.Shutdown only knows about the HTTP server's own listener and connections. A database pool, a background worker draining a queue, or an open log file each need their own explicit cleanup after srv.Shutdown returns, or the process can exit mid-write on a worker even though the HTTP layer drained perfectly. Treat graceful shutdown as one path that has to fold in every resource your handlers own, not a single function call that handles everything for you.
Why do liveness and readiness need separate handlers instead of one shared /health endpoint?
Because they answer different questions with different consequences when they fail. Liveness asks "is this process stuck," and a failure restarts it; readiness asks "should traffic go here right now," and a failure just pulls the instance out of rotation. If one shared handler pings a database and that database has a brief outage, every replica fails liveness simultaneously and the orchestrator restarts all of them at once — a thundering herd of reconnects that fixes nothing, since the process itself was never actually stuck.
Now that this is the last technical chapter of Part 2, is production deployment really the end of the story for a Go network service? Not quite — deployment is where a service starts running unattended, but everything before it in this part (the servers, proxies, and clients themselves) is what actually determines whether that unattended operation goes well. Health checks, graceful shutdown, and externalized configuration are the seams that let an orchestrator manage a process it can't see inside of; the next chapter looks at what happens once several of these deployed pieces have to work together as real systems.
Key Takeaways
- Go's static binaries and built-in cross-compilation remove most of the runtime-installation complexity other languages face at deploy time.
- Implement graceful shutdown with
signal.NotifyContextandhttp.Server.Shutdownso deploys and restarts don't drop in-flight requests. - Externalize configuration through environment variables rather than hardcoding addresses and secrets.
- A small,
CGO_ENABLED=0, multi-stage Docker build produces a minimal, more secure runtime image. - Health check endpoints and stdout-based logging are what let orchestrators and log pipelines manage your service without manual intervention.