Chi #

Chi is a lightweight, idiomatic HTTP router for Go that stays very close to the standard net/http. Unlike Gin or Fiber, which bring their own framework abstractions, Chi only adds routing, middleware composition, and small helpers on top of the standard http.Handler. The result: code you write with Chi is still Go code any developer can understand — without learning a new framework.

For teams that value simplicity, container-friendliness, and compatibility with the net/http ecosystem (OpenTelemetry, Prometheus, popular middleware), Chi is a very solid choice. This article covers a Docker Compose setup for Chi local development, from multi-stage Dockerfiles, hot reload with Air, to database integration and best practices.

Prerequisites #

Make sure you have installed:

  • Docker and Docker Compose (latest versions)
  • Go 1.22+ (optional, for host-side development)
  • Git

A standard Chi project structure:

my-chi-app/
├── cmd/
│   └── api/
│       └── main.go
├── internal/
│   ├── http/
│   │   ├── router.go
│   │   └── middleware.go
│   ├── handler/
│   │   └── user.go
│   ├── service/
│   │   └── user.go
│   ├── repository/
│   │   └── user.go
│   └── model/
│       └── user.go
├── go.mod
├── go.sum
├── Dockerfile
├── Dockerfile.dev
├── docker-compose.yml
├── .air.toml
├── .env
└── .dockerignore

Splitting internal/http/ for routing + middleware and internal/handler/ for endpoint handlers is a clear convention — when a new developer arrives, they immediately know where to look for routing code versus business logic.

What Makes Chi Different #

Before getting technical, understand Chi’s position in the Go ecosystem.

FrameworkApproachnet/http compatibility
Standard net/httpMinimal, http.ServeMuxNative
ChiA router on top of http.Handler100% compatible
GinFramework with *gin.ContextPartial, adapters for middleware
FiberFramework with *fiber.Ctx on fasthttpNot compatible

Because Chi is an http.Handler (not a wrapper), all Go ecosystem middleware works directly:

// Third-party middleware that just works with Chi
import "github.com/go-chi/cors"
import "github.com/go-chi/httprate"
import "github.com/prometheus/client_golang/prometheus/promhttp"

r.Use(cors.Handler(cors.Options{...}))
r.Use(httprate.LimitByIP(100, time.Minute))
r.Handle("/metrics", promhttp.Handler())

No adapters, no different cors.New() constructors, no confusion about whether middleware “works on this framework”. Everything just works.

Multi-Stage Dockerfile #

The standard multi-stage pattern for Go.

# syntax=docker/dockerfile:1.6
FROM golang:1.22-alpine AS builder

WORKDIR /app

RUN apk add --no-cache git ca-certificates

COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/api ./cmd/api

FROM alpine:3.19

RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app

RUN addgroup -S app && adduser -S app -G app
USER app:app

COPY --from=builder /out/api /app/api

EXPOSE 8080

ENTRYPOINT ["/app/api"]

The final image is slim, < 20 MB, alpine-based.

A Development Dockerfile #

# Dockerfile.dev
FROM golang:1.22-alpine

WORKDIR /app

RUN apk add --no-cache git curl bash

# Install Air
RUN go install github.com/air-verse/air@latest

COPY go.mod go.sum ./
RUN go mod download

EXPOSE 8080

CMD ["air", "-c", ".air.toml"]

The Air configuration (.air.toml):

root = "."
tmp_dir = "tmp"

[build]
  bin = "./tmp/main"
  cmd = "go build -o ./tmp/main ./cmd/api"
  delay = 1000
  exclude_dir = ["assets", "tmp", "vendor", "testdata"]
  include_dir = ["cmd", "internal"]
  include_ext = ["go", "yaml", "yml", "tmpl", "html"]
  exclude_regex = ["_test.go"]
  exclude_unchanged = true

[log]
  time = true

docker-compose.yml #

