Chi is a router, not a framework. It's a thin, idiomatic layer over Go's standard net/http, which means everything you know about the standard library still applies — your handlers are plain http.HandlerFunc. That standard-library alignment makes Chi a favorite for teams who want minimal magic. This guide deploys a Chi API to production.
Why Chi over a full framework
Chi gives you routing, URL parameters, and a composable middleware stack while staying 100% compatible with net/http. There's no custom context type, no lock-in. If you later want to swap routers, your handlers don't change. For production this means you control everything: timeouts, the server struct, shutdown — all standard library.
A minimal Chi API
// main.go
package main
import (
"net/http"
"os"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
func main() {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Get("/", func(w http.ResponseWriter, req *http.Request) {
w.Write([]byte(`{"message":"Hello from Chi"}`))
})
r.Get("/health", func(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte("ok"))
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
http.ListenAndServe(":"+port, r)
}The middleware.RealIP is worth highlighting: behind an ingress/proxy, it rewrites RemoteAddr from X-Forwarded-For so your logs and rate limiters see the real client IP.
Using an explicit http.Server with timeouts
Never use the bare http.ListenAndServe in production — it has no timeouts, leaving you vulnerable to slow-loris attacks and resource exhaustion. Use an explicit http.Server:
srv := &http.Server{
Addr: ":" + port,
Handler: r,
ReadTimeout: 10 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}These three timeouts are the baseline every production Go HTTP server should set.
Graceful shutdown
import (
"context"
"os/signal"
"syscall"
"time"
)
go func() {
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}()
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer stop()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
srv.Shutdown(shutdownCtx)On deploy the platform sends SIGTERM; this drains active requests for up to 15 seconds before exiting, so rolling updates don't drop traffic.
Subrouters and middleware scoping
Chi's strength is composable routing. Mount versioned APIs and scope middleware to route groups:
r.Route("/api/v1", func(r chi.Router) {
r.Use(authMiddleware)
r.Get("/users", listUsers)
r.Post("/users", createUser)
})The authMiddleware applies only to /api/v1/*, keeping your /health endpoint unauthenticated so the platform can probe it.
The Dockerfile
# ---- Build ----
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /app/server .
# ---- Runtime ----
FROM gcr.io/distroless/static-debian12
COPY --from=build /app/server /server
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/server"]This produces a small static binary in a distroless image. Cold starts are typically sub-second because there's no runtime to initialize.
Health checks
The unauthenticated /health route is your readiness probe target. Keep it trivial. If you want a deeper check, add /ready that pings the database, but use the cheap /health for the high-frequency liveness probe to avoid hammering your DB.
Environment variables
| Variable | Purpose |
|---|---|
PORT | listen port, injected |
DATABASE_URL | injected if you link a managed DB |
LOG_LEVEL | logging config |
Database (optional)
Chi doesn't dictate a database layer. If you need one, link a managed PostgreSQL, read the injected DATABASE_URL, and use pgxpool. Pass the pool into handlers via a closure or a small dependency struct — keep it explicit, in the Chi spirit.
Deploying
Commit the code and Dockerfile, push, and connect the repo. The platform builds the multi-stage image in an ephemeral build pod and rolls it out. Set env vars, attach a custom domain (automatic SSL), and watch live logs to confirm the server bound to the injected port.
git push origin mainConclusion
Chi keeps you close to net/http, so production-readiness is the standard Go playbook: an explicit http.Server with timeouts, graceful shutdown on SIGTERM, scoped middleware, and a tiny distroless image. No framework magic, no surprises.
Try a Chi API on PandaStack's free tier — connect your repo at [dashboard.pandastack.io](https://dashboard.pandastack.io) and it builds and deploys with one push, with a managed database a click away.
References
- [Chi Router Documentation](https://go-chi.io/)
- [Chi on GitHub](https://github.com/go-chi/chi)
- [Go: net/http Server Timeouts](https://pkg.go.dev/net/http#Server)
- [Cloudflare: The complete guide to Go net/http timeouts](https://blog.cloudflare.com/the-complete-guide-to-golang-net-http-timeouts/)
- [Distroless Container Images](https://github.com/GoogleContainerTools/distroless)