Secrets with SOPS¶
You add secrets: - secrets/prod.yaml to a release, run helmfile diff in CI, and the database password
that file was supposed to protect scrolls straight down the build log in plaintext. Encrypting a file at
rest never promised anything about what happens after it's decrypted — and that gap is exactly where this
page slows down: what SOPS actually encrypts, how helm-secrets bridges it into Helmfile, what a
.sops.yaml rule decides, and the failure modes that show up once a team (not just a laptop) needs to
decrypt.
TL;DR — in 30 seconds:
- SOPS,
helm-secrets, and Helmfile are three separate tools chained together — SOPS encrypts leaf values,helm-secretsdecrypts them via a key backend, Helmfile just orchestrates the hand-off. - SOPS only encrypts leaf values, not the YAML's keys or shape — a
git diffon an encrypted file still shows which key changed. helm-secretsdecrypts into memory only, buthelmfile diff/--debugoutput and shell history can still leak plaintext — pass--suppress-secretsin any CI log that isn't private.
1. Three tools, one job¶
"Secrets in Helmfile" is really three separate tools chained together, each with a narrow responsibility:
| Tool | Responsibility | Knows about Helmfile? |
|---|---|---|
| SOPS | Encrypts/decrypts individual leaf values in a YAML/JSON/INI file, using a key from a backend (age, KMS, PGP) | No — a general-purpose file encryptor |
helm-secrets |
A Helm plugin that finds secrets: entries, shells out to SOPS to decrypt them, and feeds the plaintext to Helm |
No — a Helm plugin, reusable outside Helmfile |
| Helmfile | Reads the secrets: key on a release or environment, hands each entry to helm-secrets before the Helm call |
Orchestrates the other two |
None of the three requires the others to exist independently — you could run sops by hand, or use
helm-secrets with plain helm and no Helmfile. Helmfile's contribution is just: treat secrets: as a
second values list, decrypted immediately before use.
flowchart LR
SecretsKey["release/environment<br/>secrets: list"]
HelmSecrets["helm-secrets plugin"]
Sops["sops CLI"]
Backend["key backend<br/>(age / KMS / PGP)"]
Plaintext["plaintext values<br/>(in memory only)"]
Helm["helm upgrade --install"]
SecretsKey --> HelmSecrets --> Sops --> Backend
Backend --> Plaintext --> Helm
2. What actually gets encrypted¶
SOPS encrypts a file's leaf values, not the file itself — keys, structure, and YAML shape stay plaintext:
# secrets/prod.yaml, before sops --encrypt --in-place
db:
password: hunter2
url: postgres://prod.example/app
# after sops --encrypt --in-place — structure is unchanged, values are ciphertext
db:
password: ENC[AES256_GCM,data:Ay8k...,iv:...,tag:...,type:str]
url: ENC[AES256_GCM,data:Qw1z...,iv:...,tag:...,type:str]
This is deliberate: a git diff on an encrypted file still shows which key changed, even though it
can't show the new value — a meaningful signal in code review without exposing the secret itself. SOPS
also appends a sops: metadata block (the encrypted data key, the backend used, a MAC over the file) that
you should never hand-edit.
3. .sops.yaml decides who encrypts with what¶
A .sops.yaml at the repo root maps file paths to key backends via creation_rules:, so sops --encrypt
doesn't need --age or --kms flags on every call:
# .sops.yaml
creation_rules:
- path_regex: secrets/prod.*\.yaml$
kms: arn:aws:kms:eu-west-1:123456789012:key/abc
- path_regex: secrets/staging.*\.yaml$
kms: arn:aws:kms:eu-west-1:123456789012:key/def
- path_regex: secrets/.*\.yaml$
age: age1qz... # fallback for anything else under secrets/
Rules are matched top to bottom, first match wins — order the more specific path_regex entries
before the general fallback, the same way you'd order a switch statement. Decryption doesn't need
.sops.yaml at all: the file's own sops: metadata block already records which backend and key
encrypted it, so sops decrypt (or helm-secrets calling it for you) can always find its way back —
.sops.yaml only matters for the next encryption, not for reading an existing file.
4. Two key backends, two operational models¶
| Backend | Setup | Who can decrypt | Fits |
|---|---|---|---|
| age | One keypair per person (age-keygen), public key goes in .sops.yaml |
Anyone whose public key is listed as a recipient | Laptop-only, small teams, no cloud dependency |
| KMS (AWS/GCP/Azure) | A cloud key resource; .sops.yaml references its ARN/ID |
Anyone whose IAM/cloud identity has decrypt permission on that key | Teams and CI, decrypt authorization lives in the cloud, not on a laptop |
The practical difference: with age, losing a laptop means losing a private key, and rotating access means re-encrypting every file for the new recipient list. With KMS, access is an IAM policy — revoke a role, decryption stops, no file needs to change. Most teams start on age and move to KMS once more than a handful of people (or any CI runner) need to decrypt.
flowchart TB
subgraph Age["age backend"]
AgeKey["private key file<br/>on each person's laptop"]
end
subgraph Kms["KMS backend"]
IamRole["IAM role / cloud identity"]
KmsKey["cloud KMS key"]
IamRole -->|"decrypt permission"| KmsKey
end
File["encrypted values file"]
AgeKey --> File
KmsKey --> File
5. Referencing encrypted files from helmfile.yaml¶
secrets: accepts the same shapes as values: — a list of files, resolved relative to the
helmfile.yaml that declares it — on a release, on an environment, or both:
releases:
- name: demo-app
chart: ./charts/demo-app
values:
- releases/demo-app.yaml
secrets:
- secrets/prod.yaml # release-scoped secret
environments:
prod:
values:
- environments/prod.yaml
secrets:
- secrets/prod-env.yaml # environment-scoped secret, becomes .Environment.Values
The two land in different places: a release's secrets: merges into that release's Helm values (same
tree as its values: list); an environment's secrets: merges into .Environment.Values, exactly like
environment values: does — see the "Environments & values layering" page for how that tree gets
consumed by templated values files.
6. Never touches disk in plaintext — but logs and shells still can¶
helm-secrets decrypts into memory and pipes straight to Helm; the plaintext file never lands on disk.
That guarantee doesn't automatically extend to everywhere the value travels afterwards:
- Diff output.
helmfile diffprints the rendered values, which would include decrypted secrets unless you pass--suppress-secrets— non-negotiable in any CI log that isn't private. --debug. Printing the underlyinghelminvocations can include--setvalues if a secret ever ends up there — prefervalues:/secrets:files overset:for anything sensitive.- Shell history and process lists. Passing a secret via an environment variable or CLI flag (instead
of through the
secrets:file mechanism) risks it showing up inpsoutput or a shell's history file.
The rule of thumb: if a value is sensitive, it belongs in a secrets:-referenced file, decrypted only by
helm-secrets, never typed on a command line or exported as a bare env var.
7. How this connects¶
- §5 of "Environments & values layering" only covers where
secrets:sits in the merge order — how the ciphertext it points to actually gets decrypted is this page's whole job; read that page first if thevalues:/secrets:distinction itself is still unclear. - The release DAG page, next in this course, is where
needs:ordering and--suppress-secretsmeet: a release that decrypts a secret still has to apply in the right order relative to the releases that depend on it. - Ansible's Vault (module 08, Ansible) solves the same "secret at rest in git"
problem with a different trade-off:
it encrypts a whole file as one opaque blob — no partial diff, which is why that module needs the
vault-indirection naming trick just to keep references greppable — where SOPS's per-leaf-value
encryption keeps keys visible in a
git difffor free.
Common gotchas
- Assuming
.sops.yamlcontrols decryption. Fix: it only decides which backend/key a new encryption uses — decrypting an existing file relies on thesops:metadata block already inside it, not on.sops.yaml. - Running
helmfile diffin CI without--suppress-secrets. Fix: diff prints rendered values, including any decrypted secret, straight into the build log unless you suppress them. - Passing a secret via
--setor a bare environment variable instead of asecrets:file. Fix: those paths risk showing up in--debugoutput,ps, or shell history — keep sensitive values only inside asecrets:-referenced file. - Picking age for a team/CI setup. Fix: age keys live per-laptop, so rotating access means re-encrypting every file for the new recipient list — KMS ties decrypt access to an IAM policy instead, which is what scales past a handful of people or any CI runner.
Check yourself — a teammate says encrypting secrets/prod.yaml with SOPS makes the whole file unreadable in a diff. True?
No — SOPS only encrypts the leaf values; keys and YAML structure stay plaintext, so a git diff still
shows exactly which key changed, just not the new value.
Check yourself — your team just grew from 3 laptops to a CI runner that also needs to decrypt secrets. Age or KMS?
KMS — decrypt access becomes an IAM policy on a cloud identity (including the CI runner's role) rather than a private key file that has to be distributed to and rotated on every machine that needs access.
You can defend this when you can explain what SOPS actually encrypts (leaf values, not the file
shape), why .sops.yaml only matters for new encryptions and not for reading existing files, when you'd
pick age over KMS for a team's key backend, and why --suppress-secrets is required in any CI pipeline
whose logs aren't private.
Go deeper: the full
creation_rules:syntax (includingencrypted_regexand per-path key groups) lives in the SOPS project docs, and the plugin's own flags (--suppress-secrets, backend auto-detection) are in the helm-secrets docs — this page covers the 80/20 you need for the Do exercise.