Skip to content

Secrets & env

Secrets on AWS come in two tiers. Values in your .env that only your own code reads travel to the Lambda as environment variables via effect/Config — one line binds at deploy time and resolves at runtime. When the secret is a cloud resource other things consume, it belongs in Secrets Manager. The rule: in your .env and read only by your code → Config; shared, generated, or rotated → Secrets Manager.

yield* Config.redacted(...) in the function’s init phase reads your .env at deploy time and binds the value as a Lambda environment variable:

src/api.ts
import * as AWS from "alchemy/AWS";
import * as Config from "effect/Config";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
export default class Api extends AWS.Lambda.Function<Api>()(
"Api",
{ main: import.meta.url, url: true },
Effect.gen(function* () {
const apiKey = yield* Config.redacted("OPENAI_API_KEY");
return {
fetch: Effect.gen(function* () {
return HttpServerResponse.text("ok");
}),
};
}),
) {}

At runtime the same line resolves from that env var — one line, both phases.

The value is Redacted<string>; unwrap with Redacted.value only where the raw string is needed:

fetch: Effect.gen(function* () {
return HttpServerResponse.text(`Bearer ${Redacted.value(apiKey)}`);
}),

Interpolating the wrapper itself (`Bearer ${apiKey}`) produces the literal text <redacted>, not the value — that’s the point.

Non-secret config uses the same mechanism with Config.string and Config.number:

const host = yield* Config.string("HOST");
const port = yield* Config.number("PORT").pipe(Config.withDefault(3000));

Values JSON round-trip through the environment, so port comes back as a number. Combinators re-run at runtime against the bound source, and a default is never bound — see Secrets and Config for the full semantics.

A Config yielded only inside fetch is never discovered at deploy time, so the env var won’t exist at runtime:

export default class Api extends AWS.Lambda.Function<Api>()(
"Api",
{ main: import.meta.url, url: true },
Effect.gen(function* () {
return {
fetch: Effect.gen(function* () {
// 🚫 never bound — API_KEY won't exist at runtime
const apiKey = yield* Config.redacted("API_KEY");
}),
};
}),
) {}

Resolve in the outer Effect.gen, capture in a const, reference that const from fetch.

Reach for Secrets Manager when the secret is an AWS-native concern:

  • Shared consumers — other resources reference the secret by ARN (Aurora clusters and RDS proxies consume password-backed JSON secrets in exactly the shape generateSecretString produces).
  • Generated values — the plaintext never enters your repo, .env, or deploy logs.
  • Least-privilege reads — access is an IAM grant on one ARN, not an env var visible to the whole function config.
  • Rotation without redeploy — put a new value and running functions read it on their next call.

Secret owns the metadata and current value; pass the value as Redacted so it never appears in logs or state:

import * as AWS from "alchemy/AWS";
import * as Redacted from "effect/Redacted";
const dbSecret = yield* AWS.SecretsManager.Secret("DbSecret", {
description: "Database credentials",
secretString: Redacted.make(
JSON.stringify({ username: "app", password: "super-secret" }),
),
});

Omit name for a deterministic generated name; changing name replaces the secret (new ARN), everything else updates in place. On destroy alchemy deletes with ForceDeleteWithoutRecovery — removal is immediate, no recovery window.

For credentials nothing outside AWS needs to see, let Secrets Manager mint the password at deploy time:

const dbSecret = yield* AWS.SecretsManager.Secret("DbSecret", {
generateSecretString: {
secretStringTemplate: JSON.stringify({ username: "app" }),
generateStringKey: "password",
PasswordLength: 32,
},
});

The stored value is the template merged with the generated password — {"username":"app","password":"<random>"} — and the plaintext never lives in your repo, .env, or logs.

GetSecretValue is a binding: yield it in init to grant IAM at deploy time, call it at runtime for a fresh read:

src/api.ts
import * as AWS from "alchemy/AWS";
import * as Effect from "effect/Effect";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import * as Redacted from "effect/Redacted";
export default class Api extends AWS.Lambda.Function<Api>()(
"Api",
{ main: import.meta.url, url: true },
Effect.gen(function* () {
const dbSecret = yield* AWS.SecretsManager.Secret("DbSecret", {
generateSecretString: {
secretStringTemplate: JSON.stringify({ username: "app" }),
generateStringKey: "password",
},
});
const getDbSecret = yield* AWS.SecretsManager.GetSecretValue(dbSecret);
return {
fetch: Effect.gen(function* () {
const value = yield* getDbSecret();
const creds = Redacted.isRedacted(value.SecretString)
? (JSON.parse(Redacted.value(value.SecretString)) as {
username: string;
})
: undefined;
return yield* HttpServerResponse.json({ user: creds?.username });
}).pipe(Effect.orDie),
};
}).pipe(Effect.provide(AWS.SecretsManager.GetSecretValueHttp)),
) {}

The binding adds secretsmanager:GetSecretValue and secretsmanager:DescribeSecret to the execution role, scoped to this secret’s ARN only. SecretString comes back as Redacted — unwrap with Redacted.value at the point of use. The value is fetched per call, not baked into env, so putting a new version (the PutSecretValue binding) propagates on the next read.

Related:

  • RDS — Aurora generates a password-backed secret in exactly the generateSecretString shape; the Connect binding reads it at request time.
  • Lambda — the function model env vars and bindings attach to.
  • Secrets and Config — the init/runtime split and transformation semantics.
  • Secrets & env on Cloudflare — the same spine on Cloudflare.

Reference: