Run a go-chi API with Postgres and Redis on Fargate, RDS and ElastiCache in your own AWS account, with Encore or by hand.

A chi API compiles to one static binary, so it runs almost anywhere on AWS, and that is also why the choice is hard. The binary is the easy part, and the rest of a production setup is a VPC with private subnets, a load balancer, a Fargate service, an RDS database the app can reach and the internet cannot, a Redis cluster, IAM roles, secrets and a pipeline that runs migrations before new tasks start.
This guide deploys a chi link shortener that stores links in Postgres and caches hot redirects in Redis. It starts with Encore, which provisions Fargate, RDS and ElastiCache in your own AWS account from the Go code, then walks through ECS Fargate by hand, ECS Express Mode and Lambda.
Encore works alongside chi, so the router and handlers you already have keep running as they are, and the database and cache they need are declared in Go next to them. From that code Encore knows what to build and provision when you push, and it deploys into your own AWS account.
Run in your terminal to get started locally.
The migration creates the links table:
-- links/migrations/1_create_links.up.sql
CREATE TABLE links (
code TEXT PRIMARY KEY,
target_url TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
The service declares the Postgres database and a Redis cluster, and forwards every request to the chi router from a raw endpoint with a fallback route. Redirects read Redis first and fall back to Postgres on a miss:
// links/links.go
package links
import (
"crypto/rand"
"encoding/json"
"errors"
"math/big"
"net/http"
"net/url"
"time"
"encore.dev/storage/cache"
"encore.dev/storage/sqldb"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
// RDS for PostgreSQL on AWS.
var db = sqldb.NewDatabase("links", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
// ElastiCache (Redis) on AWS, for hot redirects.
var cluster = cache.NewCluster("links", cache.ClusterConfig{
EvictionPolicy: cache.AllKeysLRU,
})
var targets = cache.NewStringKeyspace[string](cluster, cache.KeyspaceConfig{
KeyPattern: "target/:key",
DefaultExpiry: cache.ExpireIn(time.Hour),
})
//encore:service
type Service struct {
router chi.Router
}
func initService() (*Service, error) {
r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.Recoverer)
r.Post("/links", createLink)
r.Get("/{code}", redirect)
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)
}
const alphabet = "abcdefghijkmnpqrstuvwxyz23456789"
func newCode() string {
b := make([]byte, 7)
for i := range b {
n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
b[i] = alphabet[n.Int64()]
}
return string(b)
}
func createLink(w http.ResponseWriter, r *http.Request) {
var in struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "invalid body", http.StatusBadRequest)
return
}
u, err := url.Parse(in.URL)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
http.Error(w, "url must be an absolute http(s) URL", http.StatusBadRequest)
return
}
code := newCode()
if _, err := db.Exec(r.Context(),
`INSERT INTO links (code, target_url) VALUES ($1, $2)`, code, u.String()); err != nil {
http.Error(w, "could not save link", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{"code": code})
}
func redirect(w http.ResponseWriter, r *http.Request) {
ctx, code := r.Context(), chi.URLParam(r, "code")
target, err := targets.Get(ctx, code)
if errors.Is(err, cache.Miss) {
err = db.QueryRow(ctx, `SELECT target_url FROM links WHERE code = $1`, code).Scan(&target)
if errors.Is(err, sqldb.ErrNoRows) {
http.NotFound(w, r)
return
} else if err != nil {
http.Error(w, "lookup failed", http.StatusInternalServerError)
return
}
_ = targets.Set(ctx, code, target) // a failed cache write only costs a query next time
} else if err != nil {
http.Error(w, "lookup failed", http.StatusInternalServerError)
return
}
http.Redirect(w, r, target, http.StatusFound)
}
encore run starts Postgres and an in-memory Redis locally and applies the migration, so POST /links followed by a GET on the returned code redirects on a fresh clone. chi middleware such as middleware.RequestID keeps working as before, and Encore adds its own tracing around each request.
In the Encore dashboard, go to App Settings > Integrations > Connect Cloud and connect your AWS account, which creates an IAM role Encore uses to provision resources, as described in Connect your cloud account. Create an environment on AWS with Fargate as the compute, then push:
Encore builds the binary into an image, pushes it to ECR and provisions a VPC across two availability zones, an Application Load Balancer, the Fargate service in private compute subnets, RDS for PostgreSQL and ElastiCache in private storage subnets, as the AWS infrastructure docs describe. Encore provisions an IAM role for the service, gives it its own database role, and runs migrations through a proxy inside your environment, without exposing the database. Encore builds the Docker image, so you do not maintain a Dockerfile, a task definition or a DATABASE_URL. Staging and preview environments get the same setup.
When the shortener later needs click analytics, a Pub/Sub topic is another declaration in the same package, and the next push provisions SNS and SQS. A coding agent working in this repo changes infrastructure in the same diff as the handler and runs it under encore run before anything is pushed.
Best fit: chi APIs that should run on Fargate with RDS and ElastiCache in your own AWS account, with the same database and cache locally and in every environment.
The manual route starts with a Dockerfile, where a multi-stage build keeps the image small and runs the binary as a non-root user on distroless:
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app ./cmd/api
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /app /app
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/app"]
Then comes the infrastructure, usually in Terraform: a VPC with public and private subnets and NAT gateways, an ECR repository, an ECS cluster, a task definition with an execution role and a task role, a Fargate service, an ALB with a listener, target group and health check path, an RDS instance and an ElastiCache replication group with their subnet and security groups, and the database password and Redis auth token in Secrets Manager, referenced from the task definition. The chi app reads DATABASE_URL and REDIS_ADDR from the environment, and CI builds the image, pushes it, runs migrations from a one-off task inside the VPC and updates the service.
Teams that already run ECS in Terraform get full control from this route, in exchange for a few hundred lines of configuration that each new environment repeats and a local Docker Compose setup that someone has to keep close to it by hand.
Best fit: platform teams with existing ECS modules who want every setting in their own Terraform.
ECS Express Mode creates a Fargate service, load balancer, scaling and HTTPS endpoint from a container image and a few settings, and AWS points App Runner users to it now that App Runner no longer accepts new customers. You still write the Dockerfile and push to ECR, and Express Mode covers the compute side only, so RDS, ElastiCache, the security groups between them and the secrets are yours to create and connect.
Best fit: a single containerized chi service that needs a quick Fargate deployment and already has its data stores.
The aws-lambda-go-api-proxy project includes an httpadapter that wraps any http.Handler, so lambda.Start(httpadapter.New(r).ProxyWithContext) runs a chi router behind API Gateway. It costs nothing at idle and scales with traffic, and it changes how the app talks to data: reaching RDS and ElastiCache means running the function in the VPC, connection counts usually call for RDS Proxy, and in-process state such as connection pools and caches resets on every cold start.
Best fit: low or spiky traffic chi APIs with light database use.
Pick Encore when you want a chi API with Postgres and Redis running on Fargate, RDS and ElastiCache in your own AWS account, with the infrastructure coming from the Go code and the same setup locally. Build ECS by hand when your team already maintains ECS in Terraform. ECS Express Mode suits a single container that already has its data stores, and Lambda suits spiky traffic with light database use.
Build the chi binary into a container image, push it to ECR and run it on ECS Fargate behind an Application Load Balancer, with RDS and ElastiCache in private subnets if the app needs them.
Mount the chi router in a raw endpoint with //encore:api public raw path=/!fallback, declare the database with sqldb.NewDatabase and the cache with cache.NewCluster, connect your AWS account in the Encore dashboard, create an AWS environment with Fargate and run git push encore. Encore builds the image and provisions Fargate, RDS for PostgreSQL and ElastiCache in your account.
Yes, with aws-lambda-go-api-proxy, whose httpadapter wraps any http.Handler, including a chi router, as a Lambda handler behind API Gateway. It suits low or spiky traffic, but connections to RDS and ElastiCache from Lambda need VPC settings and usually RDS Proxy.
Use a multi-stage build that compiles a static binary with CGO_ENABLED=0 in a golang image, then copy only the binary into a small runtime image such as distroless static with a nonroot user. The final image has no shell or compiler and does not run as root.
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.