Installation
This document walks through running the Nexus gateway against a real
OpenAI key on a local machine: bootstrap one user, workspace,
organization, project, Nexus API key, and BYOK provider key; use the gateway
from just dev, send one curl, and inspect the audit event the
gateway emitted.
Scope and limits
Section titled “Scope and limits”- One Postgres instance (or container) reachable from your machine.
The dev Compose stack in
deploy/compose/provisions bothnexus_controlandnexus_auth; any reachable Postgres with those databases migrated works. - A local KMS wrapping key persisted under
--state-dir(default.nexus/on the host, or/var/lib/nexuswhen usingjust cliagainst the dev compose stack). Wipe the directory and drop thenexus_controlandnexus_authdatabases to start over (orjust dev-cleanfor compose volumes). - Multi-tenant flows and the full audit service pipeline are out of
scope here. The local gateway writes audit events to
audit.log; the standalonenexus-gatewaybinary also publishes to NATS subjectaudit.eventswhenNATS_URLis configured.
Prerequisites
Section titled “Prerequisites”| Tool | Minimum version | Notes |
|---|---|---|
| Rust | 1.96.1 | rustup toolchain install 1.96.1 (auto via rust-toolchain.toml) |
| Docker | any | For Postgres / Compose if you don’t run your own server |
curl |
any | For the live request |
jq |
any | Optional, used to pretty-print JSON |
You also need a real OpenAI API key with access to gpt-5.4-mini.
Local state layout
Section titled “Local state layout”nexus reads and writes:
| Location | Purpose |
|---|---|
Postgres database (NEXUS_DATABASE_URL) |
Orgs, projects, memberships, API keys, wrapped provider keys, model registry, control-plane audit_events_staging. |
Postgres database (AUTH_DATABASE_URL) |
Auth users, identities, sessions, auth-side audit_events_staging (e.g. user.created). |
--state-dir/control-plane.kms.key |
32-byte AES-256 wrapping key for provider-key plaintext. Mode 0600. |
--state-dir/audit.log |
One JSON-encoded audit event per line for the local gateway-only test. |
The state directory is gitignored (host) or lives in the nexus-state
compose volume. To wipe state, drop both databases and remove the
volume (see Step 9) or rm -rf .nexus/ when you used a host-only CLI.
Step 0 — Bring up Postgres and run the CLI
Section titled “Step 0 — Bring up Postgres and run the CLI”# Postgres + nexus_control / nexus_auth via the dev compose stack (Postgres is not published to localhost in stock compose).just dev
# Run the CLI on the compose network (same DBs + `/var/lib/nexus` state as the gateway):just cli helpThe just cli … recipe runs cargo run -p nexus-cli inside a one-off container with NEXUS_DATABASE_URL / AUTH_DATABASE_URL pointing at postgres:5432 and NEXUS_STATE_DIR=/var/lib/nexus (shared with the gateway volume). Use -- only when you need to disambiguate flags for cargo vs nexus; for most commands, just cli orgs list is enough.
If you prefer a release binary on your host, run cargo build --release -p nexus-cli from the repository root and put target/release on your PATH, but you must then reach Postgres from the host (publish 5432 in a compose override or tunnel) and set NEXUS_DATABASE_URL / AUTH_DATABASE_URL accordingly — or use just cli-local …, which is plain cargo run -p nexus-cli -- … with your shell env.
nexus --help lists the auth, workspaces, orgs, projects,
credentials, registry, and other subcommands.
If you’d rather not put a host build on your PATH, substitute
./target/release/nexus for nexus when you follow this doc with a
fully host-local Postgres + .nexus/ layout instead of just cli.
CLI lifecycle (add / list / rm)
Section titled “CLI lifecycle (add / list / rm)”The CLI talks to both databases: control-plane mutations use
NEXUS_DATABASE_URL; nexus auth users ... uses AUTH_DATABASE_URL for
password hashes and also opens the control DB when --org and
optionally --project attach a membership.
| Area | Notes |
|---|---|
| Users | nexus auth users create --email you@example.com [--name ...] (--password-stdin | --password-env VAR) [--org SLUG [--project SLUG] [--role manager]] mints a user and optionally a membership. nexus auth users list [--include-inactive] [--org SLUG]. nexus auth users disable <email-or-id> (rm alias) sets disabled_at (blocks login). nexus auth users enable <email-or-id> re-enables a disabled account. |
| Orgs / projects | nexus orgs list / projects list hide soft-deleted rows unless --include-inactive. nexus orgs rm <ORG_REF> [--cascade] and nexus projects rm <ORG_REF/PROJECT_REF> [--cascade] set deleted_at. |
| API keys | nexus credentials api-keys list hides revoked keys unless --include-inactive. nexus credentials api-keys rm <ak_…> (or lookup prefix) sets revoked_at. |
| Provider keys | nexus credentials provider-keys list hides revoked unless --include-inactive. nexus credentials provider-keys rm <org/project/provider> revokes the active row for that triple. |
Passwords must never be passed as argv; use --password-stdin or
--password-env.
Step 1 — Load the OpenAI key
Section titled “Step 1 — Load the OpenAI key”set -asource .envset +aDo not pass the secret as a CLI argument; nexus credentials provider-keys create reads it from stdin or from a named env var so it never
appears in argv (and therefore not in ps).
Step 2 — Bootstrap identity, workspace, org, and project
Section titled “Step 2 — Bootstrap identity, workspace, org, and project”The browser-first path matches the product flow: open
http://127.0.0.1:14449/sign-up and create a user.
Sign-up provisions a personal workspace, a default organization, a default
project, and workspace/org/project manager memberships for that user.
Use the CLI to discover the generated slugs:
just cli orgs listjust cli projects list --org <org_slug>For a headless CLI-only bootstrap, create the user first, then a shared
workspace. workspaces create grants that user workspace manager access and
creates a default organization and default project under the workspace:
export NEXUS_DEV_PASSWORD='change-me-local-password'USER_ID=$(just cli auth users create \ --email dev@example.com \ --name "Acme Manager" \ --password-env NEXUS_DEV_PASSWORD)
just cli workspaces create --slug acme --name "Acme" --created-by "$USER_ID"just cli orgs listjust cli projects list --org acme-defaultSet ORG_SLUG to the organization slug from the browser-first or CLI-only
path before continuing:
export ORG_SLUG=<org_slug>export PROJECT_SLUG=defaultStep 3 — Mint a Nexus API key
Section titled “Step 3 — Mint a Nexus API key”TOKEN=$(just cli credentials api-keys create \ --org "$ORG_SLUG" \ --project "$PROJECT_SLUG" \ --name local-dev \ --issued-by "$(whoami)")echo "$TOKEN"The full nxs_<prefix>_<secret> token is the only copy that
will ever exist in plaintext. Store it (or pipe it into a file)
right now; the CLI cannot recover it later.
Step 4 — Register the BYOK upstream key
Section titled “Step 4 — Register the BYOK upstream key”printf '%s' "$OPENAI_API_KEY" | just cli credentials provider-keys create \ --org "$ORG_SLUG" \ --project "$PROJECT_SLUG" \ --provider openai \ --name primary \ --secret-stdinThe stdin form works with the host one-shot just cli ... recipe because the
secret is piped into the compose CLI container.
Do not source .env inside the interactive just cli shell: the compose CLI
container needs its in-network database URLs (postgres:5432), and .env
contains host-local localhost:5432 URLs for host tools.
If you are already inside the interactive just cli shell, set only the
provider secret before using the env-var form:
export OPENAI_API_KEY='sk-...'nexus credentials provider-keys create \ --org "$ORG_SLUG" --project "$PROJECT_SLUG" \ --provider openai --name primary \ --secret-env OPENAI_API_KEYThe secret is wrapped via the local KMS (AES-256-GCM with the key
in /var/lib/nexus/control-plane.kms.key inside the compose volume)
before it reaches
provider_keys.key_ciphertext in Postgres.
Step 5 — Sync the model registry
Section titled “Step 5 — Sync the model registry”Registry sync resolves its upstream credential from the tenant-scope platform key for each provider, so seed platform keys first, then sync:
just load-provider-keysjust models-syncjust cli registry models listjust cli registry models get gpt-5.4-mini# openai/gpt-5.4-mini gpt-5.4-mini https://api.openai.com/v1 0.00000075 0.00000450 enabledregistry models get takes the provider-native model id (gpt-5.4-mini) as a
positional argument and prints the Nexus endpoint key (openai/gpt-5.4-mini) in
the first column.
nexus registry models endpoints upsert ... adds or adjusts rows without
rebuilding the Rust services.
The running gateway accepts newly synced models after its registry poll cycle.
Step 6 — Confirm the gateway is up
Section titled “Step 6 — Confirm the gateway is up”just dev already runs nexus-gateway on 0.0.0.0:14450 inside
compose and publishes that port to the host. Wait until the gateway
container has finished its first cargo build, then check liveness:
curl -fsS http://127.0.0.1:14450/healthz# okIf you are not using compose and instead followed the host-binary
path from Step 0, start nexus ops serve --bind 127.0.0.1:14450 yourself
and use the same curl.
Step 7 — Send a real chat request
Section titled “Step 7 — Send a real chat request”curl -sS http://127.0.0.1:14450/v1/chat/completions \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-5.4-mini", "messages": [{"role": "user", "content": "Say hello in one word."}], "max_tokens": 8 }' | jq .You should get back a normal OpenAI chat-completions JSON
response. The gateway adds an x-nexus-request-id header to
every response — that ID also appears in the
gateway.request.completed audit event.
Streaming uses the same endpoint with stream: true:
curl -N http://127.0.0.1:14450/v1/chat/completions \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "model": "openai/gpt-5.4-mini", "messages": [{"role": "user", "content": "Say hello in one word."}], "max_tokens": 8, "stream": true }'The response content type is text/event-stream; charset=utf-8.
The gateway forwards the provider stream and records usage from the final stream chunk when the provider includes it.
Step 8 — Inspect audit events
Section titled “Step 8 — Inspect audit events”The CLI mutations write into the audit_events_staging table in
nexus_control. The gateway also appends JSONL audit events to
/var/lib/nexus/audit.log inside the compose volume; from the repo
root, just logs-audit 20 tails that file via the gateway
container. Expect five events in order:
org.createdproject.createdapi_key.createdprovider_key.createdgateway.request.completed
The fifth event has attrs.cost_total_usd, attrs.prompt_tokens,
attrs.completion_tokens, and attrs.endpoint_key populated from
the upstream response.
Step 9 — Cleanup
Section titled “Step 9 — Cleanup”Stop just dev if it is still running (just down). To wipe databases (compose Postgres):
docker compose --env-file .env -f deploy/compose/docker-compose.yml -f deploy/compose/docker-compose.dev.yml exec postgres \ psql -U vortex -c 'DROP DATABASE nexus_control;'docker compose --env-file .env -f deploy/compose/docker-compose.yml -f deploy/compose/docker-compose.dev.yml exec postgres \ psql -U vortex -c 'DROP DATABASE nexus_auth;'Remove the nexus-state volume (and other dev data) with just dev-clean, or keep volumes and only drop the databases above.
If you used a host-only CLI with .nexus/ on disk, also rm -rf .nexus/.
Scripted first-adopter test
Section titled “Scripted first-adopter test”The manual walkthrough above is automated by
test-suite/adopter/first-adopter-e2e.sh,
runnable as just test-adopter-e2e. It creates a workspace, an org under it
(orgs create --workspace), a project, and an API key; extends the org’s
classification vocabulary with an org-level label and ceiling; registers a BYOK
key; sends non-streaming and streaming chat completions with a session header
(including a labeled request, a 400 for a label outside the vocabulary, and a
403 for a label above a tightened project ceiling); confirms the request id and
audit evidence; verifies an audit inclusion proof when nexus-audit-verify is
available; then revokes the API key and asserts a subsequent request is
rejected with 401.
Requirements: a running stack (just dev or the server compose stack with TLS)
and OPENAI_API_KEY exported. Point it at a TLS endpoint with
NEXUS_BASE_URL=https://host (add INSECURE=1 for self-signed certs).
The managed-PTB extension,
first-adopter-ptb-e2e.sh
(just test-adopter-ptb-e2e), proves a project with no BYOK key completes a chat
via a pass-through-billing attempt with auth_type=ptb. It is gated on
NEXUS_GATEWAY_PTB=1 and a platform credential and skips cleanly otherwise.
Troubleshooting
Section titled “Troubleshooting”401 Unauthorizedfrom the gateway. TheAuthorizationheader value must beBearer <token>exactly. Confirm the prefix matches a row inapi_keys.prefixinnexus_control.502with"no provider key". Noprovider_keysrow matches(org_id, project_id, "openai"). Re-run step 4.gpt-5.4-minirejected as unknown model. Checkjust cli registry models list; if the model is missing, re-runjust models-sync.nexus ops serveaborts with “recorder already installed”. A different process already holds the global Prometheus recorder in this terminal session. Restart the shell.
