Skip to main content
How to run 3ngram locally from a clean clone. The same path serves local development, integration tests, and the current self-host skeleton. For the packaged adopter path (hardened Compose stack, secrets preflight), see Self-host. Everything below works on stock Postgres: the schema, RLS policies, and role grants have no cloud-specific dependencies (validated by the integration suites).

Quickstart (full stack in Docker)

The fastest path to a running server with seeded data and a working API key. The compose stack builds the server from apps/server/Dockerfile; a one-shot migrations init service (behind the init profile) migrates, provisions the runtime role, and sets its password before the server starts.
git clone https://github.com/B3dmar/3ngram && cd 3ngram
cp .env.example .env                       # defaults work as-is for local Docker

# 1. Initialize the database: migrate -> provision roles -> set app_user password
docker compose --profile init up migrations   # runs to completion, then exits

# 2. Bring up the data plane + server
docker compose up -d postgres redis server

# 3. Seed the dev user + golden set AND mint a demo API key (printed once)
set -a; source .env; set +a                # see step 2 of the manual setup below
pnpm install --frozen-lockfile
pnpm seed                                  # prints: 3ng_<prefix>_<secret>

# 4. Call the API with the printed key
curl -H "X-API-Key: 3ng_<prefix>_<secret>" \
  "http://localhost:3000/api/v1/briefing?kind=all"
# -> 200
Notes:
  • The migrations service and the server service both build from apps/server/Dockerfile (the migrations build target adds psql); server exposes 3000:3000 with a Node-fetch /health probe.
  • pnpm seed runs on the host (not in a container) because it loads the committed eval fixtures; it connects to Postgres on the published port 54320. The demo API key (name self-host demo) is minted once and is idempotent — re-running prints “already present” rather than re-issuing.
  • APP_USER_PASSWORD defaults to app-user-dev (compose) — override it in .env for anything beyond a throwaway local stack. Because the host-run pnpm seed reads the literal DATABASE_URL (it does not derive the password from APP_USER_PASSWORD the way compose does for the server service), change the password in both APP_USER_PASSWORD and the password segment of DATABASE_URL — overriding one alone desyncs the two and pnpm seed fails with an auth error.
  • A worker container is not in compose yet (no apps/worker/Dockerfile); BullMQ jobs need a separate deployable before they are part of the self-host stack.

Prerequisites

  • Docker (compose v2), Node ≥ 22, pnpm 10 (corepack enable)
  • psql client

1. Start the services

docker compose up -d postgres redis
Postgres (pgvector, PG18) listens on 54320, Redis on 63790 — non-default ports so they never collide with system services. Images are pinned by digest; the tag each digest was resolved from is in the compose comments.

2. Configure the environment

Copy .env.example to .env and set the database keys. Then load it into your shell — nothing in the repo auto-loads .env (no dotenv dependency, by design), and the migrate/seed/psql commands below read the variables from the environment:
set -a; source .env; set +a
VariableValue (local)Role
DATABASE_URL_UNPOOLEDpostgresql://postgres:3ngram-dev@localhost:54320/ngramOwner — migrations, role provisioning, seed user creation. Bypasses RLS; never used by app code.
DATABASE_URLpostgresql://app_user:<password>@localhost:54320/ngramRuntime — what the app and integration tests use. app_user has NOBYPASSRLS and append-only grants.
APP_USER_PASSWORDthe password you pick in step 3
SENTRY_DSN(empty)Empty = error tracking and tracing fully disabled — the self-host default; no phone-home. Set it to your own Sentry DSN to send errors to your instance; optionally set SENTRY_ENVIRONMENT to tag events (defaults to NODE_ENV).

3. Migrate

pnpm install --frozen-lockfile
pnpm db:migrate          # drizzle-kit migrate, runs on the unpooled (owner) URL
Migrations are forward-only; re-running is a no-op.

4. Provision roles

After migrating — the grants reference tables the migrations create (same order as CI).
psql "$DATABASE_URL_UNPOOLED" -f scripts/provision-roles.sql
psql "$DATABASE_URL_UNPOOLED" -c "ALTER ROLE app_user PASSWORD '<password>'"
provision-roles.sql is idempotent and is the only place grants live. Note what it deliberately does NOT grant the runtime role: DELETE anywhere in the memory domain, and anything but INSERT/SELECT on memory_events and audit_log — append-only is enforced at the grant level, below the application.

5. Seed (optional)

pnpm seed
Loads the anonymized eval golden set (158 memories including supersession chains) under a dev user (dev@localhost, override with SEED_EMAIL). The seed writes through the runtime role inside a set_config('app.user_id', …) transaction — the same RLS gate the app uses — and is idempotent the append-only way: existing rows (matched by content_hash) are skipped, never deleted. Embeddings stay NULL; nothing calls an external API. Sanity check:
psql "$DATABASE_URL_UNPOOLED" -c "SELECT memory_type, count(*) FROM memories GROUP BY 1 ORDER BY 2 DESC"

6. Run the test suites against it

pnpm test                 # unit (no DB)
pnpm test:integration     # the mandatory suites, serial (fileParallelism=false)

Resetting

docker compose down -v    # drops the pgdata volume — full reset
Then repeat from step 1. (Dropping the volume is the supported “delete” path; runtime-role deletes don’t exist by design.)

What’s deliberately not here yet

  • worker / web containers — the server container exists; worker and dashboard compose support land when those deployables are packaged for self-host.
  • Embedding-backed search — seeded memories have NULL embeddings; vector search comes alive once the write path embeds on write.