How-to › Operate integrations in production

How to give a Cloud Run service a secret at deploy or at run time#

Mount a Secret Manager secret with --set-secrets, read it at run time with the client library or through sekreto, and see which path picks up a new version and when.

Audience
API consumer
Level
intermediate
Topic
Load credentials and secrets
Languages
TypeScript
Verified

Your Cloud Run service needs a payment provider’s key that sits in Secret Manager. The first deploy fails with a permission error on the secret. After a rotation, some instances serve the new key and some the old, because the variable was resolved when each instance started. Which path reads the secret decides when a new version arrives, and the three paths differ.

What you get

You will end up with the three ways a Cloud Run service reads one secret, side by side before and after a new version, and the IAM binding each needs. This is for you if your service calls an API with a key in Secret Manager.

Short answer

Mount the secret with gcloud run deploy --set-secrets, as an environment variable that Cloud Run resolves when an instance starts or as a file it fetches on every read, with no client code. Read it at run time with accessSecretVersion when each call must see latest or a pinned version, or through sekreto’s gcpsecrets provider when the same code must run off GCP. Grant the service identity roles/secretmanager.secretAccessor on the secret first.

You will need

Node 22 or later, a Cloud Run service running as a service account of its own, a secret in Secret Manager, and the gcloud CLI for the deploy. Verified 2026-10-21 against Node 22.22.0, @google-cloud/secret-manager 7.2.0, google-auth-library 11.3.1, and @voxgig/sekreto 0.3.0. Nothing here reaches GCP. Secret Manager and the metadata server are one Node stand-in on loopback that speaks the REST API. It checks a bearer token against the bindings on each secret and answers with the errors Google’s API sends. Cloud Run itself is a model written from its documentation: a variable resolved when an instance starts, a volume fetched on read, and a deploy that checks access first.

Approaches compared

ApproachWhen it fitsWhat it costs youWhen to pick something else
Cloud Run —set-secretsA key pinned to a version and rolled with a revision, read from the environment or a fileNo client code, but a variable frozen at instance start, a file fetched on every read, and a deploy that fails when the identity lacks the roleThe service must pick up a rotation on a running instance without reading a file
@google-cloud/secret-managerA service that must see a new version without a redeploy, or must name a version per callA dependency with gRPC and protobuf in the image, a call and a failure mode on every cold start, and a checksum you verify yourselfThe value changes once a quarter and a revision per rotation is acceptable
sekretoThe same code runs on Cloud Run, on a laptop with a .env file, and elsewhere, naming the secret and never the storelatest only, no dataCrc32c check, a cache with no TTL until you call refresh, and a Google error reported as a status codeThe service never leaves GCP, or needs a version by number

Voxgig maintains sekreto. This page compares it with Cloud Run’s deploy-time mounts and the @google-cloud/secret-manager client.

The deploy-time mount is the cheapest path: no code, no dependency, and a value Cloud Run fetched for you. Its price is the resolution point, which the documentation fixes at instance start for a variable and at read time for a volume. The client library sees any version on any call, at the price of gRPC and protobuf in the image and a network call at cold start. sekreto reads latest and caches it, so a rotation waits for refresh, and in exchange the handler names a secret rather than Google.

Give the service identity the role it needs

A Cloud Run service runs as a service account, and that account needs secretmanager.versions.access on each secret it reads. The access control page puts that permission in roles/secretmanager.secretAccessor and in Owner, and says Editor and Viewer do not have it. The default compute account, which a deploy with no --service-account gets, may hold Editor. That is why the service identity page says to avoid it and grant a user-managed account the least it needs.1 Grant the role on the secret, not the project, so the identity reads that secret and no other.

gcloud secrets add-iam-policy-binding billco_api_key \
  --member serviceAccount:billing-run@billco-prod.iam.gserviceaccount.com \
  --role roles/secretmanager.secretAccessor

gcloud run deploy billing \
  --image europe-west1-docker.pkg.dev/billco-prod/apps/billing:2026.10 \
  --service-account billing-run@billco-prod.iam.gserviceaccount.com \
  --set-secrets=API_KEY=billco_api_key:1,/secrets/api_key=billco_api_key:latest

The binding comes first, because the deploy checks it. Cloud Run’s secrets page says that at deployment it checks the service account can access every secret named, so a missing role fails the revision rather than the first request.

Mount the secret at deploy time

A key that starts with / is a mount path and any other key is an environment variable; the value is SECRET:VERSION, where the version is a number or latest. The two mounts resolve at different moments, and the documentation states both. Variables “are resolved at instance startup time,” and for a volume “Cloud Run always fetches the secret value from Secret Manager” when the file is read. The model here does what those two sentences say.

