Back to articles
5 min read

Yes, I commit secrets to git (with SOPS)

Encrypted secrets in version control that actually work. Share API keys across your team without Vault or secret managers.

SecurityDevOpsGitSOPS

Secrets don’t belong in git. Except when they do

I’ve seen people send me production passwords in plaintext over Teams. .env files in Slack messages that disappear after 90 days. Credentials shared in Google Docs

Now I keep production API keys, database passwords, and dashboard credentials in my repositories. Encrypted with SOPS, committed alongside code, accessible to everyone who needs them

No secret managers, no Vault clusters, no separate infrastructure. Just git

Last day of unpaid internship

Why secrets in git works

Most teams solve secret sharing wrong. HashiCorp Vault needs its own infrastructure. AWS Secrets Manager costs money and locks you to AWS. Encrypted env files in Slack get lost

SOPS encrypts files with age or GPG. Each developer has a key. The encrypted file goes in git. Everyone with a key can decrypt it. Remove someone’s key when they leave

Simple, auditable, works offline

Setup takes 5 minutes

Install SOPS and age:

# Debian/Ubuntu
apt install age
go install github.com/getsops/sops/v3/cmd/sops@latest

# Arch
yay -S sops age

Generate a key for yourself:

age-keygen -o ~/.config/sops/age/keys.txt

This outputs your public key. Share it with your team. Keep the private key secret

Team onboarding

New developer joins. They generate their age key and send you the public key. You add it to .sops.yaml:

creation_rules:
  - path_regex: secrets/.*\.yaml$
    age: >-
      age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p,
      age1qlmn8m9x7xj8p6vfz2k5h3r8w6t4y9q2v5x8c3n7b4m6a1d9e2f5g8h1j4k
    encrypted_regex: '^(data|stringData|password|api_key|secret)$'

Each line is one person’s public key. Add the new dev’s key, commit the change

Re-encrypt all secrets to include the new key:

sops updatekeys secrets/**/*.yaml
git add secrets/
git commit -m 'add key for new developer'

Now they can decrypt everything. Takes 2 minutes

Creating secrets

I built a task runner command for this. task sops:create secrets/api-keys.yaml opens vim with an encrypted file:

# Makefile or Taskfile.yml
sops:create:
  cmds:
    - |
      if [ ! -f {{.CLI_ARGS}} ]; then
        echo 'Creating new encrypted file: {{.CLI_ARGS}}'
        sops {{.CLI_ARGS}}
      else
        echo 'File exists, editing: {{.CLI_ARGS}}'
        sops {{.CLI_ARGS}}
      fi

Edit the file in your editor. SOPS handles encryption on save. Commit it:

task sops:create secrets/prod/database.yaml
# Edit in vim, add credentials
git add secrets/prod/database.yaml
git commit -m 'add production database credentials'

The committed file is encrypted. Only team members with keys can read it

Using secrets in CI/CD

GitHub Actions needs the age private key. Add it as a repository secret named SOPS_AGE_KEY. Then decrypt in your workflow:

- name: Decrypt secrets
  env:
    SOPS_AGE_KEY: \$\{\{ secrets.SOPS_AGE_KEY \}\}
  run: |
    sops -d secrets/prod/api-keys.yaml > api-keys.yaml
    export API_KEY=$(yq '.api_key' api-keys.yaml)

Weak point: Your CI holds the private key. This is the most sensitive part of the system. If you use a poorly isolated self-hosted runner or someone can execute arbitrary code in your CI, they can extract SOPS_AGE_KEY and decrypt everything

We locked this down with surgical precision:

  • Only admins can modify workflows (branch protection on .github/workflows/)
  • GitHub secrets are only accessible to repo admins
  • External PRs don’t trigger workflows automatically (prevent pwn requests)
  • GitHub-hosted runners only, never self-hosted for production
  • Branch protection: no direct push to main, even for admins

Result: to steal SOPS_AGE_KEY, you need to be a repo admin AND successfully merge a malicious workflow. With CODEOWNERS and mandatory review, it’s nearly impossible

Same works for GitLab CI, CircleCI, any CI system. Set the env var, decrypt the file

For Kubernetes, decrypt and apply:

sops -d secrets/prod/k8s-secrets.yaml | kubectl apply -f -

Approval workflow for access

Use branch protection and CODEOWNERS. Only senior devs can approve changes to .sops.yaml:

