Skip to content
// v0.1.0 · built on Effect 4.0.0-rc

Kubernetes config that fails at tsc, not at argocd sync flux reconcile

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.

bun add -d @konfig.ts/cli

// Built on

  • Effect
  • Bun
  • Argo CD
  • Helm
  • sops
  • sealed-secrets
  • external-secrets
  • 721 tests
  • 95% line coverage
  • 9 packages

// Break it, then watch it not compile

Every one of these is a real file in the example repo

examples/full-stack/infra/envs/ ships compile-time catches, files that exist to not typecheck. Hover a squiggle to read what the compiler says.

infra/envs/broken.ts 1 error
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]
})
$ bun run check
infra/envs/broken.ts(30,3): error TS2345: Property '_konfig_unsatisfied' is missing … "Missing provider for Image \"api\"…" | "Missing provider for Image \"worker\"…" | "Missing provider for Secret \"ghcr-pull\"…"
Found 1 error.

// One source, both sides of the wire

Declare the env contract once. The pod spec and the process both import it.

Rename DATABASE_URL in one place and the typechecker flags every consumer.

  1. 01

    Declare the contract once

    Secrets, literals and downward-API fields become one typed value that both sides import.

  2. 02

    Bind it in the infra module

    Environment.bind emits the Deployment env block plus the secret backend's CRs. A member without a backend does not compile.

  3. 03

    Decode it in the process

    Environment.runtime reads the same variables at startup into a typed record. Secrets arrive Redacted.

  • Missing secret binding: compile error
  • Env-name collision between members: compile error

01 · shared/env-contracts

shared/env-contracts/src/secrets.ts
// 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"
  }
})
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" })
  })
})

02 · manifest side · Environment.bind

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` })
  }
})

03 · process side · Environment.runtime

apps/api/src/main.ts
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

The failure modes of YAML, moved to compile time

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

A dependency on another Application. You find out at sync time.

Dep graph at the type level

  • yield* Dep.Secret("…") records a Need
  • entrypoint requires every Need to be provided
  • Duplicate App names are rejected

App reads DATABASE_URL, Deployment sets DB_URL.

Env contracts

  • One Environment.define, both sides
  • Missing secret binding won't compile
  • Env-name collisions won't compile

A plaintext value in the sops file. It is in git now.

Secret backends

  • sops · sealed-secrets · external-secrets
  • requiresSource is in the backend's type
  • sops fails closed on plaintext

Pinned by version; the tarball changed anyway.

Helm with digest verification

  • Digest checked on pull and cache hit
  • Flip a byte, fail the next render
  • Charts stay charts

A 400-line diff, 396 lines of reordering.

Deterministic YAML, structural diff

  • Stable key order, Argo-friendly
  • konfig diff ignores reordering
  • No-op builds rewrite nothing

// The mental model

A module provides, needs, and emits. All three live in its type

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.

  • A build is a plain Effect. yield* a dependency, return manifests
  • The handle's type lists what it provides and what it still needs
  • The env file's default export is what konfig build renders
ApplicationHandle<Name, Provides, Needs>

Name

A string literal, always

Provides

Secrets, Images, Namespaces it emits

Needs

What another module must provide

infra/modules/api.ts
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]
    })
})
infra/envs/prod.ts
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

Four commands from source to a clean Argo CD sync

konfig emits manifests; Argo CD applies them. There's no operator, no admission controller, nothing running in the cluster.

zsh
$ konfig build prod
$ konfig validate prod --strict
$ konfig diff prod
$ konfig set prod api ghcr.io/example/api:1.2.0
  1. 01

    Render: 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.

  2. 02

    Validate: Renders in memory and runs structural checks; --strict hands every document to kubeconform against the pinned --k8s-version.

  3. 03

    Diff: Structural multi-document diff against the committed baseline. Key reordering is invisible; Secret data is redacted before it hits your terminal.

  4. 04

    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 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.

// What it is not

What konfig.ts leaves to other tools

Not a kustomize replacement

For overlaying hand-written YAML you already have. konfig owns the manifest source.

Not a runtime mutator

It emits manifests; Argo CD or kubectl applies them. No admission controller, no operator.

Not a higher-level abstraction

No Crossplane, no OAM, no “Service” model beyond the explicit Workload.web helper.

Not a helm replacement

It calls helm. Charts you depend on stay charts; each templated document lifts as a RawYaml manifest.

// FAQ

Questions a platform team asks first

Something missing? Open an issue.

Why is it built on Effect?

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.

Do I need Bun?

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.

Which Effect builds are supported?

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.

Can I keep my Helm charts?

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.

How do secrets stay out of git?

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.

Can I adopt it incrementally?

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"

Stop finding out at sync time

bun add -d @konfig.ts/cli