export async function startInstance(standin, revision) {
  const env = {}
  for (const [key, ref] of Object.entries(revision.secrets)) {
    if (!key.startsWith('/')) env[key] = await access(standin, revision.account, ref)
  }
  return {
    revision: revision.name,
    env,
    readFile: (path) => access(standin, revision.account, revision.secrets[path]),
  }
}

The deploy itself does not read the secret into the revision; an instance does, when it starts. Two instances of one revision can therefore hold two versions of a variable mounted as latest, which is why the same page recommends pinning a version for variables. A configuration change, a new pin included, creates a new revision.2

Read the version at run time

The client library asks Secret Manager on each call, so it sees latest move and can name a version by number. On Cloud Run new SecretManagerServiceClient() needs no options: it finds the metadata server and the service identity’s token itself.3 The options here point it at the stand-in over plain HTTP with a token the stand-in minted.

export function runtimeClient({ addr, token }) {
  const authClient = new OAuth2Client()
  authClient.setCredentials({ access_token: token, expiry_date: Date.now() + 3600_000 })
  const url = new URL(addr)
  return new SecretManagerServiceClient({
    fallback: 'rest',
    protocol: url.protocol.replace(':', ''),
    apiEndpoint: url.hostname,
    port: Number(url.port),
    authClient,
  })
}

export async function readVersion(client, name) {
  const [version] = await client.accessSecretVersion({ name })
  const data = Buffer.from(version.payload.data)
  if (crc32c(data) !== Number(version.payload.dataCrc32c)) throw new Error('Data corruption detected.')
  return { name: version.name, value: data.toString('utf8') }
}

dataCrc32c comes back beside the bytes. The data integrity page says to use it to verify that the data you received is what is stored, and the library leaves that check to you.4 sekreto takes the other position: a name, billco.api_key, flattened to the secret id billco_api_key, always latest, and a token from the metadata server unless one is configured. It skips the checksum.

export function chainFor({ addr, metadataaddr, project }) {
  return new Sekreto({
    plugins: [gcpsecrets],
    providers: [{ kind: 'gcpsecrets', project, addr, metadataaddr }],
  })
}
node compare.mjs
deployed billing-00001-run as billing-run@billco-prod.iam.gserviceaccount.com
  API_KEY=billco_api_key:latest as an environment variable, /secrets/api_key=billco_api_key:latest as a volume

one instance running, before the new version
  path                                              version  value
  env var, resolved when the instance started       1        sk_live...llc0
  volume file, fetched on each read                 1        sk_live...llc0
  client library, versions/latest                   1        sk_live...llc0
  client library, versions/1                        1        sk_live...llc0
  sekreto get('billco.api_key')                     hidden   sk_live...llc0

version 2 added, nothing redeployed
  path                                              version  value
  env var, in the instance that was running         1        sk_live...llc0
  env var, in an instance started after version 2   2        sk_live...llc1
  volume file, fetched on each read                 2        sk_live...llc1
  client library, versions/latest                   2        sk_live...llc1
  client library, versions/1                        1        sk_live...llc0
  sekreto get('billco.api_key')                     hidden   sk_live...llc0
  sekreto refresh(), then get('billco.api_key')     hidden   sk_live...llc1

deploy the same mounts as the default compute service account, which has no binding on the secret
  deploy failed: Permission denied on secret billco_api_key for the revision service account 123456789012-compute@developer.gserviceaccount.com. Grant it roles/secretmanager.secretAccessor on the secret. (PERMISSION_DENIED: Permission 'secretmanager.versions.access' denied for resource 'projects/billco-prod/secrets/billco_api_key/versions/latest' (or it may not exist).)

Read the second table against the first. The variable in the running instance kept version 1 while a fresh instance resolved version 2. The file moved on its next read. latest moved and versions/1 stayed. sekreto moved only after refresh(), and it reports no version at all. The last block is the deploy check refusing the default compute account.

Check it worked

Eight tests pin the model, the two clients, and the script. They cover the checksum against its published check value, the deploy check naming the role, and the variable frozen at start against the file read live. They cover the library’s latest, its pinned read, and its refusal of a wrong checksum, then sekreto’s flattening, cache, and refresh. Two more cover the refusal of an unbound identity by both clients, and a newest version in the DISABLED state failing latest while a pinned version still reads. The last checks that deploy.sh grants the binding before it deploys.

