CI announces a new image tag to keep (any grant is enough, read-only included); each deploy target polls for it locally instead of CI holding standing SSH access to production. keep stays a pure relay — the tag lives outside the encrypted vault payload in its own table, and keep never executes anything itself. Adds vault_announcements, POST/GET /vaults/:key/announce, and the `keep announce`/`keep watch` CLI commands, per PROPOSAL-announce-and-agent.md (now marked implemented). Verified live: a read-only identity announcing successfully and being logged, watch picking up the tag on its first poll, and watch exiting nonzero for an unknown/ungranted vault. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
9.4 KiB
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.
Announcing deploys
keep can also relay a small "there's a new image" signal alongside a
vault, so CI never needs standing SSH access to a deploy target — CI
announces a tag, and whatever already redeploys that box locally polls
for it instead of being reached into from outside:
# CI, after a successful build/push — any grant is enough, read-only included:
keep announce myapp/production --tag <short-sha>
# The deploy target polls for a change and prints it:
keep watch myapp/production --interval 60
An announced tag isn't a secret — it lives outside the encrypted vault
payload, in its own small table, and is visible to the server (same as
vault names and access timestamps already are). keep never pulls or
runs the announced image itself; it only relays the string. See
IMPLEMENTATION.md for how this fits into a bulk
deploy script with a no-keep fallback for projects not yet bootstrapped
onto it.
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, an admin
cross-vault overview, deploy-signal announce/watch, a
scripts/deploy.sh wrapper, and 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, a read-only identity successfully announcing while
still being logged, watch picking up an announced tag and exiting
nonzero for an unknown/ungranted vault, 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.
