April 13, 2026 · 5 min

SOPS, Age, and the Regex You Think Is a Glob

Patrick McClory

SOPS with age keys is the right answer for secrets in a GitOps repo, but two things will silently break you before you understand what's happening: path_regex is not a glob, and 'sops metadata not found' can mean at least three different things.

The error was sops metadata not found. No other context. I’d just committed what I was sure was a correctly-encrypted secrets file for the Vault unseal keys, and the CI workflow was telling me the file had no SOPS metadata in it.

Except it did. I could open it. The sops: block was right there at the bottom. The age recipient was listed. The MAC was present. Everything looked correct.

I spent a while confirming what I already knew before I started questioning the things I hadn’t checked. That’s usually how these go.

The Regex You Think Is a Glob

.sops.yaml is how SOPS decides which key to use when encrypting a file. The field is called path_regex. The name is accurate. It is a regex. Not a glob, not a shell pattern, a full regular expression evaluated against the file path.

My creation rules looked something like this:

creation_rules:
  - path_regex: infrastructure/ansible/inventories/prod/host_vars/*.sops.yaml
    age: age1...

The * in that rule does not mean what I wanted it to mean. In a glob, *.sops.yaml means “any filename ending in .sops.yaml.” In a regex, * means “zero or more of the preceding character,” which in this case is s. The rule matches infrastructure/ansible/inventories/prod/host_vars/ followed by any number of the letter s followed by .sops.yaml. No actual filenames in my repository match that pattern. SOPS found no applicable creation rule, fell back to no key, and encrypted the file with nothing.

The correct rule is:

creation_rules:
  - path_regex: infrastructure/ansible/inventories/prod/host_vars/.*\.sops\.yaml$
    age: age1...

.* matches any sequence of characters. \. escapes the dot so it means a literal dot rather than “any character.” The $ anchors the end of the string so the pattern doesn’t match .sops.yaml.bak or anything else with trailing characters. That rule works.

What makes this failure mode frustrating is that SOPS doesn’t warn you when no creation rule matches. It encrypts the file anyway. The file is valid SOPS output. The sops: block is present. But the recipients list is empty, which means nobody can decrypt it, and you won’t find out until you try.

I found out in CI.

Three Things “Metadata Not Found” Can Mean

The error sops metadata not found is SOPS telling you it cannot find or parse the sops: metadata block in the file you handed it. The cause is not always the same, and the fix depends on which cause you’re dealing with.

The most common cause is the one I just described: a file encrypted without a valid creation rule. The metadata block exists, the sops: key is there, but the recipients list is empty. There’s no key to use. SOPS can’t decrypt it.

Simpler and more embarrassing: you’re trying to decrypt a plaintext file. The sops: block doesn’t exist because the file was never encrypted. This happens when you’re iterating on secrets management and a directory has a mix of old plaintext files and new encrypted ones. I’ve done this. The error message is identical to the empty-recipients case, so the first instinct is that the encrypted file is broken rather than that the file was never encrypted at all.

The third cause cost me the most time before I understood it. Some YAML formatters and linters will reformat a file and either strip or relocate the sops: block. The metadata block is a top-level key in the YAML document. A formatter that normalizes key ordering alphabetically, or one that decides to deduplicate what it perceives as redundant structure, can corrupt the encryption envelope without producing any visible error. The file still looks like YAML. It still opens. The sops: block may even still be there in some mangled form. But SOPS can’t parse it as valid metadata.

The practical rule: if you have SOPS-encrypted files in your repository, your YAML formatter needs to know about them and exclude them. If you’re running yamllint or prettier or any formatter as a pre-commit hook, check what it does to .sops.yaml files before you trust it.

Wiring It Into CI That You’d Actually Trust

Once the encryption is correct, getting SOPS working in GitHub Actions is not complicated. The age private key goes into a repository secret in the target environment. In the workflow, you pass it as SOPS_AGE_KEY:

- name: Decrypt vault secrets
  env:
    SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
  run: |
    sops -d --output-type json \
      infrastructure/ansible/inventories/prod/host_vars/vault-01.sops.yaml \
      | python3 -c "
        import sys, json
        d = json.load(sys.stdin)
        for k in d['unseal_keys_b64'][:3]:
            print(k)
      "

SOPS picks up the key automatically from the environment variable. No keyring setup, no GPG agent, no KMS configuration. That’s the thing that makes age genuinely attractive here compared to the alternatives. The KMS path requires IAM roles, region configuration, and either a secrets manager integration or a way to pass credentials into the runner. The GPG path requires a keyring, a passphrase or keyring bypass, and a careful story about how the private key gets onto the runner. Age with an environment variable is two lines of setup.

The part worth spending more time on is the environment gate. The workflow that handles Vault unsealing runs against a GitHub environment called vault-production. That environment has a required reviewer. Before any run of the deploy-vault-01 workflow can proceed past the approval gate, a human has to look at the pending run and approve it.

This matters because Vault unsealing is not a routine operation. Vault starts sealed after a restart. You unseal it with the unseal keys. Automating that process removes a friction point that exists for a reason. The automated path is faster and more reliable than doing it by hand, but “faster and more reliable” is only an improvement if the automation runs when you intend it to run. An approval gate means a person confirmed that yes, this is the moment we want to unseal Vault, using these keys, against this Vault instance.

The combination is what makes it actually safe: age removes the KMS complexity, SOPS handles the encryption at rest, the SOPS_AGE_KEY secret scoped to the prod-deploy environment means the key is only available to workflows that have cleared the environment’s trust model, and the required reviewer on vault-production means no automated unseal happens without someone saying so.

None of those pieces is complicated. Each one is doing one specific job. That’s the architecture that holds up under pressure, when someone is paging at 2am and wants the fastest path to a running Vault.

The path I took to get here was not clean. I encrypted files with broken creation rules, hit the metadata error in CI after being sure the files were correct, and spent time on the wrong hypothesis before I found the actual problem. The .sops.yaml path_regex field doesn’t announce its behavior. It works exactly as documented. The documentation just uses a word, “regex,” that a lot of people will read as “pattern” and proceed with their own mental model of what patterns look like.

Once I understood both failure modes, the whole system became something I’d trust. The error messages are still not great. But I know what they mean now.