Skip to main content

Production Best Practices

Building production-ready Go applications requires more than just functional code. This chapter covers logging, configuration, deployment, and operational concerns.

Structured Logging

Using slog (Go 1.21+)

Go 1.21 introduced log/slog for structured logging.
import "log/slog"

func main() {
    // JSON logger for production
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: slog.LevelInfo,
        AddSource: true,
    }))
    
    slog.SetDefault(logger)
    
    // Basic logging
    slog.Info("Server starting", "port", 8080)
    slog.Error("Failed to connect", "error", err, "host", "localhost")
    
    // With context
    logger := slog.With("service", "user-service", "version", "1.0.0")
    logger.Info("Request received", "method", "GET", "path", "/users")
}

Custom Logger with Context

type contextKey string

const loggerKey contextKey = "logger"

func WithLogger(ctx context.Context, logger *slog.Logger) context.Context {
    return context.WithValue(ctx, loggerKey, logger)
}

func LoggerFromContext(ctx context.Context) *slog.Logger {
    if logger, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
        return logger
    }
    return slog.Default()
}

// Middleware to add request-scoped logger
func LoggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        requestID := uuid.New().String()
        
        logger := slog.With(
            "request_id", requestID,
            "method", r.Method,
            "path", r.URL.Path,
            "remote_addr", r.RemoteAddr,
        )
        
        ctx := WithLogger(r.Context(), logger)
        r = r.WithContext(ctx)
        
        start := time.Now()
        wrapped := &responseWriter{ResponseWriter: w, statusCode: 200}
        
        next.ServeHTTP(wrapped, r)
        
        logger.Info("Request completed",
            "status", wrapped.statusCode,
            "duration_ms", time.Since(start).Milliseconds(),
        )
    })
}

Using Zap (High Performance)

import "go.uber.org/zap"

func NewLogger(env string) (*zap.Logger, error) {
    var config zap.Config
    
    if env == "production" {
        config = zap.NewProductionConfig()
        config.EncoderConfig.TimeKey = "timestamp"
        config.EncoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder
    } else {
        config = zap.NewDevelopmentConfig()
        config.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
    }
    
    return config.Build()
}

func main() {
    logger, _ := NewLogger("production")
    defer logger.Sync()
    
    // Use sugar for printf-style
    sugar := logger.Sugar()
    sugar.Infow("Server starting",
        "port", 8080,
        "env", "production",
    )
    
    // Or structured logger
    logger.Info("Request received",
        zap.String("method", "GET"),
        zap.String("path", "/users"),
        zap.Duration("latency", time.Millisecond*50),
    )
}

Configuration Management

Using Environment Variables

type Config struct {
    Server   ServerConfig
    Database DatabaseConfig
    Redis    RedisConfig
    Logger   LoggerConfig
}

type ServerConfig struct {
    Host         string        `env:"SERVER_HOST" envDefault:"0.0.0.0"`
    Port         int           `env:"SERVER_PORT" envDefault:"8080"`
    ReadTimeout  time.Duration `env:"SERVER_READ_TIMEOUT" envDefault:"5s"`
    WriteTimeout time.Duration `env:"SERVER_WRITE_TIMEOUT" envDefault:"10s"`
}

type DatabaseConfig struct {
    Host     string `env:"DB_HOST" envDefault:"localhost"`
    Port     int    `env:"DB_PORT" envDefault:"5432"`
    User     string `env:"DB_USER,required"`
    Password string `env:"DB_PASSWORD,required"`
    Name     string `env:"DB_NAME,required"`
    SSLMode  string `env:"DB_SSLMODE" envDefault:"disable"`
}

// Using envconfig
import "github.com/caarlos0/env/v9"

func LoadConfig() (*Config, error) {
    cfg := &Config{}
    if err := env.Parse(cfg); err != nil {
        return nil, err
    }
    return cfg, nil
}
import "github.com/spf13/viper"

