Secret backends
A secret backend decides what manifest a secret member of an env contract turns into when it is bound: a sops-encrypted document, a SealedSecret, an ExternalSecret, or a plain Kubernetes Secret. The binding functions (Environment.bind and Secret.bind) never touch plaintext themselves. They hand the member’s name, namespace, and key list to the backend’s emit function, along with an optional secret source that can resolve the actual values when a backend needs them.
The interface
interface SecretBackend<N extends string, K extends string, RequiresSource extends boolean = boolean, Out = unknown> { readonly _tag: "Sops" | "Sops.passthrough" | "SealedSecrets" | "ExternalSecrets" | "NativeSecret" readonly requiresSource: RequiresSource readonly emit: (input: BackendEmitInput<N, K, RequiresSource>) => Manifest.Manifest<Out>}The input to emit carries the member’s name, namespace, and keys, optional labels and annotations, and the source. Whether the source is guaranteed to be present is decided by the requiresSource flag: for a true backend it is always there, for a false backend it may be undefined.
That flag lives in the type, not only at runtime, because it changes the shape of the secrets entry you must write in Environment.bind. A true backend makes source a required property; a false backend makes it optional. Forgetting the source for a backend that encrypts at render time is therefore a compile error rather than a failed render. The runtime still checks as well, because the flag is erased inside the generic bind code: a backend that requires a source and gets none fails the render with RenderError and the message backend "<tag>" requires a source but none was provided for secret "<namespace>/<name>".
Secret sources
A secret source is a small object with a list of keys and a resolve Effect that produces one Redacted<string> per key (or fails with SecretSourceError). Four are built in:
| Source | Package | Where the values come from |
|---|---|---|
SecretSource.fromConfig({ keys, envName? }) | @konfig.ts/env | One environment variable per key; envName: (key) => string maps a key to its variable name. |
SecretSource.literal({ data }) | @konfig.ts/env | Values given inline. |
SecretSource.fromCommand({ keys, run }) | @konfig.ts/env | One subprocess per key, run: (key) => ({ cmd, args }); empty output is an error. |
Sops.source({ file, keys, extract? }) | @konfig.ts/sops | Runs sops --decrypt on the file per resolve and plucks the keys from the parsed YAML. |
Backends
| Backend | Package | requiresSource | Emits |
|---|---|---|---|
NativeSecret.backend(opts?) | @konfig.ts/k8s | true | v1/Secret with stringData |
Sops.backend({ recipients, type? }) | @konfig.ts/sops | true | SopsSecret (encrypted at render time) |
Sops.passthrough({ file }) | @konfig.ts/sops | false | SopsSecret read from disk |
SealedSecrets.backend({ scope?, certPath? }) | @konfig.ts/sealed-secrets | true | SealedSecret |
ExternalSecrets.backend({ secretStoreRef, refreshInterval?, remoteRef?, target? }) | @konfig.ts/external-secrets | false | ExternalSecret |
NativeSecret
The simplest backend writes the resolved plaintext straight into a stringData Secret. It is intended for local clusters and tests, and every render logs a warning that a plaintext Secret is being written to disk unless you pass silenceWarning: true. The other options are type and immutable.
Sops
Sops.backend encrypts at render time. It resolves the source, serialises a plain SopsSecret custom resource (isindir.github.com/v1alpha3, one secretTemplates entry with stringData), pipes it through sops --encrypt for the given recipients, and decodes the output. Recipients can be age, kms, gcpKms, azureKv, or pgp, each validated against its key format.
Sops.passthrough skips encryption and reads an already-encrypted file from disk. It works offline and needs no sops binary. If the file’s name, namespace, or template name differ from the bound member, it restamps them; that is only allowed when the file’s sops.mac_only_encrypted is true, because otherwise the MAC covers those fields and the render fails.
Both paths share a fail-closed verification step before anything is emitted:
- The document must decode against the
SopsSecretschema (isindir.github.com/v1alpha3,metadata.name/metadata.namespace,spec.secretTemplates, and a top-levelsopsblock). A mismatch is aBoundaryDecodeErrorlabelledSopsSecret. - Every value under
stringData/datamust carry theENC[marker. When the file setssops.encrypted_regex, only entries whose path segment (spec,secretTemplates,stringData,data, or the key) matches the regex must. A plaintext value fails with aRenderErrorsaying it is refusing to emit because the value for that key is not sops-encrypted.
The example commits four passthrough files under infra/secrets/:
apiVersion: isindir.github.com/v1alpha3
kind: SopsSecret
metadata:
name: db-creds
namespace: app
spec:
secretTemplates:
- name: db-creds
type: Opaque
stringData:
url: ENC[AES256_GCM,data:ZmFrZS1lbmNyeXB0ZWQtdXJsLWRlbW8K,iv:0000,tag:0000,type:str]
username: ENC[AES256_GCM,data:ZmFrZQ==,iv:0000,tag:0000,type:str]
password: ENC[AES256_GCM,data:ZmFrZQ==,iv:0000,tag:0000,type:str]and binds them per member:
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` })
}
})In the cluster, the sops-secrets-operator (installed by infra/modules/sops-operator.ts at sync wave -2) reconciles each SopsSecret into a native Secret.
Sealed Secrets
SealedSecrets.backend encrypts with the cluster’s public certificate. It resolves the certificate first, then the source, builds a plain Opaque v1/Secret YAML, and pipes it into kubeseal --cert <path> --scope <scope> --format yaml. The output is decoded against the SealedSecret schema.
- The certificate path comes from the
certPathoption or theKUBESEAL_CERTenvironment variable. When neither is set the render fails with aRenderErrorwhose message isSealedSecrets(<namespace>/<name>): cert missingand whosecauseisKubesealCertMissing. scopedefaults to"strict";"namespace-wide"and"cluster-wide"are accepted.- A failed
kubesealinvocation, a non-YAML result (KubesealParseError), or a schema mismatch all show up as aRenderErrorwith the message... kubeseal failedand the underlying error ascause.
External Secrets
ExternalSecrets.backend needs no plaintext at all: the values stay in your external store, and the manifest only tells the External Secrets operator where to fetch them. It emits an external-secrets.io/v1 ExternalSecret whose data maps each key to { secretKey: key, remoteRef: remoteRef(key) }.
remoteRefdefaults to(key) => ({ key }); passremoteRef: (key) => ({ key: "prod/db", property: key })to map onto your store’s layout.secretStoreRef.kinddefaults toSecretStore.refreshIntervalis a duration string such as1h(typed as a number followed bys,m, orh).targetis passed through unchanged.
Because no source is required, a member bound this way has no values / layer unless you also pass a source for render-time use.
Next: Helm.