| 1 | --- |
| 2 | title: Secrets & env |
| 3 | description: Deliver API keys from .env to a Lambda with effect/Config, and graduate to AWS Secrets Manager when the secret is a shared, generated, or rotated cloud resource. |
| 4 | --- |
| 5 | |
| 6 | Secrets on AWS come in two tiers. Values in your `.env` that only |
| 7 | your own code reads travel to the Lambda as environment variables |
| 8 | via `effect/Config` — one line binds at deploy time and resolves at |
| 9 | runtime. When the secret is a cloud resource other things consume, |
| 10 | it belongs in [Secrets Manager](#graduate-to-secrets-manager). The |
| 11 | rule: in your `.env` and read only by your code → `Config`; |
| 12 | shared, generated, or rotated → Secrets Manager. |
| 13 | |
| 14 | ## Bind a secret from .env |
| 15 | |
| 16 | `yield* Config.Redacted(...)` in the function's Construction phase reads |
| 17 | your `.env` at deploy time and binds the value as a Lambda |
| 18 | environment variable: |
| 19 | |
| 20 | ```typescript |
| 21 | // src/api.ts |
| 22 | import * as AWS from "alchemy/AWS"; |
| 23 | import * as Config from "effect/Config"; |
| 24 | import * as Effect from "effect/Effect"; |
| 25 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 26 | |
| 27 | export default class Api extends AWS.Lambda.Function<Api>()( |
| 28 | "Api", |
| 29 | { main: import.meta.url, functionUrl: true }, |
| 30 | Effect.gen(function* () { |
| 31 | const apiKey = yield* Config.Redacted("OPENAI_API_KEY"); |
| 32 | |
| 33 | return { |
| 34 | fetch: Effect.gen(function* () { |
| 35 | return HttpServerResponse.text("ok"); |
| 36 | }), |
| 37 | }; |
| 38 | }), |
| 39 | ) {} |
| 40 | ``` |
| 41 | |
| 42 | At runtime the same line resolves from that env var — one line, |
| 43 | both phases. |
| 44 | |
| 45 | ## Unwrap at the call site |
| 46 | |
| 47 | The value is `Redacted<string>`; unwrap with `Redacted.value` only |
| 48 | where the raw string is needed: |
| 49 | |
| 50 | ```typescript |
| 51 | fetch: Effect.gen(function* () { |
| 52 | return HttpServerResponse.text(`Bearer ${Redacted.value(apiKey)}`); |
| 53 | }), |
| 54 | ``` |
| 55 | |
| 56 | Interpolating the wrapper itself (`` `Bearer ${apiKey}` ``) produces |
| 57 | the literal text `<redacted>`, not the value — that's the point. |
| 58 | |
| 59 | ## Plain vars and defaults |
| 60 | |
| 61 | Non-secret config uses the same mechanism with `Config.String` and |
| 62 | `Config.Number`: |
| 63 | |
| 64 | ```typescript |
| 65 | const host = yield* Config.String("HOST"); |
| 66 | const port = yield* Config.Number("PORT").pipe(Config.withDefault(3000)); |
| 67 | ``` |
| 68 | |
| 69 | Values JSON round-trip through the environment, so `port` comes |
| 70 | back as a number. Combinators re-run at runtime against the bound |
| 71 | source, and a default is never bound — see |
| 72 | [Secrets and Config](/environments/secrets) for the full semantics. |
| 73 | |
| 74 | ## Resolve Config in the constructor, not in fetch |
| 75 | |
| 76 | A `Config` yielded only inside `fetch` is never discovered at |
| 77 | deploy time, so the env var won't exist at runtime: |
| 78 | |
| 79 | ```typescript |
| 80 | export default class Api extends AWS.Lambda.Function<Api>()( |
| 81 | "Api", |
| 82 | { main: import.meta.url, functionUrl: true }, |
| 83 | Effect.gen(function* () { |
| 84 | return { |
| 85 | fetch: Effect.gen(function* () { |
| 86 | // 🚫 never bound — API_KEY won't exist at runtime |
| 87 | const apiKey = yield* Config.Redacted("API_KEY"); |
| 88 | }), |
| 89 | }; |
| 90 | }), |
| 91 | ) {} |
| 92 | ``` |
| 93 | |
| 94 | Resolve in the outer `Effect.gen`, capture in a `const`, reference |
| 95 | that `const` from `fetch`. |
| 96 | |
| 97 | ## Graduate to Secrets Manager |
| 98 | |
| 99 | Reach for Secrets Manager when the secret is an AWS-native concern: |
| 100 | |
| 101 | - **Shared consumers** — other resources reference the secret by |
| 102 | ARN (Aurora clusters and RDS proxies consume password-backed JSON |
| 103 | secrets in exactly the shape `generateSecretString` produces). |
| 104 | - **Generated values** — the plaintext never enters your repo, |
| 105 | `.env`, or deploy logs. |
| 106 | - **Least-privilege reads** — access is an IAM grant on one ARN, |
| 107 | not an env var visible to the whole function config. |
| 108 | - **Rotation without redeploy** — put a new value and running |
| 109 | functions read it on their next call. |
| 110 | |
| 111 | ## Create a secret |
| 112 | |
| 113 | `Secret` owns the metadata and current value; pass the value as |
| 114 | `Redacted` so it never appears in logs or state: |
| 115 | |
| 116 | ```typescript |
| 117 | import * as AWS from "alchemy/AWS"; |
| 118 | import * as Redacted from "effect/Redacted"; |
| 119 | |
| 120 | const dbSecret = yield* AWS.SecretsManager.Secret("DbSecret", { |
| 121 | description: "Database credentials", |
| 122 | secretString: Redacted.make( |
| 123 | JSON.stringify({ username: "app", password: "super-secret" }), |
| 124 | ), |
| 125 | }); |
| 126 | ``` |
| 127 | |
| 128 | Omit `name` for a deterministic generated name; changing `name` |
| 129 | replaces the secret (new ARN), everything else updates in place. |
| 130 | On destroy alchemy deletes with `ForceDeleteWithoutRecovery` — |
| 131 | removal is immediate, no recovery window. |
| 132 | |
| 133 | ## Generate the value instead |
| 134 | |
| 135 | For credentials nothing outside AWS needs to see, let Secrets |
| 136 | Manager mint the password at deploy time: |
| 137 | |
| 138 | ```typescript |
| 139 | const dbSecret = yield* AWS.SecretsManager.Secret("DbSecret", { |
| 140 | generateSecretString: { |
| 141 | secretStringTemplate: JSON.stringify({ username: "app" }), |
| 142 | generateStringKey: "password", |
| 143 | PasswordLength: 32, |
| 144 | }, |
| 145 | }); |
| 146 | ``` |
| 147 | |
| 148 | The stored value is the template merged with the generated password |
| 149 | — `{"username":"app","password":"<random>"}` — and the plaintext |
| 150 | never lives in your repo, `.env`, or logs. |
| 151 | |
| 152 | ## Read it from a Lambda |
| 153 | |
| 154 | `GetSecretValue` is a binding: yield it in the constructor to grant IAM at |
| 155 | deploy time, call it at runtime for a fresh read: |
| 156 | |
| 157 | ```typescript |
| 158 | // src/api.ts |
| 159 | import * as AWS from "alchemy/AWS"; |
| 160 | import * as Effect from "effect/Effect"; |
| 161 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 162 | import * as Redacted from "effect/Redacted"; |
| 163 | |
| 164 | export default class Api extends AWS.Lambda.Function<Api>()( |
| 165 | "Api", |
| 166 | { main: import.meta.url, functionUrl: true }, |
| 167 | Effect.gen(function* () { |
| 168 | const dbSecret = yield* AWS.SecretsManager.Secret("DbSecret", { |
| 169 | generateSecretString: { |
| 170 | secretStringTemplate: JSON.stringify({ username: "app" }), |
| 171 | generateStringKey: "password", |
| 172 | }, |
| 173 | }); |
| 174 | |
| 175 | const getDbSecret = yield* AWS.SecretsManager.GetSecretValue(dbSecret); |
| 176 | |
| 177 | return { |
| 178 | fetch: Effect.gen(function* () { |
| 179 | const value = yield* getDbSecret(); |
| 180 | const creds = Redacted.isRedacted(value.SecretString) |
| 181 | ? (JSON.parse(Redacted.value(value.SecretString)) as { |
| 182 | username: string; |
| 183 | }) |
| 184 | : undefined; |
| 185 | return yield* HttpServerResponse.json({ user: creds?.username }); |
| 186 | }).pipe(Effect.orDie), |
| 187 | }; |
| 188 | }).pipe(Effect.provide(AWS.SecretsManager.GetSecretValueHttp)), |
| 189 | ) {} |
| 190 | ``` |
| 191 | |
| 192 | The binding adds `secretsmanager:GetSecretValue` and |
| 193 | `secretsmanager:DescribeSecret` to the execution role, scoped to |
| 194 | this secret's ARN only. `SecretString` comes back as `Redacted` — |
| 195 | unwrap with `Redacted.value` at the point of use. The value is |
| 196 | fetched per call, not baked into env, so putting a new version (the |
| 197 | [`PutSecretValue` binding](/providers/aws/secretsmanager/putsecretvalue)) |
| 198 | propagates on the next read. |
| 199 | |
| 200 | ## Where next |
| 201 | |
| 202 | Related: |
| 203 | |
| 204 | - [RDS](/aws/data/rds) — Aurora generates a password-backed secret in |
| 205 | exactly the `generateSecretString` shape; the `Connect` binding |
| 206 | reads it at request time. |
| 207 | - [Lambda](/aws/compute/lambda) — the function model env vars and bindings |
| 208 | attach to. |
| 209 | - [Secrets and Config](/environments/secrets) — the Construction/Runtime split |
| 210 | and transformation semantics. |
| 211 | - [Secrets & env on Cloudflare](/cloudflare/security/secrets-env) — the same |
| 212 | spine on Cloudflare. |
| 213 | |
| 214 | Reference: |
| 215 | |
| 216 | - [`Secret` reference](/providers/aws/secretsmanager/secret) |
| 217 | - [`GetSecretValue` reference](/providers/aws/secretsmanager/getsecretvalue) |