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.

secrets/db.yamlsession
$ 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.

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 .keyfold.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 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

Bootstrap
# writes .keyfold.yml, generates a key, installs hooks
git keyfold init
git add .keyfold.yml .gitignore
git commit -m "chore: configure keyfold"
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 .keyfold.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 (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.
versionShow version, commit, and Go runtime info.
0 ok1 error2 key unavailable3 verify found plaintext in history

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.

Repo config
version: 1
patterns:
  - "secrets/**"
  - "*.secret.env"
exclude:
  - "secrets/public/**"
key_backend: gpg
key_source: .keyfold/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 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.

sessionkeyfold
$ 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
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

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

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.

VISIBLE

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.

SAFE ADOPT

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.

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.

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.

deploy/app-secret.yamlsession
$ 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
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 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 to git-keyfold (git-keyfold.exe on Windows) and put it on PATH; git secret then works as a native git subcommand
  • keyfold-<os>-<arch> — seals values into a GitSecret manifest (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 directly
  • keyfold-sbom.spdx.json — SPDX SBOM
  • kubectl-keyfold has 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.exe explicitly

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.

1 · Give the controller its own GPG identity
# 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
2 · Install the chart
helm install keyfold \
  oci://ghcr.io/opscalehub/charts/keyfold \
  --version 0.10.0 --namespace keyfold-system \
  --set gpgPrivateKey.existingSecret=keyfold-gpg
3 · Seal and apply
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 Secret must be in the release namespace; pin --version to the release tag you want.
  • The chart never accepts key material in values.yaml — only a reference to the Secret you 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.

Binary (SLSA provenance)
gh attestation verify ./git-keyfold-linux-amd64 --repo OpScaleHub/keyfold
Container image (keyless cosign)
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.