Deployment
All services are defined in docker-compose.yaml, run as a singleton stack: one database container serves every indexed codebase. The compose project name is pinned (name: enggraph) and the data directory is fixed at ~/.local/share/enggraph/db - neither is configurable, on purpose. Running a second stack from this compose file corrupts the data directory; there is no per-stack path to keep two apart.
Configuration
Copy .env.example to .env. make up refuses to run without it.
| Variable | Default | Purpose |
|---|---|---|
PROJECT_PATH | empty | Host path a one-shot indexer run records; nothing else reads it |
PROJECT_NAME | derived | Name the codebase is stored and addressed under; defaults to the last segment of PROJECT_PATH |
POSTGRES_PASSWORD | required | Database password; compose fails fast when unset |
POSTGRES_USER | user | Database user |
POSTGRES_DB | context | Database name |
GATEWAY_PORT | 3000 | The one published host port; nginx routes it to every service |
GATEWAY_BIND | 0.0.0.0 | Interface the entry point binds. 127.0.0.1 keeps the whole stack off the network |
GATEWAY_HOSTS | derived | Host header values the MCP server and the dashboard API accept, comma separated |
TAG | latest | Tag applied to images built by make build; compose only ever runs :latest |
INDEX_SCHEDULER | on | Whether the worker API indexes on a schedule at all. 0 leaves every project to the button |
SCHEDULER_TICK_SECONDS | 30 | How often it looks for a project that is due |
SUMMARY_WORDS_BATCH | 50000 | Summaries turned into search words per tick while a project is first filled |
INDEX_MAX_RUNNING | 1 | Index runs the worker API does at once; the rest queue in memory. They share one memory limit |
AUTH_ENFORCE | on | off, warn or on; see Authentication |
AUTH_OIDC_ISSUER | empty | The OIDC issuer people log in through; empty is local mode |
AUTH_OIDC_INTERNAL_URL | empty | Where the dashboard reaches the issuer when its public address does not resolve in a container |
AUTH_OPERATORS | empty | Subjects or emails whose OIDC login makes them an operator, comma separated |
AUTH_PUBLIC_URL | derived | The address browsers reach the gateway at; derived from Host and X-Forwarded-Proto |
PROJECT_PATH/PROJECT_NAME are arguments to the indexing job only - they name the tree one such run records, and never reach the compose project or the other containers.
The dashboard’s project list reads node, edge and file counts from a diskcache the worker API keeps on the list-cache volume. Every finished index run rewrites its project’s entry, and a project without one is counted live. make down keeps the volume, and make clean drops it with the rest of the stack.
The schedule itself is per project and lives in the database, not here - see Indexing on a schedule. One host limit belongs on this page though: auto mode has the agent watch the tree on its host, and the kernel caps how many directories may be watched at once.
sysctl fs.inotify.max_user_watches
The default is generous on most distributions and not on all of them. A watch the kernel refuses is logged by the worker API, and the projects it covered fall back to their interval - so auto degrades to periodic rather than to nothing. Raise it on the host if you would rather have the watch:
sudo sysctl -w fs.inotify.max_user_watches=524288
Resource limits
The whole stack shares one CPU and memory budget: STACK_CPUS (default 4) and STACK_MEM (default 6g). make splits it into a share per service and passes every share to compose, so no container can take the whole host. Memory is rounded down to a power of two per service.
| Service | CPU share | Memory share | At 4 CPU / 6g |
|---|---|---|---|
postgres | 30% | 34% | 1.2 CPU / 2048m |
worker-api | 30% | 34% | 1.2 CPU / 2048m |
embedder | 15% | 10% | 0.6 CPU / 512m |
valkey | 5% | 10% | 0.2 CPU / 512m |
mcp-server | 8% | 4% | 0.32 CPU / 128m |
viewer | 5% | 3% | 0.2 CPU / 128m |
web | 4% | 3% | 0.16 CPU / 128m |
nginx | 3% | 2% | 0.12 CPU / 64m |
Postgres gets a quarter of its memory as shared_buffers, and Valkey keeps three quarters of its own as maxmemory. To see the split for the current settings:
make limits
A per-service value in .env wins over its share: PG_, API_, EMBEDQ_, SUMMARIZE_, EMBED_, VALKEY_, MCP_, VIEWER_, WEB_ or NGINX_ followed by CPUS or MEM, plus PG_SHARED_BUFFERS and VALKEY_MAXMEMORY. EMBEDQ_ and SUMMARIZE_ are the two queue services, EMBED_ the embedding model. The one-shot indexer container sits outside the budget and keeps INDEXER_CPUS and INDEXER_MEM.
Check the limits after make restart:
docker stats --no-stream
The MEM USAGE / LIMIT column shows the share of each service, not the host total.
The entry point
nginx is the only service that publishes a host port. It picks the backend from the request path:
| Path | Served by | What it is |
|---|---|---|
/mcp, /mcp/<project> | mcp-server | Streamable HTTP, the transport to use |
/sse, /sse/<project>, /message | mcp-server | the older SSE pair |
/health | mcp-server | what make status probes |
/worker/... | worker-api | the remote summarization queue, prefix stripped |
/auth/login, /callback, /logout | web | logging in and out of the dashboard |
/auth/v1/... | rauthy | the identity provider, under the auth profile |
/agent/agents/... | worker-api | host agents, which sign every request |
| everything else | web | the dashboard, its API and the graph page |
The config is nginx/default.conf, bind-mounted read-only. Two details in it are load-bearing rather than stylistic. Every proxy_pass goes through a variable so that names resolve per request: worker-api sits behind the remote profile, and a static upstream name that does not resolve makes nginx refuse to start instead of answering 502 for that one path. Response buffering is off on the MCP routes, because both transports hold a response open and a buffered stream reaches the client only when the session ends.
The viewer is not routed directly. The dashboard already proxies /graph and /vis-network.min.js same-origin, and the viewer emits absolute root paths, so it cannot be mounted under a prefix.
GATEWAY_BIND defaults to every interface, which is what a summarization worker or an agent on another machine needs. Until AUTH_ENFORCE=on it also puts the dashboard and the MCP server within reach of the local network unauthenticated, and make up warns about it. Set GATEWAY_BIND=127.0.0.1 if you run no remote worker.
Authentication
Every request through the gateway is asked about first. nginx sends an auth_request to the dashboard’s /auth/check, which names the principal - a person by a session cookie, an agent or a script by an enggraph token - and answers 204, 401 for nobody or 403 for a principal that may not do this. The check lives in enggraph rather than in the identity provider, so any OIDC issuer will do.
AUTH_ENFORCE | Anonymous request | Use it |
|---|---|---|
off | passed; nothing is asked | to switch the check off entirely |
warn | passed, logged by method and path | first, to see what still runs anonymously |
on | refused: pages go to the login form | the default |
With off nginx answers the check itself, so the gateway does not depend on the dashboard; warn and on ask it on every request. Read what warn logged with make web-logs. Exempt from the check are /health, /nginx/health, the login and callback addresses, the static assets of the login page, Rauthy’s /auth/v1/ and /agent/agents/, where an agent’s signature does the job.
Tokens
A token reads egt_<id>_<secret> and is sent as Authorization: Bearer egt_.... Only the sha256 of the secret is stored, so the text is shown once, when it is issued. A token carries a kind (operator, mcp, api), a role (operator or reader), scopes (read, write-records, admin) and the projects it may read, or *.
- An MCP call naming a project outside the token’s list is refused, and so is a call that reads across every project. A write tool needs the
operatorrole or thewrite-recordsscope. - The dashboard needs a token for every project. A reader may read it; a write, the Ask page and the Tokens page are for operators.
- On
/worker/the gateway replacesAuthorizationwith the internalWORKER_API_TOKENonce the check passed, so that token never leaves the stack. A remote summarization worker carries anapitoken with thewrite-recordsscope instead.
Operators issue and revoke tokens on the dashboard’s Tokens page, or inside the stack:
make token WHO=alpha-laptop
make token WHO=summarizer KIND=api SCOPES=read,write-records
make token WHO=admin KIND=operator
The token is printed once. docker compose exec web python -m enggraph.core.authn list lists the tokens and revoke <id> ends one, with the sessions it opened.
Local mode
With AUTH_OIDC_ISSUER empty, the login form takes an operator token and opens a session for it: 12 hours sliding, 7 days at most, in an HttpOnly eg_session cookie. Issue the first one with make token WHO=<name> KIND=operator.
Upgrading an existing install
on is the default, so after the upgrade everything that reached the stack without a token is refused: agents, scripts, a remote summarization worker, a browser that has not logged in. To keep the old behaviour, put AUTH_ENFORCE=off into .env. To move over, start in warn:
echo AUTH_ENFORCE=warn >> .env
make up
make token WHO=admin KIND=operator
- Log in at
/auth/loginwith that operator token. - On every machine an agent runs on, issue a token, put it into the
envblock of Claude’ssettings.jsonasENGGRAPH_TOKENand runmake installagain in each codebase; see Onboarding. - Give a remote summarization worker an
apitoken withwrite-recordsas its--token; see Summarization. - Watch
make web-logs. Once it names no anonymous request that should not be there, drop theAUTH_ENFORCEline from.envand runmake up.
Rauthy
Rauthy ships in compose under the auth profile, served at <gateway>/auth/v1. Its data lives in a database rauthy on the same PostgreSQL server, created on its first start, and make backup takes it along. Set these in .env, then start it:
COMPOSE_PROFILES=auth
AUTH_OIDC_ISSUER=http://localhost:3000/auth/v1
AUTH_OIDC_INTERNAL_URL=http://rauthy:8080/auth/v1
AUTH_OPERATORS=admin@localhost
RAUTHY_SECRET_RAFT=<at least 16 random characters>
RAUTHY_SECRET_API=<at least 16 random characters>
RAUTHY_ENC_KEYS=<id>/<base64 of 32 random bytes>
RAUTHY_ENC_KEY_ACTIVE=<id>
make up
make auth-secret
make restart
On its first start Rauthy creates the enggraph client from rauthy/bootstrap/clients.json, with the redirect URI http://localhost:3000/auth/callback; another gateway address means editing that file before the first start, or the client in Rauthy’s admin UI after it. make auth-secret writes the client’s id and secret under secrets/, which the dashboard reads. The admin’s password is in make logs on the first start. Any other issuer - Keycloak, Authentik, Zitadel - replaces Rauthy through AUTH_OIDC_ISSUER and the two files under secrets/.
Host agents
A host agent serves its directories to the stack, read-only, over a socket it opens itself. Host agent covers installing it. It registers and gets a one-time claim code, an operator approves it on the dashboard’s Agents page, and the claim hands over a credential. Every later request is signed with a key derived from that credential and a master key, so the stack stores no agent key.
The stack makes the master key itself the first time an agent needs it and keeps it in the database, in agent_master_keys. There is nothing to write or copy, and a whole-database backup carries it. Whoever holds that backup can forge agent credentials, so guard it as you guard your tokens.
To rotate the key, name a new one in .env and apply that. The stack makes it, and older keys keep verifying the credentials issued under them, which last an hour.
AGENT_CURRENT_KID=k2
make up
Approve an agent on the Agents page:
- Compare the fingerprint shown there with the one the agent logged: the first 8 hex digits of the sha256 of its claim code.
- Approve the agent. A registration nobody decides lapses after 24 hours.
- Approve each project it offers, separately. A project added later, or one whose path changed, waits for approval again.
- Bind an approved project. It joins an existing project only when its path equals that project’s
root_path; otherwise it becomes a new project of its name. A path another project already reads gets the rootagent:<agent-name>:<host-path>.
Approve the import beside an agent does steps 3 and 4 at once, for every project it offers on the path of an unbound project.
A bound project is read through its agent at once. Unbind on the same page leaves it with nothing to read it: it keeps its graph and gets no run. A removed, moved or refused project loses its binding. A token bound to an agent on the Tokens page makes describe_project(path) look among that host’s bound projects first.
An agent that goes offline stops the project’s runs and removes nothing from its graph. Text search and file text answer nothing until the agent returns. A reconnecting agent owes the project a full pass, which reads only what changed.
Files in the denied list are never read through an agent. A selected one such as .env.example has no node in a project first indexed through an agent. A project indexed before it was bound keeps the nodes it has.
| Control | What it does |
|---|---|
| Disable | Refuses the agent’s requests and closes its socket at the next ping |
| Enable | The only way back for a disabled agent, including one a new claim was approved for |
| Revoke | Bumps the generation; the agent’s next signed request disables it, and it must enroll again |
| Delete | Forgets the agent and its bindings, not its projects; a running agent registers again |
An agent still signing with a generation it was rotated past is disabled automatically, because two parties hold that credential. Registering and claiming are limited to 60 requests a minute per address, which answers 429 past a burst of 20.
Host header allowlists
Behind the entry point the Host header carries the gateway’s port, not the port the service itself listens on, so both services that check it are given their allowlist by compose from GATEWAY_HOSTS. Change GATEWAY_PORT, or reach the stack under any other name, and GATEWAY_HOSTS has to say so - a value that does not name the address you use answers 403 on /mcp and /api while /health, which is not guarded, still looks healthy.
nginx passes Host through unchanged on purpose. The dashboard derives the Origin it expects for a write from the Host it sees, so rewriting the header would make every cross-site request look same-origin, and that check is what guards an unauthenticated dashboard.
Two optional variables, read by the MCP server, implement DNS rebinding protection (GHSA-w48q-cv73-mx4w). Compose sets ALLOWED_HOSTS from GATEWAY_HOSTS; these are the raw variables behind it:
| Variable | Default | Purpose |
|---|---|---|
ALLOWED_HOSTS | loopback names on PORT | Comma-separated Host header allowlist, or * to disable the check |
ALLOWED_ORIGINS | empty | Comma-separated Origin header allowlist |
Backup and restore
make backup # the whole database
make backup PROJECT_NAME=api # one codebase
make backup PROJECT=/home/you/work/api # the same, resolved by root path
make backup KEEP=20 # keep more than the seven default
make backup KEEP= # keep everything, prune nothing
Files land in ~/.local/share/enggraph/backups. A whole-database backup is a pg_dump custom archive (context-<timestamp>.dump); a single-project backup is a plain-SQL replay of that project’s rows in foreign-key order (<name>-<timestamp>.sql.gz), since pg_dump can’t select by row. Neither file gets its final name until it’s been read back, so an interrupted dump is never mistaken for a real backup.
Backups rotate independently per kind, keeping the newest KEEP (7 by default); FILE=<path> writes elsewhere and is never pruned. With the auth profile a whole-database backup also writes Rauthy’s database beside it as rauthy-<timestamp>.dump, which make restore FILE= puts back into rauthy; stop the rauthy service first.
make restore FILE=context-20260819-115420.dump # replaces the whole database
make restore FILE=api-20260819-115137.sql.gz # replaces that one project
make restore # lists what's in the backup dir
Both directions print what’s about to be replaced and ask first (FORCE=1 skips the prompt), and both run inside a single transaction so a failed restore can’t leave a half-restored database.
Worth doing before dropping a project and before make clean: nodes, edges, hashes and embeddings come back with one index run, but plans, manually written summaries and everything in _memory don’t come back from anywhere else.
Upgrading
Take a whole-database backup before every upgrade, without exception. A new version can carry schema migrations, and make up applies them before any service starts. A migration is not reversible: the only way back is the backup taken before it.
An install on 0.x does not upgrade to 1.0.0 in one step: it goes through 0.24.0, as BREAKING CHANGES describes.
make backup
docker compose stop worker-api mcp-server web viewer
git pull
make build
make up
make backupwith noPROJECTwrites a whole-database.dump. Check that thewrote ...line names that file and a plausible size before going on.- The services are stopped so that no open connection holds a table a migration replaces.
make upruns themigrateservice first; the other services start only once it succeeds.- A migration that copies a large table needs free space for the copy on the database volume until it commits.
Check the result:
make status
docker compose logs --tail 5 migrate
make status shows the schema revision the database is at, and the migrate log names every revision it applied: Running upgrade -> 0001. A log with no such line means nothing was pending.
When a migration fails, its transaction rolls back: the database stays at the version it had, and the services stay down. Send the migrate log with the report. To go back instead, check out the previous release, make build, and make restore FILE=<the .dump taken above>.
GitHub Pages Setup
This documentation is published via GitHub Pages.
- Enable Pages: repository Settings -> Pages -> set Build and deployment -> Source to “GitHub Actions”.
- Automatic build:
.github/workflows/pages.ymlbuilds and deploys/docson every push tomain. - After the first push, the site is live at
https://<owner>.github.io/<repo>/within a few minutes.