Configure from the environment
How-to. The lesson that fronts this recipe: Configure and test. Turn a handful of environment variables into a typed service the rest of your graph depends on, and let the kernel report a bad deployment as exit code
78. For the full surface, see@btravstack/config; for why configuration is a port and not aprocess.envread, see Starters.
You want DATABASE_URL and DATABASE_POOL_SIZE read once, checked once, and handed to the provider that opens the pool — with nothing in your application touching process.env. The recipe is one provider.
Recipe
- Describe the slice with
Config.object({...})— oneConfig.string,Config.integer,Config.boolean,Config.portorConfig.urlfield per variable. - Bind it with
Config.provider("Name")(schema), which mints the port, orConfig.provider(Port)(schema)for a port you declared. - Put the provider in a module and list its port in the deps of whatever reads it.
- Boot under
start/runMain: the kernel providesEnv, and a bad value is aConfigInvalidbefore anything serves.
import { Config, Env } from "@btravstack/config";
import { Module, Port, Provider, type ServiceOf } from "@btravstack/di";
class Database extends Port("Database")<{ readonly query: () => string }> {}
declare const openDatabase: (config: {
url: string;
poolSize: number;
}) => ServiceOf<Database>;
const databaseConfig = Config.provider("DatabaseConfig")(
Config.object({
url: Config.string("DATABASE_URL"),
poolSize: Config.integer("DATABASE_POOL_SIZE", {
min: 1,
max: 64,
default: 8,
}),
}),
);
export const Persistence = Module("Persistence")({
// Every reader of the environment says so: `start` is what provides `Env`.
needs: [Env],
provides: [
databaseConfig,
Provider(Database)({
inject: { config: databaseConfig.port },
sync: ({ config }) => openDatabase(config),
}),
],
exports: [Database],
});Config.provider("DatabaseConfig") mints a port whose service is the schema's output — { url: string; poolSize: number } — and hands back the provider carrying it as databaseConfig.port. That is the shape for a slice one application owns: nothing else ever needs to name the port, so no class line names it twice.
A variable with no default is a statement too. @btravstack/outbox's OUTBOX_TENANTS is required because the relay sweeps outside any unit, so there is no ambient record to read a tenant from, and "whatever is in the table" is how one deployment starts broadcasting another's facts. A required variable is what makes an operator say whose.
A port other packages name
When the slice is public API — a starter's HttpConfig, which another package imports — declare the port and pass the class. Same provider, same schema:
import { Config } from "@btravstack/config";
import { Port } from "@btravstack/di";
export class CacheConfig extends Port("CacheConfig")<{
readonly url: string;
readonly ttlSeconds: number;
}> {}
export const cacheConfig = Config.provider(CacheConfig)(
Config.object({
url: Config.string("CACHE_URL", { default: "redis://127.0.0.1:6379" }),
ttlSeconds: Config.integer("CACHE_TTL_SECONDS", { min: 0, default: 60 }),
}),
);What each field accepts
Every field goes through the same three-way read, pinned by the package's own spec. An empty or blank value is an error, never the default — Number("") is 0, and PORT= would otherwise bind the ephemeral port.
| Variable is… | Result |
|---|---|
| unset | the default, or is required without one |
set to "" or whitespace | is set but empty |
abc for an integer or port | is not a whole number: "abc" |
3.5 for an integer or port | is not a whole number: "3.5" |
0x1F90 for an integer or port | is not a whole number: "0x1F90" — decimal only, never 8080 |
outside min/max (inclusive) | must be between 1 and 64, got 100 |
| a port | 0..65535 — 0 is legal, an ephemeral bind |
| a URL with no scheme | is not a URL |
| a malformed URL carrying a password | is not a URL — the raw value is omitted to protect credentials |
Config.port has a floor of 0 deliberately: PORT=0 is how a test asks the OS for a free port and reads it back from runtimeInfo(). Config.url keeps the string it was given and checks only that new URL will accept it, which is what turns a mistyped HTTP_JWT_JWKS_URI into a named startup failure instead of a defect at the first request.
Pin a field from code
A starter's options pin a field instead of reading it — HttpModule's port: 0 still reads HOST. Config.pinned(value, field) is that rule, per field: explicit beats environment beats default.
export const cacheConfigWith = (options: { readonly url?: string } = {}) =>
Config.provider(CacheConfig)(
Config.object({
url: Config.pinned(
options.url,
Config.string("CACHE_URL", { default: "redis://127.0.0.1:6379" }),
),
ttlSeconds: Config.integer("CACHE_TTL_SECONDS", { min: 0, default: 60 }),
}),
);The shipped starters expose the same knob: HttpModule's port / hostname, TemporalModule's address / namespace, AmqpModule's url. A pinned field reads nothing from the environment; the module's Env need and ConfigInvalid error stay in its type either way.
Hand a test its environment
Under start, Env is provided by the kernel — process.env by default, StartOptions.env when a test says otherwise. Nothing in the module changes:
const app = start(App, {
env: { DATABASE_URL: "postgres://localhost/orders", DATABASE_POOL_SIZE: "4" },
signals: false,
probes: false,
});
// app.exited: AsyncResult<ExitReport, ConfigInvalid | RuntimeStartFailed>That env is typed by what the graph reads. Every Config field names its variable in its type, and a module's needs carry the names up to start, runMain and a test's boot: the record accepts the variables the module's configuration reads and the kernel's own, requires the ones nothing defaults or pins, and refuses any other. A misspelt DATABSE_URL, or a variable the root stopped reading, is a compile error at the call rather than a ConfigInvalid at boot. A starter option that pins a variable takes it out of the record — jwtAuthenticator({ issuer: "https://…" }) no longer needs HTTP_JWT_ISSUER — and one whose value may be undefined leaves it optional. To type an environment kept apart from the call, such as a test's shared one, use StartEnvironment:
const env: StartEnvironment<typeof App> = { DATABASE_URL: "postgres://localhost/orders" };A reader that names no variables — a hand-written field whose variable is a plain string, or a provider injecting Env itself — opens the type again, and any record is accepted.
Outside the kernel — a bare Module.scoped — provide Env yourself with Provider(Env)({ inject: {}, value: process.env }).
What a bad deployment looks like
runMain reports a ConfigInvalid as a startFailed event on stderr — one line per variable, every fault at once — and sets exit code 78 (sysexits' EX_CONFIG: the deployment is wrong, not the code). Booting the module above with DATABASE_URL unset and DATABASE_POOL_SIZE=100:
{"type":"building"}
{"type":"startFailed","cause":{"name":"ConfigInvalid","message":"DatabaseConfig could not be configured:\n DATABASE_URL: is required\n DATABASE_POOL_SIZE: must be between 1 and 64, got 100","stack":"…"}}
{"type":"stopping"}
{"type":"exited"}The kernel's own PROBE_PORT, PROBE_HOST, PRE_DRAIN_DELAY_MS, DRAIN_TIMEOUT_MS and STOP_TIMEOUT_MS are bound the same way, in one pass; a bad one is a RuntimeStartFailed for "kernel" whose cause is the ConfigInvalid, and runMain still exits 78. See runMain and exit codes.
What the framework itself reads
Every starter binds its own configuration this way, and every one of those variables is pinned by the matching option — explicit beats environment beats default, per field — so a value that varies by deployment can move to the deployment without a code change, and a value that is a decision stays in the composition root.
| Variable | Default | Bound by |
|---|---|---|
PROBE_PORT | 9000 | the kernel (probes) |
PROBE_HOST | 0.0.0.0 | the kernel — the interface the probe server binds; 0.0.0.0 is what lets a kubelet httpGet probe reach it over the pod IP |
PRE_DRAIN_DELAY_MS | 5000 | the kernel (drain) |
DRAIN_TIMEOUT_MS | 20000 | the kernel |
STOP_TIMEOUT_MS | 5000 | the kernel — the deadline on stopping, so a wedged finaliser still reports |
PORT / HOST | 3000 / 0.0.0.0 | HttpModule |
HTTP_BODY_LIMIT | 1048576 | HttpModule — 0 is unbounded |
HTTP_STREAM_BODY_LIMIT | 1073741824 | HttpModule — the body a route reads as a stream; 0 is unbounded |
HTTP_CORS_ORIGIN | unset (CORS off) | HttpModule — comma-separated origins, or * |
HTTP_HEADERS_TIMEOUT_MS | 60000 | HttpModule — ms a client may take to send its headers; 408 past it |
HTTP_REQUEST_TIMEOUT_MS | 300000 | HttpModule — ms a client may take to send a whole request |
HTTP_COMPRESSION | false | HttpModule — response compression |
HTTP_JWT_JWKS_URI | required | jwtAuthenticator() — the issuer's JWKS endpoint, a Config.url field |
HTTP_JWT_ISSUER | required | jwtAuthenticator() — the required iss |
HTTP_JWT_AUDIENCE | required | jwtAuthenticator() — the required aud; a second scheme reads <variablePrefix>_JWKS_URI, _ISSUER and _AUDIENCE instead of the three |
HTTP_SESSION_KEYS | required | sessionCodec() — comma-separated 32-byte base64url keys; the first seals, every one unseals |
HTTP_RESOURCE_URI | required | protectedResource() — this API's resource identifier, an https: URL |
HTTP_RESOURCE_AUTHORIZATION_SERVERS | required | protectedResource() and protectedResourcePerHost() — comma-separated issuers its tokens come from |
HTTP_RESOURCE_PATH | required | protectedResourcePerHost() — the path the resource answers at on every served host |
HTTP_OIDC_ISSUER | required | oidc() — the provider, as its discovery document names itself, a Config.url field; sessionAuthenticator() reads it too, with the client id, to accept only that login's sessions |
HTTP_OIDC_CLIENT_ID | required | oidc() — this deployment's client; sessionAuthenticator() reads it too |
HTTP_OIDC_CLIENT_SECRET | required | oidc() — its secret: the authorization-code flow here is a confidential client's |
HTTP_OIDC_REDIRECT_URI | required | oidc() — the URI registered with the provider, which is also what the code grant is checked against, a Config.url field; a second login reads <variablePrefix>_* instead |
HTTP_OIDC_POST_LOGOUT_REDIRECT_URI | unset | oidc() — provider-registered absolute HTTP(S) logout return, without credentials or a fragment; requires tokens, pinned by postLogoutRedirectUri, and follows variablePrefix |
GRAPHQL_DEVELOPER_TOOLS | false | graphql() — GraphiQL, introspection and field suggestions, all or none; pinned by developerTools |
GRAPHQL_MAX_TOKENS | 1000 | graphql() — the most tokens one document may parse to, 0 unbounded; pinned by maxTokens |
TEMPORAL_ADDRESS | 127.0.0.1:7233 | TemporalModule |
TEMPORAL_NAMESPACE | default | TemporalModule |
TEMPORAL_GRACE_PERIOD_MS | 10000 | TemporalModule — shutdownGraceTime |
TEMPORAL_FORCE_AFTER_MS | 15000 | TemporalModule — shutdownForceTime |
TEMPORAL_TLS | unset — on with an API key | TemporalModule, temporalSecurity — TLS on or off; the certificate variables below require it on |
TEMPORAL_TLS_SERVER_CA_CERT_PATH | — | the server CA, PEM |
TEMPORAL_TLS_SERVER_NAME | — | the server name verified and sent as SNI |
TEMPORAL_TLS_CLIENT_CERT_PATH | — | mTLS client certificate, with its key |
TEMPORAL_TLS_CLIENT_KEY_PATH | — | mTLS client key, with its certificate |
TEMPORAL_API_KEY | — | API key, sent as a bearer token |
TEMPORAL_API_KEY_PATH | — | a file holding the API key, reloaded as it rotates |
TEMPORAL_CREDENTIALS_RELOAD_MS | 60000 | how often the files above are read again — see Securing the connection |
AMQP_URL | amqp://127.0.0.1:5672 | AmqpModule |
AMQP_CONNECT_TIMEOUT_MS | 5000 | AmqpModule |
OUTBOX_TENANTS | required | outbox() — the tenants this relay serves, comma-separated; pinned by tenants |
OUTBOX_POLL_MS | 200 | outbox() — the idle sleep between sweeps; pinned by pollMs |
OUTBOX_MAX_LAG_MS | 60000 | outbox() — the oldest pending age its health check tolerates; pinned by maxLagMs |
OUTBOX_RETAIN_MS | 0 (kept) | outbox() — how long a published row is kept before the relay deletes it; pinned by retainMs |
OUTBOX_CONCURRENCY | 4 | outbox() — how many tenants claim or prune, each on a pooled connection, at once; pinned by concurrency |
LOG_LEVEL | info | observability() |
DATABASE_URL | required | prismaDatabase() — the application role's credentials, not the owner's, where row security is on: prisma db migrate runs as the owner |
REDIS_URL | required | cache() over Redis |
SMTP_URL | required | mailer() over SMTP |
OAUTH_ISSUER | required | oauthClient() — the authorization server, a Config.url field; an http: one off loopback is refused unless allowInsecureIssuer is pinned |
OAUTH_CLIENT_ID | required | oauthClient() — this service's client; pinned by clientId |
OAUTH_CLIENT_SECRET | required | oauthClient() — its secret, from a Kubernetes Secret; pinned by clientSecret |
STORAGE_S3_* | see the page | storage() over S3 |
KEYS_KMS_REGION | required | kmsKeys() — the AWS region of the owners' KMS keys |
KEYS_KMS_ALIAS_PREFIX | required | kmsKeys() — an owner's KMS key is the alias of this prefix and the owner |
KEYS_KMS_ENDPOINT | AWS's own | kmsKeys() — an emulator or a VPC endpoint, a Config.url field; pinned by endpoint |
KEYS_CACHE_TTL_MS | 300000 | kmsKeys() — how long an unwrapped root key is kept, 0 none; pinned by cacheTtlMs |
KEYS_KMS_PENDING_WINDOW_DAYS | 30 | kmsKeys() — the days an erasure can be cancelled, 7 to 30; pinned by pendingWindowDays |
A variable carries its starter's prefix, so two starters in one process cannot collide — an HTTP deployment that also publishes to AMQP and reads a database composes three of them. HTTP_, GRAPHQL_, TEMPORAL_, AMQP_, OUTBOX_, OAUTH_, STORAGE_S3_, KEYS_ are the namespaces; the exceptions are names the ecosystem already owns and that a platform injects for you (PORT, HOST, DATABASE_URL, REDIS_URL, SMTP_URL, LOG_LEVEL), where a prefix would break the convention rather than protect it. The kernel's own are unprefixed because there is exactly one kernel in a process and nothing else binds them.
A shape is never a variable: a plugin list, a CORS record's allowed headers, a set of security headers. An environment carries strings, so what it carries here is scalars.
Profiles, files, secrets and refresh are the platform's
The framework reads the process environment, once, at boot, and nothing else — no dev/prod profile selector, no file it loads itself, no secret-store client, no reload. Each is something the platform already does:
- Profiles are an overlay per environment in the deployment's manifests, so the difference between staging and production is a file somebody reviews. A required variable one overlay forgot is a
78at boot rather than a value meant for somewhere else — provided the shared base does not carry it; a variable with a default simply takes the default. - Files are read by the runtime before the process starts —
node --env-file=.env, which is whatpnpm devdoes — so what was validated and what was in the environment are one surface. - Secrets reach the pod as environment variables from a Secret, which a secret store (Vault, AWS Secrets Manager, SSM) syncs into.
- Refresh is a restart — with one exception, credentials a connection presents: a mounted certificate or API key rotates on a schedule its issuer sets, so Temporal's starters read those files again (Securing the connection). Everything else: A live-reloaded configuration is a consistency problem at every reader; a restart drains in three beats and revalidates, and a bad value fails the new pod's boot. The old pods keep serving only under a
RollingUpdatethat keeps them available until replacements are ready (maxUnavailable: 0);Recreateterminates them first.
Configure a Kubernetes deployment is all four, concretely.
Any Standard Schema
Config.object produces a Standard Schema, and Config.provider accepts any — zod, valibot, arktype — as long as it takes the flat environment record and produces the port's service:
import { z } from "zod";
export const cacheConfig = Config.provider(CacheConfig)(
z
.object({
CACHE_URL: z.string().url(),
CACHE_TTL_SECONDS: z
.string()
.default("60")
.transform(Number)
.pipe(z.int().min(0)),
})
.transform((raw) => ({
url: raw.CACHE_URL,
ttlSeconds: raw.CACHE_TTL_SECONDS,
})),
);The fields exist so the starters, and an application with ordinary needs, bring no schema library at all. Never call a schema's own .parse() — it throws, and unthrown/no-throw bans it; the provider is what validates.
See also
@btravstack/config— every field, option and error.- Configure a Kubernetes deployment — ConfigMap, Secret, an overlay per environment, and rotating a value.
- runMain and exit codes — where
78sits among the others. - Serve an oRPC contract over HTTP —
PORT/HOSTbound by a starter. - Test an application —
envand the other options a test forces.