Nov 12, 202610 min read

Memorystore for Redis with Terraform: Private Access, AUTH, TLS and Cloud Run

Terraform for a Standard Tier Memorystore for Redis instance on private services access, with AUTH, in-transit encryption, read replicas and persistence, the AUTH string in Secret Manager, and a Cloud Run service that reaches it through Direct VPC egress, plus how Encore provisions Memorystore from code.

Illustration for Memorystore for Redis with Terraform: Private Access, AUTH, TLS and Cloud Run

A Memorystore for Redis instance takes five lines of Terraform with the defaults, which give you a Basic Tier instance with no replica, no AUTH, no TLS and a direct peering connection. A setup you would put sessions or login codes in needs a replicated tier, private services access, AUTH and in-transit encryption, a home for the AUTH string, and a Cloud Run service that can reach a private IP and trust the instance's certificate.

This guide covers each of those with the Google provider 7.x, the settings worth deciding before the first apply, and how Encore provisions Memorystore from a cache declared in Go.

What a Memorystore Terraform setup needs

PieceTerraform resourcePurpose
APIsgoogle_project_serviceMemorystore, Service Networking, Cloud Run, Secret Manager
Networkgoogle_compute_network, google_compute_subnetworkThe VPC and the subnet Cloud Run egresses from
Private services accessgoogle_compute_global_address, google_service_networking_connectionRange and peering the instance IP comes from
Instancegoogle_redis_instanceTier, size, version, AUTH, TLS, replicas, persistence
AUTH stringgoogle_secret_manager_secret, _versionLets the service read the password at runtime
Servicegoogle_cloud_run_v2_service, google_service_accountThe client, with Direct VPC egress and secret access

Network and private services access

terraform { required_providers { google = { source = "hashicorp/google" version = "~> 7.0" } } } variable "project" { type = string } variable "region" { type = string default = "europe-west1" } variable "image" { type = string description = "Container image for the API service" } provider "google" { project = var.project region = var.region } resource "google_project_service" "apis" { for_each = toset([ "redis.googleapis.com", "servicenetworking.googleapis.com", "run.googleapis.com", "secretmanager.googleapis.com", ]) service = each.key disable_on_destroy = false } resource "google_compute_network" "main" { name = "main" auto_create_subnetworks = false } # Cloud Run sends traffic for private ranges into the VPC from this subnet. resource "google_compute_subnetwork" "run" { name = "run-egress" network = google_compute_network.main.id region = var.region ip_cidr_range = "10.20.0.0/24" } # Private services access: Memorystore takes its IPs from this range. resource "google_compute_global_address" "psa" { name = "google-managed-services" purpose = "VPC_PEERING" address_type = "INTERNAL" prefix_length = 20 network = google_compute_network.main.id } resource "google_service_networking_connection" "psa" { network = google_compute_network.main.id service = "servicenetworking.googleapis.com" reserved_peering_ranges = [google_compute_global_address.psa.name] depends_on = [google_project_service.apis] }

Memorystore for Redis defaults to DIRECT_PEERING, where Memorystore creates a VPC peering between your network and a Google-managed one. With PRIVATE_SERVICE_ACCESS, the instance draws its IP from the reserved range above, the same connection that Cloud SQL private IP uses, and Google's Memorystore networking docs list it as a prerequisite for instances on a Shared VPC. A /20 leaves room for more instances and other Google-managed services in the same network.

The instance and its AUTH string

