Nov 13, 20268 min read

Google Secret Manager with Terraform: Secrets, Versions and Access

Manage Google Secret Manager with Terraform while keeping values out of state: google_secret_manager_secret, write-only secret_data_wo with an ephemeral password, per-secret IAM for a Cloud Run service, replication and rotation, and how Encore declares secrets in Go code.

Illustration for Google Secret Manager with Terraform: Secrets, Versions and Access

Terraform can create Google Secret Manager secrets, their versions and the IAM bindings that let a service read them, but the obvious way to set a value, secret_data on google_secret_manager_secret_version, writes that value into the Terraform state file in plain text. Anyone who can read the state bucket can then read every secret it manages, which turns state access into secret access.

This guide builds a Secret Manager setup for a billing service on Cloud Run with an API key set outside Terraform, a generated signing key that never reaches state, and read access granted per secret. It is validated with Terraform 1.13 and the Google provider 8.10, followed by the same service in Encore, where the secrets are declared in the Go code.

What a Secret Manager Terraform setup needs

PieceResourceNotes
APIgoogle_project_servicesecretmanager.googleapis.com has to be enabled once per project
Secretgoogle_secret_manager_secretThe name, replication policy, labels and rotation schedule, with no value
Valuegoogle_secret_manager_secret_versionIdeally through secret_data_wo, or added outside Terraform
Accessgoogle_secret_manager_secret_iam_memberroles/secretmanager.secretAccessor on each secret for the service account
Consumergoogle_cloud_run_v2_servicevalue_source.secret_key_ref maps a secret version to an environment variable

The full configuration

terraform { required_version = ">= 1.11" required_providers { google = { source = "hashicorp/google", version = "~> 8.10" } random = { source = "hashicorp/random", version = "~> 3.7" } } } variable "project_id" { type = string } variable "region" { type = string default = "europe-west1" } variable "image" { type = string } variable "signing_key_version" { type = string default = "1" description = "Change this to write a new signing key." } provider "google" { project = var.project_id region = var.region } resource "google_project_service" "secretmanager" { service = "secretmanager.googleapis.com" } # A secret whose value is set outside Terraform, such as a third-party API key. resource "google_secret_manager_secret" "stripe_key" { secret_id = "stripe-api-key" replication { auto {} } labels = { service = "billing" } deletion_protection = true depends_on = [google_project_service.secretmanager] } # Generated during apply and never written to state (Terraform 1.11+). ephemeral "random_password" "signing_key" { length = 48 special = false } resource "google_secret_manager_secret" "signing_key" { secret_id = "webhook-signing-key" replication { user_managed { replicas { location = var.region } } } depends_on = [google_project_service.secretmanager] } resource "google_secret_manager_secret_version" "signing_key" { secret = google_secret_manager_secret.signing_key.id secret_data_wo = ephemeral.random_password.signing_key.result secret_data_wo_version = var.signing_key_version } # The identity the Cloud Run service runs as. resource "google_service_account" "billing" { account_id = "billing-run" display_name = "billing Cloud Run service" } # Read access on these two secrets only, not on every secret in the project. resource "google_secret_manager_secret_iam_member" "billing_access" { for_each = { stripe = google_secret_manager_secret.stripe_key.secret_id signing = google_secret_manager_secret.signing_key.secret_id } secret_id = each.value role = "roles/secretmanager.secretAccessor" member = "serviceAccount:${google_service_account.billing.email}" } resource "google_cloud_run_v2_service" "billing" { name = "billing" location = var.region template { service_account = google_service_account.billing.email containers { image = var.image env { name = "STRIPE_API_KEY" value_source { secret_key_ref { secret = google_secret_manager_secret.stripe_key.secret_id version = "latest" } } } env { name = "WEBHOOK_SIGNING_KEY" value_source { secret_key_ref { secret = google_secret_manager_secret.signing_key.secret_id version = google_secret_manager_secret_version.signing_key.version } } } } } depends_on = [google_secret_manager_secret_iam_member.billing_access] }

Secrets whose value is set outside Terraform

A third-party API key arrives from a vendor dashboard, so Terraform should own the secret and its access but not the value. The stripe_key secret has no version resource, and the value goes in from a terminal or a CI job:

~
$ printf '%s' "$STRIPE_KEY" | gcloud secrets versions add stripe-api-key --data-file=-

Terraform never sees the value, and a new version from the same command is how the key gets rotated.

Generated secrets with write-only values

For a value Terraform should generate, such as a webhook signing key, the configuration pairs an ephemeral "random_password" with secret_data_wo. Both need Terraform 1.11 or later, as the write-only arguments docs explain. The password exists only during the run, and Terraform stores secret_data_wo_version instead of the value, so it writes the secret on creation and again whenever you change that variable.

secret_data_wo_version is a string as of Google provider 8.0, where the provider changelog also made it required whenever secret_data_wo is set.

Read access for a Cloud Run service

The Secret Manager access docs let you grant roles/secretmanager.secretAccessor on a project or on a single secret. The for_each in the configuration grants it on the two secrets the billing service reads, which keeps a new secret for another team out of reach of this service.

On the Cloud Run side, the Cloud Run secrets docs note that environment variables are resolved at instance startup and recommend pinning a version for them. The signing key above is pinned to the version Terraform wrote, so changing the key rolls out as a new revision. The Stripe key reads latest for simplicity, and if you pin it too, adding a version with gcloud takes effect once you update the revision to that version number.

