Verified13 commandsAI-assisted

SOPS

.md

Verified against `sops --help` / `sops --version` run locally (sops 3.11.0), cross-checked against · official docs

What it is and where it fits 🎯#

SOPS (Secrets OPerationS), originally built by Mozilla and now a CNCF project, is an editor and CLI for encrypting the values inside a structured file (YAML, JSON, ENV, INI) while leaving the keys readable in plaintext — so a diff of an encrypted file still shows which setting changed, even though its value stays unreadable without the decryption key. It solves the exact gap this series names directly in Container & Kubernetes Security: a Kubernetes Secret is only base64-encoded by default, not encrypted — trivially reversible by anyone who can read the manifest — which makes committing one straight to a GitOps repository unsafe. SOPS is the standard fix for exactly that: it encrypts the sensitive fields so the file is genuinely safe to commit, and a GitOps controller (Flux's sops integration, or a kustomize plugin) decrypts it automatically at apply time.

This is a different model from both other secrets tools in this series. Vault (its own cheat sheet) is a live server an application calls at runtime — nothing sensitive is ever committed anywhere, but a server has to be running and reachable. External Secrets Operator syncs a live value from Vault (or a cloud secrets manager) into Kubernetes continuously. SOPS instead encrypts a static value once, commits the ciphertext to git alongside all your other GitOps manifests, and decrypts it only at apply time — no server dependency, but also no automatic rotation-in-flight the way Vault's dynamic secrets provide. age is the modern, simple asymmetric encryption tool SOPS pairs with most often today — a lightweight alternative to managing GPG keyrings, and the pairing this cheat sheet focuses on.

How a GitOps pipeline decrypts a SOPS-encrypted manifest#

Diagram

Only the ciphertext ever touches git — the decryption key lives only where it's actually needed (a developer's local keyring, or the cluster's secret store for the GitOps controller), never in the repository itself.

Installation#

# Binary download (Linux) — pin an exact version rather than trusting "latest" unattended in CI
curl -LO https://github.com/getsops/sops/releases/download/v3.11.0/sops-v3.11.0.linux.amd64
sudo mv sops-v3.11.0.linux.amd64 /usr/local/bin/sops
sudo chmod +x /usr/local/bin/sops

brew install sops                                 # macOS/Linux Homebrew
apt-get install sops                              # if a distro package is available

# age — the key-pair generator/encryptor SOPS uses for the "age" backend
brew install age                                  # or: apt-get install age

sops --version

Core concepts#

ConceptWhat it means
Key backendWhere the actual encryption/decryption keys live — age, AWS KMS, GCP KMS, Azure Key Vault, PGP, or Vault's Transit engine, mixed freely per file
.sops.yamlA rules file at the repo root mapping file-path patterns to which key(s) encrypt them — so sops doesn't need -a/-k flags typed by hand every time
Structural encryptionOnly values are encrypted (YAML/JSON/ENV are parsed); keys, comments, and structure stay in plaintext — this is what makes diffs meaningful
MAC (message authentication code)SOPS stores a MAC of the file's structure so any tampering with an "unencrypted" key or the file's shape is detected on decrypt, not silently accepted

Generating an age key pair#

age-keygen -o key.txt
cat key.txt
# Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

The public key (starts with age1...) is what you give SOPS to encrypt to — safe to commit or share. The private key file (key.txt) is what decrypts — treat it exactly like any other sensitive credential; it is never committed.

Encrypting and decrypting a file#

export SOPS_AGE_KEY_FILE=./key.txt                                     # where sops looks for the private key

sops --encrypt --age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \
  secrets.yaml > secrets.enc.yaml                                       # writes ciphertext to stdout

sops --decrypt secrets.enc.yaml                                          # prints decrypted plaintext to stdout
sops --decrypt secrets.enc.yaml > secrets.yaml                           # ...or redirect it to a real file locally

sops secrets.enc.yaml                                                    # EDIT mode: decrypts, opens $EDITOR,
                                                                          # re-encrypts automatically on save+exit

sops --encrypt --in-place secrets.enc.yaml                               # re-encrypt a file in place (e.g. after key rotation)

sops <file> with no --encrypt/--decrypt flag is edit mode — the single most common way SOPS is used day to day, since it never leaves a decrypted copy sitting on disk after you're done.

Sample output — an encrypted YAML file#

Representative shape of a SOPS-encrypted YAML file (age backend) — note the keys stay readable, only values are ciphertext, and SOPS appends its own sops: metadata block:

apiVersion: v1
kind: Secret
metadata:
    name: checkout-db-credentials
stringData:
    password: ENC[AES256_GCM,data:Tqzf8P3vN1c=,iv:...,tag:...,type:str]
sops:
    age:
        - recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
          enc: |
            -----BEGIN AGE ENCRYPTED FILE-----
            ...
            -----END AGE ENCRYPTED FILE-----
    lastmodified: "2026-09-05T10:00:00Z"
    mac: ENC[AES256_GCM,data:...,type:str]
    version: 3.11.0

The metadata.name key is fully readable in plaintext — a reviewer can see which Secret changed in a git diff without ever decrypting anything, while password's actual value stays opaque.

Config file format — .sops.yaml#

# .sops.yaml — at the repo root
creation_rules:
  - path_regex: environments/production/.*\.enc\.yaml$
    age: >-
      age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p,
      age1another9pubkeyforasecondadminorci0000000000000000000000
  - path_regex: environments/staging/.*\.enc\.yaml$
    age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

Once .sops.yaml exists, sops secrets.enc.yaml (with no --age/-k flags at all) automatically picks the right recipient key(s) based on which rule the file path matches — this is what makes SOPS practical across a repo with many environments, rather than remembering the right key for every file by hand.

Choosing a key backend#

BackendBest fit when...
ageA small-to-medium team, no existing KMS investment — simple key pairs, no cloud dependency to decrypt
AWS KMS / GCP KMS / Azure Key VaultAlready using that cloud's IAM for everything else — access control to the key itself is managed centrally, and CI runners can decrypt via their existing cloud identity with no extra key file to distribute
PGPLegacy teams with existing GPG keyrings/web-of-trust infrastructure already in place
Vault TransitAlready running Vault (see its own cheat sheet) and want encryption-as-a-service instead of managing raw key material at all

Multiple backends can encrypt the same file simultaneously — SOPS wraps the data key separately for each configured recipient, so any one of several keys can decrypt it independently:

sops --encrypt --age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \
  --kms arn:aws:kms:us-east-1:123456789012:key/abc-123 \
  secrets.yaml > secrets.enc.yaml

This is genuinely useful during a migration between backends (e.g. moving a team from PGP to age) — add the new recipient alongside the old one, confirm everyone can decrypt with the new key, then remove the old recipient in a follow-up updatekeys pass rather than a risky one-shot cutover.

Encrypting only specific fields with encrypted_regex#

By default SOPS encrypts every value in a structured file — sometimes only a subset genuinely needs it, and leaving the rest in plaintext makes the diff even more reviewable:

# .sops.yaml
creation_rules:
  - path_regex: config/.*\.yaml$
    age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
    encrypted_regex: '^(password|apiKey|token)$'   # only keys matching this pattern get encrypted
sops --encrypt --encrypted-regex '^(password|apiKey|token)$' config/app.yaml > config/app.enc.yaml

A config file with a dozen ordinary settings and one apiKey field ends up mostly plaintext and fully reviewable in a PR diff, with only the genuinely sensitive field ever encrypted — a smaller blast radius for accidental over-encryption, and a much easier file for a reviewer to actually read.

Real-world scenario: encrypting a Kubernetes Secret for GitOps#

Continuing directly from the base64-isn't-encryption problem the tutorial's Kubernetes chapter raises:

cat secrets.yaml
# apiVersion: v1
# kind: Secret
# metadata: { name: checkout-db-credentials }
# stringData: { password: checkout_app_static_password }

echo "checkout_app_static_password" | base64
# Y2hlY2tvdXRfYXBwX3N0YXRpY19wYXNzd29yZA==     ← trivially reversible, this is the actual risk

sops --encrypt --age $(cat age-public-key.txt) secrets.yaml > secrets.enc.yaml
git add secrets.enc.yaml && git commit -m "Add encrypted checkout DB credentials"

The GitOps controller (Flux, with its sops decryption provider configured against the same age key, stored as a cluster Secret it alone can read) decrypts secrets.enc.yaml at apply time — the plaintext never exists in git history at any point, unlike the naive secrets.yaml this scenario started with.

Real-world scenario: rotating a key after an admin leaves the team#

# Re-encrypt every managed file to a new key set, dropping the departed admin's key
sops updatekeys secrets.enc.yaml       # interactively confirms adding/removing recipients per .sops.yaml
sops --rotate --in-place secrets.enc.yaml   # generates a fresh data encryption key and re-wraps it

Important

Removing a departing admin's public key from .sops.yaml does nothing on its own. SOPS only re-encrypts files when you explicitly run updatekeys (or re-encrypt them another way) — the old key can still decrypt every file it was already a recipient of until you actually rotate. Treat "someone left the team" as a required rotation task, not something .sops.yaml handles automatically.

CI/CD integration recipe#

# .github/workflows/decrypt-and-deploy.yml
name: Decrypt and apply secrets
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: mozilla-services/sops-action@v1
        with:
          file: environments/production/secrets.enc.yaml
        env:
          SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_PRIVATE_KEY }}    # the ONLY place the private key exists in this workflow
      - run: kubectl apply -f environments/production/secrets.enc.yaml

