A CLI that generates a single SOPS .sops.yaml
from small sops-config.yaml files scattered throughout a directory tree.
Hand-maintaining .sops.yaml in a monorepo gets painful once different
subdirectories need to be encrypted for different sets of people: the file
becomes one long, centrally-owned list of regexes and keys that nobody near
the actual secrets wants to touch.
sops-config lets you instead drop a small sops-config.yaml next to the
secrets it governs. One is required at the root of the tree to define the
baseline set of users and rules; any subdirectory can add its own
sops-config.yaml to extend the rule set for just that subtree, without
being able to affect anything outside it. Running the tool merges every
sops-config.yaml it finds into one correctly-scoped .sops.yaml at the
root, in the format SOPS expects.
Download a prebuilt binary from the
latest release:
grab the archive matching your OS/arch (e.g.
sops-config_vX.Y.Z_linux_amd64.tar.gz, ..._darwin_arm64.tar.gz,
..._windows_amd64.zip), extract it, and put the sops-config binary on
your PATH. Each release also includes a checksums.txt to verify the
download.
# example: Linux amd64
curl -L -o sops-config.tar.gz \
https://github.com/Kreibich04/sops-config/releases/latest/download/sops-config_vX.Y.Z_linux_amd64.tar.gz
tar -xzf sops-config.tar.gz
sudo mv sops-config /usr/local/bin/Or, with a Go toolchain installed:
go install github.com/Kreibich04/sops-config/cmd/sops-config@latestOnly needed if you're working on sops-config itself / need an unreleased
change:
go build -o sops-config ./cmd/sops-configsops-config uses cobra, which provides
completion for bash, zsh, fish, and PowerShell out of the box:
sops-config completion bash > /etc/bash_completion.d/sops-config # or zsh/fish/powershellRun sops-config completion --help for shell-specific setup instructions.
Every discovery root needs exactly one sops-config.yaml. It defines
users (each with group memberships and PGP/Age keys) and rules (each
naming a path_regex, a priority, and the groups allowed to decrypt
matching files). encrypted_regex is optional — omit it to encrypt the
entire file, which is the usual choice for a directory that holds nothing
but secrets (e.g. a secrets/ dir loaded via secretGenerator):
users:
- name: "Admin One"
groups:
- admin
keys:
pgp:
- "AABB11"
age:
- "AABB11"
rules:
- path_regex: .*/secrets/.*
encrypted_regex: '^(data|stringData)$'
comment: "default secrets rule"
priority: 100
groups:
- adminAny subdirectory may contain its own sops-config.yaml to add more users
and/or rules. A subdirectory rule's path_regex is relative to that
subdirectory, and matches the same way it would if you'd written it as a
root pattern scoped to just that subtree — see Path scoping
below for exactly how it's combined with the directory prefix, and what a
leading ^ changes.
Users declared at the root are visible to every rule in the tree. Users
declared in a subdirectory are visible only to rules in that subdirectory
and its descendants — a subdirectory's sops-config.yaml can never change
who a rule outside its own subtree resolves to (see
User visibility below).
sops-config generate --root .Walks --root (default .), merges every sops-config.yaml found, and
writes <root>/.sops.yaml. Flags:
--root, -r— directory to search (default.)--output, -o— output path (default<root>/.sops.yaml)--force— write.sops.yamleven if no rules resolved
If any error-level diagnostic is found, generate prints it and exits
non-zero without writing — it never produces a partial or wrong
.sops.yaml. The write itself is atomic (write to a temp file in the same
directory, then rename over the target), so an interrupted generate can
never leave a truncated .sops.yaml behind either.
sops-config validate --root .Runs the exact same discovery/merge/validation pipeline as generate, but
never writes output. Exits non-zero if any error-level diagnostic is found,
or if zero rules resolved (same as generate, and overridable the same way
with --force) — so validate never passes something generate would
refuse. Intended for CI or a pre-commit hook, so a broken sops-config.yaml
is caught before it silently fails to grant (or worse, silently grants)
decrypt access.
cmd/sops-config/ entrypoint
internal/config/ sops-config.yaml types, loading, tree discovery
internal/merge/ user merge, path-regex scoping, rule resolution
internal/sopsyaml/ .sops.yaml rendering
internal/cli/ cobra commands (generate, validate)
generate and validate both call the same internal/merge.Run(root)
pipeline, so they can never drift in what they check.
-
Discovery (
internal/config.Discover): walks--root, finds every file namedsops-config.yaml, in lexical order (root's own config first, then subdirectories in walk order). Errors if no config exists at the root. YAML is decoded withKnownFields(true), so an unrecognized field (e.g. a typo'dcomnent:) is rejected rather than silently ignored. Malformed YAML, an unknown field, or a missing/invalid field (see Validation rules) aborts discovery immediately, reporting just that one file — fix it and rerun to see what's next. This stage is deliberately fail-fast: a config file that doesn't even parse can't be reasoned about well enough to keep walking past it. -
User merge (
internal/merge.MergeUsers): users from every discovered config are combined into a registry, keyed byname, that also tracks which directory(ies) declared each user. A name redeclared with identical groups/keys is a silent no-op that extends that user's visibility to the redeclaring directory too (lets a shared user be conveniently repeated across directories that need it). A name redeclared with differing groups or keys is an error — key-material conflicts are never resolved by silently picking one side.User (and therefore group) visibility is scoped by directory ancestry, not global: a rule can only resolve a
groupsentry to users declared at the root, or declared in the rule's own directory or one of its ancestors. A user declared only infoo/bar/sops-config.yamlis invisible to rules infoo/, in a siblingfoo/baz/, or at the root — so a subdirectory can never grant itself, or anyone else outside its own subtree, access to a rule it doesn't own. If a rule references a group that has no users visible from its directory, that's the same "group has no matching users" error as referencing a group that doesn't exist anywhere. -
Path scoping (
internal/merge.ScopePathRegex): a root-level rule'spath_regexis used unmodified — SOPS matches it as an unanchored substring search over the whole repo, same as vanilla.sops.yaml. A subdirectory rule'spath_regexgets the same substring-search treatment, just scoped to its own directory: the fragment matches whether it's directly inside that directory or nested arbitrarily deeper, exactly as if the author had run that same fragment as a root pattern against only their own subtree.muc/sops-config.yamlwithpath_regex: secrets/.*→^muc/.*secrets/.*(matchesmuc/secrets/xandmuc/anything/secrets/x)Writing a leading
^opts into anchoring the fragment to the top of the directory instead, the same way^anchors a root pattern to the top of the repo:muc/sops-config.yamlwithpath_regex: ^secrets/.*→^muc/secrets/.*(matchesmuc/secrets/xonly, notmuc/anything/secrets/x)The directory component is escaped with
regexp.QuoteMetaso directory names containing regex metacharacters are matched literally, and the composed pattern is always anchored at the start with^dir/...: without that, a directory named e.g.mucappearing anywhere else in the tree could accidentally match a rule meant only formuc/. No trailing$is added — the author decides whether a rule covers one file or an entire subtree. Anypath_regex(root or subdirectory) that doesn't start with^gets a warning, since it's easy to underestimate how broadly an unanchored substring search can match. -
Rule resolution (
internal/merge.BuildRules): for each rule, itsgroupsare resolved to the union of matching visible users (see step 2), whose PGP/Age keys are flattened, deduplicated, and alpha-sorted (for deterministic, diff-friendly output). Rules are then stably sorted bypriorityascending — SOPS evaluatescreation_rulestop-to-bottom and uses the first match, so a lowerprioritynumber means a rule is placed, and therefore tried, earlier. Because the pre-sort order is already root-then-subdirectory/in-file order, equal-priority rules keep a deterministic tie-break automatically. -
Rendering (
internal/sopsyaml.Render): maps resolved rules tocreation_rulesentries (pgp/ageas comma-joined strings) and prepends a fixed, timestamp-free "generated file, do not edit" header — so re-runninggenerateover unchanged input produces byte-identical output.generatewrites the result atomically (temp file + rename).
| Condition | Severity |
|---|---|
Malformed path_regex / encrypted_regex |
error |
No sops-config.yaml at the root |
error |
| Duplicate user name with conflicting groups/keys | error |
| Rule references a group with zero visible matching users | error |
| Rule resolves to no PGP and no Age keys | error |
Duplicate priority across rules |
warning |
path_regex doesn't start with ^ (unanchored substring search) |
warning |
| Zero rules resolved overall | error (both commands refuse unless --force) |
These are all raised during the merge stage, after discovery has already succeeded, and are aggregated rather than fail-fast: a single run reports every one of these found across every file in one pass.
Discovery-stage problems abort the run immediately, before merging starts,
so only the first one found is reported — fix it and rerun to uncover the
next. These include: malformed or unrecognized-field YAML; a missing
required field (name, path_regex, priority); no
sops-config.yaml at the root; an empty or duplicate group name; a user
with no pgp/age keys at all; a rule with no groups; and a pgp/age
key that doesn't look like a real key (an empty string, a duplicate within
the same user, or a value that doesn't match the expected shape — an
even-length hex string for pgp, an age1... recipient for age).
spf13/cobra— CLI frameworkgopkg.in/yaml.v3— YAML parsing and rendering
Changelog entries are managed with towncrier.
Every change worth mentioning gets a fragment file in changelog.d/, named
+<slug>.<type>.md (e.g. changelog.d/+scoped-regex.feature.md), where
<type> is one of feature, bugfix, doc, removal, or misc (see
towncrier.toml). The fragment's contents become a bullet in the rendered
CHANGELOG.md.
To cut a release:
./release.sh X.Y.ZRun from main with a clean working tree, this renders the accumulated
changelog.d/ fragments into CHANGELOG.md via towncrier build, bumps the
VERSION file, commits both, pushes a release/vX.Y.Z branch, and opens a
PR via gh pr create. Requires towncrier (or pipx, which the script
falls back to) and an authenticated gh CLI.
Merging that PR is what actually ships the release:
- The merge changes
VERSIONonmain, which triggerstag-release.yml. It tags the merge commitvX.Y.Zand pushes the tag using theRELEASE_TOKENsecret rather than the defaultGITHUB_TOKEN— a tag pushed with the default token cannot trigger other workflows, so a real token is required for the next step to fire.RELEASE_TOKENmust be a PAT (fine-grained, "Contents: Read and write" on this repo) added as a repository secret. - That tag push triggers
release.yml, which cross-compiles and publishes the release binaries.