A Secret nobody created. You find out from the pod events.
Branded refs
- Secret.make(...).ref, not a string
- Wrong namespace won't compile
- Typo'd port names won't compile
Typesafe Kubernetes + ArgoCD manifests in TypeScript, powered by Effect. Branded refs, env contracts and a compile-time dependency graph catch the mistakes you'd otherwise discover during a Sunday-morning sync.
// Built on
// Break it, then watch it not compile
examples/full-stack/infra/envs/ ships compile-time catches, files
that exist to not typecheck. Hover a squiggle to read what the compiler says.
const api = defineApi({
name: "api",
source: src("api"),
replicas: 1,
sopsBase: "infra/secrets"
})
const worker = defineWorker({
name: "worker",
source: src("worker"),
replicas: 1,
sopsBase: "infra/secrets"
})
export default AppOfApps.fromModules({error TS2345: Missing provider for Image "api", Image "worker" and Secret "ghcr-pull".
Add a module that provides them to AppOfApps.fromModules({ modules }), or check that providers come before consumers in the list.
target: { repoURL: cluster.repositoryUrl, branch, rootPath },
defaults: {},
modules: [api, worker]
})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 }
}
})const api = Container.define({
name: "api",
image: "ghcr.io/example/api:1.0.0",
ports: [Port.make({ name: "http", containerPort: 8080 })],
readinessProbe: {
httpGet: { path: "/healthz", port: Port.ref("htp")error TS2322: Type 'PortName<"htp">' is not assignable to type 'number | PortName<"http">'.
The container declares only the port "http". }
}
})
const wrongTarget = Workload.web({
name: "api",
namespace: "app",
deployment: { containers: [api] },
service: { ports: [{ port: 80, targetPort: Port.ref("metrics")error TS2322: Type 'PortName<"metrics">' is not assignable to type 'number | PortName<"http">'.
No container in this workload declares a port named "metrics". }] }
})const data = Volume.fromPvc({ name: "data", claim: PvcRef.of("postgres-data") })
const typo = Pod.define({
volumes: [data],
containers: [
Container.define({error TS2322: Type 'ContainerSpec<"pg", "dat">' is not assignable to type 'ContainerSpec<string, "data">'.
The pod declares only the volume "data"; the mount names "dat".
name: "postgres",
image: "postgres:16",
ports: [Port.make({ name: "pg", containerPort: 5432 })],
volumeMounts: [{ name: Volume.mountRef("dat"), mountPath: "/var/lib/postgresql/data" }]
})
]
})
const rawClaim = Volume.fromPvc({
name: "data",
claim: "postgres-data"error TS2322: Type 'string' is not assignable to type 'PvcRef<string>'.
Use PvcRef.of("postgres-data") or a Dep.Pvc provider so the claim is tracked.
})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" })
})// One source, both sides of the wire
Rename DATABASE_URL in one place and the typechecker flags every consumer.
Secrets, literals and downward-API fields become one typed value that both sides import.
Environment.bind emits the Deployment env block plus the secret backend's CRs. A member without a backend does not compile.
Environment.runtime reads the same variables at startup into a typed record. Secrets arrive Redacted.
01 · shared/env-contracts
// Shared by api and worker so rotating once rotates everywhere.
export const dbCreds = Secret.define({
name: "db-creds",
namespace: "app",
env: {
url: "DATABASE_URL",
username: "DATABASE_USER",
password: "DATABASE_PASSWORD"
}
})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" })
})
})02 · manifest side · Environment.bind
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` })
}
})03 · process side · Environment.runtime
import { apiEnv } from "@example/env-contracts"
import { Environment } from "@konfig.ts/k8s"
import { Effect } from "effect"
const config = await Effect.runPromise(
Environment.runtime(apiEnv)
)
const port = config.http.port
const podName = config.runtime.podName
const logLevel = config.http.logLevel// What konfig.ts catches
A Secret nobody created. You find out from the pod events.
A dependency on another Application. You find out at sync time.
App reads DATABASE_URL, Deployment sets DB_URL.
A plaintext value in the sops file. It is in git now.
Pinned by version; the tarball changed anyway.
A 400-line diff, 396 lines of reordering.
// The mental model
Module.fixedNs wraps a build function into an
Application handle. Compose handles with
AppOfApps.fromModules; the unmet Needs shrink as providers join the list, and
entrypoint requires them to reach never.
yield* a dependency, return manifests konfig build renders Name
A string literal, always
Provides
Secrets, Images, Namespaces it emits
Needs
What another module must provide
export const defineApi = Application.module({
namespace: "app",
build: ({ name, namespace }, opts: ApiOpts) =>
Effect.gen(function*() {
const ghcrRef = yield* Dep.Secret("ghcr-pull")
const apiImage = yield* Dep.Image("api")
// … Environment.bind + Container.define, see the section above …
const workload = Workload.web({
name,
namespace,
deployment: {
replicas: opts.replicas,
imagePullSecrets: [{ name: ghcrRef }],
containers: [apiContainer]
},
service: {
ports: [{ port: 80, targetPort: Port.ref("http") }]
}
})
return [...bound.manifests, workload]
})
})export default AppOfApps.fromModules({
target: { repoURL: cluster.repositoryUrl, branch, rootPath },
defaults: { destination: { server: "https://kubernetes.default.svc" } },
modules: [
sopsOperator,
imagePulls,
featureFlags,
postgres,
apiBuild,
workerBuild,
redisCache,
api,
worker
]
})// The workflow
konfig emits manifests; Argo CD applies them. There's no operator, no admission controller, nothing running in the cluster.
$ konfig build prod
$ konfig validate prod --strict
$ konfig diff prod
$ konfig set prod api ghcr.io/example/api:1.2.0Render: Loads infra/env/prod.ts, resolves the dep graph, writes one directory per Application. Builds are input-hashed: a no-op build rewrites nothing, so Argo CD sees no churn.
Validate: Renders in memory and runs structural checks; --strict hands every document to kubeconform against the pinned --k8s-version.
Diff: Structural multi-document diff against the committed baseline. Key reordering is invisible; Secret data is redacted before it hits your terminal.
Promote: Rewrites images.json (schema-validated before and after) so image promotion is a one-line commit that re-renders deterministically.
Also in the box
$ konfig --help
$ konfig docker write apps/api
→ apps/api/Dockerfile
Multi-stage Dockerfile derived from the workspace graph: only the packages apps/api actually imports make it into the build context.
$ konfig crd extract | verify
→ types/crds.d.ts
TypeScript types generated from the CRDs in your Helm charts; verify fails the build when the chart's CRDs drift from the committed types.
$ konfig helm fetch --all
→ .helm-cache/
Warms the chart cache for every pinned chart and checks each archive against its recorded digest before anything renders.
$ konfig graph --with-dev
→ stdout
ASCII workspace dependency graph, dev dependencies included. Same graph that build and docker write use.
// Packages
Everything is published under @konfig.ts/*.
// What it is not
For overlaying hand-written YAML you already have. konfig owns the manifest source.
It emits manifests; Argo CD or kubectl applies them. No admission controller, no operator.
No Crossplane, no OAM, no “Service” model beyond the explicit Workload.web helper.
It calls helm. Charts you depend on stay charts; each templated document lifts as a RawYaml manifest.
The dependency graph is Effect's Requirements channel doing what it already does. A module's build is an Effect; yield* Dep.Secret("ghcr-pull") adds a requirement; composing modules provides it. There is no bespoke graph engine to learn. The compiler error you get is the ordinary "this Effect still needs X" error, with a konfig message attached.
Effect also gives us Schema at every binary boundary (helm, sops, kubeseal stdout), Config for the runtime decoder, and Redacted for secret values.
The CLI import()s your .ts sources at runtime, so it needs a TypeScript-capable runtime. Bun is the smoothest path; Node ≥ 23.6 (native type stripping) and Node 22.6+ with --experimental-strip-types work too, as does tsx. Pure-YAML commands like konfig diff run under plain Node.
Effect 4 is still in release-candidate. Its pre-release line makes breaking changes between builds, so the range is deliberately narrow: every @konfig.ts/* package accepts any rc build from 4.0.0-rc.111 on, and never crosses into another prerelease line or a stable major. The range widens once Effect ships a stable 4.x.
Yes, that is the intended path. Helm.release({ repo, chart, version, digest, values }) pulls the chart, verifies the tarball's SHA-256 against digest on every pull and every cache hit, templates it, and lifts each document as a RawYaml manifest inside your module. konfig crd extract even generates TypeScript types for the CRDs a chart ships.
Secret.define declares the shape only. At compose time you bind it to a backend: sops (encrypted file in the repo, decrypted in-cluster by the operator), sealed-secrets (kubeseal against the cluster cert), or external-secrets (fetched from your vault). Whether a backend needs a plaintext source is encoded in its type; the sops backend fails closed if it sees an unencrypted value.
One Application at a time. Wrap an existing workload in Module.fixedNs, point konfig build at a new manifests directory, and add an Argo CD Application for it. Everything else keeps syncing from wherever it syncs from today.
Start with the full-stack example: a monorepo with env contracts, a sops backend and Argo CD wiring, plus the compile-time catches shown above.
import { AppOfApps } from "@konfig.ts/argocd"