Installing Nessie
Run Nessie on infrastructure you choose, from a single machine to a managed cloud.
What you need before you start
Nessie is a pnpm monorepo of Node services plus two static bundles. Whichever way you run it, the same four things have to exist first.
- PostgreSQL with
pgvector. Not optional, and not substitutable — the migrations runCREATE EXTENSION vectorand createvector(1024)columns. Production runspgvector/pgvector:pg17. - Node 22 and pnpm. Every Dockerfile in
infrastructure/docker/builds onnode:22-slim, and the repository pins its package manager in the rootpackage.json(pnpm@10.22.0). - An OpenAI-compatible model endpoint and a key for it. Agents and embeddings both go through it. See “Configuring inference” below.
- S3-compatible object storage, for any deployment that is not the local developer one. Uploads on local disk are refused outside
localmode, and that refusal is a start-up failure rather than a degraded boot — see “One machine, or a managed cloud”.
Docker and a reverse proxy are needed for the container path described further down, and a domain plus TLS for anything reachable from the internet.
Hardware sizing — minimum CPU, memory and disk for a given number of people — is not stated anywhere in the repository, so this page does not state it either.
The components, and how they fit together
| Component | What it is | Ingress |
|---|---|---|
api/ | The Fastify HTTP/WebSocket API. Every client talks to this and nothing else. | Yes |
worker/ | Agent orchestration and background runs. Polls the job queue; binds no port. | No |
admin/ | The product interface — a static bundle built by Vite and served by nginx. | Yes |
web/ | The public site. Static, optional, and unrelated to running the product. | Yes |
| PostgreSQL | The database, the job queue and the realtime bus, all three. | No |
| Object storage | Attachments, avatars and knowledge-base files. S3-compatible; MinIO in production. | No |
gateway/ | An optional push relay for APNs and FCM. Off unless you enable it deliberately. | Yes |
The API and the worker are one image. The two packages are tightly interlinked, so Dockerfile.app builds the whole team once and the worker container overrides the command to node worker/dist/index.js. In local mode there is no worker container at all: the API runs the worker embedded in its own process.
There is no Redis. The job queue and the realtime transport are both Postgres-backed, by decision rather than by omission — the Google Cloud tree deletes its Pub/Sub and Redis modules for the same reason.
Ports
In local development the API is on 5454 and the admin on 5455 by default. A checkout can take its own pair by setting NESSIE_API_PORT and NESSIE_ADMIN_PORT, in the environment or in the repository’s .env; every tool in the repository, the browser test suites included, reads the same two values. Pick the pair before starting and keep it: moving a running service to another port leaves the admin talking to the wrong API.
Production is a different matter and is unchanged by that rule. The API container listens internally on 5554 (pinned with NESSIE_API_PORT) and the push relay on 5556, both behind the reverse proxy.
Running it on one machine, for development
The repository ships a Compose file with just a database in it, which is all the local loop needs.
pnpm install
docker compose -f infrastructure/compose/docker-compose.yml up -d postgresThat container publishes Postgres on 55432 with the user, password and database all named nessie. Put the connection string in a .env at the repository root — the API’s dev script reads it — then apply the migrations and build the worker once, because in local mode the API loads the worker from its built dist:
DATABASE_URL=postgresql://nessie:nessie@localhost:55432/nessie
pnpm --filter @nessie/api prisma:migrate:deploy
pnpm --filter @nessie/worker build
pnpm devpnpm dev runs the API and the admin in parallel with hot reload, on 5454 and 5455. Check the API answered with GET /api/health.
Postgres, and the pgvector requirement
Three extensions are created by the migrations themselves — vector, pg_trgm and pgcrypto — so the role that runs them must be allowed to create extensions. On a managed Postgres that is the thing to check before anything else.
The vector width is part of the schema, not a setting. thoughts.embedding, thought_recalls.query_embedding and knowledge_page_chunks.embedding are vector(N) columns, and N is stated once as EMBEDDING_DIMENSIONS in packages/schemas/src/embedding.ts — currently 1024, the native width of the embedding model production uses. Every embed request sends that width, so a provider that would answer at another one fails loudly instead of writing vectors the database rejects. Moving to an embedding model of a different width means editing that constant, writing a migration that re-types the three columns, and re-embedding: vectors of different widths are not convertible, so the migration nulls them rather than truncating them.
If you run Nessie beside other applications on a shared Docker network, give its database container a distinct name. The project’s own Compose service is called nessie-postgres and never postgres, because the service name becomes a DNS alias on the shared network and a collision makes postgres:5432 round-robin between two databases.
Configuration
Runtime configuration is layered: a nessie.config.json file mounted read-only into the API and the worker, with environment variables over the top of it. The mapping between the two is ConfigEnvMap in packages/config, which is the authoritative list.
NESSIE_MODE picks the deployment shape: local (the default, and the developer path), hosted, or selfHosted — which is what a self-hosted install sets. It disables dev login and requires a CORS allowlist.
These are the settings a selfHosted deployment will not start, or will not work, without:
| Variable | Why |
|---|---|
DATABASE_URL / NESSIE_DB_URL | The Postgres connection string. |
NESSIE_AUTH_SECRET | 32 bytes of hex (openssl rand -hex 32). Signs sessions and bootstrap tokens. Start-up throws without it outside local mode, and it must be the same value on every replica and across deploys or every session dies at each release. |
NESSIE_ENCRYPTION_KEY_RING, NESSIE_ENCRYPTION_ACTIVE_KEY_VERSION | A JSON key ring and the label selecting the active root, required outside local mode. This is a separate root from the signing secret — the ring encrypts every durable secret Nessie stores. Do not reuse one as the other. |
NESSIE_STORAGE_PROVIDER | Must be s3. filesystem is the default and is refused in hosted and selfHosted mode, so a deployment that sets nothing here fails to start rather than writing uploads to a disk no other container can read. |
NESSIE_CORS_ORIGINS | The admin origin. The admin and API are separate origins, and the API must allowlist the one it serves. |
NESSIE_API_PUBLIC_URL, NESSIE_ADMIN_PUBLIC_URL | Used to mint OAuth redirect URIs outside an HTTP request. The API throws at request time without the first. |
NESSIE_MODEL_PROVIDER, _BASE_URL, _API_KEY | Where inference goes. See below. |
infrastructure/compose/.env.prod.example has the exact shape of all of them, with the optional blocks — object storage, secret vault, SSO, connected mail — commented in place. The full catalog, including every optional setting, is docs/deployment/configuration.md.
Deploying with Docker Compose
Copy .env.prod.example to .env beside docker-compose.prod.yml and fill it in, then bring the database up, build, migrate and start. The order matters: the migrations and the seeds run against a database that is already up, before any service is serving.
COMPOSE="docker compose -f infrastructure/compose/docker-compose.prod.yml"
bash infrastructure/compose/ensure-encryption-key-ring.sh infrastructure/compose/.env
$COMPOSE up -d nessie-postgres
$COMPOSE build nessie-api nessie-admin nessie-web
$COMPOSE run --rm --no-deps nessie-api pnpm --filter @nessie/api prisma:migrate:deploy
$COMPOSE run --rm --no-deps nessie-api pnpm --filter @nessie/api seed:connectors
$COMPOSE run --rm --no-deps nessie-api pnpm --filter @nessie/api seed:apps
$COMPOSE run --rm --no-deps nessie-api pnpm --filter @nessie/api reconcile
$COMPOSE up -dThe production Dockerfiles run each package’s lint before building it, so a lint failure is a build failure.
The reconcile step is not optional
reconcile seeds each organization’s default policy rules and backfills the protected-MCP and Personal Assistant tool grants. The API used to do this at boot on every replica; it now connects, listens, and does nothing else, so without this step the first organization has no policy rules and every agent bind is denied. It is idempotent, so running it on every deploy is free — and an upgrade applied by hand has to run it too.
The first owner account
A fresh selfHosted install has no users, so the API mints a one-time bootstrap token and logs a setup URL. The token lives in Postgres rather than in a process, so every replica agrees on it.
docker compose -f infrastructure/compose/docker-compose.prod.yml logs nessie-api 2>&1 | grep bootstrapOpen https://<your-admin-host>/bootstrap?token=<token> and create the owner account. The token expires after 15 minutes; restart the API service to mint a fresh one.
If you configure SSO, there is no bootstrap step at all. Bootstrap mode is suppressed the moment an external auth provider is enabled — otherwise it would hijack the login screen — and the first person to sign in through SSO bootstraps the default team and becomes its owner.
Configuring inference
Chat is OpenAI-compatible. NESSIE_MODEL_BASE_URL is the endpoint, NESSIE_MODEL_API_KEY the bearer, and NESSIE_MODEL_PROVIDER names the adapter — Nessie compiles adapters for openai, kimi and deepseek, and reaches anything else through the generic OpenAI-compatible connector. NESSIE_MODEL_NAME sets the deployment’s default model, which an individual agent’s own model selection still outranks.
Embeddings are routed separately, because the chat provider may not serve them at all — DeepSeek, for instance, has no embeddings endpoint. Every unset NESSIE_EMBEDDING_* field inherits the chat provider, so a deployment that sets none of them embeds through the chat endpoint. Set NESSIE_EMBEDDING_PROVIDER, NESSIE_EMBEDDING_MODEL and, on a proxy that routes by service, the service segment, when the chat provider cannot embed. Getting this wrong fails quietly rather than loudly: memory recall and knowledge-base search degrade to a lexical channel and carry on, logging kb_search: query embedding failed as they go.
Nessie’s own spend accounting — the per-organization token ledger and the budget gate — is unaffected by which endpoint you point it at, and works identically whether or not signed per-call attribution is configured.
Signing in
Nessie authenticates people through UnlikeOtherAI, which also manages the organization and team structure. The admin login page shows a single “Login / Register” button.
The integration is a config-JWT flow rather than standard OIDC: the API serves a signed RS256 config document at GET /api/auth/sso/config and the matching JWKS at GET /.well-known/jwks.json, both on the API host. Standing it up is a one-time sequence — generate an RSA-2048 keypair and set UOA_CONFIG_JWT_PRIVATE_KEY_B64, UOA_CONFIG_JWT_KID, UOA_DOMAIN, UOA_CONFIG_URL, UOA_JWKS_URL, UOA_REDIRECT_URL and UOA_CONTACT_EMAIL; click the button once, which raises an integration request; have it approved, which sends a one-time link to the contact address carrying the client secret; set UOA_CLIENT_SECRET and restart the API. The kid must be unique per domain.
Putting it behind a domain, with TLS
A deployment serves three names: the public site, the admin, and the API. The admin and the API origins are not interchangeable. Dockerfile.admin bakes VITE_API_BASE_URL into the bundle at build time, so every built admin artifact — including a desktop build that embeds it — must be built against the API origin. Build it against the admin’s own origin and /api/auth/providers resolves to the admin’s HTML shell, leaving login stuck on “Loading providers...”.
Production terminates TLS at Caddy, which obtains per-hostname certificates over HTTP-01 and needs no DNS-provider plugin for that. Point the proxy at the admin and web containers on port 80 and at the API on its internal port.
Set NESSIE_API_TRUSTED_PROXY_HOPS to the number of proxies in front of the API — 1 behind a single reverse proxy. It defaults to 0, which ignores X-Forwarded-For entirely. This is one decision with two consequences: it is also what keys the authentication rate limits, so too low and every client shares the proxy’s bucket, too high and a client can forge its own address. Err low and measure it.
Serving each organization and team at its own subdomain is possible and off by default. It is governed by NESSIE_TEAM_HOST_BASE_DOMAIN, unset means tenants are not routed by hostname at all, and the certificate-issuance gate NESSIE_TLS_CHECK_KEY refuses everything while it is unset — which is the right default for a deployment that does not use the feature.
Health checks
| Endpoint | Question it answers |
|---|---|
GET /api/health | Is this process alive? Fails only once it has begun draining. |
GET /api/health/ready | May this replica take new requests? This is the one to point a load balancer at. |
GET /api/ops/health | How is the deployment doing? A report for a person — worker heartbeats, queue depth, dead jobs — not a probe. |
On SIGTERM the API drains rather than dying where it stands. NESSIE_SHUTDOWN_TIMEOUT_MS bounds that drain and defaults to 25 seconds; keep it under whatever grace period your orchestrator gives the container, or the runtime kills the process first and the drain buys nothing.
Upgrading
An upgrade is prisma migrate deploy against the existing database, then the seeds, then reconcile. The same four commands as the first deploy.
The path is tested for the route a self-hoster actually takes rather than against a fresh database: CI restores a snapshot taken twenty migrations behind, migrates it to the current head, validates the schema and runs a smoke check over the core tables. Any release inside that trailing window is a proven upgrade source. Migration folders are immutable once committed, and a lint step fails the build if one is renamed, renumbered, deleted or modified, because all four break migrate deploy for a database that already recorded the old row.
One failed migration parks every deploy after it. migrate deploy stops at the first failure and refuses every later migration with P3009, so the installation stays on the old release until somebody clears it by hand. The project’s own production spent a day rejecting every deploy this way. Clearing one means confirming the failed migration really did roll back — none of the objects it creates should exist, anything it alters should still be in its original shape — and only then resolving it as rolled back. A migration that half-applied is not a candidate.
Backup and restore for the self-hosted Compose deployment is not documented in the repository, so this page does not describe a procedure for it.
One machine, or a managed cloud
The single-host Docker Compose path above is the one that is proven: it is what the project’s own production runs, on a single server, with the database, object storage, API, worker, admin and site as containers behind a shared reverse proxy.
Scaling past one machine changes three things, all of which are enforced rather than advised. Outside local mode Nessie runs the API and the worker as separate processes, and as several copies of each, so any capability that assumes one process owns the machine’s disk is refused: filesystem object storage (a start-up failure), execution-environment templates with the docker provider (inert, and provisioning fails loudly), and the file_read, file_write and file_glob builtin tools (a failed tool result the agent can read). Each refusal names the setting, the mode, and what to use instead.
For a managed cloud the repository carries a Terraform tree in infrastructure/terraform/ targeting Google Cloud: the API as a Cloud Run service with a minimum of one instance — it holds a persistent Postgres LISTEN client and runs maintenance sweeps, so scaling it to zero stops realtime delivery — the worker as a Cloud Run worker pool, because it binds no port and a Service would never become ready, a migrate job that gates every rollout, Cloud SQL for PostgreSQL 17, and a GCS bucket reached through the S3-compatible backend.
That path is written, not walked. The repository says so plainly, and so does this page: no Google Cloud project exists for it, nothing in the tree has been planned or applied against a real project, the multipart upload path to GCS is untested against a real bucket, the worker-pool resource type is unvalidated, and the trusted-proxy hop count in it is a placeholder to be measured rather than a measurement. The admin and the public site are not modeled there at all. Treat it as a starting point you verify, not a supported deployment.
Where a deployment’s data lives, what the software holds and what it deliberately does not, is a separate page: EU made, and where your data lives.