Transparent repository encryption
Plaintext on disk.
Ciphertext in history.
Keyfold 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.
On Kubernetes? The same crypto core ships as a GitSecret CRD and controller — ciphertext inline in the object, decrypted in-cluster, no repo clone.
$ git keyfold 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.
Automatic, not manual
pre-commit, post-checkout, post-merge, and pre-push do the work. There's no encrypt step to forget.
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.
Glob patterns, committed
A versioned .keyfold.yml decides what's in scope. Everything outside the pattern is left alone.
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.
A backstop against --no-verify
verify and pre-push refuse to let plaintext that slipped past a bypassed hook reach a remote.
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.
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.
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.
pre-push
Runs verify over the whole range you're about to push — not just HEAD — and blocks it if a bypassed pre-commit ever let plaintext through.
First five minutes
Quick start
# writes .keyfold.yml, generates a key, installs hooks
git keyfold init
git add .keyfold.yml .gitignore
git commit -m "chore: configure keyfold"
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
| Command | Effect |
|---|---|
init [pattern...] | Bootstrap: write .keyfold.yml (idempotent), generate a key if missing, install hooks. |
status | Show which matched files are plaintext vs encrypted right now. |
lock | Encrypt every matched file in place — end of session. |
unlock | Decrypt 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-keys | Generate a new key and re-encrypt every matched file under it. |
verify | Check every matched file committed at HEAD is ciphertext. |
adduser [recipient] | gpg backend only — grant access cheaply (rewraps 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. |
version | Show version, commit, and Go runtime info. |
Set KEYFOLD_SKIP_HOOKS=1 explicitly, per invocation, to make every installed hook exit 0 immediately. This is deliberately not tied to the ambient CI variable — every CI provider, IDE, and automation wrapper sets CI=1 by convention, so honoring it implicitly would silently disable both encryption and push-protection in exactly the environments most likely to push on someone's behalf.
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 keyfold lock, then KEYFOLD_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
.keyfold.yml
Committed at the repo root — this is how a teammate's clone knows what to encrypt.
version: 1 patterns: - "secrets/**" - "*.secret.env" exclude: - "secrets/public/**" key_backend: gpg key_source: .keyfold/key.gpg gpg_recipients: - AAAABBBBCCCCDDDD...
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 keyfold 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.
Kubernetes
GitSecret: a native CRD, ciphertext inline, no clone at all
GitSecret is a custom resource with its own controller. Ciphertext lives inline in the object — no external store, no repo clone in the cluster, no SSH transport, no network hop anywhere in the decrypt path — delivered by whatever already applies your manifests (ArgoCD, kubectl apply) and decrypted in-cluster by a controller holding one of the GPG keys it's wrapped to.
The point isn't "GPG instead of a single keypair" — it's recovery independence: losing the cluster, the controller, or any one key doesn't make the secrets unrecoverable, as long as the encrypted repo and one authorized recipient key survive. See the threat model and disaster-recovery runbooks.
$ keyfold --namespace myapp --name my-secrets \ --recipient <controller-fpr> --recipient <your-fpr> \ --from-literal API_KEY=... > gitsecret.yaml $ cat gitsecret.yaml apiVersion: keyfold.opscalehub.io/v1alpha1 kind: GitSecret spec: encryptedData: API_KEY: UkVOQwEReGNoYWNoYTIwcG9seTEzMDVh... $ kubectl apply -f gitsecret.yaml gitsecret.keyfold.opscalehub.io/my-secrets created
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.
Wrapped to every recipient you choose
The content key wraps to as many GPG recipients as you want — the controller's own identity, and independently, any number of humans or offline 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 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.
You can see who can decrypt
The fingerprints sealed to are recorded in spec.recipients and mirrored to status — adding a recipient is a one-line diff in review, not an opaque blob change. Roles (controller, recovery, human) travel with the object; keyfold recipients won't let you drop the last recovery key by accident.
Won't clobber a Secret it doesn't own
If a Secret with the target name already exists and isn't managed by this GitSecret, the controller leaves it alone and reports a TargetConflict — taking it over needs an explicit spec.target.adopt.
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.
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.
Prefer per-value encryption of a plain Secret manifest? See the kubectl-keyfold plugin. Prefer a form to the flags? keyfold ui serves a local, public-key-only web page for producing these manifests — it never decrypts, never touches the cluster, never persists.
A signed container image and a Helm chart ship on every tagged release — install with one helm install. Full usage — recipients, --rewrap, --keyring, the sealing UI, disaster recovery — is in the README and docs/.
Companion tool
kubectl-keyfold: per-value encryption for one 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-keyfold 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.
$ kubectl keyfold 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 keyfold apply -f deploy/app-secret.yaml secret/app-credentials configured
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.
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.
apply/create pipe straight to kubectl
Decryption happens in this process only; the plaintext manifest goes to kubectl over stdin, never touching disk.
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.
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 above is that step, natively: a controller, no sidecar, no manifest-generation hook to wire up by hand.
Get the plugin from Install (built from source; no release binary yet). Opt a manifest in with k8s_secret_paths in .keyfold.yml — see the README for the full verb reference.
Get it
Install
Prebuilt binaries
GitHub Releases — linux and macOS on amd64 and arm64; Windows on amd64 only. Each file has a .sha256 beside it.
git-keyfold-<os>-<arch>— the CLI. Rename togit-keyfold(git-keyfold.exeon Windows) and put it onPATH;git secretthen works as a native git subcommandkeyfold-<os>-<arch>— seals values into aGitSecretmanifest (used in the Kubernetes steps below)keyfold-controller-linux-<arch>— the in-cluster controller, already packaged in the Helm chart and image; you rarely need it directlykeyfold-sbom.spdx.json— SPDX SBOMkubectl-keyfoldhas no release binary yet:go build -o kubectl-keyfold ./cmd/kubectl-keyfold
Build from source
git clone https://github.com/OpScaleHub/keyfold.git cd keyfold go build -o git-keyfold ./cmd/git-keyfold sudo mv git-keyfold /usr/local/bin/
- Go 1.26+, git
- Windows: build with
-o git-keyfold.exeexplicitly
Deploy to Kubernetes
The GitSecret controller ships as a Helm chart on GHCR (OCI), versioned to each release. It installs the CRD and the controller together.
# dedicated key, never a human's; only the private half goes in-cluster gpg --batch --passphrase '' --quick-generate-key \ "keyfold-controller <controller@yourcluster>" default default never gpg --list-secret-keys --with-colons # note the fingerprint gpg --export-secret-keys --armor <fingerprint> > private.asc kubectl create namespace keyfold-system kubectl -n keyfold-system create secret generic keyfold-gpg \ --from-file=private.asc=./private.asc rm private.asc
helm install keyfold \
oci://ghcr.io/opscalehub/charts/keyfold \
--version 0.10.0 --namespace keyfold-system \
--set gpgPrivateKey.existingSecret=keyfold-gpg
keyfold --namespace myapp --name my-secrets \
--recipient <controller-fpr> --recipient <your-fpr> \
--from-literal API_KEY=... > gitsecret.yaml
kubectl apply -f gitsecret.yaml # or commit it and let ArgoCD apply it
- The key
Secretmust be in the release namespace; pin--versionto the release tag you want. - The chart never accepts key material in
values.yaml— only a reference to theSecretyou created. - Cluster-scoped by default; see the chart README for RBAC and upgrades (Helm doesn't upgrade CRDs for you).
Verify what you download
Every release is built in CI with signed provenance and an SBOM — check before you run it.
gh attestation verify ./git-keyfold-linux-amd64 --repo OpScaleHub/keyfold
cosign verify \ --certificate-identity-regexp '^https://github.com/OpScaleHub/keyfold/' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ ghcr.io/opscalehub/keyfold-controller:<tag>
Details, SBOM, and how to report a vulnerability: SECURITY.md.