Secret Manager & KMS
Plain config goes in env. Credentials go in Secret Manager, which the
service reads while it runs. Data you need to encrypt goes through
Cloud KMS, so your code never holds a key.
Plain config
Section titled “Plain config”Values that aren’t secret go on the service as environment variables:
{ main: import.meta.url, env: { LOG_LEVEL: "info" } }The service reads process.env.LOG_LEVEL.
Values from a secrets manager
Section titled “Values from a secrets manager”Config values, plain or secret, come from your .env by default. A Stack’s secrets option reads the same Config values from a secrets manager instead, like Doppler or Infisical, so production keys never sit in a file on anyone’s machine. Pick the source per stage:
import * as Alchemy from "alchemy";import * as GCP from "@alchemy.run/gcp";import * as Doppler from "@alchemy.run/doppler";import * as Effect from "effect/Effect";import Api from "./src/api.ts";
export default Alchemy.Stack( "MyApp", { providers: GCP.providers(), state: GCP.state(), secrets: ({ stage }) => stage === "prod" ? Doppler.Secrets({ project: "my-app", config: "prd" }) : Alchemy.Secrets.DotEnv(), }, Effect.gen(function* () { const api = yield* Api; return { url: api.uri }; }),);Your code doesn’t change: Config.Redacted("API_KEY") reads from whichever source the stage uses. See Secret providers for Doppler, Infisical, ordering several sources, and writing your own.
Create a secret
Section titled “Create a secret”import * as GCP from "@alchemy.run/gcp";
export const ApiKey = GCP.SecretManager.Secret("ApiKey", {});Alchemy creates the secret, not its value. Add the value yourself, so it never passes through your Stack or deploy logs:
printf 'my-key' | gcloud secrets versions add "$SECRET_ID" --data-file=-Return ApiKey.secretId from your Stack to get $SECRET_ID.
Read it per request
Section titled “Read it per request”ReadSecret returns access(), which reads the latest version as a
string:
import * as GCP from "@alchemy.run/gcp";import * as Effect from "effect/Effect";import { HttpServerRequest } from "effect/http/HttpServerRequest";import * as HttpServerResponse from "effect/http/HttpServerResponse";import { ApiKey } from "./resources.ts";
export default class Api extends GCP.Function<Api>()( "Api", { main: import.meta.url, invokerIamDisabled: true }, Effect.gen(function* () { const key = yield* GCP.SecretManager.ReadSecret(ApiKey);
return { fetch: Effect.gen(function* () { const request = yield* HttpServerRequest; const expected = yield* key.access();
if (expected === undefined) { return yield* HttpServerResponse.json({ error: "not configured" }, { status: 503 }); } if (request.headers["x-api-key"] !== expected) { return yield* HttpServerResponse.json({ error: "denied" }, { status: 401 }); } return HttpServerResponse.text("welcome"); }).pipe(Effect.orDie), }; }).pipe(Effect.provide(GCP.SecretManager.ReadSecretHttp)),) {}access() returns undefined until a version exists, so the service
can refuse requests instead of letting everyone in. Reading per
request means a new version takes effect without a redeploy.
| version | state | added |
|---|---|---|
| 1 | disabled | 2026-09-02 |
| 2 | enabled | 2026-10-14 |
| 3 | enabled | 2026-11-04 |
ReadSecret grants roles/secretmanager.secretAccessor on this secret
only.
Write versions from code
Section titled “Write versions from code”WriteSecret adds, disables, and destroys versions. Use it to rotate a
value from a job:
const secret = yield* GCP.SecretManager.WriteSecret(ApiKey);// inside run:yield* secret.addVersion(crypto.randomUUID());ReadWriteSecret does both.
Encrypt with Cloud KMS
Section titled “Encrypt with Cloud KMS”Create a key ring and a key:
export const Keys = GCP.KMS.KeyRing("Keys", {});
export const DataKey = Effect.gen(function* () { const keys = yield* Keys; return yield* GCP.KMS.CryptoKey("DataKey", { keyRing: keys.name, purpose: "ENCRYPT_DECRYPT", });});Bind Encrypt and Decrypt. The key material never leaves KMS:
const encrypt = yield* GCP.KMS.Encrypt(yield* DataKey);const decrypt = yield* GCP.KMS.Decrypt(yield* DataKey);
// inside fetch:const { ciphertext } = yield* encrypt({ body: { plaintext: btoa("hello") } });const { plaintext } = yield* decrypt({ body: { ciphertext } });Provide GCP.KMS.EncryptHttp and GCP.KMS.DecryptHttp. Each grants
the matching cryptoKeyEncrypter or cryptoKeyDecrypter role on this
key only.
Destroying keys
Section titled “Destroying keys”Cloud KMS has no delete. alchemy destroy schedules every key version
for destruction and leaves the empty key ring, which costs nothing.
Deploying a key with the same id again reuses it with a fresh version.
Example
Section titled “Example”examples/gcp-secrets-kms
is an encryption API gated by a Secret Manager key.
Where next
Section titled “Where next”- IAM & bindings — exactly which role each binding grants.
- Secrets —
.envvalues and Config. - SecretManager reference — every prop.