func LoadConfig(path string) (*Config, error) {
    viper.SetConfigName("config")
    viper.SetConfigType("yaml")
    viper.AddConfigPath(path)
    viper.AddConfigPath(".")
    
    // Environment variable override
    viper.AutomaticEnv()
    viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
    
    // Set defaults
    viper.SetDefault("server.port", 8080)
    viper.SetDefault("server.read_timeout", "5s")
    
    if err := viper.ReadInConfig(); err != nil {
        if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
            return nil, err
        }
        // Config file not found, use defaults and env vars
    }
    
    var cfg Config
    if err := viper.Unmarshal(&cfg); err != nil {
        return nil, err
    }
    
    return &cfg, nil
}

Config File Example

# config.yaml
server:
  host: "0.0.0.0"
  port: 8080
  read_timeout: "5s"
  write_timeout: "10s"

database:
  host: "localhost"
  port: 5432
  user: "postgres"
  name: "myapp"
  ssl_mode: "disable"

redis:
  host: "localhost"
  port: 6379
  db: 0

logger:
  level: "info"
  format: "json"

Graceful Shutdown

func main() {
    cfg := LoadConfig()
    
    // Initialize dependencies
    db := initDatabase(cfg.Database)
    defer db.Close()
    
    cache := initRedis(cfg.Redis)
    defer cache.Close()
    
    // Create server
    srv := &http.Server{
        Addr:         fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port),
        Handler:      setupRoutes(db, cache),
        ReadTimeout:  cfg.Server.ReadTimeout,
        WriteTimeout: cfg.Server.WriteTimeout,
    }
    
    // Start server in goroutine
    go func() {
        slog.Info("Server starting", "addr", srv.Addr)
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            slog.Error("Server error", "error", err)
            os.Exit(1)
        }
    }()
    
    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    
    slog.Info("Shutting down server...")
    
    // Graceful shutdown with timeout
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    
    // Shutdown order matters!
    // 1. Stop accepting new requests
    if err := srv.Shutdown(ctx); err != nil {
        slog.Error("Server shutdown error", "error", err)
    }
    
    // 2. Close database connections
    if err := db.Close(); err != nil {
        slog.Error("Database close error", "error", err)
    }
    
    // 3. Close cache connections
    if err := cache.Close(); err != nil {
        slog.Error("Cache close error", "error", err)
    }
    
    slog.Info("Server stopped")
}

Health Checks

type HealthChecker struct {
    db    *sql.DB
    redis *redis.Client
}

type HealthStatus struct {
    Status    string            `json:"status"`
    Timestamp time.Time         `json:"timestamp"`
    Services  map[string]string `json:"services"`
}

func (h *HealthChecker) Check(ctx context.Context) *HealthStatus {
    status := &HealthStatus{
        Status:    "healthy",
        Timestamp: time.Now(),
        Services:  make(map[string]string),
    }
    
    // Check database
    if err := h.db.PingContext(ctx); err != nil {
        status.Status = "unhealthy"
        status.Services["database"] = err.Error()
    } else {
        status.Services["database"] = "ok"
    }
    
    // Check Redis
    if err := h.redis.Ping(ctx).Err(); err != nil {
        status.Status = "unhealthy"
        status.Services["redis"] = err.Error()
    } else {
        status.Services["redis"] = "ok"
    }
    
    return status
}

// Endpoints
func (h *HealthChecker) LivenessHandler(w http.ResponseWriter, r *http.Request) {
    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}

func (h *HealthChecker) ReadinessHandler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
    defer cancel()
    
    status := h.Check(ctx)
    
    w.Header().Set("Content-Type", "application/json")
    if status.Status != "healthy" {
        w.WriteHeader(http.StatusServiceUnavailable)
    }
    json.NewEncoder(w).Encode(status)
}

Metrics with Prometheus

