Skip to content
Docs menu / Secrets with sops

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

  1. 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"
      }
    })
  2. Create the encrypted file. The committed file is a complete SopsSecret CR encrypted with sops. Only the values under stringData (and data) are ciphertext; the metadata and the sops: block stay readable. Write the plaintext CR, then run sops --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.1

    Three things must line up: spec.secretTemplates[].name must equal metadata.name, both should equal the name in the contract, and the keys under stringData must cover every key of the contract’s env map.

  3. Install the operator as its own Application at an early sync wave, so the CRD and controller exist before any SopsSecret is applied. The operator reads its age key from a Secret you create out of band (sops-age in 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]
      }
    })
  4. Bind the contract in the workload module. Binding turns it into manifests and container env entries. Environment.bind takes the env contract and one backend per secret member. With Sops.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 under manifests and the secretKeyRef env entries for the container under envVars.

    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’s image-pulls.ts uses it for the registry credential. Its result, a DeclaredSecret, exposes:

    FieldMeaning
    ref, name, namespace, keysThe secret’s reference and identity
    envVarsThe secretKeyRef env entries
    manifestThe rendered CR
    refLayerProvides the secret to the dependency graph
    values, layerOnly when you pass a source: the decrypted values and the layer providing them
  5. Render with konfig build prod. It writes SopsSecret-db-creds.yaml into the api Application’s directory (and into worker’s, which binds the same contract). Argo CD applies it, the operator decrypts it in-cluster, and the pod’s DATABASE_URL env 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 buildNoYes (sops --decrypt then sops --encrypt)
Needs the private key at buildNoYes, to decrypt the source
source in the bindOptionalRequired by the type
OutputThe committed file, verified and restampedA 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" }) adds reloader.stakater.com/auto: "true" to the Deployment, so a running Reloader restarts pods when the in-cluster Secret changes. "stakater-strict" adds reloader.stakater.com/match: "true" next to it. { secrets: [...], configMaps: [...] } writes secret.reloader.stakater.com/reload and configmap.reloader.stakater.com/reload with 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/k8s produces 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 with Sops.source (or Secret.bind({ source }), which exposes the values), not with passthrough.

Next: Secret backends compares sops with sealed-secrets and external-secrets and explains requiresSource.