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
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Cloud Run —set-secrets | A key pinned to a version and rolled with a revision, read from the environment or a file | No 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 role | The service must pick up a rotation on a running instance without reading a file |
| @google-cloud/secret-manager | A service that must see a new version without a redeploy, or must name a version per call | A dependency with gRPC and protobuf in the image, a call and a failure mode on every cold start, and a checksum you verify yourself | The value changes once a quarter and a revision per rotation is acceptable |
| sekreto | The same code runs on Cloud Run, on a laptop with a .env file, and elsewhere, naming the secret and never the store | latest only, no dataCrc32c check, a cache with no TTL until you call refresh, and a Google error reported as a status code | The 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.
Related how-tos
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
-
The access control page says
roles/ownerincludessecretmanager.versions.accesswhileroles/editorandroles/viewerdo 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 itroles/secretmanager.secretAccessor. ↩︎ Back to text -
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
-
The token comes from the metadata server, which the querying metadata page reaches at
metadata.google.internalor169.254.169.254, and which denies any request that lacks the headerMetadata-Flavor: Google. sekreto’s provider sends the header, asks for the default account’s token, and renews it a minute before theexpires_init was given runs out. ↩︎ Back to text -
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 asdataCrc32c. 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 inreadVersiongoes throughNumber. ↩︎ Back to text