import (
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    httpRequestsTotal = prometheus.NewCounterVec(
        prometheus.CounterOpts{
            Name: "http_requests_total",
            Help: "Total number of HTTP requests",
        },
        []string{"method", "path", "status"},
    )
    
    httpRequestDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "http_request_duration_seconds",
            Help:    "HTTP request duration in seconds",
            Buckets: prometheus.DefBuckets,
        },
        []string{"method", "path"},
    )
    
    dbQueryDuration = prometheus.NewHistogramVec(
        prometheus.HistogramOpts{
            Name:    "db_query_duration_seconds",
            Help:    "Database query duration in seconds",
            Buckets: []float64{.001, .005, .01, .025, .05, .1, .25, .5, 1},
        },
        []string{"query"},
    )
    
    activeConnections = prometheus.NewGauge(
        prometheus.GaugeOpts{
            Name: "active_connections",
            Help: "Number of active connections",
        },
    )
)

func init() {
    prometheus.MustRegister(httpRequestsTotal)
    prometheus.MustRegister(httpRequestDuration)
    prometheus.MustRegister(dbQueryDuration)
    prometheus.MustRegister(activeConnections)
}

// Middleware
func MetricsMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        wrapped := &responseWriter{ResponseWriter: w, statusCode: 200}
        
        next.ServeHTTP(wrapped, r)
        
        duration := time.Since(start).Seconds()
        path := r.URL.Path
        
        httpRequestsTotal.WithLabelValues(r.Method, path, fmt.Sprint(wrapped.statusCode)).Inc()
        httpRequestDuration.WithLabelValues(r.Method, path).Observe(duration)
    })
}

func main() {
    mux := http.NewServeMux()
    
    // Your routes
    mux.Handle("/api/", apiHandler)
    
    // Metrics endpoint
    mux.Handle("/metrics", promhttp.Handler())
    
    http.ListenAndServe(":8080", MetricsMiddleware(mux))
}

Docker Deployment

Dockerfile

# Build stage
FROM golang:1.21-alpine AS builder

WORKDIR /app

# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download

# Build
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-w -s" -o /app/server ./cmd/server

# Final stage
FROM alpine:3.18

# Add CA certificates for HTTPS
RUN apk --no-cache add ca-certificates tzdata

WORKDIR /app

# Copy binary
COPY --from=builder /app/server .
COPY --from=builder /app/config ./config

# Create non-root user
RUN adduser -D -g '' appuser
USER appuser

EXPOSE 8080

HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
    CMD wget --no-verbose --tries=1 --spider http://localhost:8080/health/live || exit 1

ENTRYPOINT ["./server"]

Docker Compose

version: '3.8'

services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      - SERVER_PORT=8080
      - DB_HOST=postgres
      - DB_USER=postgres
      - DB_PASSWORD=secret
      - DB_NAME=myapp
      - REDIS_HOST=redis
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "--spider", "http://localhost:8080/health/live"]
      interval: 10s
      timeout: 5s
      retries: 3

  postgres:
    image: postgres:15-alpine
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: secret
      POSTGRES_DB: myapp
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
  redis_data:

Kubernetes Deployment

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  labels:
    app: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: myapp:latest
          ports:
            - containerPort: 8080
          env:
            - name: DB_HOST
              valueFrom:
                configMapKeyRef:
                  name: myapp-config
                  key: db_host
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: myapp-secrets
                  key: db_password
          resources:
            requests:
              memory: "128Mi"
              cpu: "100m"
            limits:
              memory: "512Mi"
              cpu: "500m"
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
  name: myapp
spec:
  selector:
    app: myapp
  ports:
    - port: 80
      targetPort: 8080
  type: ClusterIP

Build and Version Info

// Injected at build time
var (
    Version   = "dev"
    GitCommit = "unknown"
    BuildTime = "unknown"
)

// Build with:
// go build -ldflags "-X main.Version=1.0.0 -X main.GitCommit=$(git rev-parse HEAD) -X main.BuildTime=$(date -u +%Y-%m-%dT%H:%M:%SZ)"

func main() {
    slog.Info("Starting application",
        "version", Version,
        "commit", GitCommit,
        "build_time", BuildTime,
    )
}

