Two changes based on further review: - Explicitly considers and rejects letting keep's server execute the redeploy directly on announce, even though co-location removes the credential/network objection that sank the CI-SSHes-in alternative. The reason it's still wrong: a fully compromised keep server today can't do anything worse than leak ciphertext, since it never holds a decryption key. Giving it exec capability breaks that property — a routine web app bug becomes "triggers arbitrary redeploys," not "leaks encrypted blobs." Also notes the concrete cost: doing this from inside a container needs the Docker socket mounted in, which is broadly equivalent to root on the host. - Replaces the "vault key = deploy directory name" assumption with an explicit .keep-vault marker file per project directory. Repo name, deploy directory name, and vault key are three independent strings in practice (confirmed by a real example already in this fleet) — no naming convention should assume any two of them match. Bootstrap is now: choose a vault key, keep push it, drop a one-line .keep-vault file. CI's announce step uses the same deliberately hardcoded key, never derived from the repo name automatically. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
keep
A self-hosted, end-to-end encrypted secrets store for deploy scripts.
keep pull myapp/production gets you a .env-shaped bundle of secrets,
decrypted locally — the server never sees plaintext values, and never
sees the key that would let it.
keep has no web UI by design — it's API + CLI only, so there's nothing
server-side to show beyond the terminal above.
Why
Across a small family of self-hosted projects, every repo tends to have
its own .env/.env.example pair, and every one of those .env files
exists today by being hand-copied onto whatever machine needs it. That's
a real, already-felt annoyance, not a hypothetical one:
- Rotating a credential means finding and updating every machine that has a copy, by hand.
- There's no record of which machine has which secret, or when it was last updated.
- Onboarding a new deploy target means manually reconstructing a
.envfrom memory, a password manager note, or asking "hey, what's the value for X."
keep is a small always-on service that holds encrypted secret bundles
and lets authorized machines/people pull them, decrypted locally, without
ever exposing plaintext to the server in between.
What it isn't
- Not Vault or Doppler. Those are the established, heavier answers —
full policy engines, dynamic secrets, enterprise everything.
keepis the "already-solved problem, but self-hosted and scoped to what this actually needs" answer, closer in spirit toage/sops(multi-recipient encrypted files) than to a full secrets-management platform. - Not a static encrypted file committed to git, which is what
age/sopstypically produce.keepexists specifically for the two things a committed encrypted file can't give you without re-running a tool and re-committing: live rotation (push once, everyone with access can pull the update) and an access log (who/what actually fetched a secret bundle, and when — a git history shows content changes, not access). - Not an ephemeral file-drop tool. Something built for single-retrieval, self-destructing file sharing is the opposite of what a secret needs to be — a deploy script needs to fetch the same bundle on every deploy, not once and then have it vanish.
Core model
Adapts the pattern age/sops/PGP already use for multi-recipient
encrypted files — a symmetric key encrypts the actual payload, and that
symmetric key is separately "wrapped" (encrypted) once per authorized
recipient's public key — into a live service instead of a static file:
- Each client (a developer's machine, a deploy script running on a VPS) has its own Ed25519/X25519 keypair.
- A vault (
myapp/production,myapp/staging, whatever key) holds one encrypted blob — the actual secret bundle, encrypted under a random symmetric key. - That symmetric key is wrapped separately for every recipient granted access to that vault. The server stores N small wrapped-key blobs, one per recipient, alongside the one encrypted payload.
keep pull <vault>fetches the payload plus this identity's wrapped key, unwraps locally with the private key, decrypts, and prints (or writes) the.env-shaped result.- Revoking a recipient's access is deleting their one wrapped-key row — the payload and every other recipient's access are untouched.
- Rotating a secret is
keep push <vault>again — a new random symmetric key, re-wrapped for every currently-granted recipient. Cheap, since the recipient list rarely changes. By default the outgoing version is kept as a one-step rollback safety net (keep pull --previous);keep push --purgeskips that for compromise-driven rotations where the old value should stop being retrievable, full stop. - Grants distinguish read from write — a recipient can be given
read-only access (
keep grant --read-only), which can pull a vault but can't push to it or grant others access.
Full design — data model, the atomic-grant/revoke details, and what this explicitly does and doesn't protect against — is in IMPLEMENTATION.md. Ideas for wiring this into existing deploy scripts are in INTEGRATION.md.
Quickstart
# 1. Run the server (see "Docker" below for a real deploy)
cp .env.example .env
npm install
npm run dev:server
# 2. Each machine/person that needs access generates its own identity
npm run cli -- identity init
# → prints a public key
# 3. An admin registers that public key as a recipient
KEEP_ADMIN_PASSWORD=... npm run cli -- recipient add --label "my-laptop" --pubkey <hex>
# → prints a recipient id
# 4. The machine that generated the identity records its assigned id
npm run cli -- identity set-id <recipient-id>
# 5. Push a vault — the pusher is automatically its first recipient
npm run cli -- push myapp/production --file .env.production
# 6. Anyone else with a grant can pull it, decrypted, ready to use
npm run cli -- pull myapp/production > .env
Granting, revoking, rotating
# An existing recipient of a vault can grant a NEW recipient access,
# without needing admin rights or re-encrypting the payload. Read access
# by default — add --read-only for a recipient that should only ever
# pull, never push (e.g. an automated deploy identity):
keep grant myapp/production <new-recipient-id>
keep grant myapp/production <deploy-recipient-id> --read-only
# Revoking is an admin operation — pure metadata deletion, immediate:
KEEP_ADMIN_PASSWORD=... keep revoke myapp/production <recipient-id>
# Revoke only stops FUTURE pulls. If this was a compromise response,
# rotate AND purge — --purge skips retaining the outgoing version as a
# rollback, since the whole point of rotating for a leak is that the old
# value stops being retrievable by anyone:
keep push myapp/production --file .env.production --purge
# A routine push (no compromise involved) keeps the outgoing version as
# a one-step rollback safety net:
keep push myapp/production --file .env.production
keep pull myapp/production --previous # "oops, undo that last push"
# See who's touched a vault and when:
keep log myapp/production
Pushing to an existing vault requires a write grant — a read-only recipient can pull but can't push or grant others access. A brand-new vault's first push is always read-write for the pusher.
Docker
docker compose up -d
Uses the image from IMAGE in .env. For a local build:
docker build -t keep:local .
IMAGE=keep:local docker compose up -d
CI/CD
Gitea Actions workflow at .gitea/workflows/docker.yml. Builds and
pushes to the Gitea container registry on every push to main. Requires
a TKNTKN secret (PAT with packages:write) in the repo settings.
Environment variables
| Variable | Default | Description |
|---|---|---|
PORT |
3050 |
Port to listen on |
DATA_DIR |
./data |
Where the SQLite DB (vaults, recipients, grants) lives |
ADMIN_PASSWORD |
— | Gates recipient add/remove and revoke. Unset disables /api/admin/* (503) — push/pull/grant for already-registered recipients keep working |
REQUEST_MAX_SKEW_SECONDS |
300 |
Replay-protection window for signed CLI requests |
CLI-side: KEEP_SERVER_URL (default http://localhost:3050),
KEEP_ADMIN_PASSWORD (for admin commands: recipient add/list/remove,
revoke).
Status
Implemented: identity, push/pull/grant/revoke/log, read/write grant
scoping, one-step rollback with an explicit purge mode for
compromise-driven rotations, admin recipient management, Docker deploy.
Verified end-to-end — two and three independent identities, read-only
grants correctly blocked from push/grant, grants preserved across
rotation, previous-version pull working and correctly wiped by
--purge for every recipient, malformed-auth rejection — against both
a local server and the built Docker image. See
IMPLEMENTATION.md for the full design and its
resolved decisions (per-secret-key granularity deliberately not built —
use multiple vaults instead).
License
MIT. See LICENSE.