Storing SOPS_AGE_KEY as a CI secret rather than a file checked into the runner keeps the actual private key itself out of the repository at every stage — the encrypted file is the only artifact that's ever version-controlled.

Shell completion and scripting helpers#

sops --decrypt --extract '["password"]' secrets.enc.yaml     # pull exactly one field out, for a script
sops exec-env secrets.enc.yaml 'echo $PASSWORD'                # decrypt, inject as env vars, run a command, then discard
sops exec-file secrets.enc.yaml 'cat {}'                        # decrypt to a temp file, run a command against it, then delete it

exec-env/exec-file matter specifically because they never leave a decrypted copy sitting on disk (or in a shell variable that outlives the command) after the wrapped command exits — the safer default versus piping sops --decrypt output into a file by hand and remembering to delete it afterward.

Common pitfalls#

  • Committing the age private key alongside the encrypted files. SOPS's entire security model depends on the decryption key living somewhere separate from the ciphertext — a private key sitting in the same repo defeats the purpose completely.
  • Forgetting to re-run updatekeys/--rotate after a team member with access leaves. See the IMPORTANT box above.
  • Assuming sops --decrypt output is safe to leave in a shell history or temp file. Treat any decrypted output exactly like the plaintext secret it is — pipe it directly to where it's needed rather than writing it to disk.
  • Encrypting a whole file with --encrypt and expecting structural (per-key) encryption on a format SOPS doesn't understand. SOPS parses YAML/JSON/ENV/INI structurally; anything else (a binary blob, an arbitrary text file) gets encrypted wholesale, not per-value — still safe, but the diff-friendliness benefit is lost.

Shell completion#

sops completion bash | sudo tee /etc/bash_completion.d/sops    # or: source <(sops completion bash) per-session

Exit codes and when to reach for something else#

sops exits non-zero on a decryption failure (wrong/missing key), a MAC mismatch (tampering detected), or invalid flags — genuinely fail-closed by default, unlike some scanners in this series that default to exit 0. If secrets need to be fetched live from a running secrets manager at runtime rather than committed as (encrypted) files, reach for Vault directly, or External Secrets Operator for the Kubernetes sync case — both covered in their own cheat sheets in this series. SOPS's sweet spot is specifically GitOps-committed configuration that needs to travel through the same review/PR/audit process as the rest of a repository's manifests.