// Version endpoint
func versionHandler(w http.ResponseWriter, r *http.Request) {
    json.NewEncoder(w).Encode(map[string]string{
        "version":    Version,
        "git_commit": GitCommit,
        "build_time": BuildTime,
    })
}

Makefile

VERSION ?= $(shell git describe --tags --always --dirty)
COMMIT := $(shell git rev-parse HEAD)
BUILD_TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
LDFLAGS := -ldflags "-X main.Version=$(VERSION) -X main.GitCommit=$(COMMIT) -X main.BuildTime=$(BUILD_TIME)"

.PHONY: build
build:
	go build $(LDFLAGS) -o bin/server ./cmd/server

.PHONY: test
test:
	go test -v -race -cover ./...

.PHONY: lint
lint:
	golangci-lint run

.PHONY: docker
docker:
	docker build -t myapp:$(VERSION) .

.PHONY: run
run: build
	./bin/server

Security Best Practices

// Secure HTTP server
srv := &http.Server{
    Addr:              ":8080",
    Handler:           mux,
    ReadTimeout:       5 * time.Second,
    WriteTimeout:      10 * time.Second,
    IdleTimeout:       120 * time.Second,
    ReadHeaderTimeout: 2 * time.Second,
    MaxHeaderBytes:    1 << 20, // 1 MB
}

// Security headers middleware
func SecurityHeadersMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("X-Content-Type-Options", "nosniff")
        w.Header().Set("X-Frame-Options", "DENY")
        w.Header().Set("X-XSS-Protection", "1; mode=block")
        w.Header().Set("Content-Security-Policy", "default-src 'self'")
        w.Header().Set("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
        next.ServeHTTP(w, r)
    })
}

// Rate limiting per IP
func PerIPRateLimiter() func(http.Handler) http.Handler {
    type client struct {
        limiter  *rate.Limiter
        lastSeen time.Time
    }
    
    var (
        mu      sync.Mutex
        clients = make(map[string]*client)
    )
    
    // Cleanup old entries
    go func() {
        for {
            time.Sleep(time.Minute)
            mu.Lock()
            for ip, c := range clients {
                if time.Since(c.lastSeen) > 3*time.Minute {
                    delete(clients, ip)
                }
            }
            mu.Unlock()
        }
    }()
    
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            ip := getIP(r)
            
            mu.Lock()
            c, exists := clients[ip]
            if !exists {
                c = &client{limiter: rate.NewLimiter(10, 20)} // 10 req/s, burst 20
                clients[ip] = c
            }
            c.lastSeen = time.Now()
            mu.Unlock()
            
            if !c.limiter.Allow() {
                http.Error(w, "Too Many Requests", http.StatusTooManyRequests)
                return
            }
            
            next.ServeHTTP(w, r)
        })
    }
}

Interview Questions

  • Use environment variables for secrets and deployment-specific values
  • Use config files (YAML/JSON) for application defaults
  • Libraries like Viper for unified configuration
  • Never commit secrets to version control
  • Support configuration hot-reloading for non-sensitive values
  1. Stop accepting new connections
  2. Wait for in-flight requests to complete (with timeout)
  3. Close database connections
  4. Close cache connections
  5. Flush logs and metrics
  6. Exit cleanly
Two types:
  • Liveness: Is the process running? Simple 200 OK response
  • Readiness: Can the service handle traffic? Check dependencies (DB, cache, etc.)
Kubernetes uses these to manage pod lifecycle.
  • HTTP request count/rate by status code
  • Request latency (histogram)
  • Active connections
  • Database query latency
  • Cache hit/miss rate
  • Goroutine count
  • Memory usage
  • Error rates

Summary

AreaKey Points
LoggingStructured (slog/zap), request IDs, context propagation
ConfigurationEnv vars + files, secrets management, validation
ShutdownGraceful, proper ordering, timeouts
Health ChecksLiveness + readiness probes
MetricsPrometheus, business + technical metrics
DeploymentMulti-stage Docker, K8s manifests
SecurityHeaders, rate limiting, input validation