resource "google_redis_instance" "sessions" { name = "sessions" tier = "STANDARD_HA" # replica in another zone, automatic failover memory_size_gb = 5 region = var.region redis_version = "REDIS_7_2" authorized_network = google_compute_network.main.id connect_mode = "PRIVATE_SERVICE_ACCESS" auth_enabled = true transit_encryption_mode = "SERVER_AUTHENTICATION" # TLS, clients need the server CA replica_count = 1 read_replicas_mode = "READ_REPLICAS_ENABLED" redis_configs = { "maxmemory-policy" = "volatile-lru" } persistence_config { persistence_mode = "RDB" rdb_snapshot_period = "TWELVE_HOURS" } maintenance_policy { weekly_maintenance_window { day = "SUNDAY" start_time { hours = 3 minutes = 0 } } } depends_on = [google_service_networking_connection.psa] } # The AUTH string is generated by Memorystore and is also stored in Terraform # state, so the state bucket needs the same protection as the secret. resource "google_secret_manager_secret" "redis_auth" { secret_id = "redis-auth" replication { auto {} } depends_on = [google_project_service.apis] } resource "google_secret_manager_secret_version" "redis_auth" { secret = google_secret_manager_secret.redis_auth.id secret_data = google_redis_instance.sessions.auth_string }

The settings that change behavior later:

  • tier = "STANDARD_HA" replicates across zones with automatic failover. Basic Tier has no replica and no automatic failover, according to the Memorystore for Redis overview, which also lists the 300 GB limit and up to five read replicas.
  • read_replicas_mode exposes read_endpoint, which spreads reads across replicas with replication lag, so writes and reads that must see the latest value still go to host.
  • auth_enabled makes Memorystore generate a UUID as the AUTH string, and without in-transit encryption that string is not guaranteed to be encrypted on the wire, as the AUTH docs point out.
  • transit_encryption_mode = "SERVER_AUTHENTICATION" turns on TLS, and clients have to trust the instance's certificate authority, exported as server_ca_certs, per the in-transit encryption docs.
  • maxmemory-policy = "volatile-lru" evicts only keys that have a TTL, which protects keys written without one at the cost of write errors if those fill memory.

auth_string is a sensitive attribute of the instance resource, so it lives in Terraform state whatever you do with it afterwards. Copying it into Secret Manager keeps the value out of the Cloud Run service definition and lets access be granted per service account, and the state bucket needs the same access controls as the secret.

Cloud Run with Direct VPC egress

resource "google_service_account" "api" { account_id = "api-service" display_name = "API service on Cloud Run" } resource "google_secret_manager_secret_iam_member" "api_redis_auth" { secret_id = google_secret_manager_secret.redis_auth.id role = "roles/secretmanager.secretAccessor" member = "serviceAccount:${google_service_account.api.email}" } resource "google_cloud_run_v2_service" "api" { name = "api" location = var.region template { service_account = google_service_account.api.email # Direct VPC egress: only private ranges go through the VPC. vpc_access { network_interfaces { network = google_compute_network.main.id subnetwork = google_compute_subnetwork.run.id } egress = "PRIVATE_RANGES_ONLY" } containers { image = var.image env { name = "REDIS_ADDR" value = "${google_redis_instance.sessions.host}:${google_redis_instance.sessions.port}" } env { name = "REDIS_READ_ADDR" value = "${google_redis_instance.sessions.read_endpoint}:${google_redis_instance.sessions.read_endpoint_port}" } env { name = "REDIS_CA_PEM" value = google_redis_instance.sessions.server_ca_certs[0].cert } env { name = "REDIS_AUTH" value_source { secret_key_ref { secret = google_secret_manager_secret.redis_auth.secret_id version = "latest" } } } } } depends_on = [google_secret_manager_secret_iam_member.api_redis_auth] }

Memorystore has no public IP, and Google recommends Direct VPC egress over a Serverless VPC Access connector for reaching it from Cloud Run, in its Cloud Run connection guide. PRIVATE_RANGES_ONLY sends only private destinations through the VPC, so calls to public APIs keep using Cloud Run's default egress. The application still has to build a TLS config from REDIS_CA_PEM, send AUTH with the secret, and route reads to REDIS_READ_ADDR. The configuration passes terraform validate with Terraform 1.13 and the Google provider 7.x.

What still takes work after the first apply

  • Client code has to load the CA certificate, handle the AUTH string, and split reads and writes between two endpoints.
  • CA rotation means every client has to pick up the new certificate from server_ca_certs before the old authority is retired, so the CA should not be baked into an image.
  • Local development needs a Redis on each laptop configured without TLS and AUTH, so the production connection path is first exercised after deploy.
  • Every additional cache repeats the instance, secret and IAM resources, and services that share one instance share its memory and eviction policy.

How does Encore provision Memorystore from code?

Encore

Encore is an infrastructure-from-code platform for TypeScript and Go: a cache cluster is declared in the service that uses it and the Encore platform provisions it as its own Redis instance in your GCP project when you deploy. The GCP infrastructure docs describe Memorystore for Redis in a high-availability setup, starting from the smallest size and able to scale up, with Redis AUTH credentials managed and rotated by the platform, and services running on Cloud Run in a private network.

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 login --example=hello-world$ cd login && mkdir auth

Email login codes are a good fit for a cache with expiry: each code lives for ten minutes, is used once, and a counter limits guesses. The cluster and two typed keyspaces replace the instance, secret, CA and IAM resources above:

// auth/codes.go package auth import ( "context" "crypto/rand" "crypto/sha256" "crypto/subtle" "encoding/hex" "errors" "fmt" "math/big" "strings" "time" "encore.dev/beta/errs" "encore.dev/storage/cache" ) // Memorystore for Redis on GCP, ElastiCache on AWS, in-memory Redis locally. var cluster = cache.NewCluster("auth-cache", cache.ClusterConfig{ EvictionPolicy: cache.NoEviction, // login codes must not be evicted early }) // Hashed login codes, keyed by email, gone after 10 minutes. var codes = cache.NewStringKeyspace[string](cluster, cache.KeyspaceConfig{ KeyPattern: "login-code/:key", DefaultExpiry: cache.ExpireIn(10 * time.Minute), }) // Failed verification attempts per email in a fixed 10-minute window. // The window is part of the key, so later requests cannot extend the lockout. var attempts = cache.NewIntKeyspace[string](cluster, cache.KeyspaceConfig{ KeyPattern: "login-attempts/:key", DefaultExpiry: cache.ExpireIn(10 * time.Minute), }) // Code sends per email in the same fixed window, independent of verifies. var sends = cache.NewIntKeyspace[string](cluster, cache.KeyspaceConfig{ KeyPattern: "login-sends/:key", DefaultExpiry: cache.ExpireIn(10 * time.Minute), }) func windowKey(email string) string { return email + "/" + time.Now().UTC().Truncate(10*time.Minute).Format("2006-01-02T15:04") } type RequestParams struct { Email string `json:"email"` } //encore:api public method=POST path=/auth/code func RequestCode(ctx context.Context, p *RequestParams) error { email := strings.ToLower(strings.TrimSpace(p.Email)) key := windowKey(email) sent, err := sends.Increment(ctx, key, 1) if err != nil { return err } if sent > 3 { return &errs.Error{Code: errs.ResourceExhausted, Message: "try again later"} } n, err := rand.Int(rand.Reader, big.NewInt(1_000_000)) if err != nil { return err } code := fmt.Sprintf("%06d", n.Int64()) // Replace the previous unused code, but leave the attempt counter in place. if err := codes.Set(ctx, email, hash(code)); err != nil { return err } return sendCode(ctx, email, code) } type VerifyParams struct { Email string `json:"email"` Code string `json:"code"` } //encore:api public method=POST path=/auth/verify func VerifyCode(ctx context.Context, p *VerifyParams) error { email := strings.ToLower(strings.TrimSpace(p.Email)) key := windowKey(email) n, err := attempts.Increment(ctx, key, 1) if err != nil { return err } if n > 5 { return &errs.Error{Code: errs.ResourceExhausted, Message: "too many attempts, try again later"} } want, err := codes.Get(ctx, email) if errors.Is(err, cache.Miss) { return &errs.Error{Code: errs.Unauthenticated, Message: "code expired or invalid"} } else if err != nil { return err } if subtle.ConstantTimeCompare([]byte(want), []byte(hash(p.Code))) != 1 { return &errs.Error{Code: errs.Unauthenticated, Message: "code expired or invalid"} } // Single use: remove the code once it has been accepted. _, err = codes.Delete(ctx, email) return err } func hash(code string) string { sum := sha256.Sum256([]byte(code)) return hex.EncodeToString(sum[:]) } // sendCode hands the code to your email provider. func sendCode(ctx context.Context, email, code string) error { return nil }

NoEviction makes Redis return errors when memory is full instead of dropping a code before it expires, and each keyspace's DefaultExpiry sets the TTL on every write. Get returns an error matching cache.Miss once a code has expired, and sendCode is where your email provider's client goes. The caching docs cover the other keyspace types and eviction policies.

encore run runs the cache in memory on your machine, so the request, verify and lockout paths can be tried locally with the same code that runs against Memorystore, with no TLS config or AUTH string in the application. When a coding agent adds a keyspace, for example to remember trusted devices, the change is a few lines in this file and the next deploy needs nothing new in the cloud. The instance size and tier can be adjusted per environment afterwards, as described in the infrastructure configuration docs, and the same code becomes ElastiCache if you connect an AWS account, per the AWS infrastructure docs.

Best fit: Go and TypeScript teams on GCP who want a replicated Memorystore instance next to their Cloud Run services, created and connected from the code that reads and writes it.

Good to know

  • google_cloud_run_v2_service defaults to deletion_protection = true in the Google provider, so a terraform destroy fails until you set it to false and apply.
  • Scaling memory_size_gb on a Standard Tier instance causes a short interruption, under a minute according to Google's overview, so schedule it like maintenance.
  • RDB snapshots restore the cache after a failure, but a cache that can lose data entirely, such as rendered pages, can skip persistence_config to avoid snapshot overhead.

Frequently asked questions

How do I create a Memorystore for Redis instance with Terraform?

Use google_redis_instance with a name, tier, memory_size_gb, region and authorized_network. For production, set tier = "STANDARD_HA" for a cross-zone replica with automatic failover, auth_enabled = true, transit_encryption_mode = "SERVER_AUTHENTICATION", and connect_mode = "PRIVATE_SERVICE_ACCESS" with a google_service_networking_connection created first.

What is the difference between DIRECT_PEERING and PRIVATE_SERVICE_ACCESS for Memorystore?

DIRECT_PEERING, the default, has Memorystore create a VPC peering between your network and a Google-managed network. PRIVATE_SERVICE_ACCESS takes the instance IP from a range you reserve for Google services and reuses the same service networking connection as Cloud SQL, and Google requires it for instances on a Shared VPC network.

How does Cloud Run connect to Memorystore for Redis?

Memorystore has only a private IP, so the Cloud Run service needs Direct VPC egress or a Serverless VPC Access connector on the instance's network. Google recommends Direct VPC egress. The service then connects to the instance host and port with the AUTH string and, when in-transit encryption is on, the server CA certificate.

How do I provision Memorystore for Redis from Go code?

With Encore, declare cache.NewCluster("auth-cache", cache.ClusterConfig{EvictionPolicy: cache.NoEviction}) and typed keyspaces such as cache.NewStringKeyspace and cache.NewIntKeyspace. On deploy to GCP, Encore provisions Memorystore for Redis in a high-availability setup with AUTH credentials it manages and rotates, and under encore run the cache runs in memory.

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