Skip to content
Docs menu / Secret backends

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:

SourcePackageWhere the values come from
SecretSource.fromConfig({ keys, envName? })@konfig.ts/envOne environment variable per key; envName: (key) => string maps a key to its variable name.
SecretSource.literal({ data })@konfig.ts/envValues given inline.
SecretSource.fromCommand({ keys, run })@konfig.ts/envOne subprocess per key, run: (key) => ({ cmd, args }); empty output is an error.
Sops.source({ file, keys, extract? })@konfig.ts/sopsRuns sops --decrypt on the file per resolve and plucks the keys from the parsed YAML.

Backends

BackendPackagerequiresSourceEmits
NativeSecret.backend(opts?)@konfig.ts/k8struev1/Secret with stringData
Sops.backend({ recipients, type? })@konfig.ts/sopstrueSopsSecret (encrypted at render time)
Sops.passthrough({ file })@konfig.ts/sopsfalseSopsSecret read from disk
SealedSecrets.backend({ scope?, certPath? })@konfig.ts/sealed-secretstrueSealedSecret
ExternalSecrets.backend({ secretStoreRef, refreshInterval?, remoteRef?, target? })@konfig.ts/external-secretsfalseExternalSecret

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 SopsSecret schema (isindir.github.com/v1alpha3, metadata.name / metadata.namespace, spec.secretTemplates, and a top-level sops block). A mismatch is a BoundaryDecodeError labelled SopsSecret.
  • Every value under stringData / data must carry the ENC[ marker. When the file sets sops.encrypted_regex, only entries whose path segment (spec, secretTemplates, stringData, data, or the key) matches the regex must. A plaintext value fails with a RenderError saying it is refusing to emit because the value for that key is not sops-encrypted.

The example commits four passthrough files under infra/secrets/:

examples/full-stack/infra/secrets/SopsSecret-db-creds.yaml
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:

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

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 certPath option or the KUBESEAL_CERT environment variable. When neither is set the render fails with a RenderError whose message is SealedSecrets(<namespace>/<name>): cert missing and whose cause is KubesealCertMissing.
  • scope defaults to "strict"; "namespace-wide" and "cluster-wide" are accepted.
  • A failed kubeseal invocation, a non-YAML result (KubesealParseError), or a schema mismatch all show up as a RenderError with the message ... kubeseal failed and the underlying error as cause.

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

  • remoteRef defaults to (key) => ({ key }); pass remoteRef: (key) => ({ key: "prod/db", property: key }) to map onto your store’s layout.
  • secretStoreRef.kind defaults to SecretStore.
  • refreshInterval is a duration string such as 1h (typed as a number followed by s, m, or h).
  • target is 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.