Nov 13, 20269 min read

How to Deploy a chi App to Google Cloud Run

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.

Illustration for How to Deploy a chi App to Google Cloud Run

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.

Ways to deploy chi to Cloud Run at a glance

EncoreDocker + gcloud by hand
Container imageBuilt by Encore from the Go codeYour Dockerfile, pushed to Artifact Registry
PostgresCloud SQL, declared with sqldb.NewDatabaseCloud SQL instance plus --add-cloudsql-instances
RedisMemorystore, declared with cache.NewClusterMemorystore instance plus Direct VPC egress flags
Background workPub/Sub topic and subscription declared in GoTopic, push subscription, invoker account and push handler
Redelivery handlingYour handler, run once per deliveryYour handler, behind an HTTP push endpoint
Local developmentencore run starts Postgres locally, with in-memory Redis and Pub/SubDocker Compose with Postgres, Redis and the Pub/Sub emulator
New environmentCreate it in the dashboard and pushRepeat every command per project

How do you deploy chi to Cloud Run with Encore?

Encore

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.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

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 waitlist-api --example=hello-world$ cd waitlist-api && go get github.com/go-chi/chi/v5@v5.5.0$ mkdir -p waitlist/migrations
-- 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.

Deploy to your GCP account

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:

~/waitlist-api
$ git add -A && git commit -m "chi waitlist api"$ git push encore

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.

Docker, Direct VPC egress and gcloud by hand

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:

~/waitlist-api
$ gcloud sql instances create waitlist-db --database-version=POSTGRES_17 \ --tier=db-f1-micro --region=europe-west1$ gcloud sql databases create waitlist --instance=waitlist-db$ gcloud redis instances create waitlist-cache --size=1 --region=europe-west1 --network=default$ gcloud pubsub topics create restocks$ gcloud iam service-accounts create waitlist-api$ gcloud projects add-iam-policy-binding $PROJECT \ --member=serviceAccount:waitlist-api@$PROJECT.iam.gserviceaccount.com \ --role=roles/cloudsql.client$ gcloud pubsub topics add-iam-policy-binding restocks \ --member=serviceAccount:waitlist-api@$PROJECT.iam.gserviceaccount.com --role=roles/pubsub.publisher$ gcloud builds submit --tag europe-west1-docker.pkg.dev/$PROJECT/apps/waitlist-api$ gcloud run deploy waitlist-api --region=europe-west1 \ --image=europe-west1-docker.pkg.dev/$PROJECT/apps/waitlist-api \ --service-account=waitlist-api@$PROJECT.iam.gserviceaccount.com \ --add-cloudsql-instances=$PROJECT:europe-west1:waitlist-db \ --network=default --subnet=default \ --set-env-vars=REDISHOST=$REDIS_IP,DATABASE_URL=$DATABASE_URL$ gcloud iam service-accounts create waitlist-push$ gcloud run services add-iam-policy-binding waitlist-api --region=europe-west1 \ --member=serviceAccount:waitlist-push@$PROJECT.iam.gserviceaccount.com --role=roles/run.invoker$ gcloud pubsub subscriptions create send-email --topic=restocks \ --push-endpoint=$SERVICE_URL/pubsub/push \ --push-auth-service-account=waitlist-push@$PROJECT.iam.gserviceaccount.com

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.

How to choose

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.

Frequently asked questions

How do I deploy a go-chi app to Cloud Run?

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.

How do I deploy chi to Cloud Run with Encore?

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.

How does a Cloud Run service reach Cloud SQL and Memorystore at the same time?

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.

How does a Cloud Run service receive Pub/Sub messages?

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.

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