# docker-compose.yml
services:
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev
    image: chi-app:dev
    container_name: chi-api
    ports:
      - "8080:8080"
    volumes:
      - ./cmd:/app/cmd
      - ./internal:/app/internal
      - ./go.mod:/app/go.mod
      - ./go.sum:/app/go.sum
      - go-build:/app/tmp
    environment:
      - APP_ENV=development
      - SERVER_PORT=8080
      - DATABASE_URL=postgres://app:pass@db:5432/chiapp?sslmode=disable
      - REDIS_URL=redis://cache:6379/0
      - JWT_SECRET=local-dev-secret-change-me
    depends_on:
      db:
        condition: service_healthy
      cache:
        condition: service_healthy
    command: air -c .air.toml

  db:
    image: postgres:16-alpine
    container_name: chi-db
    environment:
      - POSTGRES_USER=app
      - POSTGRES_PASSWORD=dev
      - POSTGRES_DB=chiapp
    volumes:
      - db-data:/var/lib/postgresql/data
      - ./db/init:/docker-entrypoint-initdb.d:ro
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U app -d chiapp"]
      interval: 10s
      timeout: 5s
      retries: 5
    ports:
      - "5432:5432"

  cache:
    image: redis:7-alpine
    container_name: chi-cache
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3
    volumes:
      - cache-data:/data
    ports:
      - "6379:6379"

volumes:
  db-data:
  cache-data:
  go-build:

An Example Chi Application #

Router (internal/http/router.go):

package http

import (
    "net/http"
    "time"

    "github.com/go-chi/chi/v5"
    "github.com/go-chi/chi/v5/middleware"
    "github.com/go-chi/cors"
    "github.com/go-chi/httprate"

    "myapp/internal/handler"
)

type Router struct {
    *chi.Mux
    userHandler *handler.UserHandler
}

func NewRouter(userHandler *handler.UserHandler) *Router {
    r := chi.NewRouter()

    // Chi's built-in middleware
    r.Use(middleware.RequestID)
    r.Use(middleware.RealIP)
    r.Use(middleware.Logger)
    r.Use(middleware.Recoverer)
    r.Use(middleware.Timeout(60 * time.Second))

    // CORS
    r.Use(cors.Handler(cors.Options{
        AllowedOrigins:   []string{"*"},
        AllowedMethods:   []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
        AllowedHeaders:   []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token"},
        ExposedHeaders:   []string{"Link"},
        AllowCredentials: false,
        MaxAge:           300,
    }))

    // Rate limit
    r.Use(httprate.LimitByIP(100, time.Minute))

    // Routes
    r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.Write([]byte(`{"status":"ok"}`))
    })

    r.Route("/api/v1", func(r chi.Router) {
        r.Route("/users", func(r chi.Router) {
            r.Post("/", userHandler.Create)
            r.Get("/{id}", userHandler.Get)
            r.Get("/", userHandler.List)
        })
    })

    return &Router{Mux: r, userHandler: userHandler}
}

Handler (internal/handler/user.go):

package handler

import (
    "encoding/json"
    "errors"
    "net/http"
    "strconv"

    "github.com/go-chi/chi/v5"

    "myapp/internal/service"
)

type UserHandler struct {
    svc *service.UserService
}

func NewUserHandler(svc *service.UserService) *UserHandler {
    return &UserHandler{svc: svc}
}

type createUserRequest struct {
    Email    string `json:"email"`
    Name     string `json:"name"`
    Password string `json:"password"`
}

type errorResponse struct {
    Error string `json:"error"`
}

func writeJSON(w http.ResponseWriter, code int, payload interface{}) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(code)
    json.NewEncoder(w).Encode(payload)
}

func (h *UserHandler) Create(w http.ResponseWriter, r *http.Request) {
    var req createUserRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        writeJSON(w, http.StatusBadRequest, errorResponse{Error: "invalid body"})
        return
    }
    if req.Email == "" || req.Name == "" || len(req.Password) < 8 {
        writeJSON(w, http.StatusBadRequest, errorResponse{Error: "email, name, password (min 8) required"})
        return
    }
    u, err := h.svc.Register(r.Context(), req.Email, req.Name, req.Password)
    if err != nil {
        writeJSON(w, http.StatusBadRequest, errorResponse{Error: err.Error()})
        return
    }
    writeJSON(w, http.StatusCreated, u)
}

func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
    idStr := chi.URLParam(r, "id")
    id, err := strconv.ParseUint(idStr, 10, 64)
    if err != nil {
        writeJSON(w, http.StatusBadRequest, errorResponse{Error: "invalid id"})
        return
    }
    u, err := h.svc.Get(r.Context(), id)
    if err != nil {
        writeJSON(w, http.StatusInternalServerError, errorResponse{Error: err.Error()})
        return
    }
    if u == nil {
        writeJSON(w, http.StatusNotFound, errorResponse{Error: "user not found"})
        return
    }
    writeJSON(w, http.StatusOK, u)
}

