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. examples/order-amqp-worker/src/outbox-relay.ts uses exactly this:

ts
export const relayConfig = Config.provider("RelayConfig")(
  Config.object({
    pollMs: Config.integer("OUTBOX_POLL_MS", {
      min: 1,
      max: 60_000,
      default: 200,
    }),
    tenants: Config.string("OUTBOX_TENANTS"),
  }),
);

tenants has no default, and that is the interesting half: 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 defaultNumber("") 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"
outside min/max (inclusive)must be between 1 and 64, got 100
a port0..655350 is legal, an ephemeral bind
a URL with no schemeis not a URL: "issuer.test/jwks"

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 — http({ 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>

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, 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)
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.0http()
HTTP_BODY_LIMIT1048576http()0 is unbounded
HTTP_CORS_ORIGINunset (CORS off)http() — comma-separated origins, or *
HTTP_COMPRESSIONfalsehttp() — 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
HTTP_SESSION_KEYSrequiredsessionCodec() — comma-separated 32-byte base64url keys; the first seals, every one unseals
HTTP_OIDC_ISSUERrequiredoidc() — the provider, as its discovery document names itself, a Config.url field
HTTP_OIDC_CLIENT_IDrequiredoidc() — this deployment's client
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
TEMPORAL_ADDRESS127.0.0.1:7233temporal()
TEMPORAL_NAMESPACEdefaulttemporal()
TEMPORAL_GRACE_PERIOD_MS10000temporal()shutdownGraceTime
TEMPORAL_FORCE_AFTER_MS15000temporal()shutdownForceTime
AMQP_URLamqp://127.0.0.1:5672amqp()
AMQP_CONNECT_TIMEOUT_MS5000amqp()
LOG_LEVELinfoobservability()
DATABASE_URLrequiredprismaDatabase() — the application role's credentials, not the owner's, where row security is on: prisma migrate deploy runs as the owner
REDIS_URLrequiredcache() over Redis
SMTP_URLrequiredmailer() over SMTP
STORAGE_S3_*see the pagestorage() over S3

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_, TEMPORAL_, AMQP_, STORAGE_S3_ 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 three 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.

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.