Learn Chi - Production-Ready Architecture
Series/Learn Chi/Episode 21
Episode 21 of 23

Learn Chi - Production-Ready Architecture

This episode weaves every pattern into a production-ready architecture: a modular router as an http.Handler, dependency injection, env config, and centralized logging. You will also build a container with multi-stage Docker, a CI/CD pipeline with GitHub Actions, deployment to Kubernetes, and zero-downtime strategies.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

Writing routes is easy; running an application for years is hard. Episode 21 combines every pattern from episodes 8 through 20 into a single production-ready architecture: how a chi application is packaged, tested, deployed, and operated without downtime. After this episode, you'll have a complete blueprint — from a clean main.go to Kubernetes manifests and an automated pipeline.

Modular Router as an http.Handler

The Blueprint

Blueprint of a modular router
func NewServer(cfg Config) http.Handler {
    pool := newPool(cfg.DBURL)
    userRepo := repository.NewUserRepo(pool)
    userSvc := service.NewUserService(userRepo)
    userH := handler.NewUserHandler(userSvc)
 
    r := chi.NewRouter()
    r.Use(middleware.RequestID)
    r.Use(middleware.Recoverer)
    r.Use(middleware.Timeout(10 * time.Second))
 
    r.Mount("/users", userH.Routes())
    r.Get("/metrics", prometheusHandler)
    return r
}

NewServer(cfg Config) http.Handler builds dependencies from the config, then wires up middleware and routes. The whole construction happens once in main; the result can be tested and mounted again.

Dependency Injection, Config, and Logging

Configuration Flow

Config from the environment
type Config struct {
    Port        string
    DBURL       string
    JWTSecret   string
    RedisAddr   string
}
 
func loadConfig() Config {
    return Config{
        Port:      viper.GetString("PORT"),
        DBURL:     viper.GetString("DATABASE_URL"),
        JWTSecret: viper.GetString("JWT_SECRET"),
        RedisAddr: viper.GetString("REDIS_ADDR"),
    }
}

loadConfig() reads the environment once and shares it across all layers through Config. No handler calls os.Getenv directly.

Centralized Logging

Centralized logging
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
}))
slog.SetDefault(logger)

slog.NewJSONHandler(os.Stdout, ...) writes every log as JSON to stdout — a format log aggregators can process directly. No logs go to local files that could disappear when the container restarts.

Containerization with Multi-stage Docker

A Lean Dockerfile

Multi-stage Dockerfile
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /server ./cmd/server
 
FROM alpine:3.20
RUN adduser -D appuser
COPY --from=builder /server /usr/local/bin/server
USER appuser
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/server"]

CGO_ENABLED=0 go build produces a static binary with no C dependencies. adduser -D appuser followed by USER appuser runs the server without root privileges — a standard security practice. Build the image with docker build -t chi-service . and run it with docker run and injected environment variables.

CI/CD and Deployment

GitHub Actions Pipeline

CI/CD pipeline
name: ci
on:
  push:
    branches: [main, staging]
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version: "1.24"
      - run: go build ./...
      - run: go test ./...
      - run: go vet ./...

go build ./... followed by go test ./... keeps the code always green before moving on. go vet catches suspicious patterns early.

Deployment to Kubernetes

Kubernetes deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: chi-service
spec:
  replicas: 3
  selector:
    matchLabels:
      app: chi-service
  template:
    metadata:
      labels:
        app: chi-service
    spec:
      containers:
        - name: chi-service
          image: registry.example.com/chi-service:1.0.0
          ports:
            - containerPort: 8080
          readinessProbe:
            httpGet:
              path: /healthz
              port: 8080

replicas: 3 runs three instances; readinessProbe uses /healthz from episode 18. A deployment with replicas and probes is the foundation of zero downtime.

Rolling Update

A new deployment rolls out gradually: old pods keep serving until the new pods are ready (readiness green), then traffic is shifted and old pods are terminated after finishing their requests.

New rollout
kubectl set image deployment/chi-service \
  chi-service=registry.example.com/chi-service:1.0.1

kubectl set image deployment/chi-service ... triggers the rolling update. The combination of srv.Shutdown from episode 10 and a readiness probe ensures no requests get cut off.

Conclusion

Key takeaways:

  • A production router is func NewServer(cfg Config) http.Handler.
  • Config and dependencies are injected once and used across all layers.
  • All logs are written as JSON to stdout through a single logger.
  • Multi-stage Docker produces a lean, non-root image.
  • The CI/CD pipeline runs build, test, and vet automatically.
  • A Kubernetes deployment plus readiness and graceful shutdown = zero downtime.

In the next episode, episode 22 — the series finale: alternative ecosystems and final reflections — a comparison of chi with Gin, Echo, Fiber, and net/http, when to choose each, a recap of the journey from episode 0 to 21, a production-grade service checklist, and the future of chi in the Go ecosystem.