Skip to main content

Go Application Deployment and Production Readiness

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 6 min read

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
  • /metrics endpoint for Prometheus
  • Version injected via -ldflags visible in /health/live or /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 in distroless/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.Fatal on errors — never discover invalid config under load

Resources

Comments

👍 Was this article helpful?