Secrets with sops
The sops approach to secrets is to commit ciphertext to git and let an in-cluster operator decrypt it. @konfig.ts/sops connects that to konfig.ts: it takes a secret contract written with Secret.define and renders it as a SopsSecret custom resource, which the sops-secrets-operator decrypts into a native Kubernetes Secret. Ciphertext is the only thing that ever reaches the rendered manifests. This guide follows the example project’s db-creds secret from contract to rotation.
Steps
-
Define the contract with
Secret.define. A contract names the Kubernetes Secret, its namespace, and which key becomes which env var. The same value is imported by the workload module (to bind it) and by the app (to decode the env at runtime), so renaming a key moves both sides at once.shared/env-contracts/src/secrets.ts export const dbCreds = Secret.define({ name: "db-creds", namespace: "app", env: { url: "DATABASE_URL", username: "DATABASE_USER", password: "DATABASE_PASSWORD" } }) -
Create the encrypted file. The committed file is a complete
SopsSecretCR encrypted withsops. Only the values understringData(anddata) are ciphertext; the metadata and thesops:block stay readable. Write the plaintext CR, then runsops --encrypt --age <recipient> --encrypted-regex '^(data|stringData)$' --in-place infra/secrets/SopsSecret-db-creds.yaml. The result looks like this (the example ships fake ciphertext so it renders offline):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] sops: age: - recipient: age1demo000000000000000000000000000000000000000000000000000example enc: | -----BEGIN AGE ENCRYPTED FILE----- -----END AGE ENCRYPTED FILE----- lastmodified: "2026-05-31T00:00:00Z" mac: ENC[AES256_GCM,data:demo,iv:demo,tag:demo,type:str] pgp: [] encrypted_regex: ^(data|stringData)$ version: 3.8.1Three things must line up:
spec.secretTemplates[].namemust equalmetadata.name, both should equal the name in the contract, and the keys understringDatamust cover every key of the contract’s env map. -
Install the operator as its own Application at an early sync wave, so the CRD and controller exist before any
SopsSecretis applied. The operator reads its age key from a Secret you create out of band (sops-agein the example).infra/modules/sops-operator.ts export const defineSopsOperator = Application.module({ namespace: "sops", annotations: Sync.wave(-2), build: ({ namespace }, opts: Record<never, never>) => { const ns = Namespace.make({ name: namespace }) const release = Helm.release({ repo: "https://isindir.github.io/sops-secrets-operator/", chart: "sops-secrets-operator", version: "0.19.0", digest: "sha256:e2a1cd7ef2c6fd53aad8fa49a1080d425c3648177a87fc20d5f9f6133cbb8e54", namespace, extraOpts: ["--include-crds"], values: { secretsAsFiles: [ { name: "age-key", mountPath: "/etc/sops-age", secretName: "sops-age" } ], extraEnv: [{ name: "SOPS_AGE_KEY_FILE", value: "/etc/sops-age/age.key" }] } }) return [ns, release] } }) -
Bind the contract in the workload module. Binding turns it into manifests and container env entries.
Environment.bindtakes the env contract and one backend per secret member. WithSops.passthrough({ file })as the backend, the encrypted file is read at render time and emitted as the manifest. The result of the bind carries the CRs undermanifestsand thesecretKeyRefenv entries for the container underenvVars.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` }) } })For a single secret outside an env contract,
Secret.bind({ secret, backend })does the same for one contract; the example’simage-pulls.tsuses it for the registry credential. Its result, aDeclaredSecret, exposes:Field Meaning ref,name,namespace,keysThe secret’s reference and identity envVarsThe secretKeyRefenv entriesmanifestThe rendered CR refLayerProvides the secret to the dependency graph values,layerOnly when you pass a source: the decrypted values and the layer providing them -
Render with
konfig build prod. It writesSopsSecret-db-creds.yamlinto theapiApplication’s directory (and intoworker’s, which binds the same contract). Argo CD applies it, the operator decrypts it in-cluster, and the pod’sDATABASE_URLenv var resolves from the resulting native Secret.
passthrough versus backend + source
There are two ways to get from an encrypted file to a rendered CR, and the difference is whether the build machine needs to decrypt anything. Sops exposes three functions for them:
Sops.passthrough({ file }) | Sops.backend({ recipients, type? }) + Sops.source({ file, keys, extract? }) | |
|---|---|---|
Needs sops binary at build | No | Yes (sops --decrypt then sops --encrypt) |
| Needs the private key at build | No | Yes, to decrypt the source |
source in the bind | Optional | Required by the type |
| Output | The committed file, verified and restamped | A fresh SopsSecret re-encrypted to recipients |
Use passthrough when the file is already encrypted for the cluster and CI should not hold a decryption key. Use backend plus source when the source of truth is encrypted for developers and CI re-encrypts it to the cluster’s recipient. The source is also the way to feed a different backend altogether: Sops.source yields redacted values that any secret backend can consume, sealed-secrets for example.
recipients accepts age, kms, gcpKms, azureKv, and pgp arrays. type sets the Kubernetes Secret type (secretTemplates[].type) and defaults to Opaque.
Restamping: passthrough compares the file’s metadata.namespace, metadata.name, and spec.secretTemplates[].name with the values from the bind. When they differ, it rewrites them only if the file’s sops.mac_only_encrypted is true. Otherwise the sops MAC covers those fields, rewriting would break it, and the render fails with a RenderError (refusing to restamp ...). Name the file’s metadata to match the contract and this never comes up.
Fail closed
Nothing that looks like plaintext gets rendered. Every emitted CR passes through the same pipeline: parse the YAML, decode it against the SopsSecret schema, then check that each value under spec.secretTemplates[].stringData and .data starts with ENC[. When the file’s sops.encrypted_regex is present, only keys whose path segment matches it must carry the marker. A value without the marker fails the render with a RenderError whose cause is a SopsUnencryptedValueError. konfig build then fails as a whole and writes nothing for the environment. The message reads:
Sops.passthrough(app/db-creds): refusing to emit — value for "password" is not sops-encrypted (missing ENC[ marker)The same check runs on the sops --encrypt output in backend mode, so a misconfigured encrypted_regex cannot produce a plaintext CR.
Rotation
Rotating a value means re-encrypting the file (sops --rotate, or edit and re-encrypt), committing, and rendering. The manifest changes, Argo CD applies it, and the operator updates the native Secret. Whether running pods pick the change up depends on which mechanism you asked for.
- Reloader annotations.
Workload.web({ reloader: "stakater" })addsreloader.stakater.com/auto: "true"to the Deployment, so a running Reloader restarts pods when the in-cluster Secret changes."stakater-strict"addsreloader.stakater.com/match: "true"next to it.{ secrets: [...], configMaps: [...] }writessecret.reloader.stakater.com/reloadandconfigmap.reloader.stakater.com/reloadwith the given names. This is the option that works with passthrough, because konfig never sees the plaintext. - A value hash in the pod template.
hashSecretValues({ values, salt })from@konfig.ts/k8sproduces a fingerprint of the resolved values that you put in a pod annotation. A re-render with new values changes the pod template and rolls the Deployment without any controller. It needs the plaintext at build time, so it pairs withSops.source(orSecret.bind({ source }), which exposes the values), not with passthrough.
Next: Secret backends compares sops with sealed-secrets and external-secrets and explains requiresSource.