# SOPS Cheat Sheet

> **Tool:** Mozilla SOPS (Secrets OPerationS) + age
> **Category:** Security & Compliance
> **Verified against:** `sops --help` / `sops --version` run locally (sops 3.11.0), cross-checked against
> the current official README at github.com/getsops/sops, 2026-09-05
> **Official docs:** https://github.com/getsops/sops

## 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](/tutorials/devsecops/03-container-and-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

```mermaid
flowchart TD
    Dev["Developer edits<br/>secrets.enc.yaml"] -->|"sops secrets.enc.yaml"| Edit["SOPS decrypts in memory,<br/>opens $EDITOR,<br/>re-encrypts on save"]
    Edit -->|"git commit + push"| Repo[("Git repository<br/>(ciphertext only)")]
    Repo --> CD["GitOps controller<br/>(Flux/ArgoCD + sops plugin)"]
    CD -->|"has the age/KMS<br/>private key"| Decrypt["Decrypt at apply time"]
    Decrypt --> Cluster["Kubernetes Secret<br/>applied to cluster"]

    classDef muted fill:#eaeef1,stroke:#c3ccd4,color:#10161c
    classDef ok fill:#e5f5ea,stroke:#1f8a4c,color:#10161c
    classDef info fill:#e5f0fa,stroke:#1d6fb8,color:#10161c
    class Repo muted
    class Cluster ok
    class Decrypt info
```

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

```bash
# 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

| Concept | What it means |
|---|---|
| **Key backend** | Where 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.yaml`** | A 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 encryption** | Only *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

```bash
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

```bash
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:

```yaml
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`

```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

| Backend | Best fit when... |
|---|---|
| **age** | A small-to-medium team, no existing KMS investment — simple key pairs, no cloud dependency to decrypt |
| **AWS KMS / GCP KMS / Azure Key Vault** | Already 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 |
| **PGP** | Legacy teams with existing GPG keyrings/web-of-trust infrastructure already in place |
| **Vault Transit** | Already 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:

```bash
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:

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

```bash
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:

```bash
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

```bash
# 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

```yaml
# .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

```bash
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

```bash
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.