func (h *UserHandler) List(w http.ResponseWriter, r *http.Request) {
    limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
    offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
    if limit == 0 {
        limit = 20
    }
    users, err := h.svc.List(r.Context(), limit, offset)
    if err != nil {
        writeJSON(w, http.StatusInternalServerError, errorResponse{Error: err.Error()})
        return
    }
    writeJSON(w, http.StatusOK, map[string]interface{}{
        "data":   users,
        "limit":  limit,
        "offset": offset,
    })
}

Service (internal/service/user.go):

package service

import (
    "context"
    "errors"
    "strings"

    "golang.org/x/crypto/bcrypt"

    "myapp/internal/model"
    "myapp/internal/repository"
)

type UserService struct {
    repo *repository.UserRepository
}

func NewUserService(repo *repository.UserRepository) *UserService {
    return &UserService{repo: repo}
}

func (s *UserService) Register(ctx context.Context, email, name, password string) (*model.User, error) {
    email = strings.ToLower(strings.TrimSpace(email))
    existing, _ := s.repo.FindByEmail(ctx, email)
    if existing != nil {
        return nil, errors.New("email already registered")
    }
    hashed, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
    if err != nil {
        return nil, err
    }
    u := &model.User{
        Email:    email,
        Name:     strings.TrimSpace(name),
        Password: string(hashed),
    }
    if err := s.repo.Create(ctx, u); err != nil {
        return nil, err
    }
    return u, nil
}

func (s *UserService) Get(ctx context.Context, id uint64) (*model.User, error) {
    return s.repo.FindByID(ctx, id)
}

func (s *UserService) List(ctx context.Context, limit, offset int) ([]model.User, error) {
    return s.repo.List(ctx, limit, offset)
}

cmd/api/main.go:

package main

import (
    "context"
    "log"
    "net/http"
    "os"
    "os/signal"
    "syscall"
    "time"

    "github.com/redis/go-redis/v9"
    "gorm.io/driver/postgres"
    "gorm.io/gorm"

    "myapp/internal/handler"
    httpx "myapp/internal/http"
    "myapp/internal/model"
    "myapp/internal/repository"
    "myapp/internal/service"
)

func main() {
    // Database with retry
    var db *gorm.DB
    var err error
    dsn := os.Getenv("DATABASE_URL")
    for i := 0; i < 30; i++ {
        db, err = gorm.Open(postgres.Open(dsn), &gorm.Config{})
        if err == nil {
            break
        }
        log.Printf("waiting for db: %v", err)
        time.Sleep(2 * time.Second)
    }
    if err != nil {
        log.Fatal("db connection failed: ", err)
    }
    if err := db.AutoMigrate(&model.User{}); err != nil {
        log.Fatal("migrate failed: ", err)
    }

    // Redis
    rdb := redis.NewClient(&redis.Options{Addr: os.Getenv("REDIS_URL")})
    _ = rdb

    // Layers
    userRepo := repository.NewUserRepository(db)
    userSvc := service.NewUserService(userRepo)
    userHandler := handler.NewUserHandler(userSvc)

    // Router
    r := httpx.NewRouter(userHandler)

    srv := &http.Server{
        Addr:         ":8080",
        Handler:      r,
        ReadTimeout:  10 * time.Second,
        WriteTimeout: 10 * time.Second,
    }

    // Graceful shutdown
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Printf("server stopped: %v", err)
        }
    }()
    log.Printf("listening on %s", srv.Addr)

    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit
    log.Println("shutting down...")

    ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        log.Fatal(err)
    }
}

Chi’s Built-in Middleware #

Chi has a very complete official middleware package.

MiddlewareFunction
RequestIDGenerate/extract X-Request-ID
RealIPGet the real IP from X-Forwarded-For
LoggerLog every request
RecovererCatch panics, return 500
TimeoutTime out handlers after a duration
URLFormatURL format: /users.{format}
CompressGzip responses
ContentCharsetValidate Content-Type charset
GetHeadAuto-handle HEAD for GET
StripSlashesRemove trailing slashes
Heartbeat/healthz endpoint for liveness
ThrottleLimit concurrent requests
WithValueInject values into the context

