Run a Go Gin API on Cloud Run with a Memorystore for Redis rate limiter and a Pub/Sub topic for background sends, with Encore provisioning it in your own GCP account or with Docker, Direct VPC egress and gcloud by hand.

Gin compiles to one static binary that starts in milliseconds, which suits Cloud Run and its scale to zero well. The trouble starts once the API needs shared state and background work: Memorystore for Redis only has a private IP, so the service needs VPC egress configured, and moving work off the request path means a Pub/Sub topic, a push subscription, an invoker service account and a handler that copes with redelivery.
This guide deploys a Gin notifications API that rate limits each user in Redis and hands sends to a Pub/Sub subscriber. It starts with Encore, which provisions Cloud Run, 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 Gin and deploys it to Cloud Run in your own GCP account together with the infrastructure the Go code declares. The Gin engine keeps every route and sits behind a raw fallback route, and the Redis cluster and the topic are package-level declarations next to the handlers that use them.
Run in your terminal to get started locally.
// notify/notify.go
package notify
import (
"context"
"net/http"
"time"
"encore.dev/pubsub"
"encore.dev/rlog"
"encore.dev/storage/cache"
"encore.dev/storage/sqldb"
"github.com/gin-gonic/gin"
)
var db = sqldb.NewDatabase("notify", sqldb.DatabaseConfig{
Migrations: "./migrations",
})
// Memorystore for Redis on GCP, an in-memory Redis with `encore run`.
var cluster = cache.NewCluster("notify", cache.ClusterConfig{
EvictionPolicy: cache.AllKeysLRU,
})
type rateKey struct {
UserID string
Hour string // e.g. 2026-11-12T09
}
// Sends per user per clock hour, gone an hour after the last increment.
var sendsPerHour = cache.NewIntKeyspace[rateKey](cluster, cache.KeyspaceConfig{
KeyPattern: "rate/:UserID/:Hour",
DefaultExpiry: cache.ExpireIn(time.Hour),
})
type Notification struct {
ID string `json:"id"` // set by the caller, reused on retries
UserID string `json:"user_id"`
Title string `json:"title"`
Body string `json:"body"`
}
// A Pub/Sub topic on GCP; delivery is retried until the handler succeeds.
var Notifications = pubsub.NewTopic[*Notification]("notifications", pubsub.TopicConfig{
DeliveryGuarantee: pubsub.AtLeastOnce,
})
var _ = pubsub.NewSubscription(Notifications, "send-push",
pubsub.SubscriptionConfig[*Notification]{Handler: sendPush},
)
const maxPerHour = 5
//encore:service
type Service struct {
router *gin.Engine
}
func initService() (*Service, error) {
r := gin.New()
r.Use(gin.Recovery())
r.GET("/healthz", func(c *gin.Context) { c.Status(http.StatusOK) })
r.POST("/notify", enqueue)
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)
}
func enqueue(c *gin.Context) {
var n Notification
if err := c.ShouldBindJSON(&n); err != nil || n.ID == "" || n.UserID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "id and user_id are required"})
return
}
ctx := c.Request.Context()
key := rateKey{UserID: n.UserID, Hour: time.Now().UTC().Format("2006-01-02T15")}
count, err := sendsPerHour.Increment(ctx, key, 1)
if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "try again"})
return
}
if count > maxPerHour {
c.JSON(http.StatusTooManyRequests, gin.H{"error": "hourly limit reached"})
return
}
if _, err := Notifications.Publish(ctx, &n); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "try again"})
return
}
c.JSON(http.StatusAccepted, gin.H{"id": n.ID})
}
// sendPush records the ID as pending before calling the provider, then
// marks it sent after a successful deliver. A redelivered message whose
// row is already sent is a no-op. A row still pending is retried, so a
// crash between the insert and the provider call cannot drop the send,
// and a cache eviction cannot make it go out twice.
func sendPush(ctx context.Context, n *Notification) error {
res, err := db.Exec(ctx, `
INSERT INTO push_deliveries (id, status) VALUES ($1, 'pending')
ON CONFLICT (id) DO NOTHING
`, n.ID)
if err != nil {
return err
}
if res.RowsAffected() == 0 {
var status string
if err := db.QueryRow(ctx, `SELECT status FROM push_deliveries WHERE id = $1`, n.ID).Scan(&status); err != nil {
return err
}
if status == "sent" {
return nil
}
}
if err := deliver(ctx, n); err != nil {
return err
}
_, err = db.Exec(ctx, `
UPDATE push_deliveries SET status = 'sent' WHERE id = $1 AND status = 'pending'
`, n.ID)
return err
}
func deliver(ctx context.Context, n *Notification) error {
// Call your push provider (FCM, APNs) with Idempotency-Key set to n.ID,
// so a retry after a crash between a successful send and the status
// update does not deliver the notification twice.
rlog.Info("push sent", "id", n.ID, "user", n.UserID)
return nil
}
The migration that sits next to this file creates the delivery table:
CREATE TABLE push_deliveries (
id TEXT PRIMARY KEY,
status TEXT NOT NULL CHECK (status IN ('pending', 'sent')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Under encore run the cache is an in-memory Redis, Postgres starts locally and the topic runs locally, so posting six notifications for the same user returns 202 five times and 429 on the sixth, and posting one ID twice logs a single send. Neither the Gin code nor a Dockerfile reads PORT, since Encore runs the HTTP server and passes requests to Gin through the fallback endpoint. The caching docs cover the rate-limit keyspace. Delivery claims live in Postgres, so a Redis eviction cannot mark a sent notification as new and a crash after the pending insert still retries the provider call.
In the Encore dashboard, connect your GCP account under the app's settings as described in Connect your cloud account, create an environment on GCP with Cloud Run as the compute, and push:
Encore builds the image and deploys the service to Cloud Run, creates a Memorystore for Redis instance with Redis authentication whose credentials Encore manages, and sets up the Pub/Sub topic and subscription with a dead-letter topic for messages that keep failing, as listed in the GCP infrastructure docs. Those resources are deployed into a private network in your account, so there are no --network flags or Redis IP addresses to carry from one environment to the next. Encore provisions an IAM role for the service, which on GCP is a service account.
A coding agent asked to add a daily digest to this service declares a cron job in the same package, tests the endpoint behind it with encore run, and the next push schedules it, with no Cloud Run job or scheduler resource to create.
Best fit: Gin APIs that should run on Cloud Run with Redis and background processing in your own GCP account, where the cache and the topic are declared in the same Go package as the handlers and run the same way on a laptop.
The hand-built version connects to Memorystore with go-redis, publishes with the Pub/Sub client and exposes a push endpoint that Pub/Sub calls for every message:
func main() {
ctx := context.Background()
// 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("notifications")
defer pub.Stop()
r := gin.New()
r.Use(gin.Recovery())
registerRoutes(r, rdb, pub)
// Pub/Sub push subscription target. A 2xx acks the message, anything else retries.
r.POST("/pubsub/push", func(c *gin.Context) {
var env struct {
Message struct {
Data []byte `json:"data"` // base64 in JSON, decoded by encoding/json
} `json:"message"`
}
var n Notification
if err := c.ShouldBindJSON(&env); err != nil || json.Unmarshal(env.Message.Data, &n) != nil {
c.Status(http.StatusNoContent) // malformed, do not retry
return
}
if err := sendOnce(c.Request.Context(), rdb, &n); err != nil {
c.Status(http.StatusInternalServerError)
return
}
c.Status(http.StatusNoContent)
})
log.Fatal(r.Run(":" + os.Getenv("PORT")))
}
A multi-stage Dockerfile builds the binary and runs it as a non-root user:
FROM golang:1.25 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 Direct VPC egress and the push subscription:
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, while the Pub/Sub push tutorial has the invoker account, its roles/run.invoker binding, and the Pub/Sub service agent's roles/iam.serviceAccountTokenCreator grant so Pub/Sub can mint an OIDC token for that account, 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 and Pub/Sub layout in GCP that want each subscription, network flag and role binding written out, and services where the platform team owns that setup.
Pick Encore to keep Gin as it is and get Cloud Run, Memorystore and Pub/Sub provisioned in your own GCP account from the API code, with the same cache and topic working under encore run. Pick Docker and gcloud when the VPC, subscriptions and service accounts are already managed elsewhere and the Gin service only needs to plug into them.
Build a container that starts Gin 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 for Redis, add --network and --subnet so the service uses Direct VPC egress, and pass the instance IP as an environment variable.
Mount the Gin engine in a raw endpoint with //encore:api public raw path=/!fallback, declare 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, Memorystore for Redis and Pub/Sub in your account.
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.