Skip to content

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, config and layer. Only layer reads config. No Config read happens in make, in a handler, or anywhere outside a config export.
  • 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 bare Options. A service with no configuration exports only make and layer; layerWithOptions arrives in the PR that adds its first option.
  • Accepted cost: no single file lists every variable a deployable needs. .env.example is 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 config export. On the day a deploy fails with a missing variable, grep STRIPE_API_KEY finds its reader.
  • Use the narrowest constructor: Config.Int, Config.Port, Config.URL, Config.Duration, Config.Boolean. Enums use Config.Literals([...], "NAME"), never Config.String plus a hand check. Branded or checked values reuse the domain schema with Config.schema(Schema, "NAME").
  • A rule across fields goes in Config.mapEffect on that feature’s config. It must fail with a Config.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 them Redacted.Redacted<string> in <Service>Options and in every service shape, never string | Redacted, which lets a plain string through.
  • Call Redacted.value only 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, write Redacted.make("…").
  • Accepted cost: every consumer unwraps and every test wraps. Redacted.value in 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.withDefault is 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_URL should refuse to boot, not point at localhost and fail on the first request.
  • DEPLOYMENT_ENVIRONMENT and SHUTDOWN_DELAY are 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

Impact: MEDIUM one local setup path, no file in production

  • On the Node and Bun profiles, each main.ts provides ConfigProviderLayer: the process environment first, then .env if it exists. A value set in the shell or by the platform wins. The existence check is required, because fromDotEnv fails when the file is missing.
  • Deployed environments ship no .env. Values travel GitHub environment → the platform’s secret store → environment variables, and fromEnv() reads them.
  • .env.example is 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 Postgres DATABASE_OWNER_URL and DATABASE_URL for the compose database. pnpm run setup copies it to .env when .env is absent.
  • On a Cloudflare Worker there is no ConfigProviderLayer. Locally alchemy dev reads .env with the file over the process environment, the reverse of the container rule. On that profile, set a variable in .env or 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

Impact: LOW tests decode exactly like production

  • Tests of the service use layerWithOptions(...) with plain values and no ConfigProvider.
  • Each feature with a config gets 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.example and parses every feature’s config against 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

  • A shared helper that builds a Config.ConfigError from a message — trigger: a second cross-field Config.mapEffect rule.