test('an environment variable is resolved when the instance starts; a volume is read live', async () => {
  const revision = await deploy(standin, { service: 'billing', account, secrets: { API_KEY: 'billco_api_key:latest', '/secrets/api_key': 'billco_api_key:latest' } })
  const running = await startInstance(standin, revision)
  assert.equal(running.env.API_KEY.value, V1)

  await standin.admin({ op: 'addVersion', secret: 'billco_api_key', value: V2 })

  assert.equal(running.env.API_KEY.value, V1, 'the running instance keeps what it resolved at start')
node --test secrets.test.mjs
1..8
# tests 8
# suites 0
# pass 8
# fail 0
# cancelled 0
# skipped 0
# todo 0

When it goes wrong

The deploy fails with Permission denied on secret and the revision never serves. The service account has no binding on the secret, or it is the default compute account. Grant roles/secretmanager.secretAccessor on the secret to the account the message names, and deploy again.

Some instances serve the new key and some the old. The variable was mounted as latest, and each instance resolved it when it started:

node pitfall.mjs
API_KEY=billco_api_key:latest
  billing-00001-run, instance started before version 2  1   sk_live...llc0
  billing-00001-run, instance started after version 2   2   sk_live...llc1
  two instances of billing-00001-run serve different keys, with no deploy in between

API_KEY=billco_api_key:1, then a revision pinned to version 2
  billing-00002-run, first instance                     1   sk_live...llc0
  billing-00002-run, second instance                    1   sk_live...llc0
  billing-00003-run, first instance                     2   sk_live...llc1
  every instance of a revision serves the version the revision names

Pin the version and roll a revision per rotation, so every instance of a revision agrees, or mount a volume and read the file again when the provider answers 401.

Every read through sekreto fails with sekreto: gcp error: 403 and the URL. The provider reports the status and drops Google’s message, which names the permission and the resource. The client library’s error carries PERMISSION_DENIED and secretmanager.versions.access; run the one read through it, or check the binding with gcloud secrets get-iam-policy.

When not to do this

Do not mount a secret as an environment variable with latest and expect a rotation to reach running instances. The variable is resolved when an instance starts, so a rotation lands on new instances only, and two instances of one revision can disagree. Pin a version and deploy a revision per rotation, or use a volume.

Do not reach for sekreto on a service that only ever runs on Cloud Run. The deploy-time mount needs no code and no dependency, and the client library sees every version. sekreto’s case is a handler that reads billco.api_key from a .env file on a laptop and from Secret Manager in production without a change. That is the twelve-factor separation of config from code, with the store swapped underneath.

Do not grant roles/secretmanager.secretAccessor on the project to make a deploy pass. The access control page says to apply permissions at the lowest level, and a project-wide grant lets one leaked token read every secret.

Do not skip the checksum because the library hands you bytes. The library leaves the check to the caller. Google’s Go, Java, and Python samples make it before using the value, but its Node.js sample does not, and neither does sekreto.

Last verified

Verified 2026-10-21 against Node 22.22.0, @google-cloud/secret-manager 7.2.0, google-auth-library 11.3.1, and @voxgig/sekreto 0.3.0. Every output block is what the command preceding it printed. Secret Manager and the metadata server were a Node stand-in on 127.0.0.1 speaking the REST API. Cloud Run did not run: revision.mjs models the deploy check, the variable resolved at instance start, and the volume fetched on read, each from the documentation’s statement of it. The status the stand-in returns for a version in the DISABLED state is modeled, not observed. No GCP project was used.

Footnotes

  1. The access control page says roles/owner includes secretmanager.versions.access while roles/editor and roles/viewer do not. The service identity page adds that the default compute account “might automatically be granted the Editor role on your project,” depending on organization policy. An organization created after May 3, 2024 has the constraint against that grant enforced by default. Either way, the account cannot read a secret until somebody grants it roles/secretmanager.secretAccessor. ↩︎ Back to text

  2. The pricing page bills active versions by the hour, $0.000082192 per version per hour after the first six, which comes to $0.06 a month. Access operations cost $0.03 per 10,000 after the first 10,000. A variable resolved at instance start costs one access per instance; a volume fetched on every read costs one per read. ↩︎ Back to text

  3. The token comes from the metadata server, which the querying metadata page reaches at metadata.google.internal or 169.254.169.254, and which denies any request that lacks the header Metadata-Flavor: Google. sekreto’s provider sends the header, asks for the default account’s token, and renews it a minute before the expires_in it was given runs out. ↩︎ Back to text

  4. CRC32C is the 32-bit CRC with the generator 0x11edc6f41. RFC 3720 made it mandatory for every iSCSI initiator and target in April 2004, as a digest with “error detection significance” only. Secret Manager returns it as dataCrc32c. The data integrity page notes that the value is an int64 in the proto and arrives in JSON as a decimal string, which is why the comparison in readVersion goes through Number. ↩︎ Back to text

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Listen to Fireside, the Voxgig podcast

Since 2018, Richard Rodger has talked with developer advocates, event organizers, and the people who build developer-facing companies about the specifics of the work.