Skip to content
Docs menu / Env contracts

Env contracts

An env contract is a TypeScript value that describes every environment variable a process reads: which ones come from Secrets, which are literals, which come from the Kubernetes downward API. The same value drives two things. In the infra module, Environment.bind turns it into the pod’s env block and the secret manifests. In the application, Environment.runtime decodes process.env into typed values. Rename a variable in one place and both sides move together.

Declaring members

@konfig.ts/env has three leaf constructors (Secret.define, Literal.define, Downward.define) and one composite (Environment.define). Each returns an Effect Config (so the runtime can decode it) plus the metadata that bind reads to build manifests.

Secret.define({ name, namespace, env }) declares a Kubernetes Secret and maps each key to the env var it feeds:

examples/full-stack/shared/env-contracts/src/secrets.ts
export const dbCreds = Secret.define({
  name: "db-creds",
  namespace: "app",
  env: {
    url: "DATABASE_URL",
    username: "DATABASE_USER",
    password: "DATABASE_PASSWORD"
  }
})

Literal.define({ envName, value, schema?, serialize? }) declares a constant. Without a schema, runtime returns the value as declared. With one (for example Config.number("HTTP_PORT").pipe(Config.withDefault(8080))) it reads and parses the variable instead. serialize controls how the value is written into the pod; it is optional for primitives (String(value)) and required for objects.

Downward.define({ envName, fieldPath }) declares a downward-API field such as metadata.name. The runtime reads it as a string.

Environment.define({ ... }) groups members. The group is itself a member, so contracts nest:

examples/full-stack/shared/env-contracts/src/bundles.ts
export const apiEnv = Environment.define({
  db: dbCreds,
  s3: s3Creds,
  jwt: jwtKey,
  http: Environment.define({
    port: Literal.define({
      envName: "HTTP_PORT",
      value: 8080,
      schema: Config.Number("HTTP_PORT").pipe(Config.withDefault(8080))
    }),
    logLevel: Literal.define({
      envName: "LOG_LEVEL",
      value: "info",
      schema: Config.String("LOG_LEVEL").pipe(Config.withDefault("info"))
    })
  }),
  runtime: Environment.define({
    nodeEnv: Literal.define({ envName: "NODE_ENV", value: "production" }),
    podName: Downward.define({ envName: "POD_NAME", fieldPath: "metadata.name" })
  })
})

workerEnv reuses dbCreds and adds its own worker group, so a rotation of db-creds covers both apps and no env var name is claimed twice.

Env name collisions

Two members claiming the same variable would let one silently overwrite the other in the pod. Environment.define rejects that at two levels. For direct leaf members it is a type error (a _konfig_error: 'Environment: envName "X" is claimed by multiple members' hint). The authoritative check, which also covers nested environments, throws EnvNameCollision { envName, claims } when the defining module is evaluated.

examples/full-stack/infra/envs/envname-collision.ts 1 error
const secretLiteralDup = Environment.define({error TS2345: Property '_konfig_error' is missing but required in type _EnvNameCollisionError<"DATABASE_URL">.
Two members claim the env var DATABASE_URL.
  db: Secret.define({
    name: "db",
    namespace: "app",
    env: { url: "DATABASE_URL" }
  }),
  shadow: Literal.define({ envName: "DATABASE_URL", value: "x" })
})
$ bun run check
infra/envs/envname-collision.ts(23,46): error TS2345: Property '_konfig_error' is missing … but required in type '_EnvNameCollisionError<"DATABASE_URL">'.

Binding in a module

Binding is where the contract meets Kubernetes. Environment.bind (exported from @konfig.ts/k8s, which merges the contract API with bind and runtime) takes the contract plus the information only the infra side knows:

Environment.bind({
env, // the Environment
namespace?, // Ns literal; brands every SecretRef it returns
secrets, // required when the env has any Secret member
literals? // per-member overrides for Literal values
})

secrets says how each secret member becomes a manifest. It is keyed by member name (nested environments nest the record) and becomes required as soon as any member, nested or not, is a Secret. Each entry is { backend, source?, labels?, annotations? }. Whether source is required depends on the backend (see Secret backends): a backend whose type says requiresSource: true makes source mandatory, one that says false makes it optional. An entry with only { source } is also allowed, for members that need render-time values but no manifest. literals is keyed the same way and overrides a Literal member’s value, re-serialised with that member’s serialize.

