Run a go-chi waitlist API on Cloud Run with Cloud SQL, Memorystore and Pub/Sub in your own GCP account, with Encore or with Docker, Direct VPC egress and gcloud by hand.

A chi API is a single static binary that starts quickly, which is a good match for Cloud Run and its scale-to-zero billing. The work appears once the API needs Postgres, Redis and a background send: Cloud SQL and Memorystore sit on private addresses, and Pub/Sub needs a push subscription, an invoker account and a handler that can see the same message twice.
This guide deploys a chi waitlist API that stores signups in Postgres, rate-limits joins in Redis and notifies customers over Pub/Sub. It starts with Encore, which provisions Cloud Run, Cloud SQL, Memorystore and Pub/Sub in your own GCP account from the Go code, and then walks through the same deploy with Docker and gcloud.
Encore works alongside chi and deploys it to Cloud Run in your own GCP account together with the infrastructure the Go code declares. The chi router keeps every route and sits behind a raw fallback route, and the database, the Redis cluster and the topic are package-level declarations next to the handlers that use them. Encore provisions an IAM role for the service, or a service account on GCP.

Run in your terminal to get started locally.
-- waitlist/migrations/1_waitlist.up.sql
CREATE TABLE signups (
id BIGSERIAL PRIMARY KEY,
sku TEXT NOT NULL,
email TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (sku, email)
);
// waitlist/waitlist.go
package waitlist
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"encore.dev/pubsub"
"encore.dev/storage/cache"
"encore.dev/storage/sqldb"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
var db = sqldb.NewDatabase("waitlist", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
// Memorystore for Redis on GCP, an in-memory Redis with `encore run`.
var cluster = cache.NewCluster("waitlist", cache.ClusterConfig{
EvictionPolicy: cache.AllKeysLRU,
})
type rateWindow struct {
Email string
Hour string // e.g. 2026-11-13T10
}
// Joins per email per clock hour. The hour is part of the key so a
// reset of the expiry on Increment cannot extend the window.
var joins = cache.NewIntKeyspace[rateWindow](cluster, cache.KeyspaceConfig{
KeyPattern: "joins/:Email/:Hour",
DefaultExpiry: cache.ExpireIn(2 * time.Hour),
})
// Signup IDs already notified, kept for a day to drop redeliveries.
var notified = cache.NewStringKeyspace[int64](cluster, cache.KeyspaceConfig{
KeyPattern: "notified/:key",
DefaultExpiry: cache.ExpireIn(24 * time.Hour),
})
type Restocked struct {
SKU string `json:"sku"`
Email string `json:"email"`
ID int64 `json:"id"`
}
var Restocks = pubsub.NewTopic[*Restocked]("restocks", pubsub.TopicConfig{
DeliveryGuarantee: pubsub.AtLeastOnce,
})
var _ = pubsub.NewSubscription(Restocks, "send-email", pubsub.SubscriptionConfig[*Restocked]{
Handler: sendEmail,
RetryPolicy: &pubsub.RetryPolicy{
MinBackoff: 10 * time.Second,
MaxBackoff: 10 * time.Minute,
MaxRetries: 8,
},
})
const maxJoinsPerHour = 5
//encore:service
type Service struct {
router *chi.Mux
}
func initService() (*Service, error) {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
r.Get("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
r.Post("/waitlist", join)
r.Post("/skus/{sku}/restock", restock)
return &Service{router: r}, nil
}
//encore:api public raw path=/!fallback
func (s *Service) Fallback(w http.ResponseWriter, req *http.Request) {
s.router.ServeHTTP(w, req)
}
type joinBody struct {
SKU string `json:"sku"`
Email string `json:"email"`
}
func join(w http.ResponseWriter, r *http.Request) {
var body joinBody
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<16)).Decode(&body); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
body.SKU = strings.TrimSpace(body.SKU)
body.Email = strings.ToLower(strings.TrimSpace(body.Email))
if body.SKU == "" || body.Email == "" || !strings.Contains(body.Email, "@") {
http.Error(w, "sku and email are required", http.StatusBadRequest)
return
}
window := rateWindow{Email: body.Email, Hour: time.Now().UTC().Format("2006-01-02T15")}
n, err := joins.Increment(r.Context(), window, 1)
if err != nil {
http.Error(w, "rate limiter unavailable", http.StatusInternalServerError)
return
}
if n > maxJoinsPerHour {
w.Header().Set("Retry-After", "3600")
http.Error(w, "too many join attempts", http.StatusTooManyRequests)
return
}
var id int64
err = db.QueryRow(r.Context(), `
INSERT INTO signups (sku, email) VALUES ($1, $2)
ON CONFLICT (sku, email) DO NOTHING
RETURNING id
`, body.SKU, body.Email).Scan(&id)
if errors.Is(err, sqldb.ErrNoRows) {
writeJSON(w, http.StatusOK, map[string]string{"status": "already_joined"})
return
} else if err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"id": id, "sku": body.SKU})
}
func restock(w http.ResponseWriter, r *http.Request) {
sku := chi.URLParam(r, "sku")
if sku == "" {
http.Error(w, "sku is required", http.StatusBadRequest)
return
}
rows, err := db.Query(r.Context(), `SELECT id, email FROM signups WHERE sku = $1`, sku)
if err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
defer rows.Close()
var published int
for rows.Next() {
var ev Restocked
ev.SKU = sku
if err := rows.Scan(&ev.ID, &ev.Email); err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
if _, err := Restocks.Publish(r.Context(), &ev); err != nil {
http.Error(w, "publish error", http.StatusInternalServerError)
return
}
published++
}
if err := rows.Err(); err != nil {
http.Error(w, "database error", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusAccepted, map[string]int{"published": published})
}
func sendEmail(ctx context.Context, ev *Restocked) error {
err := notified.SetIfNotExists(ctx, ev.ID, "sending")
if errors.Is(err, cache.KeyExists) {
return nil
}
if err != nil {
return err
}
// Send the restock email. Return an error to retry until MaxRetries.
return 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 Postgres locally, applies the migration, and uses an in-memory Redis and a local topic, so POST /waitlist followed by a second post for the same email returns already_joined, and a sixth join in the same hour returns 429. chi middleware such as middleware.RequestID keeps working as before. The chi code does not need to read PORT, since Encore runs the HTTP server and passes requests to chi through the fallback endpoint. Encore builds the Docker image, so you do not maintain a Dockerfile for this path.
A coding agent working in this repository adds a table, a cache key or a subscriber in the same change as the handler and runs it under encore run before anything is pushed.
In the Encore dashboard, go to App Settings > Integrations > Connect Cloud and connect your GCP account, as described in Connect your cloud account. Create an environment on GCP with Cloud Run as the compute, then push:
Encore builds the binary into an image and provisions Cloud Run, Cloud SQL for PostgreSQL, Memorystore for Redis and Pub/Sub in your account, as the GCP infrastructure docs describe. Encore gives the service its own database role, and runs migrations through a proxy inside your environment, without exposing the database. Encore builds the Docker image and the Cloud SQL and Memorystore networking, so you do not maintain a Dockerfile, a Direct VPC flag or a DATABASE_URL. Staging and preview environments get the same setup.
Best fit: chi APIs that should run on Cloud Run with Cloud SQL, Memorystore and Pub/Sub in your own GCP account, with the same database, cache and topic locally and in every environment.
The hand-built version listens on PORT, connects to Cloud SQL over the Unix socket, talks to Memorystore with go-redis and exposes a push endpoint that Pub/Sub calls for every restock email:
func main() {
ctx := context.Background()
// Cloud Run sets PORT. The Cloud SQL Auth Proxy exposes the instance
// at /cloudsql/PROJECT:REGION:INSTANCE when --add-cloudsql-instances is set.
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatal(err)
}
// Memorystore has a private IP only; the service reaches it through Direct VPC egress.
rdb := redis.NewClient(&redis.Options{Addr: os.Getenv("REDISHOST") + ":6379"})
ps, err := pubsub.NewClient(ctx, pubsub.DetectProjectID)
if err != nil {
log.Fatal(err)
}
pub := ps.Publisher("restocks")
defer pub.Stop()
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.Recoverer)
registerRoutes(r, db, rdb, pub)
log.Fatal(http.ListenAndServe(":"+port, r))
}
A multi-stage Dockerfile builds the binary and runs it as a non-root user:
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 /out/api .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/api /api
USER nonroot:nonroot
ENTRYPOINT ["/api"]
Then the GCP resources, the runtime service account, the deploy with both the Cloud SQL connection and Direct VPC egress, and the push subscription:
The Cloud SQL for Cloud Run guide is what --add-cloudsql-instances follows, and the Unix socket path belongs in DATABASE_URL as host=/cloudsql/PROJECT:REGION:INSTANCE. The Memorystore guide for Cloud Run recommends Direct VPC egress with --network and --subnet, deploying in the region where the Redis instance lives on a subnet of /26 or larger. The Pub/Sub push tutorial has the invoker account and its roles/run.invoker binding, and notes that IAM changes can take several minutes to propagate, during which the service logs 403 errors. When the API itself allows unauthenticated traffic, /pubsub/push is open to anyone with the URL, so the handler has to verify the OIDC token Pub/Sub attaches or the push target has to move to a separate private service, and a dead-letter topic is a separate --dead-letter-topic setting with its own IAM grants for the Pub/Sub service agent.
Best fit: teams with an existing VPC, Cloud SQL instance and Pub/Sub layout in GCP that want each connection, network flag and role binding written out, and services where the platform team owns that setup.
Pick Encore to keep chi as it is and get Cloud Run, Cloud SQL, Memorystore and Pub/Sub provisioned in your own GCP account from the API code, with the same database, cache and topic working under encore run. Pick Docker and gcloud when the VPC, instance connections, subscriptions and service accounts are already managed elsewhere and the chi service only needs to plug into them.
Build a container that starts the chi server on the port in the PORT environment variable, push it to Artifact Registry and run gcloud run deploy with the image, a region and a runtime service account. To reach Memorystore, add --network and --subnet so the service uses Direct VPC egress. To reach Cloud SQL, add --add-cloudsql-instances and connect over the Unix socket the Cloud SQL Auth Proxy exposes.
Mount the chi router in a raw endpoint with //encore:api public raw path=/!fallback, declare the database with sqldb.NewDatabase, the cache with cache.NewCluster and the topic with pubsub.NewTopic, connect your GCP account in the Encore dashboard, create an environment on GCP with Cloud Run and run git push encore. Encore builds the image and provisions Cloud Run, Cloud SQL, Memorystore and Pub/Sub in your account.
Cloud SQL can be reached through the Cloud SQL Auth Proxy that Cloud Run starts when you pass --add-cloudsql-instances, over a Unix socket at /cloudsql/PROJECT:REGION:INSTANCE. Memorystore has a private IP only, so the service also needs Direct VPC egress with --network and --subnet in the same region as the Redis instance, as the [Memorystore guide for Cloud Run](https://cloud.google.com/memorystore/docs/redis/connect-redis-instance-cloud-run) describes.
Through a push subscription that POSTs each message to an HTTPS path on the service, authenticated with an OIDC token from a service account that has roles/run.invoker. The handler returns 2xx to acknowledge and any error status to have Pub/Sub retry, so it has to handle the same message arriving twice.
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.