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.

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.
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]
}
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:
Terraform never sees the value, and a new version from the same command is how the key gets rotated.
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.
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.
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_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 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..env file that is easy to commit by accident.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.

Run in your terminal to get started locally.
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
}
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.
secret_data.gcloud secrets versions access latest --secret=stripe-api-key prints a value, and it needs the same accessor role as the service.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.
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.
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.
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.