Skip to content

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 a process.env read, 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 ​

  1. Describe the slice with Config.object({...}) — one Config.string, Config.integer, Config.boolean, Config.port or Config.url field per variable.
  2. Bind it with Config.provider("Name")(schema), which mints the port, or Config.provider(Port)(schema) for a port you declared.
  3. Put the provider in a module and list its port in the deps of whatever reads it.
  4. Boot under start/runMain: the kernel provides Env, and a bad value is a ConfigInvalid before anything serves.
ts
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:

ts
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
unsetthe default, or is required without one
set to "" or whitespaceis set but empty
abc for an integer or portis not a whole number: "abc"
3.5 for an integer or portis not a whole number: "3.5"
0x1F90 for an integer or portis not a whole number: "0x1F90" — decimal only, never 8080
outside min/max (inclusive)must be between 1 and 64, got 100
a port0..65535 — 0 is legal, an ephemeral bind
a URL with no schemeis not a URL
a malformed URL carrying a passwordis 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.

ts
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:

ts
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:

ts
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:

json
{"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.

VariableDefaultBound by
PROBE_PORT9000the kernel (probes)
PROBE_HOST0.0.0.0the 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_MS5000the kernel (drain)
DRAIN_TIMEOUT_MS20000the kernel
STOP_TIMEOUT_MS5000the kernel — the deadline on stopping, so a wedged finaliser still reports
PORT / HOST3000 / 0.0.0.0HttpModule
HTTP_BODY_LIMIT1048576HttpModule — 0 is unbounded
HTTP_STREAM_BODY_LIMIT1073741824HttpModule — the body a route reads as a stream; 0 is unbounded
HTTP_CORS_ORIGINunset (CORS off)HttpModule — comma-separated origins, or *
HTTP_HEADERS_TIMEOUT_MS60000HttpModule — ms a client may take to send its headers; 408 past it
HTTP_REQUEST_TIMEOUT_MS300000HttpModule — ms a client may take to send a whole request
HTTP_COMPRESSIONfalseHttpModule — response compression
HTTP_JWT_JWKS_URIrequiredjwtAuthenticator() — the issuer's JWKS endpoint, a Config.url field
HTTP_JWT_ISSUERrequiredjwtAuthenticator() — the required iss
HTTP_JWT_AUDIENCErequiredjwtAuthenticator() — the required aud; a second scheme reads <variablePrefix>_JWKS_URI, _ISSUER and _AUDIENCE instead of the three
HTTP_SESSION_KEYSrequiredsessionCodec() — comma-separated 32-byte base64url keys; the first seals, every one unseals
HTTP_RESOURCE_URIrequiredprotectedResource() — this API's resource identifier, an https: URL
HTTP_RESOURCE_AUTHORIZATION_SERVERSrequiredprotectedResource() and protectedResourcePerHost() — comma-separated issuers its tokens come from
HTTP_RESOURCE_PATHrequiredprotectedResourcePerHost() — the path the resource answers at on every served host
HTTP_OIDC_ISSUERrequiredoidc() — 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_IDrequiredoidc() — this deployment's client; sessionAuthenticator() reads it too
HTTP_OIDC_CLIENT_SECRETrequiredoidc() — its secret: the authorization-code flow here is a confidential client's
HTTP_OIDC_REDIRECT_URIrequiredoidc() — 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_URIunsetoidc() — provider-registered absolute HTTP(S) logout return, without credentials or a fragment; requires tokens, pinned by postLogoutRedirectUri, and follows variablePrefix
GRAPHQL_DEVELOPER_TOOLSfalsegraphql() — GraphiQL, introspection and field suggestions, all or none; pinned by developerTools
GRAPHQL_MAX_TOKENS1000graphql() — the most tokens one document may parse to, 0 unbounded; pinned by maxTokens
TEMPORAL_ADDRESS127.0.0.1:7233TemporalModule
TEMPORAL_NAMESPACEdefaultTemporalModule
TEMPORAL_GRACE_PERIOD_MS10000TemporalModule — shutdownGraceTime
TEMPORAL_FORCE_AFTER_MS15000TemporalModule — shutdownForceTime
TEMPORAL_TLSunset — on with an API keyTemporalModule, 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_MS60000how often the files above are read again — see Securing the connection
AMQP_URLamqp://127.0.0.1:5672AmqpModule
AMQP_CONNECT_TIMEOUT_MS5000AmqpModule
OUTBOX_TENANTSrequiredoutbox() — the tenants this relay serves, comma-separated; pinned by tenants
OUTBOX_POLL_MS200outbox() — the idle sleep between sweeps; pinned by pollMs
OUTBOX_MAX_LAG_MS60000outbox() — the oldest pending age its health check tolerates; pinned by maxLagMs
OUTBOX_RETAIN_MS0 (kept)outbox() — how long a published row is kept before the relay deletes it; pinned by retainMs
OUTBOX_CONCURRENCY4outbox() — how many tenants claim or prune, each on a pooled connection, at once; pinned by concurrency
LOG_LEVELinfoobservability()
DATABASE_URLrequiredprismaDatabase() — the application role's credentials, not the owner's, where row security is on: prisma db migrate runs as the owner
REDIS_URLrequiredcache() over Redis
SMTP_URLrequiredmailer() over SMTP
OAUTH_ISSUERrequiredoauthClient() — the authorization server, a Config.url field; an http: one off loopback is refused unless allowInsecureIssuer is pinned
OAUTH_CLIENT_IDrequiredoauthClient() — this service's client; pinned by clientId
OAUTH_CLIENT_SECRETrequiredoauthClient() — its secret, from a Kubernetes Secret; pinned by clientSecret
STORAGE_S3_*see the pagestorage() over S3
KEYS_KMS_REGIONrequiredkmsKeys() — the AWS region of the owners' KMS keys
KEYS_KMS_ALIAS_PREFIXrequiredkmsKeys() — an owner's KMS key is the alias of this prefix and the owner
KEYS_KMS_ENDPOINTAWS's ownkmsKeys() — an emulator or a VPC endpoint, a Config.url field; pinned by endpoint
KEYS_CACHE_TTL_MS300000kmsKeys() — how long an unwrapped root key is kept, 0 none; pinned by cacheTtlMs
KEYS_KMS_PENDING_WINDOW_DAYS30kmsKeys() — 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 78 at 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 what pnpm dev does — 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 RollingUpdate that keeps them available until replacements are ready (maxUnavailable: 0); Recreate terminates 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:

ts
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 ​

Released under the MIT License.