Skip to content
Docs menu / Branded refs

Branded refs

Kubernetes links resources by plain strings: an env var names a Secret, a volume names a ConfigMap, a Service names a container port. Nothing checks those strings until the cluster tries to resolve them. @konfig.ts/k8s replaces the strings with branded refs: values that are still plain strings at runtime but carry, in their type, which resource they point at. A reference to something nobody created, or created in the wrong namespace, fails to type-check.

The ref types

Every ref serialises to YAML as the bare name; the brand exists only in the type. You get one either by creating the resource (the builder’s ref field) or by consuming it through the dependency graph:

RefWhat the type tracksProduced by
SecretRefname, the keys it holds, its namespaceSecret.make(...).ref, Secret.bind(...).ref, Environment.bind(...).members.X.ref, yield* Dep.Secret(name)
ConfigMapRefname, the keys it holdsConfigMap.make(...).ref, yield* Dep.ConfigMap(name)
ServiceAccountRefnameServiceAccount.make(...).ref, yield* Dep.ServiceAccount(name)
PvcRefnameyield* Dep.Pvc(name), PvcRef.of(name)
BuiltImageRefapp nameyield* Dep.Image(app), BuiltImageRef.of({ app, registry, tag })
PortNameport namePort.make({ name, ... }), Port.ref(name)

Secret and ConfigMap refs also know their keys. Secret.make reads them from data / stringData, ConfigMap.make from data / binaryData. So EnvVar.fromConfigMap({ ref: featureFlags.ref, key: "NEW_UI" }) only accepts a key that exists, and renaming a key in the ConfigMap breaks every consumer at compile time.

For resources managed outside konfig.ts there are escape hatches: SecretRef.of(name), ConfigMapRef.of(name), ServiceAccountRef.of(name), and PvcRef.of(name). They mint a ref with no provider behind it, so use them only for genuinely external objects.

Enforcement points

The following inputs take a branded ref and reject a raw string:

  • Env vars: EnvVar.fromSecret and EnvVar.fromSecretForPod (the secret name in secretKeyRef), EnvVar.fromConfigMap (the name in configMapKeyRef).
  • Volumes: Volume.fromSecret({ ref }), Volume.fromConfigMap({ ref }), Volume.fromPvc({ claim }).
  • Pull secrets: imagePullSecrets on Workload.web, Workload.cron, ServiceAccount.make, and the pod spec inputs.
  • TLS: Ingress.tls({ secretName }) and the tls array of Ingress.make / Workload.web.
  • Ports: probes (httpGet.port, tcpSocket.port, grpc.port) and the targetPort of Workload.web service ports and Service.fromContainer accept a number or a Port.ref of a port declared on the containers. Plain Service.make takes untyped port objects.

Namespace match

The kube-apiserver only resolves a secretKeyRef inside the pod’s own namespace, so a secret ref also carries the namespace it was bound in. EnvVar.fromSecretForPod({ name, ref, key, podNamespace }) requires the two to match: passing a ref bound in "app" to a pod in "batch" is a compile error. EnvVar.secretEnv(ref, { DATABASE_URL: "url", DATABASE_PASSWORD: "password" }, { podNamespace }) does the same for several vars in one call (EnvVar.configMapEnv is the ConfigMap twin). EnvVar.fromSecret, or secretEnv without podNamespace, is the unchecked variant for cases where the namespace is not known at the call site, and SecretRef.unsafeReNamespace widens the slot when you have to.

Duplicate env names

Kubernetes silently keeps the last env var when two share a name. Container.define catches that at compile time by intersecting the names of all entries in env. A collision, for example a manual EnvVar.value({ name: "DATABASE_URL" }) next to ...bound.envVars (which already emits DATABASE_URL), produces a _konfig_duplicate_env_names hint: Duplicate env name(s): "DATABASE_URL". K8s silently last-wins; rename one of the colliding entries or remove the manual valueEnv that shadows another producer.

Container.define and Workload.web

Container.define returns a container spec that remembers the port names and mount names you declared. Probes on the same container, and Service ports on the enclosing Workload.web, are typed against those names, so a probe cannot point at a port that does not exist.

examples/full-stack/infra/modules/api.ts
const apiContainer = Container.define({
  name,
  image: apiImage,
  ports: [Port.make({ name: "http", containerPort: 8080 })],
  env: [
    ...bound.envVars,
    ...EnvVar.secretEnv(bound.members.db.ref, { DATABASE_URL_PRIMARY: "url" }, { podNamespace: namespace }),
    ...EnvVar.configMapEnv(featureFlags.ref, { FEATURE_NEW_UI: "NEW_UI" }),
    EnvVar.value({ name: "API_NAME", value: name })
  ],
  readinessProbe: {
    httpGet: { path: "/healthz", port: Port.ref("http") },
    periodSeconds: 5
  }
})

const workload = Workload.web({
  name,
  namespace,
  deployment: {
    replicas: opts.replicas,
    imagePullSecrets: [{ name: ghcrRef }],
    containers: [apiContainer]
  },
  service: {
    ports: [{ port: 80, targetPort: Port.ref("http") }]
  }
})

Workload.web bundles the common web-service shape into one call. It takes:

  • name, namespace, and optional labels, annotations, reloader.
  • deployment: containers plus optional replicas, volumes, imagePullSecrets, serviceAccountName, podLabels, podAnnotations.
  • service: ports (each targetPort is a number or a Port.ref of a port on one of the containers) and an optional type.
  • ingress (optional): ingressClassName, rules, tls, annotations.

It renders a Deployment and a Service, plus an Ingress when ingress is given. The Deployment’s selector is fixed to { app: name } and merged last into the pod labels, so a colliding podLabels entry cannot desync the selector from the pods.

Workload.cron is the equivalent for scheduled jobs. It renders a ServiceAccount named after the job and a CronJob that uses it. It takes:

  • name, namespace, schedule, containers (required).
  • labels, annotations, volumes, imagePullSecrets (optional).
  • concurrencyPolicy, successfulJobsHistoryLimit, failedJobsHistoryLimit, restartPolicy (optional; restartPolicy defaults to "OnFailure").

For anything outside those two shapes, use Deployment.make, StatefulSet.make, Job.make, CronJob.make, Service.make, and Ingress.make directly, as infra/modules/worker.ts does with Deployment.make.

Rolling on secret change

A Deployment does not restart when a Secret it references changes. konfig.ts has two ways to make it roll.

The first relies on Stakater Reloader. Workload.web({ reloader: "stakater" }) adds reloader.stakater.com/auto: "true" to the Deployment. "stakater-strict" adds reloader.stakater.com/match: "true" as well. { secrets: [...], configMaps: [...] } emits the comma-joined secret.reloader.stakater.com/reload / configmap.reloader.stakater.com/reload annotations. "off" (the default) emits nothing. This requires Stakater Reloader in the cluster and rolls on in-cluster change.

The second is a fingerprint of the values in the pod template. hashSecretValues({ values, salt }) returns a SHA-256 hex digest of the secret’s values (netstring-framed, prefixed with the version tag konfig/secret-values-hash/v1 and the salt, keys sorted). Put it in a pod annotation and the pod template changes whenever the values change at render time. Values are captured at build time only; a rotation between builds needs the reloader.

Next: Env contracts.