Install, encrypt & decrypt
sops -e / -d in practice.
You have a file called secrets.yaml sitting on your laptop with a real database password in it, and it needs to live in Git (the version control system that keeps your team's code and every past version of it). SOPS (Secrets OPerationS) is the lockbox that makes that safe. The file goes in, sops encrypt closes the lid, and what comes out is still valid YAML (a plain-text format for writing structured configuration): same key names, same shape, same reviewable diffs, except every value is now ciphertext. sops decrypt opens the lid again. The trick that makes the whole thing work is that the key which opens the box never travels inside the box. The file records only which recipients sealed it, so a keyholder can open it and nobody else can.
This lesson runs that loop end to end on a real file. Install the binary, make a key, encrypt, decrypt, and run the checks that prove it worked. The commands are short. What matters is what SOPS does between them, and the two or three ways people quietly destroy a secrets file while they are still learning.
Install the binary and check what you downloaded
SOPS ships as a single static binary. No daemon, no server, nothing listening in the background. Installing it means putting one file on your PATH (the list of directories your shell searches through when you type a command name). The project started at Mozilla and was donated to the CNCF (Cloud Native Computing Foundation, the same foundation that hosts Kubernetes) as a sandbox project in 2023, so releases now come from github.com/getsops/sops. The old mozilla/sops links still redirect, but anything you write into a runbook should point at the new home.
Check the checksum (a short fingerprint of the file's exact bytes) while you are there. You are about to hand this binary every secret your team owns, which makes a swapped download a real attack rather than a paranoid daydream. Every release publishes a checksums file next to the binaries, plus a Sigstore signature bundle if you want to go further.
# macOS or Linux, with Homebrewbrew install sops age# or install the release binary by hand (Linux, x86-64)VER=3.13.2BASE=https://github.com/getsops/sops/releases/download/v${VER}curl -sLO ${BASE}/sops-v${VER}.linux.amd64curl -sLO ${BASE}/sops-v${VER}.checksums.txtsha256sum --ignore-missing -c sops-v${VER}.checksums.txtsudo install -m 0755 sops-v${VER}.linux.amd64 /usr/local/bin/sopssops --version --check-for-updates
sops-v3.13.2.linux.amd64: OKsops 3.13.2 (latest)
--check-for-updates makes SOPS call the GitHub releases API and compare your build against the newest tag, which is where (latest) comes from. Plain sops --version still runs that check today, but it now trails a long deprecation warning telling you the automatic check is going away. On a build agent with no route to the internet the call hangs and then fails, so set --disable-version-check or the SOPS_DISABLE_VERSION_CHECK environment variable there (both arrived in 3.10.0) and get the bare version string back. Version matters more than usual with this tool. Every encrypted file records the SOPS version that wrote it in a version field, and a newer SOPS reads older files happily while the reverse is not promised.
Make a key that is yours alone
To encrypt anything you need a key, and the quickest one to start with is age, a small modern encryption tool built around recipients. An age recipient works like the slot on a locked mailbox. The address is public, anyone walking past can post something through the slot, and only the person holding the mailbox key ever takes anything out. age1... is the address you hand out freely. AGE-SECRET-KEY-1... is the one you never hand out, not in a ticket, not in a chat message. Cloud key services (AWS KMS and GCP KMS, both short for Key Management Service, plus Azure Key Vault) and PGP (Pretty Good Privacy, the older public-key standard) look identical from SOPS's side and get proper depth in 'Key backends: age & KMS'.
mkdir -p ~/.config/sops/ageage-keygen -o ~/.config/sops/age/keys.txtls -l ~/.config/sops/age/keys.txt
Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p-rw------- 1 you you 189 Jul 22 09:41 /home/you/.config/sops/age/keys.txt
# created: 2026-07-22T09:41:12+01:00# public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8pAGE-SECRET-KEY-1QGFW7ZQ4X8VD5CEUM3JN9TKHS2LA0PQY6RVW8ZGF2TVDW0S3JN54KHCE6M
age-keygen -o writes that file with 0600 permissions (owner reads and writes, nobody else gets in) and prints the public half to your terminal. The path is not arbitrary either. With no environment variable set, SOPS looks in $XDG_CONFIG_HOME/sops/age/keys.txt on Linux, which for most people resolves to ~/.config/sops/age/keys.txt, then ~/Library/Application Support/sops/age/keys.txt on macOS and %AppData%\sops\age\keys.txt on Windows. One file can hold several identities, one per line, and SOPS tries all of them. Back it up somewhere outside the repository. A file encrypted only for a key you have lost is gone permanently, and no support ticket brings it back.
Encrypt: what actually happens to the file
Here is the file you want in Git. Nothing exotic: one comment, two flat values, one nested block.
# staging credentials, rotated quarterlydb_password: hunter2-stagingstripe_api_key: sk_test_51NxQ2eLkR8vNqW3dsmtp:host: smtp.eu-west-1.example.netpassword: correct-horse-battery
# encrypt every value for one age recipient, writing a NEW filesops encrypt \--age age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p \--output secrets.enc.yaml \secrets.yaml# ask SOPS whether a file is encrypted (useful in a pre-commit hook)sops filestatus secrets.enc.yaml# prove the plaintext value is not in the file you are about to commitgrep -c 'hunter2-staging' secrets.enc.yaml
{"encrypted":true}0
#ENC[AES256_GCM,data:8Kk1Wm5xQ2vTbR0aYd3sLpN7uZcE4hFj9Gt6MwXqSvA1pL5eK9zQ,iv:P4dK9sQm2LzXcV7bN0tRyH3gJfE8uW1aZoI5kTqD6xY=,tag:R7mVzQ1kLxT0eR9bAyN4dg==,type:comment]db_password: ENC[AES256_GCM,data:5vXKrQ9tRmB1wZ2sYcD4,iv:9Kj2XcQ0mB7yTaF1sV6uE4nHwL8dRpZgOiC5tYxA3Ns=,tag:Qv8mZ1kLxT0eR7bAyN4dCg==,type:str]stripe_api_key: ENC[AES256_GCM,data:Ht3pQ9vZ2mK7sXbW1nR5yD8cE0aJfL4uGi==,iv:Lm7ZxQ4vT1kR9bN2sY6uH0dCgW8pJfE3aVzX5tM1oKs=,tag:Zx4Kq8Lm2VtR0yB7nD5aCw==,type:str]smtp:host: ENC[AES256_GCM,data:Qz8Lm4Vt1kR7yB0nD6aCwX3sJfE9uH2pZgT=,iv:T2vX9kQ7mL0zR4bY6nS1dH8cJfE5uW3aPgZ0tK7xNis=,tag:M9kQ2vZ7xL1tR4bN8aYdCg==,type:str]password: ENC[AES256_GCM,data:W3nK8vQ1zR5mT7bY0sD4uH2cJfE6,iv:B5nT8kQ2vZ7xL0mR4bY9sD1uH6cJfE3aPgW7tX2oKzs=,tag:V1tR7bN4aYdCgM9kQ2vZx==,type:str]sops:age:- recipient: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8penc: |-----BEGIN AGE ENCRYPTED FILE-----YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBqUW5MZE1rN3NUdlpiOEFyWXhIMndGdENwUjNnTnU1S2VMOWQKN3ZUcWJzWWhGMm1SMEtqTndFeDRVYVA4Z0xkQzVuVjdyTXlaSTNIUXBLdwotLS0gdEZxWjhSbldrMkxjWDRoQnZNN3lFNXNKZDNhUXVQMGdOelZyWDlLbFR3VQpjR2hZMmtRc1B4TjdiVjRtRDlmUjhMd0F0Nkp1M3ZaYjBTeUVjSG5LcU0xZFhnPT0=-----END AGE ENCRYPTED FILE-----lastmodified: "2026-07-22T09:41:58Z"mac: ENC[AES256_GCM,data:kQ2vZ7xL1tR4bN8aYdCgM9zX5wP0uJfE6sH3rT9mV2kL8qB1nY4dCgW7xZ0tR5aM3vQ9kJfE2uH6pS1dX8bN0zY4tKqL7mR3vZ9xQ1kB5nT8aYdCgM9zX5wP0uJfE6sH3rT9mV2kL8qB1nY4dCgW7xZ0tR5aM3vQ9kJfE2uH6pS=,iv:X5wP0uJfE6sH3rT9mV2kL8qB1nY4dCgW7xZ0tR5aM3v=,tag:9kJfE2uH6pS1dX8bN0zY4g==,type:str]unencrypted_suffix: _unencryptedversion: 3.13.2
Read that result closely, because the whole design is visible in it. The key names (db_password, smtp, host) are untouched and in their original order. Only the values turned into ENC[AES256_GCM,...] blobs, each carrying its own ciphertext (data), its own random initialization vector (iv, the one-time nonce that stops two identical passwords producing identical ciphertext), and an authentication tag proving that blob has not been altered. Your comment was encrypted too, as type:comment, which catches people out the first time a helpful note vanishes from code review. The SMTP host got encrypted as well, even though a hostname is hardly a secret. SOPS encrypts every value by default, and choosing which ones stay readable is the job of 'Partial encryption & structure'.
The sops: block at the bottom is where the real mechanism lives. SOPS did not encrypt your values with your age key. It rolled a fresh random 32-byte data key for this one file, encrypted each value with that data key using AES-256-GCM (Advanced Encryption Standard at 256 bits, in a mode that authenticates as well as encrypts), and then encrypted the data key itself once per recipient. Those wrapped copies are the enc: blobs under each recipient:. One safe holds all the valuables, and a copy of the safe's key goes into a sealed envelope that only one named person can open. Adding a colleague later re-wraps 32 bytes into one more envelope instead of re-encrypting the document, which is why multi-recipient files stay cheap.
Two details in there earn their keep. First, every value is bound to its own position in the document. SOPS feeds the key path, smtp:host: for instance, into AES-GCM as additional authenticated data (extra context that is not encrypted but has to match again at decryption time). Lift the blob out of smtp.host and paste it over db_password and it will not open, because the path it was sealed under no longer matches. You get Error decrypting tree: Could not decrypt value and exit code 25. Second, the mac: line is a separate check one level up: a SHA-512 fingerprint (message authentication code, the tamper-evident seal across the whole file) taken over all the decrypted values and then encrypted with the same data key. It catches edits that leave every surviving blob individually valid, like deleting a line or swapping two entries. That failure reads MAC mismatch and exits 51. An --ignore-mac flag exists to force past the second check only. Reaching for it on a file you did not personally break is how you turn somebody else's tampering into a deployment.
Decrypt, and how SOPS finds your key
Decryption is the mirror image with one thing conspicuously missing: you never pass a recipient. SOPS reads the sops: block, sees which keys can open the file, and tries every identity it can find on the machine. That is why an encrypted file is self-describing, and why moving it between laptops needs no extra arguments. Both spellings work, by the way. Version 3.9.0 added the sops encrypt, sops decrypt, sops edit and sops rotate subcommands. The older sops -e and sops -d flags still do the same thing, and you will meet both in the wild.
There are three ways to hand SOPS an age key, in roughly the order you will run into them. SOPS_AGE_KEY_FILE points at a path, which is the normal desktop setup. SOPS_AGE_KEY holds the key material itself, which suits a CI (continuous integration, the service that builds and tests your code on every push) runner where the platform injects secrets as environment variables. SOPS_AGE_KEY_CMD, added in 3.10.0, runs a command and reads the key from whatever that command prints, so the identity can stay encrypted at rest and surface from a password manager only at the moment it is needed.
export SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt# decrypt to stdout and compare it against the original plaintextsops decrypt secrets.enc.yaml | diff secrets.yaml -
5,6c5,6< host: smtp.eu-west-1.example.net< password: correct-horse-battery---> host: smtp.eu-west-1.example.net> password: correct-horse-battery
Nothing was lost, and the diff proves it in a mildly annoying way. SOPS parses the document and re-emits it in its own style, four spaces per level, so a round trip is identical in content without always being identical byte for byte. Expect the first encryption of an existing file to land as a bigger commit than you predicted: re-indented throughout, comments turned into #ENC[...] lines. Version 3.9.0 added an --indent flag, plus a stores: block in .sops.yaml where you can set yaml: indent: 2, if you would rather match your repository's house style and keep later diffs small.
When it fails, read the failure
# a file a teammate encrypted for their own key, on your laptopsops decrypt app.enc.yamlecho "exit: $?"
Failed to get the data key required to decrypt the SOPS file.Group 0: FAILEDage1v9k4mrs2wt7pd3q8xhz6ycf0njugelva5k2t9dxm7q4pshc3zguq8raxvn: FAILED- | failed to create reader for decrypting sops data key with| age: no identity matched any of the recipientsRecovery failed because no master key was able to decrypt the file. Inorder for SOPS to recover the file, at least one key has to be successful,but none were.exit: 128
That output tells you more than it first appears. Group 0 is a key group, one bundle of keys that can independently recover the file. Under it, SOPS lists every recipient it tried and the reason each one lost. The indented text is age talking, not SOPS: no identity matched any of the recipients means your key is not on the list, which is a completely different problem from a damaged file. The exit codes keep those apart for scripts. 128 means SOPS could not recover the data key at all, so wrong key or missing key. 25 means a value refused to decrypt, and 51 means the MAC did not match, and both of those point at integrity rather than access. A pipeline that logs all three as 'decryption failed' sends the next on-call engineer hunting in the wrong direction. The fix for 128 is social rather than technical: someone who can already decrypt the file adds your public key as a recipient and re-encrypts, which is the 'Rotation & multi-recipient' lesson.
Using secrets without leaving plaintext behind
A decrypted file on a laptop or a build agent is a plaintext secret sitting in a filesystem, and from there in a backup, a container layer, a crash dump, or an editor swap file. Every decrypt that ends in a file on disk is a decision to store that secret a second time, somewhere with weaker protection than the original. Prefer the forms that keep plaintext in memory and pipes.
# pull one value out of a nested documentsops decrypt --extract '["smtp"]["password"]' secrets.enc.yaml# stream a decrypted manifest straight into the cluster, nothing on disksops decrypt app-secret.enc.yaml | kubectl apply -f -# load a flat key/value file into one child process environmentsops exec-env app.enc.env 'printenv DB_PASSWORD'
correct-horse-batterysecret/app-staging configuredhunter2-staging
--extract takes a path expression and returns a single value, so only that one value reaches your screen, though SOPS still decrypts the whole tree in memory. Piping into kubectl apply -f - never touches disk, and Flux and Argo pull the same trick inside the cluster instead, covered in 'SOPS in GitOps'. exec-env sets each top-level key as an environment variable for one child process and nothing else. When a tool insists on a real path, sops exec-file hands it a named pipe by default, a file-shaped thing that exists only while the process reads from it, and --no-fifo falls back to a genuine temporary file if the tool cannot cope. When you truly do need a file written, use --output FILE and let SOPS do the writing, which sidesteps the trap in the next box.
sops encrypt secrets.yaml > secrets.yaml hands SOPS an empty file: the plaintext is gone and nothing was encrypted in exchange. The identical trap bites sops decrypt secrets.enc.yaml > secrets.enc.yaml. Use -i / --in-place for same-file work, or --output FILE so SOPS controls the write. Redirection sits outside SOPS's control either way, so a command that dies halfway can still leave a truncated or half-written target. Until you have watched one clean round trip on real data, keep the source committed or copied elsewhere, so a mistyped command is annoying instead of fatal.git commit -a later, the secret is in history forever and the only honest fix is rotating the credential itself. To change a value, use the editor workflow (sops edit secrets.enc.yaml), which decrypts to a temporary file, opens your editor, and re-encrypts on save. Add plaintext filenames to .gitignore, and wire sops filestatus into a pre-commit hook so a file that is supposed to be encrypted cannot be committed while it is not.What the ciphertext still tells the world
Be honest about what survives encryption. Anyone with read access to the repository sees the shape of your configuration: which keys exist, how many environments there are, that you run an smtp block and hold a stripe_api_key, roughly how long each value is, when the file last changed, and the complete recipient list. That last one doubles as an access map, a precise roster of which public keys can open this file, which is a useful shopping list for an attacker deciding whose laptop to target. The values themselves stay safe. Whether the surrounding metadata matters depends on how public the repository is.
The sharper problem is history. Git keeps every version forever, so a stolen keys.txt opens today's file and also every past commit that key could ever decrypt, including the password you rotated last year. Encryption at rest in a repository reaches backwards as well as forwards. If an age private key leaks, treat it like a leaked password file: rotate the underlying credentials at the source first (change the database password, roll the API key), then re-encrypt the files under a new age key. Doing it in the other order protects tomorrow's commits and none of yesterday's.
sops encrypt secrets.yaml > secrets.yaml?--in-place is a SOPS-controlled write that happens after encryption succeeds; a shell redirect is a different mechanism entirely.app.enc.yaml. Your sops decrypt app.enc.yaml prints Group 0: FAILED, then the recipient line marked FAILED with no identity matched any of the recipients, and exits 128. What is wrong and what fixes it?Try this
Run brew install sops age on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: sops encrypt file > file destroys the file. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.