Replication, deletion and rotation

  • Replication: auto {} lets Google choose where the payload is stored and is the default for gcloud and the console, per the replication docs. user_managed pins the payload to the regions you list, as the signing key does here for a data residency requirement.
  • Deletion: the secret resource has a deletion_protection argument that defaults to false, so the configuration sets it to true on the Stripe key, whose loss would break production. The version_destroy_ttl argument delays the destruction of old versions, which leaves time to notice a mistake.
  • Rotation: a rotation block with rotation_period makes Secret Manager publish a SECRET_ROTATE message to a Pub/Sub topic listed in topics. As the rotation docs state, you must run a subscriber that creates the new value, since Secret Manager only sends the reminder. The Secret Manager service agent also needs roles/pubsub.publisher on that topic.

What stays manual

  • Every new secret needs a secret resource, an IAM binding per consumer and an environment variable on each Cloud Run service that reads it.
  • Local development needs its own source for the same values, such as a .env file that is easy to commit by accident.
  • A service account that reads a secret it no longer uses keeps that access until someone removes the binding.

How does Encore handle secrets from code?

Encore

Encore works as infrastructure from code for TypeScript and Go backends. A secret is declared in the service that uses it, the value is set per environment through the CLI or dashboard, and when you deploy to your own GCP account Encore stores it in Secret Manager and injects it into the services that declare it, as the GCP infrastructure docs describe.

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

The billing service from the Terraform example, with the Stripe key and the webhook signing key declared where they are used:

// billing/billing.go package billing import ( "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "net/http" "encore.dev/beta/errs" "encore.dev/rlog" ) // Values are set per environment with `encore secret set`, never in the repo. // On GCP they are stored in Secret Manager in your project. var secrets struct { StripeAPIKey string // restricted key for reading customers WebhookSigningKey string // shared with the partner that calls the webhook } // PartnerWebhook accepts events signed with HMAC-SHA256 over the raw body. // //encore:api public raw method=POST path=/webhooks/partner func PartnerWebhook(w http.ResponseWriter, req *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, req.Body, 1<<20)) if err != nil { http.Error(w, "body too large", http.StatusRequestEntityTooLarge) return } mac := hmac.New(sha256.New, []byte(secrets.WebhookSigningKey)) mac.Write(body) want := mac.Sum(nil) got, err := hex.DecodeString(req.Header.Get("X-Signature")) if err != nil || !hmac.Equal(got, want) { http.Error(w, "invalid signature", http.StatusUnauthorized) return } rlog.Info("partner event accepted", "bytes", len(body)) w.WriteHeader(http.StatusNoContent) } type Customer struct { ID string `json:"id"` Email string `json:"email"` } // GetCustomer reads one customer from Stripe with the restricted key. // //encore:api private method=GET path=/billing/customers/:id func GetCustomer(ctx context.Context, id string) (*Customer, error) { req, err := http.NewRequestWithContext(ctx, "GET", "https://api.stripe.com/v1/customers/"+id, nil) if err != nil { return nil, err } req.Header.Set("Authorization", "Bearer "+secrets.StripeAPIKey) resp, err := http.DefaultClient.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, &errs.Error{Code: errs.Unavailable, Message: fmt.Sprintf("stripe returned %d", resp.StatusCode)} } var c Customer if err := json.NewDecoder(resp.Body).Decode(&c); err != nil { return nil, err } return &c, nil }
~/payments
$ encore secret set --type prod StripeAPIKey$ encore secret set --type dev,preview,local StripeAPIKey$ encore secret set --type prod,dev,preview,local WebhookSigningKey

Secret values stay out of the repository and Terraform state, and a coding agent working in the code sees the field names but not the values. A missing secret produces a warning under encore run, and a deploy to a cloud environment fails until every secret the code declares has a value, as the secrets docs explain. For a local value that differs from the shared one, a .secrets.local.cue file next to encore.app overrides it on your machine only.

Under encore run, a correctly signed POST /webhooks/partner returns 204, and a missing or wrong X-Signature returns 401. When a coding agent later adds an integration that needs a new key, the pull request contains the new field on the secrets struct, a human sets the value with encore secret set, and the next deploy gives the service access to it.

Best fit: TypeScript and Go teams on GCP who want secrets in Secret Manager, readable only by the services that use them, declared next to the code that reads them.

Good to know

  • A secret version payload can be at most 64 KiB, the same limit the provider documents for secret_data.
  • Disabling a version is reversible and destroying one is not, so disable first when retiring a key.
  • gcloud secrets versions access latest --secret=stripe-api-key prints a value, and it needs the same accessor role as the service.

Frequently asked questions

How do I keep Secret Manager values out of Terraform state?

On Terraform 1.11 or later, set secret_data_wo on google_secret_manager_secret_version instead of secret_data, and change secret_data_wo_version whenever the value should change. Generate the value with an ephemeral random_password, or create the secret without a version in Terraform and add the value with gcloud secrets versions add.

How do I give a Cloud Run service access to one secret in Terraform?

Add a google_secret_manager_secret_iam_member on that secret with role roles/secretmanager.secretAccessor and the Cloud Run service account as the member. Granting the role at project level instead gives the service every secret in the project, including ones created later.

Should Cloud Run read the latest secret version or a pinned one?

Secrets exposed as environment variables are resolved when an instance starts, so Google recommends pinning a version for them. A pinned version means a new value only reaches the service through a new revision, which you can roll out and roll back like any other change.

How does Encore manage Google Secret Manager?

Declare the secret as a field on a secrets struct in the Go service that uses it and set the value per environment with encore secret set. When the app deploys to your GCP account, Encore stores the value in Secret Manager and injects it into that service. Secret values are not stored in the repository or in Terraform state.

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