Config & secrets
Every deployable reads environment variables, and some of them are secrets. This page answers where config is read, how each variable is named and typed, what keeps a secret from being printed, and where local values come from on each runtime profile.
Read config in the feature’s layer, never in make
Section titled “Read config in the feature’s layer, never in make”Impact: HIGH a missing variable stops boot, not a request
- A configured service exports
make(options),layerWithOptions,configandlayer. Onlylayerreadsconfig. NoConfigread happens inmake, in a handler, or anywhere outside aconfigexport. - Every layer builds at boot from the one root graph, so a missing variable stops the process before it takes a request. On the Worker profile the graph is built in the init closure, so every read happens during Init, where alchemy can bind it as a secret.
- The options type is named for its service:
<Service>Options, never a bareOptions. A service with no configuration exports onlymakeandlayer;layerWithOptionsarrives in the PR that adds its first option. - Accepted cost: no single file lists every variable a deployable needs.
.env.exampleis that list, kept by hand.
❌ Incorrect — Config read inside make, so every test of the service needs a provider:
export const make = Effect.gen(function* () { const apiKey = yield* Config.Redacted("STRIPE_API_KEY") // …})// in a handler it is worse: a missing secret becomes a 500 for the first caller✅ Correct — make takes options; layer feeds it from config:
export const make = (options: StripeClientOptions) => // pure of Config Effect.gen(function* () { … })
export const layerWithOptions = (options: StripeClientOptions) => Layer.effect(StripeClient, make(options))
export const config: Config.Config<StripeClientOptions> = Config.all({ apiKey: Config.Redacted("STRIPE_API_KEY"), baseUrl: Config.URL("STRIPE_API_URL"), timeout: Config.Duration("STRIPE_TIMEOUT").pipe(Config.withDefault(Duration.seconds(10))),})
export const layer = Layer.unwrap(Effect.map(config, layerWithOptions))Source: notes/08-application-surfaces/config-and-secrets.md · Decision 1, amended
Write every variable name as a literal, once
Section titled “Write every variable name as a literal, once”Impact: MEDIUM grep finds the single reader of a variable
- Every variable name appears as a string literal in exactly one
configexport. On the day a deploy fails with a missing variable,grep STRIPE_API_KEYfinds its reader. - Use the narrowest constructor:
Config.Int,Config.Port,Config.URL,Config.Duration,Config.Boolean. Enums useConfig.Literals([...], "NAME"), neverConfig.Stringplus a hand check. Branded or checked values reuse the domain schema withConfig.schema(Schema, "NAME"). - A rule across fields goes in
Config.mapEffecton that feature’sconfig. It must fail with aConfig.ConfigError, and 4.0.3 has no public helper, so the first such rule writes the construction inline. - Accepted cost: it is verbose, and the feature prefix is typed by hand in every name.
❌ Incorrect — one struct schema with constantCase: no variable name exists anywhere to grep:
export const config = Config.schema(Schema.Struct({ apiKey: Schema.Redacted(Schema.String), baseUrl: Schema.String,}))// + ConfigProvider.constantCase in every entrypoint and test, or every name misses✅ Correct — literal names, narrow constructors:
export const config = Config.all({ env: Config.Literals(["development", "production"], "APP_ENV"), port: Config.Port("PORT"), appId: Config.schema(GitHubAppId, "GITHUB_APP_ID"),})Source: notes/08-application-surfaces/config-and-secrets.md · Decision 2, amended
Make every secret Redacted from the first read
Section titled “Make every secret Redacted from the first read”Impact: HIGH only the type stops a secret being printed
- Read secrets with
Config.Redacted("NAME"). Type themRedacted.Redacted<string>in<Service>Optionsand in every service shape, neverstring | Redacted, which lets a plain string through. - Call
Redacted.valueonly on the line that needs the raw bytes, such as an HTTP header or an HMAC call. Never keep the unwrapped value in a variable that outlives that line. - A secret inside an encodable Schema (a response, a cache entry, an event) is
Schema.Redacted(Schema.String, { disallowJsonEncode: true }), because the default encodes to plaintext. In tests, writeRedacted.make("…"). - Accepted cost: every consumer unwraps and every test wraps.
Redacted.valuein a log line still compiles; catching that belongs to secrets, encryption & tokens.
❌ Incorrect — plain-string or “either” secrets:
export type StripeClientOptions = { readonly apiKey: string | Redacted.Redacted<string> // a raw string passes straight through}const webhookSecret = Config.String("WEBHOOK_SECRET") // printed the day someone logs config✅ Correct — Redacted end to end, unwrapped on the header line:
export type StripeClientOptions = { readonly apiKey: Redacted.Redacted<string>}
const headers = { authorization: `Bearer ${Redacted.value(options.apiKey)}` }Source: notes/08-application-surfaces/config-and-secrets.md · Decision 3
Default only tunables, never environment values
Section titled “Default only tunables, never environment values”Impact: HIGH production refuses to boot instead of misbehaving
Config.withDefaultis for values that are the same in every environment: timeouts, pool sizes, TTLs, a model name.- Anything that differs by environment (URLs, hosts, ports exposed to others, every secret) has
no default. A production process missing
REDIS_URLshould refuse to boot, not point at localhost and fail on the first request. DEPLOYMENT_ENVIRONMENTandSHUTDOWN_DELAYare examples of variables with no default; each deployed environment sets them.
❌ Incorrect — a dev-friendly default for an environment value:
redisUrl: Config.URL("REDIS_URL").pipe( Config.withDefault(new URL("redis://localhost:6380")), // prod boots against localhost),✅ Correct — the environment value is required; the tunable has a default:
redisUrl: Config.URL("REDIS_URL"),timeout: Config.Duration("REDIS_TIMEOUT").pipe(Config.withDefault(Duration.seconds(10))),Source: notes/08-application-surfaces/config-and-secrets.md · Decision 4
Layer .env under the process environment
Section titled “Layer .env under the process environment”Impact: MEDIUM one local setup path, no file in production
- On the Node and Bun profiles, each
main.tsprovidesConfigProviderLayer: the process environment first, then.envif it exists. A value set in the shell or by the platform wins. The existence check is required, becausefromDotEnvfails when the file is missing. - Deployed environments ship no
.env. Values travel GitHub environment → the platform’s secret store → environment variables, andfromEnv()reads them. .env.exampleis committed at the deployable’s root and lists every variable: defaulted tunables commented out with their default,DEPLOYMENT_ENVIRONMENT=development,SHUTDOWN_DELAY=0 seconds, and on PostgresDATABASE_OWNER_URLandDATABASE_URLfor the compose database.pnpm run setupcopies it to.envwhen.envis absent.- On a Cloudflare Worker there is no
ConfigProviderLayer. Locallyalchemy devreads.envwith the file over the process environment, the reverse of the container rule. On that profile, set a variable in.envor in the shell, never both.
❌ Incorrect — the file alone: it fails in production and hides shell overrides:
const ConfigProviderLayer = ConfigProvider.layer( ConfigProvider.fromDotEnv({ path: ".env" }),)✅ Correct — process env over the file, only when the file exists:
const ConfigProviderLayer = ConfigProvider.layer( Effect.gen(function* () { const fs = yield* FileSystem.FileSystem if (!(yield* fs.exists(".env"))) return ConfigProvider.fromEnv() return ConfigProvider.orElse( ConfigProvider.fromEnv(), yield* ConfigProvider.fromDotEnv({ path: ".env" }), ) }),)Source: notes/08-application-surfaces/config-and-secrets.md · Decision 4, amended
Test config through string values
Section titled “Test config through string values”Impact: LOW tests decode exactly like production
- Tests of the service use
layerWithOptions(...)with plain values and noConfigProvider. - Each feature with a
configgets one config test:config.parse(ConfigProvider.fromEnv({ env: { … } })), covering names, defaults and rejection of bad input. String values go through the same string-to-type decoding as production. - One test per deployable loads
.env.exampleand parses every feature’sconfigagainst it. Placeholders must decode like real values, and the commented-out tunables prove their defaults decode. - Accepted cost: the list of feature configs in that test is kept by hand; review catches a missing line.
❌ Incorrect — fromUnknown with typed values skips the string decoding:
config.parse(ConfigProvider.fromUnknown({ GITHUB_APP_ID: 7 }))✅ Correct — strings in, and .env.example checked against every feature:
it.effect(".env.example satisfies every feature config", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const provider = ConfigProvider.fromDotEnvContents( yield* fs.readFileString(".env.example"), ) yield* Labeling.config.parse(provider) yield* Billing.config.parse(provider) yield* Lifecycle.config.parse(provider) // SHUTDOWN_DELAY }).pipe(Effect.provide(NodeFileSystem.layer)))Source: notes/08-application-surfaces/config-and-secrets.md · Decisions 1, 4, amended
Deferred
Section titled “Deferred”- A shared helper that builds a
Config.ConfigErrorfrom a message — trigger: a second cross-fieldConfig.mapEffectrule.