Back to Blog
Tutorial10 min read2026-06-29

How to Deploy a Chi Router Go API to Production

Chi is a lightweight, idiomatic Go router built on net/http. Learn how to deploy a Chi-based API to production with middleware, graceful shutdown, and a minimal container image.

Ajay Kumar
Ajay Kumar
Founder & DevOps, PandaStack

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

VariablePurpose
PORTlisten port, injected
DATABASE_URLinjected if you link a managed DB
LOG_LEVELlogging 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 main

Conclusion

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)

Ready to deploy?

Start free on PandaStack.

Start free on PandaStack

More in Tutorial

Browse all Tutorial articles →

See also