Dev In The Mountain Header
A Developer In The mountains having fun

Your First Real Secrets Setup: SOPS + age for New Developers

Sooner or later every project needs a password. A database password, an API key for a payment service, a token for a private package registry. The first time it happens, most of us do the same thing: paste it into the code, or into a .env file, and hope nobody notices. Then someone asks "how do I get the API key so I can run this?" and the answer is a Slack message with the key in it.

This post is for you if that sounds familiar, or if you're setting up secrets for the first time and want to do it right from the start. It walks through SOPS and age, two small open source tools that let you keep encrypted secrets right in your git repo, safely. You don't need to know any cryptography, and you almost certainly don't need to change your application code.

If you want the bigger picture (SSH keys, password managers, and leak scanning), see my earlier post, Managing SSH Keys, Passwords, and Dev Secrets on Linux. This one zooms in on SOPS + age and goes step by step.


TL;DR

  1. age makes a pair of keys: a public key you can share with anyone, and a private key you never share.
  2. SOPS encrypts the values in a file like .env or YAML using the public keys, so the encrypted file is safe to commit to git.
  3. Your app doesn't change. It keeps reading environment variables. You start it with sops exec-env, which decrypts the secrets in memory and hands them to your app.
  4. Never bake decrypted secrets into a Docker image or build artifact. Secrets go in when the app starts, not when it's built.
  5. If a secret leaks, change the secret. Deleting the file or the commit is not enough.

First, what counts as a "secret"?

A secret is anything that would let someone act as you or your app if they got hold of it:

  • Database passwords and connection strings
  • API keys and tokens (Stripe, OpenAI, GitHub, AWS, npm, and so on)
  • OAuth client secrets
  • Private keys and certificates
  • Webhook signing secrets

Things that are not secrets: the database host name, the port, feature flags, a public site URL. Those can live in normal config.

One trap catches a lot of new frontend developers: anything with a VITE_ or NEXT_PUBLIC_ prefix gets copied into the JavaScript that ships to the browser. Anyone can read it with their browser's dev tools. If a value has to be in the browser, it is public, no matter how you store it. Real secrets have to stay on a server.

Why not just commit it?

Because git never forgets. Once a secret is in a commit, it is in the history of every clone of that repo forever, even if you delete it in the next commit. Bots scan public GitHub constantly and find leaked keys within minutes. Private repos leak too: they get made public by accident, forked, cloned onto laptops that get stolen, or shared with contractors.

Why not just a gitignored .env file?

That's a fine starting point, and much better than committing plaintext. The problem is sharing. Every new teammate needs the values, so they get pasted into chat or email, where they sit forever. When a value changes, someone has to tell everyone. SOPS solves that: the encrypted file lives in the repo, so git pull gets you the latest secrets, and only people who hold a matching private key can read them.


The mental model

Two ideas cover almost everything.

1. age keys are a padlock and its key. Your public key (it starts with age1...) is like an open padlock. You can hand out copies to anyone, and anyone can use it to lock a box. Only your private key (it starts with AGE-SECRET-KEY-...) can open boxes locked with your padlock. So: share the public key freely, guard the private key.

2. SOPS locks the values, not the names. Given a file like this:

sh
DB_PASSWORD=hunter2
STRIPE_KEY=sk_test_abc123

SOPS encrypts hunter2 and sk_test_abc123 but leaves DB_PASSWORD and STRIPE_KEY readable. That means a git diff still tells you which secret changed, even though nobody can see what it changed to. It can lock the file with several people's padlocks at once, so everyone on the team can open it with their own private key.

Here's how the pieces fit together:

And who holds what:

