DevSecOps

age + SOPS: A Git-Native Secrets Workflow

How age and SOPS together deliver a lightweight, auditable, Git-native secrets workflow that stands up to real production use without a vault server.

Shadab Khan
Security Engineer
7 min read

There is a class of platform teams that keeps drifting back to the same setup: secrets encrypted in the repo, decrypted at deploy time, no vault server to run, no extra control plane to explain to auditors. For about a decade the tooling answer was git-crypt or Ansible Vault, both of which have rough edges. The combination that has replaced them in most shops I work with is age plus SOPS. It is not the right tool for every situation, but when it fits, the ergonomics and the security posture are genuinely hard to beat.

Why age, and why SOPS on top

age, Filippo Valsorda's replacement for PGP's file-encryption use cases, solves a very specific problem: modern, simple, opinionated file encryption with small keys. It uses X25519 or SSH keys directly, the key format is readable, the CLI does not surprise you, and there is no web of trust to manage. If your job is to encrypt a file so that a specific set of recipients can decrypt it, age gets out of your way.

SOPS, originally from Mozilla and now stewarded by the Getsops community, adds what age does not: structured-file awareness. SOPS understands YAML, JSON, ENV, INI, and binary formats. It encrypts only the values, leaving keys in cleartext, which means a diff of an encrypted SOPS file still shows you which keys changed. That is the feature that makes SOPS actually usable in code review. A completely opaque ciphertext tells a reviewer nothing; a SOPS file tells them "this PR modified the database.password and oauth.client_secret values, all others unchanged."

SOPS supports multiple backends for the key encryption: AWS KMS, GCP KMS, Azure Key Vault, HashiCorp Vault, PGP, and age. The age backend is the one that has won for GitOps teams because it has no cloud dependency, no network round-trip on decrypt, and no IAM configuration to get wrong.

The threat model this actually covers

Before writing any configuration it is worth being precise about what age plus SOPS does and does not protect against. It protects against a repository compromise: a malicious read of your GitHub org, a leaked clone of the code, a disgruntled engineer with historical access to the tree. It protects against backup exposure because encrypted blobs stay encrypted. It does not protect against a compromised CI runner that holds the decryption key, and it does not prevent a malicious commit from inserting code that exfiltrates runtime secrets. If those threats are in scope, you need a runtime secret store with short-lived credentials; SOPS is not a vault.

Within its threat model, the security posture is excellent. The keys are modern curves, the ciphertexts are authenticated, and the key material never needs to be present during repository reads.

A working repository layout

The pattern that has held up across teams looks like this. Each environment (dev, staging, prod) has an age key pair. Public keys are committed in a top-level .sops.yaml. Private keys are stored in the systems that need to decrypt: CI runners, deployment controllers, engineer laptops as appropriate. Secret files live alongside the config they relate to, with a naming convention (secrets.enc.yaml) that makes pre-commit checks easy.

A minimal .sops.yaml looks like this:

creation_rules:
  - path_regex: environments/prod/.*\.enc\.yaml$
    age: age1prodxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  - path_regex: environments/staging/.*\.enc\.yaml$
    age: age1stagingxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
  - path_regex: .*\.enc\.yaml$
    age: age1devxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx

The ordering matters. SOPS uses the first matching rule, so more specific paths must come first. A frequent mistake is putting a general rule above a specific one and accidentally encrypting production secrets with dev keys.

Key management is the entire game

The strength of this workflow lives or dies on how the age private keys are handled. A few patterns that work:

Engineer keys are personal. Each engineer generates their own age key with age-keygen, and their public key is added to .sops.yaml as an additional recipient for the environments they can access. When they leave, a rotation removes their key and re-encrypts. This is the only mechanism that gives you real revocation.

CI keys live in the CI vault. The age private key for each environment is stored as a CI secret (GitHub Actions encrypted secret, GitLab masked variable, Buildkite agent secret) and exposed only to the pipelines that need to decrypt for that environment. Scope narrowly: dev pipelines never see the prod key.

Runtime decryption uses short-lived access. For Kubernetes, the common pattern is to have Flux or Argo CD decrypt at apply time using a cluster-local age key. That key is provisioned out of band (via a cloud KMS-wrapped secret, or a sealed bootstrap procedure). The key never appears in the repo.

Do not reuse keys across environments. The temptation to have a single master key is strong and wrong. Separate keys let you revoke and rotate at the environment boundary without a global re-encryption event.

CI integration that does not leak

In CI the decrypt step must not expose plaintext to the log or to artifacts. GitHub Actions, GitLab, and Buildkite all support masking values, but SOPS decrypt output must be redirected straight into the consumer, not printed. A common and dangerous anti-pattern is sops -d secrets.enc.yaml > /tmp/secrets.yaml on a shared runner without cleanup.

Better patterns: use sops exec-env or sops exec-file to scope plaintext to the lifetime of a single command, and prefer in-memory pipes where possible. If your deployment tool supports SOPS natively (Flux, Argo CD with helm-secrets, Kustomize's SOPS plugin), let it do the decrypt inside the controller rather than in the CI step. The attack surface is smaller.

Rotation, in practice

The rotation story for age plus SOPS is manual but tractable. sops rotate re-encrypts the file with the current set of recipients, generating a new data encryption key. That covers the case where you want to cycle the data key without changing recipients. For a recipient change (adding an engineer, removing a leaving one, rotating a CI key), you update .sops.yaml and run sops updatekeys on the affected files.

What age plus SOPS cannot do is rotate the underlying secret value itself. That is still on you. A good workflow tracks rotation cadence per secret, and the platform team runs a quarterly or monthly rotation pass that generates new values in the upstream system, updates the SOPS file, and triggers the deployment.

When to reach for something else

This workflow is excellent for configuration-like secrets: API keys, database credentials, certificates, tokens for integrations. It is a poor fit for highly dynamic credentials that change per-request or per-session, for cases where you need a central audit log of every read, or for multi-tenant systems where secrets must be scoped to users at runtime. For those, reach for Vault, a cloud secret manager, or a dedicated platform. Do not try to force SOPS into a role it was not designed for.

How Safeguard Helps

Safeguard gives teams running age and SOPS the supply chain visibility that file-based workflows historically lack. It tracks which repositories contain encrypted secrets, monitors the age recipient list for drift or unauthorized additions, and correlates CVE disclosures on the SOPS toolchain itself so upgrades get prioritized appropriately. For platform teams running GitOps at scale, this turns a lightweight secrets workflow into an auditable component of the broader software supply chain posture rather than an isolated CLI habit.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.