Transparent repository encryption

Plaintext on disk.
Ciphertext in history.

git-secret installs four git hooks that quietly encrypt what you commit and decrypt what you check out — modern authenticated encryption, no server, nothing to remember to run. GPG is entirely optional, for teams who'd rather wrap the key to existing identities than pass around a raw one.

secrets/db.yamlsession
$ git secret init
Installed hooks: pre-commit, post-checkout, post-merge, pre-push
$ cat secrets/db.yaml
password: hunter2
$ git commit -m "add db credentials"
pre-commit: encrypted 1 file for commit
$ git show HEAD:secrets/db.yaml
RENCxchacha20poly1305·h␛T·?··E·קּ,[··S···En··
$ cat secrets/db.yaml
password: hunter2

What's actually different

Six facts, not adjectives

No "secure" or "seamless." Here's what the tool does and how.

HOOKS

Automatic, not manual

pre-commit, post-checkout, post-merge, and pre-push do the work. There's no encrypt step to forget.

AEAD

Modern authenticated crypto

XChaCha20-Poly1305 by default, AES-256-GCM available. This is what encrypts every file, whichever key backend you pick — GPG is never in that path.

SCOPE

Glob patterns, committed

A versioned .repo-enc.yml decides what's in scope. Everything outside the pattern is left alone.

KEYS

Pluggable, optionally committed

A gitignored local file, an env var, or GPG — wrap the key to existing identities and commit the wrapped blob, no out-of-band transfer needed.

VERIFY

A backstop against --no-verify

verify and pre-push refuse to let plaintext that slipped past a bypassed hook reach a remote.

PORTABLE

One binary, three platforms

Pure Go, no runtime dependency but git itself. Hooks ship as POSIX shell and PowerShell.

The mechanism

How it works

In the order git actually calls them.

1

pre-commit

Encrypts the staged content of each matched file and repoints the index at the ciphertext blob — your working-tree copy is never touched.

2

post-checkout / post-merge

Decrypts matched files that checkout or merge just populated with ciphertext, if a key is available. No key just means it stays encrypted — the checkout itself never fails.

3

pre-push

Runs the same check as verify against HEAD and blocks the push if a bypassed pre-commit ever let plaintext through.

First five minutes

Quick start

Bootstrap
# writes .repo-enc.yml, generates a key, installs hooks
git secret init
git add .repo-enc.yml .gitignore
git commit -m "chore: configure repo-enc"
Just use git
echo "password: hunter2" > secrets/db.yaml
git add secrets/db.yaml
git commit -m "add db credentials"
# pre-commit encrypts what's staged; your working copy stays plaintext

A teammate cloning the repo gets ciphertext in their working tree — that's what's committed. They need the key transferred out of band before unlock can decrypt it for them.

Reference

Commands

CommandEffect
init [pattern...]Bootstrap: write .repo-enc.yml (idempotent), generate a key if missing, install hooks.
statusShow which matched files are plaintext vs encrypted right now.
lockEncrypt every matched file in place — end of session.
unlockDecrypt every matched file in place — start of session. Marks files skip-worktree so git status stays quiet while you view them.
encrypt <path...>Encrypt specific files in place.
decrypt <path...>Decrypt specific files in place.
rotate-keysGenerate a new key and re-encrypt every matched file under it.
verifyCheck every matched file committed at HEAD is ciphertext.
adduser [recipient]gpg backend only — grant access cheaply (re-wraps the key, no file re-encryption). Omit the argument to pick interactively.
removeuser <recipient>gpg backend only — revoke access and rotate to a new key (the removed recipient already saw the old one).
hook <name>Internal — invoked by the installed hooks.
versionShow version, commit, and Go runtime info.
0 ok1 error2 key unavailable3 verify found plaintext in history

Set SECRETIZE_SKIP_HOOKS=1 (or run under CI=1) to make every installed hook exit 0 immediately.

Editing an unlocked file? Run lock before git add — recent git versions refuse a plain git add on a skip-worktree'd path outright. lock re-encrypts straight from the working tree and clears the flag, so git add/git commit work normally right after.

If a teammate changes a file you currently have unlocked, git pull will refuse with git's standard "local changes would be overwritten" error. Recovery (if you were only viewing, not editing): git secret lock, then SECRETIZE_SKIP_HOOKS=1 git checkout -- <path> to discard your view without the post-checkout hook immediately re-decrypting it, then git pull. See the README for the full explanation.

Configuration

.repo-enc.yml

Committed at the repo root — this is how a teammate's clone knows what to encrypt.

Repo config
version: 1
patterns:
  - "secrets/**"
  - "*.secret.env"
exclude:
  - "secrets/public/**"
key_backend: gpg
key_source: .repo-enc/key.gpg
gpg_recipients:
  - AAAABBBBCCCCDDDD...
Key backends
file  32-byte hex key at key_source,
      gitignored automatically by init.

env   key read from the env var named
      by key_source. init/rotate-keys
      print export VAR=<hex> once.

gpg   key wrapped to gpg_recipients,
      safe to commit -- only a matching
      GPG secret key can unwrap it.

git secret init --key-backend gpg picks a recipient interactively from your local GPG keys (or pass --gpg-recipient <fingerprint> directly). GPG operations need gpg-agent/pinentry, which isn't available in a non-interactive session — prefer file/env for CI.

A practical use case, not a second product

The same core, one Kubernetes Secret

A real Secret manifest is rarely one credential — it's a dozen unrelated ones (an OIDC client secret, payment gateway keys, a webhook signing key...) in one stringData map. Whole-file encryption means rotating any single one means decrypting all of them, and every re-encryption rewrites the whole file, so the diff never tells you which key actually changed. kubectl-secret is a companion kubectl plugin, built on this project's exact same crypto core and key backends, that encrypts one value at a time instead.

deploy/app-secret.yamlsession
$ kubectl secret encrypt-value -f deploy/app-secret.yaml -k OIDC_CLIENT_SECRET "..."
repo-enc:v1:UkVOQwEReGNoYWNoYTIwcG9seTEzMDVhrP5K...
$ cat deploy/app-secret.yaml
stringData:
  OIDC_CLIENT_SECRET: "repo-enc:v1:UkVOQwEReGNoYWNoYTIwcG9seTEzMDVhrP5K..."
  WEBHOOK_SIGNING_KEY: "repo-enc:v1:8f3Nk2QpL9xVrTcMh7bYs2..."
  DEBUG: "true"
$ kubectl secret apply -f deploy/app-secret.yaml
secret/app-credentials configured
PER-KEY

Not per-file

Only the keys you actually encrypt change ciphertext — a plaintext DEBUG value sits right next to an encrypted OIDC_CLIENT_SECRET in the same map.

BOUND

To the object, not just the file

Ciphertext is bound to the manifest's apiVersion/kind/metadata.name/namespace as well as the file and key — moving valid ciphertext onto a different object, or retargeting it to another namespace with -n, fails to decrypt instead of silently authenticating onto the wrong Secret.

IN MEMORY

apply/create pipe straight to kubectl

Decryption happens in this process only; the plaintext manifest goes to kubectl over stdin, never touching disk.

ROTATE

rotate-keys covers it too

Per-value manifests are re-encrypted right alongside whole files — key rotation isn't partial just because you're using this.

GITOPS

For a GitOps cluster, see GitSecret

Applying a per-value-encrypted manifest straight through a GitOps controller needs a trusted decrypt step in that path — GitSecret below is that step, natively: a controller, no sidecar, no manifest-generation hook to wire up by hand.

Opt a manifest in with k8s_secret_paths in .repo-enc.yml — see the README for the full verb reference.

Beyond the CLI

GitSecret: a native CRD, ciphertext inline, no clone at all

GitSecret is a custom resource with its own controller — modeled on Bitnami sealed-secrets' shape, built on this project's existing multi-recipient GPG cryptography rather than one controller keypair. Ciphertext lives inline in the object, delivered by whatever already applies your manifests — ArgoCD, kubectl apply — with no repo clone, no SSH transport, and no network hop anywhere in the decrypt path.

sessiongit-secret-seal
$ git-secret-seal --namespace myapp --name my-secrets \
    --recipient <controller-fpr> --recipient <your-fpr> \
    --from-literal API_KEY=... > gitsecret.yaml
$ cat gitsecret.yaml
apiVersion: git-secret.opscalehub.io/v1alpha1
kind: GitSecret
spec:
  encryptedData:
    API_KEY: UkVOQwEReGNoYWNoYTIwcG9seTEzMDVh...
$ kubectl apply -f gitsecret.yaml
gitsecret.git-secret.opscalehub.io/my-secrets created
NO CLONE

Zero network hops to decrypt

Reconciling a GitSecret never makes a network call, never clones a repository, never opens an SSH connection. The only inputs are the object itself and this controller's own GPG key.

MULTI-KEY

Not one controller keypair

The content key wraps to as many GPG recipients as you want — the controller's own identity, and independently, any number of humans or backups. --rewrap re-encrypts only that wrapped key when a recipient is added or removed; every value's ciphertext is untouched, and a newly-added recipient decrypts independently, with no involvement from the key that did the rewrapping.

BOUND

Bound to the exact object and key

Every value's authenticated data is its namespace/name/key — an entry copied into a different GitSecret, or a renamed one, fails to decrypt instead of silently applying somewhere it wasn't sealed for.

OWNED

The CRD is the source of truth

Built on controller-runtime. A GitSecret created fresh owns its target Secret outright: delete one, the other goes with it.

SHIPPED

Real code, really tested

Unit-tested against real GPG operations and a fake Kubernetes client, then verified end-to-end against a real cluster — CRD install, a real apply, the actual controller binary, a correctly decrypted Secret.

Early and not yet packaged as a Helm chart or container image — build git-secret-controller and git-secret-seal from source for now (go build ./cmd/git-secret-controller, go build ./cmd/git-secret-seal), and install the CRD from config/crd/bases/. Full usage and the --rewrap reference: README.

Get it

Install

Prebuilt binary

GitHub Releases — linux, macOS, and Windows, amd64 and arm64.

  • Rename to git-secret (git-secret.exe on Windows) and put it on PATH
  • git secret then works as a native git subcommand

Build from source

git clone https://github.com/OpScaleHub/git-secret.git
cd git-secret
go build -o git-secret .
sudo mv git-secret /usr/local/bin/
  • Go 1.25+, git
  • Windows: build with -o git-secret.exe explicitly