Skip to main content
Capy has two moving parts: the CLI on your machine, and the service that brokers the co-decrypt handshake. The CLI handles everything local - syncing, encrypting, invites, deploy setup - and spawns your app with decrypted env vars via capy run. The service holds only ciphertext and membership records.

The two components

Two-component architecture: the Capy CLI runs on developer machines and CI, syncs .env, invites, kicks, and wraps your app with capy run. The Capy service brokers the co-decrypt handshake and stores opaque ciphertext plus membership records. The service never sees plaintext.
The CLI is the only place plaintext values ever exist. The service only ever sees opaque ciphertext plus membership records.

On-disk state

Inside a Capy-managed project: Gitignored: .env, .env.pre-capy.old, .env.*.decrypted, .capy/. Committed: keep.lock.
And globally, in your home directory:
key.enc holds your org master key, and it lives only on the client. It’s double-wrapped: an inner AES-256-GCM layer under HKDF(K_local) - where K_local is 32 random bytes minted per machine and stored beside it as local.key - and an outer layer added by the service’s /orgs/{orgId}/wrap endpoint. The service never stores a copy - to use key.enc the CLI posts it to /orgs/{orgId}/co-decrypt, where the service checks your membership and strips only its outer layer. K_local never leaves your machine, so what co-decrypt hands back is still opaque to the service. Both files are long-lived secrets, and both are needed: without local.key the blob is inert, and you get back in with capy redeem or capy recover.

keep.lock

keep.lock is a small JSON file that tells Capy which project this directory belongs to and what its current state is. It contains:
  • Org ID, project ID, and project name - which org and project this directory maps to.
  • Schema version - for format evolution.
  • Variable manifest - an alphabetically sorted list of variable names, each with per-branch resource IDs and value hashes. Hashes, not plaintext, not ciphertext, not keys. Variables provisioned by capy connect also carry connector metadata: provider, mode, account ID, and a masked abc…xyz fingerprint of the credential.
It doesn’t contain any keys, any values, or any ciphertext. Committing keep.lock is what lets a teammate clone your repo, run capy, and sync the same secrets you’re working with. The active branch is tracked outside it - in the .env header and in .capy/branch (local state).

.env after Capy

Your .env after capy has run looks like:
The header records the org, project, and branch these values were encrypted for, so Capy can tell when a .env belongs to a different project. Each capy:… snippet is:
  • capy: literal prefix
  • {resourceId} - a 5-character hash of the branch name and the variable name, used to diff without leaking plaintext
  • {blob} - base64 of iv || ciphertext || tag from AES-256-GCM under the project key, with a short plaintext preview spliced around it (for a value longer than 24 characters, its first four and last six characters) so you can tell values apart at a glance
The ciphertext is inert without the project key, and two projects that hold the same value still produce completely different ciphertext. The resourceId identifies the variable rather than the value: the same variable name on the same branch derives the same ID in every project. The preview is the one part of the line that is plaintext, which is why .env stays gitignored.

Git hooks

On first-run Capy installs two hooks:
  • post-checkout - runs capy status after you switch git branches, so you notice drift immediately.
  • post-merge - same, after git pull / git merge.
No pre-push hook. If an older Capy version installed one, capy cleans it out on the next run.

Sync engine

The sync engine is a three-way merge between:
  • Local - what’s currently in .env after any edits you’ve made.
  • Pinned - the value hashes keep.lock records for this branch, written on the last sync.
  • Remote - what’s currently in the service’s blob for this branch.
For each variable, Capy picks automatically when only one side changed. When both sides changed (conflict), it prompts interactively. See Syncing secrets.

The wire

Every CLI request to the service is a plain HTTPS call - GET, POST, PATCH, or DELETE - carrying a JSON body where there is one and a bearer auth token. The bodies are small and the payloads are already-encrypted blobs - compromising the transport tells an attacker nothing they couldn’t get by compromising the service itself.

What’s next

Zero trust

Why two shares, and what each share holds.

Cryptography

Every client-side algorithm, key, and parameter.
Last modified on August 11, 2026