Nov 13, 20268 min read

How to Add Redis to a chi App

Use Redis for rate limiting middleware and cache-aside lookups in a go-chi API, with Encore or go-redis and ElastiCache.

Illustration for How to Add Redis to a chi App

chi keeps everything as net/http, which makes Redis easy to add in the places Go developers expect it: a middleware that runs before every route and a helper the handlers call. The Go code is a few dozen lines, and the time goes elsewhere: a Redis for each developer, an ElastiCache or Memorystore cluster in each environment, TLS and auth settings, and a network path from the app to a cache that is only reachable inside the VPC.

This guide adds Redis to a chi currency API that rate limits each API key and caches exchange rates from an upstream provider. It starts with Encore, which declares the cache in Go and runs it locally and in your own AWS or GCP account, then shows the same middleware with go-redis and ElastiCache, and the chi-native httprate package.

Redis options for a chi app at a glance

Encorego-redis + ElastiCachego-chi/httprate
Cache declared inGo code (cache.NewCluster)Terraform or the AWS consoleNot needed (in memory)
Local RedisIn-memory Redis from encore runDocker Compose or a local installNone
KeysTyped keyspaces with key patternsStrings you format yourselfManaged by the package
Shared across replicasYesYesOnly with httprate-redis
Production cacheElastiCache on AWS, Memorystore on GCP, in your accountElastiCache you provisionProcess memory

How do you add Redis to chi with Encore?

Encore

Encore works alongside chi here: the router, the middleware chain and the handlers stay chi code, and the Redis they use is declared as a typed Go value next to them. Encore mounts the router in one raw endpoint with a fallback route, and the caching API gives you keyspaces with typed keys and values instead of hand-built key strings.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create fx-api --example=hello-world$ cd fx-api && go get github.com/go-chi/chi/v5@v5.5.0$ mkdir fx

The service below declares one cache cluster with two keyspaces. rates caches the provider response for ten minutes, and requests counts calls per API key per minute, with the minute in the key so each window starts from zero and the previous counter expires on its own. The rate limiter is a normal chi middleware registered with r.Use:

