A Go application that passes tests isn’t automatically ready for production. Production readiness means: the binary starts cleanly, fails loudly on invalid configuration, exposes health endpoints that orchestrators can probe, handles shutdown signals gracefully, logs in a structured format that aggregators can parse, and exposes metrics that monitoring systems can scrape.
This guide covers each of these requirements in a concrete, copy-ready form.
For TLS configuration see Go HTTPS and TLS. For tracing see Go distributed tracing. For Kubernetes manifests see Go deploying to Kubernetes.
Production Dockerfile
A multi-stage build keeps the final image small and secure. The builder stage compiles; the runtime stage has only the binary:
# Stage 1: Build
FROM golang:1.22-bookworm AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags="-s -w -X main.version=$(git describe --tags --always)" \
-o /app/server ./cmd/server
# Stage 2: Runtime — distroless has no shell, drastically reduces attack surface
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
CGO_ENABLED=0 produces a fully static binary — no libc needed, works in distroless or scratch images. -trimpath removes absolute build paths. -s -w strips debug symbols (~30% size reduction).
Health Check Endpoints
Kubernetes needs three kinds of probes, each with a different job:
- Startup probe: is initialization complete? (database migrations, cache warming)
- Liveness probe: is the process healthy? (not deadlocked, not memory-corrupted)
- Readiness probe: can this instance accept traffic? (dependencies reachable)
type HealthHandler struct {
db *sql.DB
started atomic.Bool
ready atomic.Bool
}
func (h *HealthHandler) Register(mux *http.ServeMux) {
mux.HandleFunc("/health/startup", h.startup)
mux.HandleFunc("/health/live", h.live)
mux.HandleFunc("/health/ready", h.ready)
}
// Startup: called repeatedly until it returns 200, then Kubernetes moves to liveness/readiness
func (h *HealthHandler) startup(w http.ResponseWriter, r *http.Request) {
if !h.started.Load() {
http.Error(w, `{"status":"starting"}`, http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"started"}`))
}
// Liveness: check ONLY internal process health — don't check external deps here
// If this fails, Kubernetes restarts the pod
func (h *HealthHandler) live(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{
"status": "alive",
"time": time.Now().UTC().Format(time.RFC3339),
})
}
// Readiness: check external deps — DB, cache, downstream services
// If this fails, Kubernetes stops routing traffic but doesn't restart
func (h *HealthHandler) ready(w http.ResponseWriter, r *http.Request) {
if !h.ready.Load() {
http.Error(w, `{"status":"not ready"}`, http.StatusServiceUnavailable)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := h.db.PingContext(ctx); err != nil {
slog.WarnContext(r.Context(), "readiness check failed", slog.Any("error", err))
http.Error(w, `{"status":"db unavailable"}`, http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"status":"ready"}`))
}
In main, complete initialization before marking started and ready:
health := &HealthHandler{db: db}
go func() {
runMigrations(db)
warmupCache()
health.started.Store(true)
health.ready.Store(true)
slog.Info("initialization complete")
}()
Graceful Shutdown
When Kubernetes sends SIGTERM, in-flight requests need time to complete:
func main() {
mux := http.NewServeMux()
setupRoutes(mux)
srv := &http.Server{
Addr: ":8080",
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
slog.Info("server listening", slog.String("addr", srv.Addr))
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
slog.Error("server error", slog.Any("error", err))
os.Exit(1)
}
}()
// Block until SIGINT or SIGTERM
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
slog.Info("shutdown signal received — draining connections")
// Allow 30 seconds for in-flight requests to complete
shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
slog.Error("shutdown error", slog.Any("error", err))
}
slog.Info("server stopped")
}
signal.NotifyContext (Go 1.16+) is the idiomatic way — it cancels the context when the signal arrives, integrating naturally with the context system.
Structured Logging
Production logs must be machine-parseable. Use log/slog (Go 1.21+) with JSON output:
func setupLogging(env string) {
level := slog.LevelInfo
if env == "development" {
level = slog.LevelDebug
}
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
AddSource: env != "production", // file:line in dev, skip in prod for performance
})
slog.SetDefault(slog.New(handler))
}
// Request logger middleware
func requestLogger(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &statusWriter{ResponseWriter: w, code: 200}
next.ServeHTTP(rw, r)
slog.InfoContext(r.Context(), "request",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", rw.code),
slog.Duration("duration", time.Since(start)),
slog.String("remote_addr", r.RemoteAddr),
)
})
}
Prometheus Metrics
Expose GET /metrics for Prometheus to scrape:
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
)
var (
httpRequests = prometheus.NewCounterVec(
prometheus.CounterOpts{Name: "http_requests_total", Help: "Total HTTP requests"},
[]string{"method", "path", "status"},
)
httpDuration = prometheus.NewHistogramVec(
prometheus.HistogramOpts{
Name: "http_request_duration_seconds",
Help: "HTTP request latency",
Buckets: []float64{.005, .01, .025, .05, .1, .25, .5, 1, 2.5},
},
[]string{"method", "path"},
)
)
func init() {
prometheus.MustRegister(httpRequests, httpDuration)
}
func metricsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rw := &statusWriter{ResponseWriter: w, code: 200}
next.ServeHTTP(rw, r)
status := strconv.Itoa(rw.code)
httpRequests.WithLabelValues(r.Method, r.URL.Path, status).Inc()
httpDuration.WithLabelValues(r.Method, r.URL.Path).Observe(time.Since(start).Seconds())
})
}
// Mount the /metrics endpoint
mux.Handle("/metrics", promhttp.Handler())
Configuration and Validation at Startup
Crash early if configuration is invalid — don’t discover missing env vars after taking live traffic:
type Config struct {
Port int
DatabaseURL string
JWTSecret string
Environment string
LogLevel string
}
func loadConfig() (*Config, error) {
cfg := &Config{
Port: envOrInt("PORT", 8080),
DatabaseURL: os.Getenv("DATABASE_URL"),
JWTSecret: os.Getenv("JWT_SECRET"),
Environment: envOr("ENVIRONMENT", "development"),
LogLevel: envOr("LOG_LEVEL", "info"),
}
var errs []string
if cfg.DatabaseURL == "" {
errs = append(errs, "DATABASE_URL is required")
}
if cfg.Environment == "production" && len(cfg.JWTSecret) < 32 {
errs = append(errs, "JWT_SECRET must be at least 32 characters in production")
}
if cfg.Port < 1 || cfg.Port > 65535 {
errs = append(errs, fmt.Sprintf("PORT %d is out of range", cfg.Port))
}
if len(errs) > 0 {
return nil, fmt.Errorf("configuration errors:\n %s", strings.Join(errs, "\n "))
}
return cfg, nil
}
func main() {
cfg, err := loadConfig()
if err != nil {
// log.Fatal includes a timestamp; the exit code signals to orchestrators that startup failed
log.Fatalf("invalid configuration: %v", err)
}
// cfg is guaranteed valid for the rest of the program
}
Docker Compose for Local Development
# docker-compose.yml
services:
app:
build: .
ports: ["8080:8080"]
environment:
DATABASE_URL: postgres://app:secret@db:5432/appdb
ENVIRONMENT: development
LOG_LEVEL: debug
depends_on:
db:
condition: service_healthy
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:8080/health/live"]
interval: 10s
timeout: 3s
retries: 3
start_period: 15s
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: secret
POSTGRES_DB: appdb
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 3s
retries: 5
Production Readiness Checklist
Before shipping to production:
- Multi-stage Docker build with distroless/static runtime image
- All three health probes:
/health/startup,/health/live,/health/ready - Server timeouts:
ReadHeaderTimeout,ReadTimeout,WriteTimeout,IdleTimeout - Graceful shutdown with 30-second drain window
- Structured JSON logging with log level from environment
-
/metricsendpoint for Prometheus - Version injected via
-ldflagsvisible in/health/liveor/version - All required env vars validated at startup — crash on missing/invalid values
- No secrets hardcoded or logged — use a secrets manager
-
go test -race ./...passes clean
Summary
- Multi-stage Dockerfile: build in
golang:N, run indistroless/static— zero OS attack surface - Three health probes serve different purposes: startup gates liveness/readiness, liveness triggers restarts, readiness gates traffic
signal.NotifyContext+srv.Shutdown(30s)gives in-flight requests time to drain cleanly- JSON logs (
slog.NewJSONHandler) are parseable by every log aggregator without configuration - Validate all config at startup and
log.Fatalon errors — never discover invalid config under load
Comments