# CODEOWNERS
.sops.yaml @senior-team
secrets/** @senior-team

New dev wants access? They open a PR adding their public key. Senior dev reviews, approves, merges. The re-encryption happens automatically in CI:

# .github/workflows/reencrypt.yml
name: Re-encrypt secrets
on:
  push:
    paths:
      - '.sops.yaml'

jobs:
  reencrypt:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Install SOPS
        run: |
          wget https://github.com/getsops/sops/releases/latest/download/sops-linux-amd64
          sudo mv sops-linux-amd64 /usr/local/bin/sops
          sudo chmod +x /usr/local/bin/sops

      - name: Re-encrypt all secrets
        env:
          SOPS_AGE_KEY: \$\{\{ secrets.SOPS_AGE_KEY \}\}
        run: |
          find secrets -name '*.yaml' -exec sops updatekeys {} \;

      - name: Commit changes
        run: |
          git config user.name 'GitHub Actions'
          git config user.email 'actions@github.com'
          git add secrets/
          git diff --quiet && git diff --staged --quiet || git commit -m 're-encrypt secrets with new recipients'
          git push

Every time .sops.yaml changes, CI re-encrypts everything. New person gets access instantly. Old person loses access when you remove their key

Developer leaves the team

Remove their public key from .sops.yaml. Commit and push. CI re-encrypts everything without their key. They can’t decrypt new secrets

Warning: Removing their key only prevents them from reading new secrets. They still have the entire git history. They still have access to every secret they decrypted before leaving. That’s why you need to rotate actual secrets:

# Update the actual passwords/keys in your services
# Then update the encrypted files
sops secrets/prod/database.yaml
# Change the password values
git commit -m 'rotate production credentials'

This is the only manual step. Everything else is automatic

File structure that scales

secrets/
  dev/
    api-keys.yaml
    database.yaml
  staging/
    api-keys.yaml
    database.yaml
  prod/
    api-keys.yaml
    database.yaml
    terraform.yaml
.sops.yaml

Different keys for different environments. Devs get dev and staging. Ops get everything:

creation_rules:
  - path_regex: secrets/dev/.*\.yaml$
    age: age1dev1...,age1dev2...,age1ops1...

  - path_regex: secrets/staging/.*\.yaml$
    age: age1dev1...,age1dev2...,age1ops1...

  - path_regex: secrets/prod/.*\.yaml$
    age: age1ops1...,age1ops2...

Fine-grained access control without infrastructure

Comparing to alternatives

Vault: Requires running infrastructure. SOPS needs zero infrastructure. Files are just git

AWS Secrets Manager: Cloud vendor lock-in. Costs money. SOPS works anywhere

Encrypted .env in Slack: Gets lost. No version history. SOPS has full git history and audit trail

1Password/Bitwarden for teams: Separate system. SOPS lives with code. Deploy uses same git SHA for code and secrets

SOPS wins when you want secrets versioned with code

Common issues

Lost private key? You’re locked out. Back up your ~/.config/sops/age/keys.txt. Store it in your password manager. No backup means no recovery. All your secrets become permanently inaccessible

Merge conflicts in encrypted files? Decrypt both versions, manually merge, re-encrypt:

sops -d secrets/file.yaml > file-yours.yaml
git checkout main
sops -d secrets/file.yaml > file-theirs.yaml
# Manually merge file-yours.yaml and file-theirs.yaml
sops secrets/file.yaml  # Edit with merged content

Someone committed a plaintext secret? Use BFG Repo-Cleaner to remove it from git history. Rotate the secret immediately. But know that if the repo is public or someone already pulled, it’s too late. The secret is burned. GitHub scans public commits for secrets, you’ll get an alert within minutes

Real workflow

New developer needs access. I tell them to run one command:

task sops:onboarding

That’s it. The command installs dependencies, generates their age key, and sends me their public key automatically. I approve the PR, merge, CI re-encrypts everything. They pull and have access to all secrets

Under the hood, the task does this:

sops:onboarding:
  cmds:
    - |
      # Detect package manager and install
      if command -v yay &> /dev/null; then
        yay -S --noconfirm sops age
      elif command -v apt &> /dev/null; then
        apt update && apt install -y age
        go install github.com/getsops/sops/v3/cmd/sops@latest
      fi
    - mkdir -p ~/.config/sops/age
    - age-keygen -o ~/.config/sops/age/keys.txt
    - |
      PUBLIC_KEY=$(grep 'public key:' ~/.config/sops/age/keys.txt | awk '{print $4}')
      echo 'Your public key: $PUBLIC_KEY'
      echo 'Send this to your team lead to get access to secrets'

They could do it manually. Install SOPS and age, generate a key, grep for the public key, send it. But why make them memorize commands when you can automate it?

Same for me adding their key. I have task sops:add-key PUBLIC_KEY=age1... that updates .sops.yaml, commits, pushes. Takes 5 seconds

Create a new secret:

task sops:create secrets/staging/new-api.yaml
# Edit file, add credentials
git add secrets/staging/new-api.yaml
git commit -m 'add staging API credentials'
git push

Use it in deployment:

sops -d secrets/staging/new-api.yaml | kubectl apply -f -

No separate systems. No UI to click. Everything is code and git

I used to avoid putting secrets in git. Now I can’t imagine managing them any other way. Version controlled, auditable, accessible, encrypted. SOPS made secret management boring again, which is exactly what it should be

Reality is often more nuanced. But me? Nuance bores me. I'd rather be clear.

Comments