Skip to content

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.

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.

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:

alchemy.run.ts
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.

src/resources.ts
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:

Terminal window
printf 'my-key' | gcloud secrets versions add "$SECRET_ID" --data-file=-

Return ApiKey.secretId from your Stack to get $SECRET_ID.

ReadSecret returns access(), which reads the latest version as a string:

src/Api.ts
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.

ApiKey · Secret Manager secretaccess() → latest
versionstateadded
1disabled2026-09-02
2enabled2026-10-14
3enabled2026-11-04
A secret holds versions. Adding version 3 changes what access() returns on the next request, with no redeploy.

ReadSecret grants roles/secretmanager.secretAccessor on this secret only.

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.

Create a key ring and a key:

src/resources.ts
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.

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.

examples/gcp-secrets-kms is an encryption API gated by a Secret Manager key.