examples/full-stack/infra/modules/api.ts
const bound = Environment.bind({
  env: apiEnv,
  namespace,
  secrets: {
    db: Sops.passthrough({ file: `${opts.sopsBase}/SopsSecret-db-creds.yaml` }),
    s3: Sops.passthrough({ file: `${opts.sopsBase}/SopsSecret-s3-creds.yaml` }),
    jwt: Sops.passthrough({ file: `${opts.sopsBase}/SopsSecret-jwt-signing-key.yaml` })
  }
})

Leaving a secret member out, or giving a source-requiring backend no source, fails to compile:

examples/full-stack/infra/envs/unbound-secret.ts 3 errors
const missingJwt = Environment.bind({
  env: apiEnv,
  namespace: "app",
  secretserror TS2741: Property 'jwt' is missing in secrets.
Every secret member of apiEnv (db, s3, jwt) needs a backend.: {
    db: { backend: dbBackend },
    s3: { backend: s3Backend }
  }
})

const emptyDb = Environment.bind({
  env: apiEnv,
  namespace: "app",
  secrets: {
    db: {}error TS2322: Type '{}' is not assignable to SecretMemberOptions<"db-creds">.
A member needs at least a backend.,
    s3: { backend: s3Backend },
    jwt: { backend: jwtBackend }
  }
})

// `Sops.backend` requires a `source` at the type level.
const sopsBackend = Sops.backend({
  recipients: {
    age: ["age1demo000000000000000000000000000000000000000000000000000example"]
  }
})
const missingSource = Environment.bind({
  env: apiEnv,
  namespace: "app",
  secrets: {
    db: { backend: sopsBackend }error TS2322: Property 'source' is missing.
Sops.backend requires a source; its type says requiresSource: true.,
    s3: { backend: s3Backend },
    jwt: { backend: jwtBackend }
  }
})
$ bun run check
infra/envs/unbound-secret.ts(39,3): error TS2741: Property 'jwt' is missing in type '{ db: …; s3: … }' but required in type 'SecretMembersOpts<…>'.

The result is a declared environment with four parts:

FieldWhat it holds
bound.envVarsOne env var per leaf, in declaration order: secret keys become valueFrom.secretKeyRef, literals become value, downward entries become valueFrom.fieldRef. Spread it into Container.define({ env: [...] }).
bound.manifestsWhatever each backend emitted (a SopsSecret, SealedSecret, ExternalSecret, or native Secret per member). Return them from build alongside the workload.
bound.members.<name>The declared shape of each member; nested environments give nested records. For a secret it holds ref, name, namespace, keys, envVars, and optionally manifest, refLayer, values, layer. The ref is a branded secret reference usable anywhere a ref is required; api.ts uses bound.members.db.ref for an extra DATABASE_URL_PRIMARY variable.
bound.valuesLayerOne merged layer providing the decrypted values (Dep.SecretValues) for every member that had a source, in case a manifest needs the plaintext at render time (for example to feed hashSecretValues).

Secret.bind({ secret, backend?, source?, namespace?, labels?, annotations? }) is the single-member version. infra/modules/image-pulls.ts uses it for the pull credential that never becomes an env var. It returns the same per-member shape described above; manifest is undefined when no backend is given, and namespace falls back to the contract’s own.

Decoding at runtime

In the application, Environment.runtime(env) returns an Effect that reads process.env and produces the same nested record as the contract, or fails with a ConfigError. Every secret key comes back as a Redacted<string>; literals come back as their schema’s output type.

examples/full-stack/apps/api/src/main.ts
const config = await Effect.runPromise(
  Environment.runtime(apiEnv).pipe(
    Effect.catchCause((cause): Effect.Effect<never> =>
      Effect.gen(function*() {
        yield* Effect.logError(`api: failed to decode env contract — ${Cause.pretty(cause)}`)
        yield* Effect.logError(
          `api: check that every env var declared in apiEnv is set (HTTP_PORT, LOG_LEVEL, NODE_ENV, POD_NAME, DATABASE_*, S3_*, JWT_SIGNING_KEY)`
        )
        return process.exit(78)
      })
    )
  )
)

const port = config.http.port
const podName = config.runtime.podName
const logLevel = config.http.logLevel

config.db.url is Redacted<string>, config.http.port is number, config.runtime.podName is string. Secrets never appear in logs unless you call Redacted.value explicitly.

Next: Secret backends.