Doppler Like Secret Manager
  • TypeScript 93.3%
  • CSS 5.4%
  • PLpgSQL 0.6%
  • Dockerfile 0.4%
  • Go Template 0.2%
Find a file
Dunemask 2bd8ca3a04
All checks were successful
Build Image / build-image (push) Successful in 24s
[FEATURE] a tag push publishes a Forgejo Release with the CLI binary
Tagging now produces something installable. Before this, a tag got you images
and a chart version and nothing else -- the CLI was compiled only inside the
Dockerfile's demo-build stage and never published, so there was no way to put
`aegis` on a machine, which is how secrets actually get consumed in a deploy.

A tag push now also builds the CLI for linux-x64 (the only target asked for),
creates the release through Forgejo's API, and attaches the binary plus a
SHA256SUMS covering it. Branch pushes, pull requests and workflow_dispatch are
untouched -- verified by running the real Compute tags script for all four
shapes, where only refs/tags/* yields release=true.

The binary is built with the bun the Dockerfile already pins by digest, reusing
its deps stage, rather than a separately pinned download that could drift from
what the app is built with. `--target=bun-linux-x64` is explicit even though the
container is already x64, so an arm64 runner cannot silently publish an arm64
ELF under an x64 name.

CLI gains --version, stamped at build time via `bun build --define`, so a
downloaded binary can say what it is. A local `bun run build:cli` is unstamped
and prints `aegis dev (unreleased local build)` rather than a fake 0.0.0. The
handler runs before loadConfig, so --version needs no URL or token.

API shapes were taken from this instance's own swagger.v1.json (v15.0.10), not
from GitHub's API: the asset upload takes raw bytes with `name` as a QUERY
parameter, not GitHub's upload_url. Every call states the statuses it accepts
and fails loudly with method, path, status and body otherwise, because a release
step that half-succeeds is worse than one that fails. The token reaches curl
through a 0600 header file, never argv.

Re-running a tag reuses the release and replaces both assets together, so the
binary and its checksum can never disagree. Deliberately NOT a skip like the
chart push: a chart version is immutable, while a release left with a truncated
asset must be fixable by re-running.

Auth prefers secrets.RELEASE_TOKEN and falls back to the automatic job token,
logging which it used. Whether the job token can create releases here is
UNVERIFIED -- it provably cannot write Packages (forgejo/forgejo#6904), which is
why PACKAGES_TOKEN exists, but releases are repo-scoped. On a 401/403 the step
says to mint a write:repository PAT as RELEASE_TOKEN, a name chosen because
Forgejo rejects the reserved FORGEJO_/GITEA_/GITHUB_ prefixes.

Verified: workflow schema valid at runner 13.2.0; the build step run for real
produced an x86-64 ELF reporting `aegis 9.9.9-test` with `sha256sum -c` passing;
local unstamped build prints the dev line; tsc clean; 892 pass / 0 fail. The
release path was exercised against a local mock of the API for a fresh tag, a
re-run, a stale asset and a bad token. A real release against Forgejo is
unverified: no token was available and no tag was pushed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-25 19:59:26 -06:00
.forgejo [FEATURE] a tag push publishes a Forgejo Release with the CLI binary 2026-10-25 19:59:26 -06:00
chart/aegis [FIX] the rendered Secret must be a hook, ordered before the migrate Job 2026-10-25 19:33:46 -06:00
cli [FEATURE] a tag push publishes a Forgejo Release with the CLI binary 2026-10-25 19:59:26 -06:00
demo [FEATURE] deploy surface: helm chart, CI image build, optional passkeys 2026-10-25 11:45:48 -06:00
docs [FEATURE] a tag push publishes a Forgejo Release with the CLI binary 2026-10-25 19:59:26 -06:00
e2e [FEATURE] deploy surface: helm chart, CI image build, optional passkeys 2026-10-25 11:45:48 -06:00
migrations [FEATURE] deploy surface: helm chart, CI image build, optional passkeys 2026-10-25 11:45:48 -06:00
src [FEATURE] deploy surface: helm chart, CI image build, optional passkeys 2026-10-25 11:45:48 -06:00
web [FEATURE] deploy surface: helm chart, CI image build, optional passkeys 2026-10-25 11:45:48 -06:00
.dockerignore [FEATURE] deploy surface: helm chart, CI image build, optional passkeys 2026-10-25 11:45:48 -06:00
.env.example [FEATURE] deploy surface: helm chart, CI image build, optional passkeys 2026-10-25 11:45:48 -06:00
.gitignore [FEATURE] deploy surface: helm chart, CI image build, optional passkeys 2026-10-25 11:45:48 -06:00
biome.json [FEATURE] deploy surface: helm chart, CI image build, optional passkeys 2026-10-25 11:45:48 -06:00
bun.lock [FEATURE] aegis — self-hosted secrets manager 2026-10-22 19:04:49 -06:00
bunfig.toml [FEATURE] aegis — self-hosted secrets manager 2026-10-22 19:04:49 -06:00
compose.prod.yaml [FEATURE] deploy surface: helm chart, CI image build, optional passkeys 2026-10-25 11:45:48 -06:00
compose.yaml [FEATURE] deploy surface: helm chart, CI image build, optional passkeys 2026-10-25 11:45:48 -06:00
Dockerfile [FIX] CI umask leaked into the work tree and broke the migrate image 2026-10-25 19:39:37 -06:00
package.json [FEATURE] deploy surface: helm chart, CI image build, optional passkeys 2026-10-25 11:45:48 -06:00
playwright.config.ts [FEATURE] aegis — self-hosted secrets manager 2026-10-22 19:04:49 -06:00
playwright.demo.config.ts [FEATURE] deploy surface: helm chart, CI image build, optional passkeys 2026-10-25 11:45:48 -06:00
README.md [FEATURE] deploy surface: helm chart, CI image build, optional passkeys 2026-10-25 11:45:48 -06:00
tsconfig.json [FEATURE] aegis — self-hosted secrets manager 2026-10-22 19:04:49 -06:00
vite.config.ts [FEATURE] aegis — self-hosted secrets manager 2026-10-22 19:04:49 -06:00

aegis

Single-user, self-hosted secrets manager. One binary plus Postgres. It holds the secrets your deploy pipelines need and hands them out on request.

Made with Claude Code tests bun NestJS Postgres TypeScript

A secrets list for acme/prod. APP_BANNER is expanded side by side: the stored value ${GREETING} - deployed straight from the vault next to the resolved Hello from acme prod, build xnbo - deployed.... Every other value stays masked.

Built with AI. aegis was designed and written with Claude Code. That is not a disclaimer, it is a fact you should price in: this is a secrets manager, so read docs/crypto.md and docs/threat-model.md before you trust it with yours. The test suite is 891 passing, the crypto and the threat model are written down in detail including what they do not cover — judge those documents, not this paragraph.

why

  • Self-hosted Infisical is a lot of machine for one person. aegis is a compiled bun binary in a distroless image, plus Postgres. No shell, no package manager, no interpreter in the runtime container.
  • Values are never at rest in plaintext. Every secret has its own DEK; the KEK that wraps them exists only in process memory while unlocked, and is never persisted in any form.
  • A Postgres dump alone cannot be decrypted. Every wrap of the KEK opens only under material the server never stores.
  • Minimal dependency surface by requirement: NestJS core, pg, zod, @simplewebauthn/server. That is the list.

features

Flat, Doppler-shaped model project → env → KEY. No folders, no paths. Versioned: every write archives the prior value.
${...} references A value can cite another key — ${KEY}, ${env.KEY}, ${project.env.KEY} — resolved server-side on read. --raw / ?raw=true returns the unresolved form, so export/import round-trips.
Three-dimensional machine tokens Grants are {project, envs[], can[], keys?[]}: address × capability (read/write) × an optional exact/prefix key allowlist. Allow-list only — no deny list, no "all".
Write-only credentials A can:["write"] token can overwrite the secret it rotates and can never read it, or anything else.
Audit log Every read, write, mint, import and settings change, queryable by actor / action / target / project / date.
Changefeed GET /v1/fetch serves a strong ETag over the bundle it served; a 304 writes no audit row. aegis run --exit-on-change polls it and exits 75 so your supervisor restarts the job.
Whole-vault backup aegis backup seals everything into one encrypted JSON file — no plaintext, no KEK, no DEK in it. aegis restore rebuilds a fresh instance from it.
Optional passkeys WebAuthn PRF unlock, off unless configured. Operator tokens and recovery codes work either way.

references complete as you type

The add-secret dialog completes ${ against the vault's key catalog and refuses to let you save an unterminated reference.

Add-secret dialog for key APP_BANNER. Typing ${ opens a completion list of DATABASE_URL, GREETING and PORT; typing ${GREET narrows it and shows an "Invalid reference" warning while the reference is still unterminated; the finished value reads ${GREETING} - deployed straight from the vault.

minting a CI token is picking grants, one at a time

Label it, then add project + environment + capability + optional keys grants until the token reaches exactly what the job needs. A granted project leaves the picker.

Machine tokens page, empty, then the Mint dialog: label "acme prod deploy", typing ac into the project picker and choosing Acme, environment prod, capability Read, Add access — a grant row appears in the table and Mint becomes enabled.

The clip stops before the token itself: it is shown exactly once, at creation, and never again.

the audit log is filterable

The audit log with 13 events, filtered by Kind=Secrets down to 7 (created / updated / revealed / imported, with actor and target columns), then by Kind=Tokens down to 2 (machine token minted, operator token issued).

rotate a secret, the deploy picks it up

aegis run --exit-on-change watches the keys it can read and exits 75 when one moves. It never restarts the child — systemd or Kubernetes already does that, with a budget and a way back.

A terminal panel showing the demo container with RestartCount=1 and Status=running closes, then the acme/prod secrets list, then the containerised demo app page, which now reads "Hello from acme prod, rotated 6hxr".

security model

Envelope encryption, two keys deep:

  • Per secret: a fresh 32-byte DEK AES-256-GCM-seals the value. The DEK is itself sealed under the KEK and stored only wrapped.
  • One KEK per vault. randomBytes(32), minted once at vault init, never persisted in plaintext — not in Postgres, not on disk, not in a log. It lives in process memory only while the vault is unlocked. A restart re-locks everything.
  • Both layers carry an AAD of <projectId>/<environmentId>/<key> (row ids, not slugs), so a ciphertext moved to another project, environment or key will not open. Mismatch is a hard failure, never a fallback decrypt.
  • Sessions are two-state: authenticated (identity proven, metadata access) and unlocked (KEK in memory, plaintext reads). Reading a value needs both.

The operator token (aeg_op_…, 32 CSPRNG bytes, shown once) is the primary credential. It is both the authenticator and the source of the KEK unwrap key: HKDF(token, salt=tokenId, "aegis-operator-v1") opens that token's own wrap of the KEK. Multiple live tokens are normal — one per device — and revoking one is a single UPDATE that re-encrypts nothing. Recovery codes (10 per batch, 125 bits each) are wraps of the same KEK under scrypt. Passkeys, when configured, are a third kind of wrap.

What aegis does not protect against:

  • Metadata. Project, environment and key names are plaintext, as is the whole audit log and every timestamp. If STRIPE_LIVE_KEY merely existing is sensitive, aegis does not hide that.
  • A stolen machine token plus the database file. A token's KEK wrap is a full-vault key offline. envs, can and keys narrow the live API and nothing else — a write-only token is not cryptographically write-only. The grant list is an allowlist, never a cryptographic restriction.
  • A phished operator token. A bearer string a human stores can be pasted into the wrong window, committed, or synced to a breached password manager, none of which was true of hardware-sealed PRF output. There is no second factor unless you enroll a passkey.
  • A compromised endpoint with an unlocked vault. aegis bounds the blast radius in time (15m idle auto-lock, slid only by real KEK use), not in value.
  • Multi-user, roles, delegation and approval workflows are explicit non-goals.

docs/threat-model.md is the real document — case by case, including what each change cost. Read it rather than this summary.

quickstart

local, with docker compose

export AEGIS_SETUP_TOKEN="$(openssl rand -base64 24)"   # min 16 chars; gates first-run enrollment
docker compose --profile tools up -d --build

--profile tools runs the one-shot migrate service first and makes aegis wait on it. Host ports bind 127.0.0.1 only (AEGIS_PORT, AEGIS_PG_PORT to move them). Postgres defaults to the password aegis-dev-only — dev only; override POSTGRES_PASSWORD for anything else.

Open http://localhost:3000 → /setup: present the setup token, take the operator token and the recovery codes (shown once), and you have a vault. Compose also sets AEGIS_ORIGIN=http://localhost:3000 / AEGIS_RP_ID=localhost, so passkeys are available locally — but a passkey enrolled against localhost will not work on a real hostname, so discard the trial volume (docker compose down -v) before deploying for real.

For a production compose deployment, overlay compose.prod.yaml, which makes POSTGRES_PASSWORD, AEGIS_IMAGE, AEGIS_MIGRATE_IMAGE, AEGIS_ORIGIN and AEGIS_RP_ID mandatory, publishes nothing to the host, and joins your reverse proxy's network:

docker compose -f compose.yaml -f compose.prod.yaml --profile tools up -d

minimum config to boot

var required note
POSTGRES_CONNECTION_URI yes postgres://user:pass@host:5432/aegis. Secret; never logged unredacted
AEGIS_SETUP_TOKEN first run ≥16 chars. Gates first-run enrollment and a cold POST /api/backup/restore. Unset on a fresh vault = both refused (503). Ignored once the vault exists
AEGIS_TRUSTED_PROXY yes behind a proxy Who may set X-Forwarded-For. 1 = one trusted hop, or a comma list of IP/CIDR peers. Unset = header ignored, socket address is the client
AEGIS_ORIGIN optional, paired Public URL exactly as in the browser bar. Both-or-neither with AEGIS_RP_ID
AEGIS_RP_ID optional, paired Bare WebAuthn RP host. Changing it after enrollment kills every passkey

AEGIS_ORIGIN + AEGIS_RP_ID are optional and both-or-neither. Leave both unset and passkeys are simply disabled — one INFO line at boot, not a warning; operator tokens and recovery codes are unaffected. Set both to enable passkeys. Setting exactly one is fatal at boot.

Behind an ingress, set AEGIS_TRUSTED_PROXY. Without it every caller on the internet is bucketed as the proxy — one shared rate-limit bucket — and every audit row records the proxy instead of the caller. Once set the header is required and an unusable chain is refused 400, deliberately.

Everything else (PORT, HOST, LOG_LEVEL, PG_POOL_MAX, AEGIS_DEFAULT_ENVS, AEGIS_WEB_DIST, AEGIS_RP_NAME, AEGIS_ALLOW_PENDING_MIGRATIONS) has a default. Full table: docs/config.md, or aegis-server --config-help. Every problem is reported together at boot, with the bad value masked.

a cluster, with Helm

The chart is chart/aegis. It never creates a Secret and never accepts a credential in values, so a rendered manifest cannot carry one.

kubectl create ns aegis
kubectl -n aegis create secret generic aegis-env \
  --from-literal=POSTGRES_CONNECTION_URI='postgres://user:pass@host:5432/aegis' \
  --from-literal=AEGIS_SETUP_TOKEN="$(openssl rand -base64 24)"

helm -n aegis install aegis chart/aegis \
  --set secrets.existingSecret=aegis-env \
  --set image.digest=sha256:... --set migration.image.digest=sha256:... \
  --set config.origin=https://secrets.example.com --set config.rpId=secrets.example.com \
  --set config.trustedProxy=1 \
  --set ingress.enabled=true --set ingress.hosts[0].host=secrets.example.com
  • Postgres is not in the chart. Your database, your backups.
  • replicaCount: 1 is a hard constraint, not tuning. The KEK and unlock state are process memory; a second replica answers with a different lock state. updateStrategy: Recreate for the same reason.
  • No PVC, by design. Vault, key wraps, tokens, audit log and the durable half of sessions are all in Postgres; the UI bundle is baked into the image. The only writable path is a 64Mi emptyDir at /tmp, so that readOnlyRootFilesystem holds.
  • Migrations run as a pre-install,pre-upgrade hook Job from a separate bun-based image — the shipped image is distroless and has no bun. The server never auto-migrates.
  • Liveness is GET / (SPA shell off the image: no DB, no KEK). A locked vault is a normal healthy state, and restarting on it would drop the KEK and re-lock everything. Readiness is GET /api/vault/status, which does touch Postgres.

Details, probe reasoning and reverse-proxy rules: docs/deploy.md.

cli

bun run build:cli → dist/aegis. Config from AEGIS_URL / AEGIS_TOKEN, else ~/.config/aegis/config.json (written 0600); env wins.

aegis login                                  # browser round-trip, stores a session

aegis ls acme/prod                           # KEY<TAB>vN, metadata only
aegis get acme/prod/DATABASE_URL             # one value to stdout
aegis set acme/prod/DATABASE_URL --value -   # value from stdin; --value <literal> is refused
aegis import acme/prod --file .env           # .env/JSON/YAML; always previews first
aegis export acme/prod > .env                # refuses a TTY without --yes

aegis token new --label deploy --scopes acme:prod                                   # read-only CI token
aegis token new --label rotate --scopes acme:prod --can write --keys STRIPE_KEY      # write-only, one key

aegis fetch --targets acme:prod >> "$GITHUB_ENV"        # machine token, READ
printf %s "$NEW" | aegis push acme/prod/STRIPE_KEY      # machine token, WRITE
aegis run --targets acme:prod -- ./server --port 80     # secrets in the CHILD env only

aegis backup --out vault.json                # whole vault, encrypted, written 0600

No command ever takes a secret or a token from argv — argv is world-readable via ps. Values come from stdin, a hidden prompt, --token-file or the environment. Exit codes: 0 ok, 1 usage, 2 auth, 3 not-found, 4 server/network, 75 a watched secret changed under run --exit-on-change.

Full reference, including every refusal and why: docs/cli.md.

ci integration

.forgejo/actions/aegis-env fetches a bundle and appends it to $GITHUB_ENV, masking every value first. The action lives in this repo, so check it out before using it.

jobs:
  deploy:
    runs-on: docker
    steps:
      - uses: actions/checkout@v4

      - uses: ./.forgejo/actions/aegis-env
        with:
          url: https://secrets.example.com          # or set AEGIS_URL
          token: ${{ secrets.AEGIS_TOKEN }}         # a machine token, from a Forgejo secret
          targets: acme:prod,billing:prod           # later target wins on key collision

      - run: ./deploy.sh                            # secrets are in the environment

Inputs: token (required), targets, project, envs, url. targets and project+envs are mutually exclusive and setting both fails the step — neither the action nor the server picks a winner. There is no paths input; secrets are flat.

The token is passed to curl over stdin, so it never appears in argv or ps, and ::add-mask:: is emitted for every value before anything is exported.

docs

Terse, structured, written for agents and for whoever is paged next.

file what is in it
architecture.md Envelope encryption, unlock methods, the two-state session model, data model, module graph
crypto.md Primitives, KDFs, AAD identity binding, the KEK, operator tokens, recovery codes
threat-model.md What an attacker gets from a dump / a stolen token / a live session. Residuals and non-goals
auth.md Operator token as primary credential, guards, the /api vs /v1 split
api.md Every route, its auth, its body, its audit row. /v1/fetch and PUT /v1/secrets
cli.md Every command, flag, exit code and deliberate refusal
config.md Every env var, boot sequence, fatal vs warn, redaction rules
deploy.md Image stages, compose, Helm, migrations, first run, reverse proxy, WebAuthn on a real host
backup.md What the backup file carries, what it deliberately omits, restore tiers
import.md .env / JSON / YAML dialects, the auto sniff, refusal rules
e2e.md Playwright suite and the demo recording
conventions.md Code conventions: logging, naming, modules, validation, style
file-map.md Directory map with ownership per area
ui-style.md UI rules for web/** and the precedent each came from
web-auth.md Auth/vault routes in the browser, PRF rules, no-PRF fallback, idle auto-lock
web-secrets.md Secrets, import and audit screens
media/README.md Manifest for the clips above: source range, dimensions, what each one shows

development

bun install
cp .env.example .env               # placeholders only; POSTGRES_CONNECTION_URI is the one you must set
docker compose up -d postgres      # local Postgres on 127.0.0.1:5432
bun run migrate                    # idempotent, advisory-locked, aborts on checksum drift
bun run web:build                  # build the SPA into web/dist
bun run dev                        # watch mode on src/main.ts
script does
bun run dev Watch-mode server
bun run start Run the server once
bun run build dist/aegis-server + dist/aegis (both compiled binaries)
bun run migrate Apply DB migrations
bun run web:dev Vite dev server, proxying /api + /v1 to :3000
bun run web:build Build the SPA into web/dist
bun run test Unit + security suite (bun test src cli) — 891 passing
bun run tsc Type-check the server
bun run web:tsc Type-check the browser bundle
bun run test:e2e Playwright suite (test:e2e:prf for the PRF path)
bun run lint Biome check
bun run format Biome check + write

bun run tsc, bun run web:tsc, bun run lint and the test suite all pass before anything is called done.

CI: .forgejo/workflows/build-image.yml runs tsc + tests, then publishes the server and migrate images to Forgejo Packages (forgejo.dunemask.dev/dunemask/aegis[-migrate]) and prints the helm --set ...digest= fragment in the job summary. See docs/deploy.md.