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.

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.

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.