A safe combination for production APIs:

r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.Timeout(60 * time.Second))
r.Use(middleware.Compress(5, "gzip"))
r.Use(middleware.Heartbeat("/healthz"))

RequestID first so the ID gets logged. RealIP before Logger so the logged IP is the real one. Recoverer before Logger so panics also get logged. Timeout last so the timeout covers handlers, not middleware.

Complex Routing with Chi #

One of Chi’s strengths is grouping and sub-routers.

r := chi.NewRouter()

// Public routes
r.Get("/", listLanding)
r.Get("/healthz", healthz)
r.Handle("/metrics", promhttp.Handler())

// API versioning
r.Route("/api/v1", func(r chi.Router) {
    // Public endpoints
    r.Group(func(r chi.Router) {
        r.Post("/auth/login", authHandler.Login)
        r.Post("/auth/register", authHandler.Register)
    })

    // Protected endpoints
    r.Group(func(r chi.Router) {
        r.Use(authMiddleware)

        r.Route("/users", func(r chi.Router) {
            r.Get("/", userHandler.List)
            r.Post("/", userHandler.Create)
            r.Route("/{id}", func(r chi.Router) {
                r.Get("/", userHandler.Get)
                r.Put("/", userHandler.Update)
                r.Delete("/", userHandler.Delete)
            })
        })

        r.Route("/posts", func(r chi.Router) {
            r.Get("/", postHandler.List)
            r.Post("/", postHandler.Create)
        })
    })
})

The r.Route("/path", func(r chi.Router) { ... }) pattern creates a sub-router with its own path prefix. r.Group(func(r chi.Router) { ... }) creates a group with extra middleware without changing the path. Combining both yields a very expressive route structure.

Custom Middleware #

Because Chi only adds routing on top of http.Handler, custom middleware is very easy to write.

package http

import (
    "context"
    "net/http"
    "time"
)

type ctxKey string

const (
    ctxKeyRequestID ctxKey = "requestID"
    ctxKeyUserID    ctxKey = "userID"
)

func RequestTime(header string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            start := time.Now()
            next.ServeHTTP(w, r)
            w.Header().Set(header, time.Since(start).String())
        })
    }
}

func RequireAuth(secret string) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            token := r.Header.Get("Authorization")
            if token == "" {
                http.Error(w, "missing token", http.StatusUnauthorized)
                return
            }
            // validate the token (JWT, session, etc.)
            userID, err := validateToken(token, secret)
            if err != nil {
                http.Error(w, "invalid token", http.StatusUnauthorized)
                return
            }
            ctx := context.WithValue(r.Context(), ctxKeyUserID, userID)
            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

// Usage
r.Use(RequestTime("X-Response-Time"))
r.Group(func(r chi.Router) {
    r.Use(RequireAuth(os.Getenv("JWT_SECRET")))
    r.Get("/me", meHandler)
})

Chi middleware functions follow the func(http.Handler) http.Handler pattern — exactly the standard library convention. This makes them compatible with other Go frameworks.

Build and Run #

# Build the image
docker compose build

# Run
docker compose up -d

# View logs
docker compose logs -f api

# Stop
docker compose down

Access:

  • API: http://localhost:8080
  • PostgreSQL: localhost:5432
  • Redis: localhost:6379

Test:

# Register
curl -X POST http://localhost:8080/api/v1/users \
  -H "Content-Type: application/json" \
  -d '{"email":"[email protected]","name":"Andi","password":"password123"}'

# Get a user
curl http://localhost:8080/api/v1/users/1

# List users
curl "http://localhost:8080/api/v1/users?limit=10&offset=0"

OpenTelemetry and Prometheus #

Because Chi is an http.Handler, standard Go observability integration just works.

import (
    "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

r := chi.NewRouter()

// OpenTelemetry: instrument all handlers
r.Use(otelhttp.Middleware("my-app"))

// Prometheus: expose metrics
r.Handle("/metrics", promhttp.Handler())

For custom trace spans:

func (h *UserHandler) Get(w http.ResponseWriter, r *http.Request) {
    tracer := otel.Tracer("myapp/handler")
    ctx, span := tracer.Start(r.Context(), "UserHandler.Get")
    defer span.End()

    // ... business logic
    span.SetAttributes(attribute.Int64("user.id", int64(id)))
}

Spans are automatically sent to Jaeger/Tempo/Honeycomb. Without custom instrumentation, every HTTP request is already traced via otelhttp.Middleware.

When Chi Fits #

Use Chi if:
  ✓ You want a lightweight router, not a full framework
  ✓ You need 100% net/http compatibility
  ✓ You use the standard Go ecosystem middleware
  ✓ Very minimal microservices
  ✓ The team wants to learn idiomatic Go

Avoid Chi if:
  ✗ You need full framework features (validation, ORM, etc.)
  ✗ You like declarative APIs like Express
  ✗ You need more performance than the stdlib (Gin/fasthttp)

Chi is honest about what it is: a router, not a framework. It doesn’t bring an ORM, validator, or template engine. Those are all separate dependencies you choose. For projects that want to “stay close to the metal” while still having powerful routing, Chi is the sweet spot.

Best Practices #

Cache Dependency Modules #

Always copy go.mod + go.sum first. Super-fast incremental builds.

Use Air for Hot Reload #

Without hot reload, Go feels slow for iteration. Air watches files and rebuilds automatically.

Use r.Route and r.Group for Organization #

Don’t write all routes flat in one function. Group by resource (users, posts) and version (v1, v2). Middleware can be attached per group.

Leverage the Ecosystem Middleware #

Chi is compatible with any net/http middleware. Use popular libraries:

  • github.com/go-chi/cors for CORS
  • github.com/go-chi/httprate for rate limiting
  • github.com/prometheus/client_golang for metrics
  • go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp for tracing

Write Custom Middleware for Specific Needs #

Need request-time logging? Write your own middleware. Need a feature flag? Write middleware. The func(http.Handler) http.Handler pattern is simple and idiomatic Go.

Bind-Mount Source Code in Dev #

Use volumes in Compose to mount source code into the container. The container uses Air to rebuild.

Healthchecks for Dependencies #

PostgreSQL and Redis must have healthchecks. The api service uses depends_on: condition: service_healthy. Add a retry loop in the Go code.

Troubleshooting #

Air Doesn’t Detect Changes #

Make sure the bind mount includes the cmd/ and internal/ directories. Raise delay in .air.toml if rebuilds happen too often.

Database Connection Refused #

Use depends_on: condition: service_healthy in Compose, and add a retry loop in the Go code.

Chi Middleware Doesn’t Run #

Make sure middleware is registered with r.Use() before routes. r.Use() after r.Get() won’t execute middleware for previously declared routes.

Chi vs Standard net/http #

Go 1.22+ introduced an enhanced http.ServeMux with pattern matching and method routing. For very minimal services, consider pure net/http first. Add Chi only when you need grouping, middleware composition, or sub-routers.

Summary #

  • Chi is ideal for Go services that want to stay idiomatic and close to the standard net/http.
  • Chi is a router, not a framework — it doesn’t bring an ORM, validator, or template engine. Choose your own dependencies per need.
  • 100% net/http compatibility — standard Go ecosystem middleware (cors, httprate, prometheus, otelhttp) just works without adapters.
  • Multi-stage Dockerfiles for production: a builder stage with the Go toolchain, a slim runtime stage. The final image is < 20 MB.
  • Dockerfile.dev for development: full toolchain + Air for hot reload.
  • Hot reload with Air: watches .go files, rebuilds, restarts. Configured via .air.toml.
  • Go layer caching: copy go.mod + go.sum first, run go mod download, then copy the source.
  • Expressive routing with r.Route() for sub-routers and r.Group() for middleware groups.
  • Built-in middleware: RequestID, RealIP, Logger, Recoverer, Timeout, Compress, Heartbeat, Throttle.
  • Custom middleware follows the func(http.Handler) http.Handler pattern — the library standard.
  • Observability: OpenTelemetry via otelhttp.Middleware, Prometheus via promhttp.Handler(). No special configuration needed.
  • Use Chi if you want a lightweight router with 100% net/http compatibility. Avoid it if you need full framework features or declarative APIs.
  • Best practices: cache dependencies, hot reload, organize routes with r.Route/r.Group, leverage ecosystem middleware, write custom middleware for specific needs.
  • Alternatives: Gin for a full framework, Fiber for an Express-like API, standard net/http for the most minimal services.

← Previous: Fiber   Next: Django →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact