net/go.book
All Parts Marketing

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")
}

Exercise: Graceful Shutdown

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.

Orchestrators expect a grace period
Container schedulers like Kubernetes send 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.

Shutdown only covers what you tell it to
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.

Environment variables are not a secrets vault
Environment variables suit non-sensitive settings (ports, feature flags, backend URLs), but they're visible to anything that can read /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.

Containers default to running as root
Unless a Dockerfile switches users, the process inside a container runs as root by default — and a container is not a full security boundary against a compromised process trying to escape to the host. Distroless ships a :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)
})

Don't fail liveness on a dependency you don't control
If /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.

Structured logging makes it easier to leak data, too
The same fields that make logs queryable also make it trivial to accidentally index a password, an auth token, or personal data straight into a searchable store, retained far longer and read far more broadly than the request that produced it. Treat the fields a handler logs as part of its API surface, review them the way you'd review a response body, and scrub anything sensitive before it reaches 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.NotifyContext and http.Server.Shutdown so 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.