compose.selfhost.yml) runs Postgres (with pgvector) and Redis, builds the server from source, and uses a one-shot init service to migrate the database and provision the runtime role before the server starts.
Everything runs on stock Postgres — the schema, row-level-security policies, and role grants have no cloud-specific dependencies.
Prerequisites
- Docker with Compose v2
- Node.js 22 or later and pnpm 11.9.0 — the repo pins
pnpm@11.9.0via thepackageManagerfield, so runcorepack enableto use the pinned version - A
psqlclient (optional, for sanity checks)
Quickstart
1
Clone and configure
.env.selfhost and set, at minimum: POSTGRES_PASSWORD and
APP_USER_PASSWORD (both openssl rand -hex 32 — use URL-safe values, they
are interpolated into connection URLs), BASE_URL, OAUTH_JWKS (generate
with the one-liner in the template), and LOG_HASH_SALT.There are no weak built-in defaults for a networked instance: a fail-closed
preflight service runs first and refuses to boot the stack on blank,
weak, or placeholder secrets — before the database volume is ever
initialized. Every other variable is optional and documented inline in the
template (LLM gateway for embeddings, SMTP for auth email, Sentry — all off
by default, no phone-home).2
Initialize the database
migrations service migrates the schema, provisions the
append-only runtime role, and sets its password, then exits. A non-zero exit
means migrations failed — stop and read the output before starting the
stack. Migrations are forward-only; re-running is a no-op.3
Start the stack
/health
probe:4
Seed data and mint an API key
3ng_<prefix>_<secret>. Re-running is idempotent: existing rows are skipped,
never deleted, and the key is not re-issued.DATABASE_URL_UNPOOLED (the owner URL) is exported inline on purpose — it is
not a key in .env.selfhost because the server refuses to boot in production
with it present in its environment.5
Call the API
200 response means the stack is up end to end: server up, database
migrated, runtime role connected, RLS in force.Configuration notes
pnpm seedruns on the host, not in a container, because it loads committed fixtures. It connects to Postgres on the published port 54320.- No phone-home by default: leaving
SENTRY_DSNempty fully disables error tracking and tracing. This is the self-host default. - Append-only below the application: the runtime database role has no
DELETEgrant anywhere in the memory domain. Destructive operations are impossible at the grant level, not just discouraged in code. - Worker container: background consolidation jobs are not part of the Compose stack yet; the server and data plane run without them.
- Seeded embeddings are pre-computed: the seed loads committed cached vectors (
eval/fixtures/embeddings-openai-large-1536.json) into the seeded memories, so no external API call is needed to embed the seed data itself. The search endpoint (POST /api/v1/search) still requires an embedding provider (LLM_GATEWAY_URL+LLM_GATEWAY_API_KEY) to embed the query — without one it returns503 embedding_unavailable. New writes need the same provider to embed their content.
Two surfaces: REST/CLI (API key) vs MCP (OAuth)
The memory operations (remember, search, revise, …) are exposed by two surfaces that authenticate differently:
- REST API + CLI — use the seeded API key. The REST surface under
/api/v1and the3ngramCLI accept the3ng_…key viaX-API-Key. This is the simplest path for scripts and for verifying the instance, as in the quickstart above. - MCP endpoint (
BASE_URL+/mcp) — OAuth 2.1 Bearer only./mcpis mounted behind the OAuth Bearer middleware and rejectsX-API-Keywith a 401 — the seeded API key does not work there. An MCP client authenticates through the OAuth 2.1 flow the server advertises (RFC 9728 / RFC 8414 discovery atBASE_URL); clients that support OAuth dynamic registration complete it automatically. This is whyBASE_URLandOAUTH_JWKSare required configuration.
Hardening notes
- Keep
NODE_ENV=productionfor any networked deployment — it enforcesBASE_URL,OAUTH_JWKS,REDIS_URL, andLOG_HASH_SALTat boot rather than degrading silently. - The bundled Postgres is bound to
127.0.0.1only (host-local), published solely for the optional host-run seed. Comment out thepostgresports:block incompose.selfhost.ymlonce you have your key to close it entirely. Redis is never published. - The server port (
3000) is published on all interfaces so clients can reach the API/MCP. Put the server behind TLS (a reverse proxy) and setBASE_URLto the public HTTPS origin so OAuth issuer/audience values are correct; if the proxy runs on the same host, bind the server to127.0.0.1:3000:3000so only the proxy can reach the plaintext port.
Resetting
Troubleshooting
serverunhealthy at boot — checkdocker compose --env-file .env.selfhost -f compose.selfhost.yml logs server; the most common cause is a missingOAUTH_JWKSorLOG_HASH_SALTunderNODE_ENV=production.pnpm seedauth error — re-exportDATABASE_URL/DATABASE_URL_UNPOOLEDfrom the sourced passwords before seeding. They are built fromAPP_USER_PASSWORD/POSTGRES_PASSWORD, so a shell sourced before you edited.env.selfhostcarries the wrong password.pnpm seedsaysDATABASE_URL_UNPOOLED must be set— export it inline before seeding; it is intentionally not a key in.env.selfhostbecause the server rejects it in its production environment.- Search returns “not configured” — embeddings are off; set
LLM_GATEWAY_URL+LLM_GATEWAY_API_KEYto enable vector search.
pnpm db:migrate), see Local development.