.sops.yaml creation rules
Automate which key encrypts what.
A busy sorting office does not ask each clerk to remember which bag a letter belongs in. A chart on the wall does that job: postcodes starting SW1 go in the red bag, everything else goes in the grey one. The clerk reads the chart, not their memory. A .sops.yaml file is that chart for your repository. It lives in the repo, it lists patterns that match file paths, and beside each pattern it names the key or keys allowed to open anything matching.
Without it, every encryption is a decision somebody types by hand. They have to remember that production files go to the AWS KMS (Key Management Service, Amazon's hosted key store) key in eu-west-1, that development files go to the team's age key (age is a small modern encryption tool whose public keys start with age1), and they have to get the --kms or --age flag right at six on a Friday. With the chart in place they run sops encrypt secrets/prod/db.enc.yaml and SOPS works the answer out itself. That command prints the result to your screen, by the way. Add -i and it writes back over the file instead.
The file holds a list called creation_rules. Each entry carries a path_regex (a regular expression, a small pattern language for matching text) tested against the file's path, the recipients allowed to decrypt it, and optional settings controlling which values inside the file get encrypted. SOPS reads the list top to bottom and stops at the first entry whose pattern matches. That one entry supplies everything. The entries below it are never consulted, and nothing is merged between them.
Be precise about what a rule protects. SOPS encrypts values, never key names, so an encrypted file still shows the shape of your config. A reviewer sees that stringData.password changed without ever seeing the new password. That readability is the point of the tool, and it is also why the field-selection setting on a rule matters as much as the recipient list. Name the wrong fields and you publish a token in clear text right beside a carefully encrypted comment.
A Rule Sheet You Can Read
# ~/infra/.sops.yaml (committed to the repo, reviewed like code)# Anchors. SOPS ignores top-level keys it does not recognise, so this# block costs nothing and saves you retyping 62-character recipients.keys:- &team_age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p- &breakglass age1a7wq3n5kc2gvhy8m4tzd0sre6xjf9lup2w5k3hyq7namc4dtvs8ge0zrxf- &prod_kms arn:aws:kms:eu-west-1:111122223333:key/8f4b2c1a-7d3e-4f90-b1c2-6a5e0d9f3b47creation_rules:# 1. Production. KMS only: no key file for these sits on anyone's laptop.- path_regex: ^secrets/prod/.*\.enc\.yaml$kms: *prod_kmsaws_profile: sops-prodencrypted_regex: '^(data|stringData)$'# 2. Shared registry creds. One group, so any key in it opens the file.- path_regex: ^secrets/shared/.*\.enc\.yaml$key_groups:- kms:- arn: *prod_kmsage:- *team_age- *breakglassencrypted_regex: '^(data|stringData)$'# 3. Catch-all for dev and anything else. Deliberately last.- path_regex: \.enc\.yaml$age: *team_ageencrypted_regex: '^(data|stringData)$'
Three rules, three different security postures. Rule one sends production to a KMS key and nothing else, so decrypting needs an AWS identity holding the kms:Decrypt permission on that key, and every use lands in CloudTrail (Amazon's log of who called which API and when). That long arn:aws:kms:... string is an ARN, an Amazon Resource Name, the unique postal address of one AWS resource. Rule two uses a key group, which works like a bag of spare keys hanging by one lock: any single key in the bag opens it. The platform team can read shared registry credentials without an AWS session, and automation can still go through KMS. Rule three sweeps up whatever the first two missed.
The keys block at the top is plain YAML (the indentation-based config format SOPS reads and writes) using anchors, where &name defines a value and *name reuses it. SOPS parses the config without strict field checking, so top-level keys it does not recognise are quietly ignored. The anchors cost nothing and save you from pasting a 62-character age recipient four times and fat-fingering one copy. The aws_profile setting pins which set of AWS credentials SOPS uses for that rule, which matters when your laptop holds five profiles and exactly one of them is allowed near production.
Where SOPS Goes Looking
Here is the part that catches people. SOPS does not look for .sops.yaml next to the file you are encrypting. It looks next to you. The search starts in your shell's current working directory, walks upward one parent at a time, up to a hundred levels, and uses the first .sops.yaml it meets. Back at the sorting office: you go and find the chart starting from where you are standing, not from where the letter is. The path you type on the command line gets no vote in that search.
$ cd ~/infra$ find . -name '.sops.yaml' -o -name '*.enc.yaml' | sort
./.sops.yaml./secrets/dev/checkout.enc.yaml./secrets/prod/db.enc.yaml./secrets/prod/stripe.enc.yaml./secrets/shared/registry.enc.yaml
Once it has a config file, SOPS turns your file argument into an absolute path and chops off the directory holding the config. With .sops.yaml sitting in ~/infra, the string tested against every path_regex for ~/infra/secrets/prod/db.enc.yaml is secrets/prod/db.enc.yaml. That is why anchoring a pattern with ^secrets/prod/ works. It is also why a pattern written as ^/home/you/infra/secrets/ matches nothing inside that repo: the leading directory is gone before the regex ever runs. (A file living outside the config's own directory keeps its absolute path, since there is no prefix to strip. Treat that as a corner to avoid, not a feature to build on.)
Two escape hatches exist for repos that do not fit one chart. The --config flag points SOPS at any file you like, including one with a different name, and it switches the upward search off entirely rather than adding to it. The SOPS_CONFIG environment variable does the same job without retyping the flag. Nested .sops.yaml files also work, because the upward walk stops at the first hit, so one team's directory inside a monorepo can carry its own chart with its own keys. The catch is the one above: the directory you are standing in decides which chart you get.
The filename is literal too. A file called .sops.yml with the shorter extension is not picked up. From version 3.10 onward SOPS spots the near miss and says so, which beats the silence you used to get. Everything in this lesson is 3.10 behaviour, including encrypting from standard input, which arrived in that same release. The steadiness is worth noticing: SOPS launched as a Mozilla project in 2015 and was donated to the CNCF (Cloud Native Computing Foundation, the body that also houses Kubernetes) as a Sandbox project in 2023, moving to the getsops organisation on GitHub. The config file behaviour barely shifted across that handover.
$ mkdir /tmp/ymltest && cd /tmp/ymltest$ cp ~/infra/.sops.yaml .sops.yml # wrong extension on purpose$ echo 'stringData: {password: probe}' | sops encrypt --filename-override secrets/dev/x.enc.yaml
[CMD] WARN[0000] ignoring ".sops.yml" when searching for config file; the config file must be called ".sops.yaml"config file not found, or has no creation rules, and no keys provided through command line options
First Match Wins, And Losing Is Quiet
Rule order is the whole safety story. Move the catch-all to the top and secrets/prod/db.enc.yaml matches it, because that path really does end in .enc.yaml. The production database password is then encrypted to a team age key sitting on thirty laptops, plus a break-glass copy in a drawer somewhere. Nothing errors. The file still looks encrypted. The diff still looks clean. Review passes.
Count what an attacker needs after that mistake. One laptop, or one stale copy of the age key in somebody's dotfiles repo, and they hold production. No CloudTrail entry exists to alert on, because KMS was never asked to do anything. And since the ciphertext is committed, swapping the key later does not undo it: the old commit still sits in history, still openable with the old key. Order rules most specific first, and treat that ordering as a control you test rather than a style preference.
So test it. The --filename-override flag tells SOPS to pick rules as though the input lived at some other path, and it is required when reading from standard input (the stream a pipe feeds into a command). Put those two facts together and you get a probe that writes nothing and touches no file in the repo. The yq in these examples is a small command-line YAML reader, the same idea as grep but aware of structure.
$ cd ~/infra$ echo 'stringData: {password: probe}' \| sops encrypt --filename-override secrets/prod/db.enc.yaml \| yq '.sops.kms[].arn'
arn:aws:kms:eu-west-1:111122223333:key/8f4b2c1a-7d3e-4f90-b1c2-6a5e0d9f3b47
$ echo 'stringData: {password: probe}' \| sops encrypt --filename-override secrets/dev/checkout.enc.yaml \| yq '.sops.age[].recipient'
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
The prod probe resolved to the KMS ARN. The dev probe resolved to the team age recipient and nothing else, which is rule three doing its job. Run both after every edit to .sops.yaml, one path per directory you care about. One honest cost: the probe makes a real KMS Encrypt call, so it shows up in your audit log and bills you a rounding error (AWS charges three cents per ten thousand requests). Cheaper than the alternative.
Making The Failure Loud
A path matching no rule at all fails immediately, which is the pleasant case.
$ cd ~/infra$ echo 'password: probe' | sops encrypt --filename-override secrets/prod/db.json; echo "exit: $?"
config file not found, or has no creation rules, and no keys provided through command line options: error loading config: no matching creation rules foundexit: 1
Compare the two failure modes side by side. No matching rule gives you an error and exit code 1, impossible to miss. A wrong matching rule gives you a perfectly valid encrypted file that hands your production secret to the wrong audience, silently, and every tool downstream accepts it happily. SOPS cannot tell those two apart on your behalf, so the check belongs in CI (continuous integration, the automated job that runs on every push).
The sops filestatus command answers one question in machine-readable form: does this file carry real SOPS metadata? It prints a one-field JSON object (JavaScript Object Notation, the format built for programs rather than people to read) with encrypted set to true or false, which catches the case where somebody committed a plaintext copy next to the encrypted one. Pair that with a read of the sops: metadata block, which lists every recipient in the clear at the bottom of every encrypted file, and you have a check that compares intent against reality.
The check itself is about a dozen lines of shell. Read each production file's recipients out of its metadata, compare them against the one ARN you expect, and fail on anything else. Run it on every pull request and the wrong-key mistake becomes a red build instead of a quiet exposure that survives until your next audit.
#!/usr/bin/env bash# Fail the build if a production secret can be opened by anything# other than the production KMS key.set -euo pipefailPROD_KEY='arn:aws:kms:eu-west-1:111122223333:key/8f4b2c1a-7d3e-4f90-b1c2-6a5e0d9f3b47'rc=0for f in secrets/prod/*.enc.yaml; do[ -e "$f" ] || continue# filestatus prints exactly {"encrypted":true} or {"encrypted":false}if [ "$(sops filestatus "$f")" != '{"encrypted":true}' ]; thenecho "PLAINTEXT: $f"rc=1continuefi# Both lookups print nothing when that key type is absent.got=$( { yq '.sops.kms[].arn' "$f"; yq '.sops.age[].recipient' "$f"; } | sort -u )if [ "$got" != "$PROD_KEY" ]; thenecho "WRONG RECIPIENTS: $f"printf '%s\n' "$got" | sed 's/^/ /'rc=1fidoneexit "$rc"
$ ./ci/check-recipients.sh; echo "exit: $?"
WRONG RECIPIENTS: secrets/prod/stripe.enc.yamlage1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8pexit: 1
What Else A Rule Carries
Recipients are the headline, but a creation rule also decides which values get encrypted. Setting encrypted_regex to a pattern matching data or stringData means: encrypt the values under any key with those names, anywhere in the tree, including whole subtrees hanging below them. Its mirror image, unencrypted_regex, encrypts everything except matching keys. The suffix variants do the same job by name endings, so a field called api_key_unencrypted stays readable. When a rule sets none of them, SOPS falls back to a default unencrypted suffix of _unencrypted.
Pick exactly one of those per rule. Command line flags beat the config file field by field, so passing a flag when your matched rule already sets a different one produces a collision rather than an override, and SOPS names all six settings in the error so you can work out which pair fought.
$ sops encrypt --unencrypted-suffix _plain secrets/dev/checkout.enc.yaml; echo "exit: $?"
Error: cannot use more than one of encrypted_suffix, unencrypted_suffix, encrypted_regex, unencrypted_regex, encrypted_comment_regex, or unencrypted_comment_regex in the same fileexit: 8
Two more settings earn their keep. Picture the MAC (message authentication code, a checksum proving nothing was altered) as a tamper-evident seal stretched across the file. By default SOPS runs that seal over every value, encrypted or not, so hand-editing a plaintext field makes the next decrypt fail loudly. Set mac_only_encrypted: true and the seal covers only the encrypted values, which lets a controller like Flux (a tool that syncs a Git repo into a Kubernetes cluster) bump a replica count in place without breaking the file. You trade tamper detection on the readable fields for that convenience.
key_groups paired with shamir_threshold is the safe deposit box where two people must turn their keys at the same time. It splits the data key into shares, one per group, and needs that many groups to rebuild it. Two groups with a threshold of 2 means no single team can decrypt alone. That is real two-person control for the handful of files deserving it, and a real outage the day one group's key is unreachable, so use it only where the second property is acceptable.
A config file can also carry a stores block, which is not a creation rule at all. It controls output formatting. Pinning the YAML indent to 2 stops SOPS re-indenting your files to its 4-space default and filling pull requests with whitespace noise that hides the one line that actually changed.
# A second repo's .sops.yaml, shown whole. Note the single creation_rules# key: YAML forbids duplicate keys, so a config with two of them fails# to load rather than merging the lists.creation_rules:# Two groups plus a threshold of 2. Neither team can decrypt alone.- path_regex: ^secrets/break-glass\.enc\.yaml$shamir_threshold: 2key_groups:- kms:- arn: arn:aws:kms:eu-west-1:111122223333:key/8f4b2c1a-7d3e-4f90-b1c2-6a5e0d9f3b47- age:- age1a7wq3n5kc2gvhy8m4tzd0sre6xjf9lup2w5k3hyq7namc4dtvs8ge0zrxf# Flux edits plaintext fields in these manifests, so MAC only the secrets.- path_regex: ^clusters/.*\.enc\.yaml$age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8pencrypted_regex: '^(data|stringData)$'mac_only_encrypted: true# Not a creation rule: output formatting for every file SOPS writes.stores:yaml:indent: 2json:indent: 2
Editing The Chart Does Not Re-Sort The Post
Change .sops.yaml and nothing already encrypted moves. Every existing file carries, in its own sops: metadata block, the exact list of keys it was encrypted to. New files follow the new rules. Old files keep the old ones until you say otherwise. sops updatekeys is how you say otherwise: it reads the current rules, works out the difference, shows it to you, and re-wraps the file's data key for the new set of recipients. It takes several paths in one go, and it prints each one as an absolute path even when you typed a relative one.
$ sops updatekeys secrets/shared/registry.enc.yaml
2026/07/22 10:41:12 Syncing keys for file /home/you/infra/secrets/shared/registry.enc.yamlThe following changes will be made to the file's groups:Group 1arn:aws:kms:eu-west-1:111122223333:key/8f4b2c1a-7d3e-4f90-b1c2-6a5e0d9f3b47age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p+++ age1a7wq3n5kc2gvhy8m4tzd0sre6xjf9lup2w5k3hyq7namc4dtvs8ge0zrxfIs this okay? (y/n):y2026/07/22 10:41:15 File /home/you/infra/secrets/shared/registry.enc.yaml synced with new keys
$ sops updatekeys --yes secrets/prod/*.enc.yaml
2026/07/22 10:44:02 Syncing keys for file /home/you/infra/secrets/prod/db.enc.yaml2026/07/22 10:44:02 File /home/you/infra/secrets/prod/db.enc.yaml already up to date2026/07/22 10:44:02 Syncing keys for file /home/you/infra/secrets/prod/stripe.enc.yamlThe following changes will be made to the file's groups:Group 1+++ arn:aws:kms:eu-west-1:111122223333:key/8f4b2c1a-7d3e-4f90-b1c2-6a5e0d9f3b47--- age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p2026/07/22 10:44:05 File /home/you/infra/secrets/prod/stripe.enc.yaml synced with new keys
Wire both halves into the job that guards your repo. Run sops updatekeys --yes across every encrypted path, then git diff --exit-code, which returns non-zero the moment the working tree changed. A non-empty diff means somebody edited .sops.yaml and never applied it, and the build stops right there. Add .sops.yaml to CODEOWNERS, the file that pins named reviewers to specific paths, so the people who approve secret changes are the same people who approve the rules protecting them.
Try this
Run find . -name '.sops.yaml' -o -name '*.enc.yaml' | sort on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: the directory you stand in picks the rule sheet. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.