ThingWhat it isCommit to git?
.sops.yamlRules saying which public keys can read which filesYes
age1... public keyYour padlock. Used to encrypt.Yes (inside .sops.yaml)
AGE-SECRET-KEY-... private keyYour key. Used to decrypt.Never
secrets/*.sops.envThe encrypted secretsYes
The real plaintext valuesThe actual passwordsNever

Step 1: Install the tools

On Ubuntu or Debian:

sh
sudo apt install age

For SOPS, download the binary for your platform from the SOPS releases page. Pick a specific version, check the checksum listed on the release page, then install it:

sh
sudo install -m 755 sops-vX.Y.Z.linux.amd64 /usr/local/bin/sops
sops --version

On macOS, brew install age sops gets you both.

This post uses the current SOPS commands: sops edit, sops encrypt, sops decrypt. Older tutorials use sops -e, sops -d, or just sops file. If your SOPS doesn't recognize a command here, upgrade it.


Step 2: Make your age key

sh
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt
chmod 600 ~/.config/sops/age/keys.txt

age-keygen prints something like:

Public key: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

Two things to do right now:

  1. Copy that public key somewhere handy. You'll paste it into your project in a minute, and it's what you send to teammates.
  2. Back up the private key file in your password manager. If you lose it and you're the only person who can decrypt a file, those secrets are gone for good.

SOPS automatically looks for your private key at ~/.config/sops/age/keys.txt on Linux, so you don't need to configure anything else. (If you keep it somewhere else, point the SOPS_AGE_KEY_FILE environment variable at it.)


Step 3: Try it on a tiny project

The best way to understand SOPS is to watch it work once. Make a throwaway project:

sh
mkdir sops-demo && cd sops-demo
git init
mkdir secrets

And a tiny app that reads a secret from the environment. Node:

js
// app.js
const key = process.env.API_KEY;
console.log(key ? `Got API_KEY ending in ...${key.slice(-4)}` : "API_KEY is not set!");

or Go, if you prefer:

go
// main.go
package main

import (
    "fmt"
    "os"
)

func main() {
    key := os.Getenv("API_KEY")
    if key == "" {
        fmt.Println("API_KEY is not set!")
        return
    }
    fmt.Println("Got API_KEY ending in ..." + key[len(key)-4:])
}

Notice there's nothing about SOPS in that code. That's the point. (Printing only the last four characters is a good habit, by the way. Never print a whole secret, even while debugging.)

Tell SOPS who can decrypt

Create a file named exactly .sops.yaml in the project root, and paste in your public key:

yaml
creation_rules:
  - path_regex: secrets/.*\.sops\.env$
    age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p

This says: "any file under secrets/ ending in .sops.env should be encrypted so this key can open it."

Create the encrypted file

sh
sops edit secrets/dev.sops.env

SOPS opens your editor (whatever $EDITOR is set to). It may pre-fill some example content; delete it and type:

sh
API_KEY=sk_test_1234567890abcd

Save and quit. SOPS encrypts the file the moment your editor closes.

Look at what you made

sh
cat secrets/dev.sops.env

You'll see something roughly like this:

API_KEY=ENC[AES256_GCM,data:8Xk2...,iv:...,tag:...,type:str]
sops_age__list_0__map_recipient=age1ql3z7hjy54pw...
sops_age__list_0__map_enc=-----BEGIN AGE ENCRYPTED FILE-----\n...
sops_lastmodified=2026-11-12T18:00:00Z
sops_mac=ENC[AES256_GCM,data:...]
sops_version=3.x.x

The name API_KEY is readable, the value is not. The sops_ lines are metadata: who can decrypt it (the recipient line is your public key) and a MAC, a checksum that lets SOPS detect if anyone tampers with the file. This file is safe to commit.

Run the app with the secret

sh
sops exec-env secrets/dev.sops.env 'node app.js'
# or
sops exec-env secrets/dev.sops.env 'go run .'
Got API_KEY ending in ...abcd

exec-env decrypted the file, set API_KEY as an environment variable for just that one command, and ran it. Nothing was written to disk. Run node app.js on its own and you get API_KEY is not set!, because the secret only existed for that child process.

Commit it

sh
git add .sops.yaml secrets/dev.sops.env app.js
git commit -m "add encrypted dev secrets"

That's the whole loop. Everything after this is just doing the same thing in more places.


The commands you'll actually use

I want to...Command
Edit secrets (decrypts, opens editor, re-encrypts on save)sops edit secrets/dev.sops.env
See the decrypted contentssops decrypt secrets/dev.sops.env
Run my app with the secretssops exec-env secrets/dev.sops.env 'npm run dev'
Encrypt a plaintext file I already have, in placesops encrypt -i secrets/dev.sops.env
Re-encrypt after adding or removing someone in .sops.yamlsops updatekeys secrets/dev.sops.env

sops edit and sops exec-env cover 90% of daily life.

A few tips that save frustration:

  • Use a terminal editor for sops edit (nano, vim, micro). Some GUI editors return control immediately and keep running in the background, so SOPS thinks you're done before you've typed anything. VS Code works if you set EDITOR="code --wait".
  • Keep secrets files flat (KEY=value lines in a .env file). Each line becomes one environment variable, which is exactly what exec-env wants.
  • Don't hand-edit encrypted values. Always go through sops edit. If you change the file any other way, the MAC check fails and SOPS refuses to decrypt.
  • Double-check after encrypting: run sops decrypt once to make sure it round-trips, and look at the recipient lines to confirm the right people are on the list.

Don't type that long command every time

Nobody wants to remember sops exec-env secrets/dev.sops.env '...'. Put it in your task runner once and forget about it.

With a Makefile (recipe lines must start with a tab, not spaces):

.PHONY: run test secrets

run:
    sops exec-env secrets/dev.sops.env 'node app.js'

test:
    sops exec-env secrets/dev.sops.env 'npm test'

secrets:
    sops edit secrets/dev.sops.env

Or with go-task (I wrote about why I switched from Make to go-task):

yaml
# Taskfile.yml
version: '3'

vars:
  ENV: '{{.ENV | default "dev"}}'
  SECRETS: secrets/{{.ENV}}.sops.env

tasks:
  run:
    desc: Run the app with decrypted secrets
    cmds:
      - sops exec-env {{.SECRETS}} 'go run ./cmd/app {{.CLI_ARGS}}'

  test:
    desc: Run tests with decrypted secrets
    cmds:
      - sops exec-env {{.SECRETS}} 'go test ./...'

  secrets:edit:
    desc: Edit the encrypted secrets file
    interactive: true
    cmds:
      - sops edit {{.SECRETS}}

Now it's just task run, or task run ENV=staging once you have a secrets/staging.sops.env.

One go-task gotcha: don't point Task's built-in dotenv: setting at a SOPS file. It can't decrypt, so your app would get the literal ENC[...] gibberish as its values. Let sops exec-env do the loading.

What if my app uses the dotenv package?

Leave it alone. By default dotenv doesn't overwrite environment variables that are already set, so the values from exec-env win. Just make sure you don't also have a stale plaintext .env lying around with different values, or you'll confuse yourself.

Docker Compose

Wrap the whole docker compose command, and list the variable names in environment: without values so Compose passes them through from your shell:

sh
sops exec-env secrets/dev.sops.env 'docker compose up'
yaml
services:
  app:
    build: .
    environment:
      - API_KEY    # no value here; it comes from the environment

Protect yourself from the classic mistakes

Gitignore plaintext

Add this to .gitignore so a decrypted copy can never sneak into a commit:

.env
*.dec.*
secrets/*.plain.*
keys.txt

Add a safety check

This little script fails if any file in secrets/ isn't actually encrypted. Run it as a pre-commit hook or in CI:

sh
#!/usr/bin/env sh
# scripts/check-secrets.sh
# fail if any secrets file is missing SOPS metadata
bad=0
for f in secrets/*.sops.*; do
  if ! grep -qE '^(sops:|sops_)|"sops"[[:space:]]*:' "$f"; then
    echo "NOT ENCRYPTED: $f"; bad=1
  fi
done
exit $bad

Also make sure any code formatter or linter in your hooks skips encrypted files. A YAML formatter that reorders keys can break the SOPS metadata and leave you with a file nobody can decrypt.

.sops.yaml gotchas

These trip up almost everyone at least once:

GotchaWhat to know
The name must be exactly .sops.yaml.sops.yml is not found automatically
path_regex is a regular expression, not a glob*.sops.env is wrong. Use .*\.sops\.env$
First matching rule winsPut specific rules (like prod) above general ones
Rules only apply when a file is encryptedChanging .sops.yaml doesn't change existing files. Run sops updatekeys on them
The file extension picks the format.env is dotenv, .yaml is YAML, .json is JSON. Keep the .sops. part in the middle

Never put secrets in your build

This is the one rule worth tattooing somewhere. Your app gets its secrets when it starts, not when it's built.

Green steps never see a secret. Orange steps do, and only when the app is deployed and running.

Why does it matter? A Docker image is a stack of layers, and every layer is kept. If you copy a .env into one layer and delete it in the next, it's still sitting in the earlier layer, and anyone who can pull the image can dig it out.

# BAD: every one of these leaks your secret into the image
COPY secrets/dev.env /app/.env
ARG DB_PASSWORD
RUN sops decrypt secrets/dev.sops.env > .env
# GOOD: the image contains no secrets at all
FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/app

FROM gcr.io/distroless/static
COPY --from=build /out/app /app
ENTRYPOINT ["/app"]

Then pass secrets in when you run the container (environment variables, Compose, or your hosting platform's secret settings).

If the build itself needs a secret, like a token to install a private npm package, Docker has a feature just for that: a BuildKit secret mount. The secret is available during one RUN step and is never saved to a layer:

# syntax=docker/dockerfile:1
FROM node:22 AS build
WORKDIR /app
COPY package*.json .npmrc ./
RUN --mount=type=secret,id=npm_token \
    NPM_TOKEN="$(cat /run/secrets/npm_token)" npm ci
COPY . .
RUN npm run build
sh
sops exec-env secrets/build.sops.env \
  'docker build --secret id=npm_token,env=NPM_TOKEN -t myapp .'

To double-check an image is clean, run docker history --no-trunc myapp:latest and look for anything that resembles a password.


Working with a team

Adding a teammate

  1. The new person installs age and SOPS, runs age-keygen, and sends you their public key. A pull request that adds it to .sops.yaml is perfect. (The public key really is safe to share in the open.)
  2. Someone who can already decrypt adds the key to .sops.yaml, then re-encrypts each file the new person should read:
    sh
    sops updatekeys secrets/dev.sops.env
    SOPS shows what will change and asks you to confirm.
  3. Commit and push. The new person pulls and runs make run or task run. No secrets were sent over chat.

With several people, .sops.yaml just lists more keys:

yaml
creation_rules:
  - path_regex: secrets/.*\.sops\.env$
    age: >-
      age1alice...,
      age1bob...,
      age1newperson...

Separate dev and prod

As soon as you have a production environment, give it its own file and a smaller list of people who can read it:

yaml
creation_rules:
  # Prod: only ops people and the prod CI key
  - path_regex: secrets/prod\.sops\.env$
    age: >-
      age1opsperson...,
      age1ciprod...

  # Everything else: the whole dev team and dev CI
  - path_regex: secrets/.*\.sops\.env$
    age: >-
      age1alice...,
      age1bob...,
      age1cidev...

Prod comes first because the first matching rule wins. If the general rule were on top, it would match prod.sops.env too, and the whole team could read prod.

EnvironmentWho can decrypt
devAll developers + dev CI
stagingA few developers + staging CI
prodOps + prod CI only

When someone leaves (or a key leaks)

Order matters here:

  1. Remove their public key from .sops.yaml.
  2. Run sops updatekeys on every file they could read.
  3. Run sops rotate -i on those files (this changes the internal encryption key).
  4. Commit and push.
  5. Change the real secrets. New database passwords, new API keys.

Step 5 is the one people skip, and it's the one that matters most. Their old private key can still decrypt every old version of the file sitting in git history. Removing them from the list only protects future values. So treat everything they could see as exposed, and replace it. Do it after steps 1 to 4, so the new values are never encrypted to their key.


Using it in CI

CI is just another "person" who needs to decrypt, so it gets its own key.

  1. Generate a separate age key for CI. Never reuse a person's key.
  2. Add its public key to .sops.yaml and run sops updatekeys.
  3. Save the private key as a CI secret named SOPS_AGE_KEY. SOPS reads the key straight from that environment variable, so it never has to be written to a file.
  4. Install SOPS in the job and use exec-env, same as on your laptop.

A GitHub Actions example:

yaml
name: test
on: [push, pull_request]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install sops
        run: |
          curl -sSLo sops https://github.com/getsops/sops/releases/download/vX.Y.Z/sops-vX.Y.Z.linux.amd64
          # verify the checksum from the release page here
          sudo install -m 755 sops /usr/local/bin/sops

      - name: Test with secrets
        env:
          SOPS_AGE_KEY: ${{ secrets.SOPS_AGE_KEY }}
        run: sops exec-env secrets/ci.sops.env 'go test ./...'

Good habits for CI:

  • Use one key per environment (dev CI, staging, prod), so a leaked staging key can't open prod.
  • Put the prod key behind a protected environment with required reviewers, so a human approves anything that touches prod.
  • Run the check-secrets.sh script from above on every push.
  • Never print decrypted values in logs. CI masks known secrets, but don't rely on it.

For tests, prefer fake values over real secrets whenever you can. Tests that need real production credentials are a smell.


When it doesn't work

What you seeWhat's probably wrongFix
sops metadata not foundThe file was never encrypted, or a formatter mangled itCheck for the sops_ lines (or a sops: block in YAML). Re-encrypt. Exclude encrypted files from formatters
"no matching creation rules"path_regex doesn't match, or .sops.yaml wasn't foundRemember it's a regex, not a glob. Run from inside the repo. Check the file name is exactly .sops.yaml
Failed to get the data keyYour key isn't on the file's recipient list, or SOPS can't find your private keyCompare the file's recipient lines with your public key (grep 'public key' ~/.config/sops/age/keys.txt). Ask a teammate to add you and run updatekeys
Works on my machine, fails in CIThe CI key isn't a recipient, or SOPS_AGE_KEY isn't set for that jobAdd the CI public key, run updatekeys, make sure the secret is exposed to that job
sops edit does nothingYour editor returned immediatelyUse a terminal editor, or a wait flag like code --wait
App runs but the variable is emptyexec-env wasn't wrapped around the actual commandPut the whole launch command inside the quotes after exec-env

Checklist

  • My private key is backed up in a password manager and has never been committed, pasted in chat, or put in an image
  • Every person and every CI system has its own age key
  • Plaintext .env files are in .gitignore
  • A check confirms everything in secrets/ is encrypted
  • Docker images are built with no secrets in them
  • Prod secrets have fewer people on the list than dev
  • When someone leaves, I run updatekeys, then rotate, then change the real secrets

What SOPS is not

SOPS is a great first step, but it is not a full secrets manager. It doesn't give you per-request access control, short-lived credentials that expire on their own, or a central log of who read what. For a small team or a side project you probably don't need those. When you do, look at OpenBao (the open source fork of HashiCorp Vault) or your cloud provider's secrets service. SOPS can even use cloud KMS keys instead of age, so it can grow with you.


Quick start (copy and paste)

sh
# 1. one time, on your machine
sudo apt install age            # plus the sops binary from GitHub releases
mkdir -p ~/.config/sops/age
age-keygen -o ~/.config/sops/age/keys.txt && chmod 600 ~/.config/sops/age/keys.txt

# 2. in your project: create .sops.yaml with your public key, then
mkdir -p secrets
sops edit secrets/dev.sops.env       # type KEY=value lines, save, quit
git add .sops.yaml secrets/dev.sops.env

# 3. run your app with the secrets
sops exec-env secrets/dev.sops.env 'npm run dev'

Further reading

More places to find me
Mental Health
follow me on Mastodon