// fx/fx.go package fx import ( "encoding/json" "errors" "fmt" "net/http" "regexp" "strconv" "time" "encore.dev/storage/cache" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) // In-memory Redis with `encore run`, ElastiCache or Memorystore when deployed. var cluster = cache.NewCluster("fx", cache.ClusterConfig{ EvictionPolicy: cache.AllKeysLRU, }) // Rates for one base currency, cached for ten minutes. type Rates struct { Base string `json:"base"` Date string `json:"date"` Rates map[string]float64 `json:"rates"` } var rates = cache.NewStructKeyspace[string, Rates](cluster, cache.KeyspaceConfig{ KeyPattern: "rates/:key", DefaultExpiry: cache.ExpireIn(10 * time.Minute), }) // One counter per API key per minute. The minute is part of the key, // so each window starts at zero and the old counter expires on its own. type window struct { APIKey string Minute int64 } var requests = cache.NewIntKeyspace[window](cluster, cache.KeyspaceConfig{ KeyPattern: "ratelimit/:APIKey/:Minute", DefaultExpiry: cache.ExpireIn(2 * time.Minute), }) const limitPerMinute = 60 var currency = regexp.MustCompile(`^[A-Z]{3}$`) //encore:service type Service struct { router chi.Router } func initService() (*Service, error) { r := chi.NewRouter() r.Use(middleware.Recoverer) r.Use(rateLimit) r.Get("/rates/{base}", getRates) r.Get("/convert", convert) return &Service{router: r}, nil } // Fallback sends every request that no Encore endpoint matches to chi. // //encore:api public raw path=/!fallback func (s *Service) Fallback(w http.ResponseWriter, req *http.Request) { s.router.ServeHTTP(w, req) } // rateLimit is ordinary chi middleware backed by the Redis counter. func rateLimit(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { key := r.Header.Get("X-API-Key") if key == "" { writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing X-API-Key"}) return } now := time.Now() n, err := requests.Increment(r.Context(), window{APIKey: key, Minute: now.Unix() / 60}, 1) if err != nil { // Fail open: a cache outage should not take the API down. next.ServeHTTP(w, r) return } w.Header().Set("X-RateLimit-Remaining", strconv.FormatInt(max(0, limitPerMinute-n), 10)) if n > limitPerMinute { w.Header().Set("Retry-After", strconv.Itoa(60-now.Second())) writeJSON(w, http.StatusTooManyRequests, map[string]string{"error": "rate limit exceeded"}) return } next.ServeHTTP(w, r) }) } func getRates(w http.ResponseWriter, r *http.Request) { base := chi.URLParam(r, "base") if !currency.MatchString(base) { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "base must be a currency code like EUR"}) return } res, err := loadRates(r, base) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": "rates provider unavailable"}) return } writeJSON(w, http.StatusOK, res) } func convert(w http.ResponseWriter, r *http.Request) { q := r.URL.Query() from, to := q.Get("from"), q.Get("to") amount, err := strconv.ParseFloat(q.Get("amount"), 64) if !currency.MatchString(from) || !currency.MatchString(to) || err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "from, to and amount are required"}) return } res, err := loadRates(r, from) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]string{"error": "rates provider unavailable"}) return } rate, ok := res.Rates[to] if !ok { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unknown currency " + to}) return } writeJSON(w, http.StatusOK, map[string]any{"from": from, "to": to, "amount": amount, "result": amount * rate, "date": res.Date}) } // loadRates is cache-aside: read Redis first, call the provider on a miss. func loadRates(r *http.Request, base string) (Rates, error) { ctx := r.Context() cached, err := rates.Get(ctx, base) if err == nil { return cached, nil } else if !errors.Is(err, cache.Miss) { return Rates{}, err } req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.frankfurter.dev/v1/latest?base="+base, nil) if err != nil { return Rates{}, err } resp, err := http.DefaultClient.Do(req) if err != nil { return Rates{}, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return Rates{}, fmt.Errorf("provider returned %d", resp.StatusCode) } var fresh Rates if err := json.NewDecoder(resp.Body).Decode(&fresh); err != nil { return Rates{}, err } // A failed cache write only costs one more provider call later. _ = rates.Set(ctx, base, fresh) return fresh, nil } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) json.NewEncoder(w).Encode(v) }

encore run starts the app with an in-memory Redis, so the limiter and the cache work on a fresh clone. Running it locally, a request without X-API-Key returns 401, the first GET /rates/EUR calls the provider and later ones within ten minutes come from Redis, and the 61st request from the same key inside one minute gets a 429 with Retry-After. The middleware fails open if Redis errors, so a cache problem slows the API down instead of rejecting every call, and you can flip that to fail closed for endpoints where the limit protects something expensive.

Keyspaces are typed: requests only accepts a window struct, and Encore checks at compile time that every field appears in the key pattern and that no two keyspaces in the cluster share a pattern. On deploy, Encore provisions ElastiCache on AWS or Memorystore on GCP in your own account, with the connection details wired into the service, so there is no REDIS_URL to set per environment.

For coding agents, the cache is a declaration in the same Go file as the middleware, so an agent that adds a per-endpoint limit adds the keyspace and the check together, and encore run exercises both against the local Redis before review.

Best fit: chi APIs that want rate limits and caching shared across every replica, with Redis that runs the same on a laptop and in your own AWS or GCP account.

go-redis and Amazon ElastiCache

The hand-built version uses go-redis against an ElastiCache cluster, and its rate limit middleware runs INCR and sets the expiry in one pipeline so a crash between the two calls cannot leave a counter that never expires:

package main import ( "crypto/tls" "fmt" "log" "net/http" "os" "time" "github.com/go-chi/chi/v5" "github.com/redis/go-redis/v9" ) func rateLimit(rdb *redis.Client, limit int64) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { apiKey := r.Header.Get("X-API-Key") if apiKey == "" { http.Error(w, "missing X-API-Key", http.StatusUnauthorized) return } key := fmt.Sprintf("ratelimit:%s:%d", apiKey, time.Now().Unix()/60) pipe := rdb.TxPipeline() incr := pipe.Incr(r.Context(), key) pipe.Expire(r.Context(), key, 2*time.Minute) if _, err := pipe.Exec(r.Context()); err != nil { next.ServeHTTP(w, r) // fail open return } if incr.Val() > limit { http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) return } next.ServeHTTP(w, r) }) } } func main() { rdb := redis.NewClient(&redis.Options{ Addr: os.Getenv("REDIS_ADDR"), Password: os.Getenv("REDIS_AUTH_TOKEN"), TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}, }) r := chi.NewRouter() r.Use(rateLimit(rdb, 60)) r.Get("/rates/{base}", func(w http.ResponseWriter, r *http.Request) { w.Write([]byte(chi.URLParam(r, "base"))) }) log.Fatal(http.ListenAndServe(":8080", r)) }

The cache-aside helper follows the same shape with rdb.Get, a check for redis.Nil on a miss and rdb.Set(ctx, key, data, 10*time.Minute) after the provider call, with JSON encoding and key formatting written by hand. Locally the TLS config has to be switched off for a plain Redis container. In AWS you create a replication group in private subnets, a subnet group, a security group that allows port 6379 from the app, and turn on in-transit encryption and an auth token, which then has to reach the app through Secrets Manager.

Best fit: teams that already run ElastiCache in Terraform and want direct access to every Redis command and client setting.

go-chi/httprate

httprate is the rate limiter from the chi organization, and for a single instance it is the shortest route: r.Use(httprate.LimitBy(60, time.Minute, keyFunc)) with a key function that returns the X-API-Key header. It uses a sliding window counter kept in process memory, so with three replicas each one allows the full limit on its own, and a deploy resets every counter. The httprate-redis package moves the counters into Redis, which brings back the same provisioning work as the go-redis route, and httprate does nothing for caching.

Best fit: a single-instance chi service that needs basic abuse protection and no shared cache.

How to choose

Pick Encore when you want the limiter and the cache to share one Redis that runs locally with encore run and on ElastiCache or Memorystore in your own account, with typed keys checked at compile time. Pick go-redis with ElastiCache when the cache is already managed in Terraform and you want the full command set. httprate fits a single instance where a per-process limit is acceptable.

Frequently asked questions

How do I add Redis to a go-chi app?

Create one Redis client at startup, for example redis.NewClient from go-redis, and use it from handlers and middleware with r.Context(). chi middleware is a func(http.Handler) http.Handler, so a Redis-backed check such as a rate limit wraps the router with r.Use and runs before every route.

How do I build rate limiting middleware for chi with Redis?

Increment a counter whose key contains the client identifier and the current minute, give it an expiry a little longer than the window, and return 429 with a Retry-After header once the count passes the limit. With Encore that is requests.Increment(ctx, window{APIKey: key, Minute: now.Unix() / 60}, 1) on an IntKeyspace with DefaultExpiry: cache.ExpireIn(2 * time.Minute).

How do I add Redis to a chi app with Encore?

Declare a cluster with cache.NewCluster and typed keyspaces such as cache.NewStructKeyspace, mount the chi router in a raw endpoint with path=/!fallback, and call the keyspaces from chi handlers and middleware. encore run provides an in-memory Redis locally, and deploys get ElastiCache on AWS or Memorystore on GCP in your own account.

Is go-chi/httprate enough for rate limiting?

httprate keeps counters in memory by default, which is fine for one instance but gives each replica its own limit once you scale out. The httprate-redis package moves the counters into Redis so every replica shares them.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders