Fredrik Johansson 1b4cd9b826 Fix deploy-cli.sh: bundle was completely broken on a real host
Caught live, deployed to the actual target -- the previous version
failed on the real host with "Cannot use import statement outside a
module" (Node 18, no ancestor package.json to signal ESM for an
extensionless file). That surfaced two stacked bugs my own testing had
missed by running the bundle from inside this repo's own directory
tree, which coincidentally supplied both things the standalone
artifact was silently depending on:

1. Module-format ambiguity: an extensionless file with no controlling
   package.json defaults to CommonJS on older Node (no auto-detection
   heuristic before recent versions). Fixed by naming the bundle
   output keep.mjs -- unconditionally ESM on any Node version,
   regardless of extension-less-file heuristics or nearby package.json.

2. The bigger one: crypto.ts's createRequire(import.meta.url) trick
   (needed because libsodium-wrappers' published ESM build follows a
   broken relative import) is opaque to esbuild's static bundler --
   it's a runtime-obtained require reference, not the literal `require`
   token esbuild's bundling recognizes. The previous "18kb bundle"
   never actually contained libsodium-wrappers at all; it silently
   relied on real node_modules being nearby on disk, which was only
   ever true by accident when testing from within this repo. On a
   clean host it threw "Cannot find module 'libsodium-wrappers'".

   Tried forcing static inlining via a literal require() call (esbuild
   does special-case that even in ESM-format output, unlike `import`,
   which is permanently pinned to the "import" resolution condition
   and can't be routed to the package's working CJS build no matter
   what --conditions/--main-fields/--alias combination is tried this
   was tried and confirmed exhaustively). That got real resolution and
   a real 1.7MB bundle, but broke at runtime with "No secure random
   number generator found" -- inlining the WASM engine breaks its own
   Node-crypto feature detection.

   Landed on: ship libsodium-wrappers (and its own dependency,
   libsodium) as real, unmodified package files alongside the ~18kb
   bundle, via a new copy-cli-deps.mjs step, rather than fighting
   further to force single-file inlining. ~1.6MB total, still one tar
   stream over SSH, still the entire deploy step.

deploy-cli.sh now tars dist-bundle/ (bundle + node_modules) instead of
catting a single file, extracts to ~/.keep-cli/ on the target, and
symlinks ~/bin/keep to the entry point inside it.

Verified this time in conditions that actually match the failure: full
isolation (mktemp'd HOME and install dir, no ancestor package.json, no
adjacent node_modules from this repo) for both `node keep.mjs` and
direct shebang execution, plus the exact tar/extract cycle
deploy-cli.sh performs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 15:59:44 +02:00
2026-07-12 19:56:59 +02:00

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 is a CLI, not a web app — no server UI to screenshot. This is a full round trip: register an identity, push a vault, grant a second identity read-only access, and watch it get correctly blocked from writing.

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 .env from 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. keep is the "already-solved problem, but self-hosted and scoped to what this actually needs" answer, closer in spirit to age/sops (multi-recipient encrypted files) than to a full secrets-management platform.
  • Not a static encrypted file committed to git, which is what age/sops typically produce. keep exists 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:

  1. Each client (a developer's machine, a deploy script running on a VPS) has its own Ed25519/X25519 keypair.
  2. A vault (myapp/production, myapp/staging, whatever key) holds one encrypted blob — the actual secret bundle, encrypted under a random symmetric key.
  3. 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.
  4. 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.
  5. Revoking a recipient's access is deleting their one wrapped-key row — the payload and every other recipient's access are untouched.
  6. 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 --purge skips that for compromise-driven rotations where the old value should stop being retrievable, full stop.
  7. 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. Build the CLI and link it onto PATH as a real `keep` command --
#    no npm run cli --, no cd-ing into this repo from wherever you
#    actually need it (a deploy directory on some VPS, for instance).
#    Symlinked, not copied: a future `git pull && npm run build` here
#    is the entire upgrade story, no need to re-run this.
./scripts/install-cli.sh

# 3. Each machine/person that needs access generates its own identity
keep identity init
#   → prints a public key

# 4. An admin registers that public key as a recipient
KEEP_ADMIN_PASSWORD=... keep recipient add --label "my-laptop" --pubkey <hex>
#   → prints a recipient id

# 5. The machine that generated the identity records its assigned id
keep identity set-id <recipient-id>

# 6. Push a vault — the pusher is automatically its first recipient
keep push myapp/production --file .env.production

# 7. Anyone else with a grant can pull it, decrypted, ready to use
keep 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.

Description
A self-hosted, end-to-end encrypted secrets store for deploy scripts.
Readme MIT 468 KiB
Languages
TypeScript 81.8%
Shell 8.8%
JavaScript 8.1%
Dockerfile 1.3%