# 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.

Source: https://voxgig.com/howto/gcp-secret-manager-in-a-cloud-run-service

- Audience: api-consumer
- Level: intermediate
- Languages: typescript
- Verified: 2026-10-21
- Published: 2026-10-19

## 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](https://docs.cloud.google.com/run/docs/configuring/services/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](https://docs.cloud.google.com/nodejs/docs/reference/secret-manager/latest) | 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](https://github.com/voxgig/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](https://docs.cloud.google.com/secret-manager/docs/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](https://docs.cloud.google.com/run/docs/securing/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.

```bash title="deploy.sh"
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](https://docs.cloud.google.com/run/docs/configuring/services/secrets) 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.

```js title="revision.mjs"
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.

```js title="runtime.mjs"
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](https://docs.cloud.google.com/secret-manager/docs/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.

```js title="chain.mjs"
export function chainFor({ addr, metadataaddr, project }) {
  return new Sekreto({
    plugins: [gcpsecrets],
    providers: [{ kind: 'gcpsecrets', project, addr, metadataaddr }],
  })
}
```

```bash
node compare.mjs
```

```text output
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.

```js title="secrets.test.mjs"
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')
```

```bash
node --test secrets.test.mjs
```

```text output
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:

```bash
node pitfall.mjs
```

```text output
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](https://12factor.net/config) 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

- [Load secrets in Lambda without a fetch per invocation](/howto/cache-lambda-secrets-across-invocations)

- [Keep test and live API keys from crossing environments](/howto/keep-test-and-live-keys-apart)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

## 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.

[^1]: The [access control](https://docs.cloud.google.com/secret-manager/docs/access-control)
page says `roles/owner` includes `secretmanager.versions.access` while `roles/editor` and
`roles/viewer` do not. The [service identity](https://docs.cloud.google.com/run/docs/securing/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`.

[^2]: The [pricing page](https://cloud.google.com/secret-manager/pricing) 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.

[^3]: The token comes from the metadata server, which the
[querying metadata](https://docs.cloud.google.com/compute/docs/metadata/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.

[^4]: CRC32C is the 32-bit CRC with the generator `0x11edc6f41`.
[RFC 3720](https://www.rfc-editor.org/rfc/rfc3720.html) 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](https://docs.cloud.google.com/secret-manager/docs/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`.