Skip to content

Secrets & env

Your code needs values it shouldn’t hard-code: an API key for a payment provider, a database password, a feature flag. On AWS those reach a runtime one of two ways. As an environment variable, set on the runtime when it’s deployed. Or as a secret in Secrets Manager, read by the runtime while it runs.

Read a value with effect/Config in a runtime’s Construction phase, and the deploy sets it on the Lambda Function as an environment variable. How Config reaches a runtime, on any cloud, is in Secrets and Config.

src/api.ts
const apiKey = yield* Config.Redacted("STRIPE_API_KEY");

On AWS, a Function’s environment variables are part of its configuration. Lambda encrypts them at rest, and together they can be at most 4 KB. Larger values go in Secrets Manager.

.env is only the 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 AWS from "@alchemy.run/aws";
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: AWS.providers(),
state: AWS.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.functionUrl };
}),
);

Your code doesn’t change: Config.Redacted("STRIPE_API_KEY") reads from whichever source the stage uses. See Secret providers for Doppler, Infisical, ordering several sources, and writing your own.

An environment variable is part of the Function’s configuration. Anyone who can read the configuration can read the value, and changing it means a deploy.

A secret in Secrets Manager is a resource of its own. Reading it is an IAM permission on one ARN. Other resources, like an Aurora cluster, can use it directly. Its value can be generated by AWS, so you never see it. And putting a new value takes effect on the next read, without a deploy.

So keep values from your own .env, read only by your code, in Config. Put values that are shared with other resources, generated, or rotated in Secrets Manager.

Declare a secret and give it a value as Redacted, so it stays out of logs and state:

alchemy.run.ts
const webhookSecret = yield* AWS.SecretsManager.Secret("WebhookSecret", {
secretString: yield* Config.Redacted("WEBHOOK_SECRET"),
});

Or let Secrets Manager generate the value at deploy time, so it never exists in your repo or your .env:

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

The stored value is the template with a random password added, the shape Aurora and RDS Proxy expect. Destroying a secret deletes it at once, with no recovery window.

Bind SecretsManager.GetSecretValue. The runtime’s role gets permission to read this one secret:

src/api.ts
const getDbSecret = yield* AWS.SecretsManager.GetSecretValue(dbSecret);

Call it while the runtime runs. SecretString comes back Redacted:

src/api.ts
const { SecretString } = yield* getDbSecret();
const { password } = JSON.parse(Redacted.value(SecretString!)) as { password: string };

Provide AWS.SecretsManager.GetSecretValueHttp to the runtime. Each call reads the current value, so a rotated secret is picked up on the next one. Cache it for the life of an environment if you read it on every request.

For deploy credentials and CI, see Setup. For secrets that come from a provider like Doppler or Infisical, see Secret providers.