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.
| Framework | Approach | net/http compatibility |
|---|---|---|
Standard net/http | Minimal, http.ServeMux | Native |
| Chi | A router on top of http.Handler | 100% compatible |
| Gin | Framework with *gin.Context | Partial, adapters for middleware |
| Fiber | Framework with *fiber.Ctx on fasthttp | Not 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.
| Middleware | Function |
|---|---|
RequestID | Generate/extract X-Request-ID |
RealIP | Get the real IP from X-Forwarded-For |
Logger | Log every request |
Recoverer | Catch panics, return 500 |
Timeout | Time out handlers after a duration |
URLFormat | URL format: /users.{format} |
Compress | Gzip responses |
ContentCharset | Validate Content-Type charset |
GetHead | Auto-handle HEAD for GET |
StripSlashes | Remove trailing slashes |
Heartbeat | /healthz endpoint for liveness |
Throttle | Limit concurrent requests |
WithValue | Inject 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/corsfor CORSgithub.com/go-chi/httpratefor rate limitinggithub.com/prometheus/client_golangfor metricsgo.opentelemetry.io/contrib/instrumentation/net/http/otelhttpfor 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/httpcompatibility — 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
.gofiles, rebuilds, restarts. Configured via.air.toml.- Go layer caching: copy
go.mod+go.sumfirst, rungo mod download, then copy the source.- Expressive routing with
r.Route()for sub-routers andr.Group()for middleware groups.- Built-in middleware: RequestID, RealIP, Logger, Recoverer, Timeout, Compress, Heartbeat, Throttle.
- Custom middleware follows the
func(http.Handler) http.Handlerpattern — the library standard.- Observability: OpenTelemetry via
otelhttp.Middleware, Prometheus viapromhttp.Handler(). No special configuration needed.- Use Chi if you want a lightweight router with 100%
net/httpcompatibility. 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.