# Troubleshooting

> Exact error messages for startup, sign-in, source, indexing and search problems, matched to their causes and fixes.

- Updated: 2026-09-25
- Source: https://contextator.com/en/docs/troubleshooting/
- Language: en-US
- Author: Muhammet Şafak

---
Start here: the dashboard's top bar tells you whether the database is up and the model is ready, and a
failing project shows its error verbatim in the detail view. The logs are
`docker compose logs -f`.

---

## Startup

### The container keeps restarting

The log line above the exit says which process died.

- *"PostgreSQL exited during startup"* — the PostgreSQL output above it explains why. Usually a data
  directory created by another PostgreSQL major version, or a bind-mounted `CONTEXTATOR_PGDATA_PATH`
  whose permissions could not be fixed. On Docker Desktop, moving the database back to a named volume
  is the reliable fix.
- An invalid configuration — the server prints every problem and exits. Fix `.env` and start again.

### `The database was created with EMBEDDING_DIMENSIONS=… but the current config says …`

You changed the embedding dimensions. Either restore the old value, or start once with
`RESET_VECTORS=1` (drops every chunk) and re-index everything. See [Embedding Models](/en/docs/embedding-models/).

### The dashboard says "Model loading" for a long time

The first start downloads ~470 MB. Watch `docker compose logs -f` for `downloading model file` and then
`embedding model ready`. To download less, set `EMBEDDING_DTYPE=q8` (~120 MB) before the first start.
On an air-gapped host, pre-populate the models volume and set `EMBEDDING_OFFLINE=1`.

Until the model is ready, indexing and search fail while the rest of the dashboard works.

### `Could not load the sharp module`

The lockfile was generated on another platform. Regenerate `package-lock.json` on Linux, or run
`npm install --os=linux --cpu=x64 sharp` before building the image.

---

## Signing in

### The dashboard sends me to `/setup`

This instance has no account yet. The setup code is in the log — `docker compose logs -f` — printed in
a box at every start until the first account exists. See
[Accounts and Permissions](/en/docs/accounts-and-permissions/).

### `That setup code is not the one in the server log`

You are reading an older box. A generated code is replaced on every restart, so use the newest one, or
set `SETUP_CODE` in `.env` and restart to fix it in place. Case, dashes and spacing do not matter.

### Signing in succeeds, then bounces straight back to `/login`

The browser is dropping the session cookie. On a plain-HTTP address — a LAN IP or hostname without TLS
— set `AUTH_COOKIE_SECURE=0` and restart. Behind a proxy that terminates TLS, set it to `1`.

### `Wrong username or password`, and I am sure it is right

The message is deliberately the same for an unknown username and a wrong password, so first check the
username exists. If it does, the account may be new with a temporary password that has since been
reset — ask whoever created it. `Too many failed attempts; this account is locked for a while` means
the lockout is running: it starts at `AUTH_LOGIN_WINDOW_MIN` minutes and doubles, up to an hour.

### Sign-in says *too many attempts*

Rate limiting, not a lockout on its own account: per IP address, `AUTH_LOGIN_MAX_ATTEMPTS` failures
inside `AUTH_LOGIN_WINDOW_MIN` minutes get a `429` with a `Retry-After`. Wait it out, or raise either
variable.

### Nobody can sign in at all

See [I forgot my password](/en/docs/faq/). Short version: another admin resets it, or
`ADMIN_TOKEN` does it over the API, or `npm run reset-password -- <username>` on a source install.

### I cannot delete the last root account

By design — the server answers `409` and the dashboard disables the button, so an instance can never
lock itself out of its own user management. Promote somebody else to `root` first. See
[Accounts and Permissions](/en/docs/accounts-and-permissions/#roles).

### A project I know exists answers `404`

Your account is not a member of it. That is deliberate — no access and no such project answer the same
way — so ask a `root` or `admin` to add you under **Members** on the project page.

---

## Sources

### `Directory is outside the allowed document roots`

The path must resolve inside `ALLOWED_DOC_ROOTS` — inside Docker that means `/docs/...`. Either use such
a path, or mount the folder and add it to `ALLOWED_DOC_ROOTS`, then restart. See
[Local Directory Source](/en/docs/local-directory-source/).

### A git source shows an authentication error

**Test connection** reports the remote's own answer. Check that the token has read access to the
repository and has not expired. Leave **Username** empty for a Bitbucket repository or workspace access
token (the default `x-token-auth` is for those only); a **Bitbucket Cloud API token** needs your
Bitbucket username, or `x-bitbucket-api-token-auth`, in that field; a **GitLab deploy token** needs its
own generated username, e.g. `gitlab+deploy-token-42` — the default `oauth2` is refused for one. See
[Git Repository Source](/en/docs/git-repository-source/#private-repositories) for the full table by
provider.

### `Subdirectory "…" does not exist in the repository`

The path is relative to the repository root and is checked against the branch you configured. Confirm
both.

### `Branch "…" was not found on the remote`

Wrong branch name — `master` rather than `main` is the usual cause.

### Adding a private git, Notion or Confluence source fails on `SECRET_KEY`

Set `SECRET_KEY` (32+ characters, e.g. `openssl rand -hex 32`) and restart. It is required only once a
source needs to store a token.

### A source that used to work now says the token cannot be decrypted

`SECRET_KEY` changed. If you still have the old key, there is no need to re-enter anything: set
`SECRET_KEY_PREVIOUS` to it alongside the new `SECRET_KEY`, restart, run `npm run rotate-secret`
(`docker exec contextator npm run rotate-secret` for a Docker install) to re-encrypt every stored token
under the new key, then remove `SECRET_KEY_PREVIOUS` and restart once it reports nothing left under it.
If the old key is gone entirely, re-enter the token for that source instead — it cannot be recovered.
See [Security](/en/docs/security/#rotating-things).

### Notion imports nothing

Nothing is shared with the integration yet. In Notion, share the pages or databases with it
(**… → Connections**). A page shared this way includes its children. See [Notion](/en/docs/notion/).

### Notion says no root could be read

The token is wrong or the root ids are not shared with the integration. Contextator deliberately fails
here instead of reporting an empty workspace, which would delete every page already imported.

### An upload rejected some files

The response lists what was skipped and why: an extension the source does not index, a dot-directory, a
name that is not portable across platforms, an entry that would escape the destination, or a size cap.
Adjust the source's file types, or the limits in [Configuration](/en/docs/configuration/).

---

## Indexing

### A project is stuck in `indexing`

Live job state lives in memory, so a restart mid-run can leave the status behind. Press **Re-index** —
the run is incremental and resumes cheaply, and the status is rewritten.

### The project is `error` but most documents are there

One source failed and the rest indexed. The message reads `2/3 sources synced; <name>: <reason>`, and
the failing source's row carries the full error. Fix it and press **Sync** on that row. Documents from a
source that could not be read are never deleted.

### Documents disappeared

Either the files really are gone from the source (a deleted file is removed from the index on the next
run), or you changed the file-type selection so they are no longer indexed. A failing sync does **not**
delete documents.

### A file is not indexed

Check, in order:

1. Its extension is selected on the source (`.md` and `.mdx` by default; `.txt`, `.html`/`.htm`,
   `.csv`, `.docx` and `.pdf` are opt-in).
2. It could be converted at all — there is no OCR, so a scanned or encrypted PDF, or a Word file that
   is all images, is refused by name on the source's row rather than half-indexed.
3. It is not inside a dot-directory, `node_modules`, `dist`, `build`, `vendor` or `__pycache__`.
4. It does not match `IGNORE_GLOBS`.
5. It is not empty after the content-type transform — an empty document is skipped.
6. It is not reached through a symlink pointing outside the source.

### `CHUNK_MAX_TOKENS=… exceeds what … reads`

The chunking budget is larger than the embedding model reads usefully. The shipped default fits the
shipped model, so seeing this means one of the two was changed on its own. Set `CHUNK_MAX_TOKENS` to the
value the message suggests and re-index. Running a model this build does not know about? State its
context window in `EMBEDDING_MAX_INPUT_TOKENS`. See
[Configuration](/en/docs/configuration/#chunking).

### Indexing is slow

Embedding is CPU-bound and one project is indexed at a time. The first run of a large project is the
expensive one; subsequent runs skip unchanged files. `EMBEDDING_DTYPE=q8` or the smaller
`Xenova/all-MiniLM-L6-v2` model both speed it up.

---

## Search and clients

### The agent cannot connect

- The project name in the URL must match exactly — `Unknown project "…"` means it does not.
- From another machine, use the server's address rather than `localhost`, and check the firewall.
- A browser-based client needs its origin in `ALLOWED_ORIGINS`.

### `search_docs` says the project was indexed with another model

The server's embedding model changed. Re-index the project — the next run is automatically a full one.

### The agent finds nothing

- Confirm the project has documents and chunks in the dashboard.
- Try `list_topics` to see what was actually indexed.
- Ask a fuller question: search is hybrid — meaning and exact wording at once — and a question carries
  more signal for the meaning half than two keywords do.
- If nothing at all comes back, the relevance floor may be refusing it: `search_docs` answers *no good
  match* below `SEARCH_SCORE_FLOOR` (`0.82` by default, a cosine similarity measured against the default
  embedding model). If you changed `EMBEDDING_MODEL`, the startup log says so — re-measure the floor
  with `npm run eval` against your own corpus rather than reusing the shipped default, or set
  `SEARCH_SCORE_FLOOR=0` to turn every project's own floor off as well. The server logs every gated
  query at `info` with the score it saw, and names the projects that carry their own floor in its
  startup warning.
- If the documentation is one enormous file with no headings, chunking has little to work with — add
  headings.

### An agent is told *no good match* for something that **is** documented

The floor refused a question it should not have. Compare the score the server logged for that query
against the floor in effect — a project's own if it set one, else `SEARCH_SCORE_FLOOR`. A corpus that
scores lower than the rest of the instance (prose more than reference pages) is usually better served by
lowering that one project's floor in the query-log panel, which previews the change before you commit
it, than by lowering the server-wide setting.

### Results are poor

- With the local model, try `CHUNK_MAX_TOKENS=250` and force a re-index ([Indexing](/en/docs/indexing/)).
- Split unrelated bodies of knowledge into separate projects.
- Exclude noise (journals, changelogs, templates) with `IGNORE_GLOBS`.
- Consider OpenAI embeddings for a large, prose-heavy corpus.

### Answers got longer after upgrading

Each excerpt now carries the chunk on either side of it, for more context around the match.
`SEARCH_NEIGHBOR_CONTEXT=0` restores the old, single-chunk shape, and `SEARCH_MAX_RESULT_CHARS` caps the
whole answer regardless.

### A webhook returns `401 invalid_signature`

The secret in the repository settings is not the one currently stored. Copy it again from the source
dialog, or **Regenerate** and paste the new one. See [Push Webhooks](/en/docs/push-webhooks/).

---

## Scripts and the admin API

### `403 csrf_blocked` from my own script

A cookie-authenticated write must come from this site (`Sec-Fetch-Site`, falling back to
`Origin`/`Referer`) — a script sending the session cookie from another origin trips it. Use
`Authorization: Bearer $ADMIN_TOKEN`, or an [API token](/en/docs/admin-api/#api-tokens), instead; bearer
requests are exempt from the check. See [Security](/en/docs/security/).

### After upgrading, `/api/*` answers `401 setup_required`

This instance had no `ADMIN_TOKEN` set and was, on the version it ran before, effectively open on
`/api/*`. It is now closed by default: open `/setup` with the code from the log and create the first
account, same as any brand-new instance. Projects, sources and indexes already there are untouched. See
[Accounts and Permissions](/en/docs/accounts-and-permissions/#first-run-the-setup-code).

---

## MCP connectors

### A member reads a project in `/mcp/…` they are not a member of

Expected while that project is `open` or `token required`: a credential that names nobody is judged by
the mode, not by memberships. Switch the project to **account required** under **MCP access** and its
endpoint starts following the member list. See
[Accounts and Permissions](/en/docs/accounts-and-permissions/#what-accounts-do-not-cover).

### An MCP client suddenly answers `401`

The project now requires a credential. Mint a token under **MCP access** and add
`--header "Authorization: Bearer …"` (or `headers` in `mcp.json`) — or, if the project says **account
required**, reconnect a client that can sign in, because a static token is refused there.

### An MCP client answers `403 … not a member of this project`

The credential is fine and the account behind it is not on the project. Add it under **Members**, or
connect with an account that already is one.

### A browser-based connector cannot connect at all

Either the project is `open`/`token required` and the connector has no header to send, or `MCP_OAUTH=0`
on this instance and there is no OAuth flow for it to use. See
[Connecting AI Clients](/en/docs/connecting-ai-clients/).

### A connector says it was disconnected and has to be approved again

Expected after a password change, after its membership was removed, or after the same credential was
presented twice — this server treats that as two parties holding one token and answers by taking the
grant down. A connector simply left unused past `MCP_OAUTH_REFRESH_TTL_DAYS` is a quieter, different
case: it is asked to authorize again and nothing is revoked. Approve it again either way.

### A connector reports `invalid_scope`

It asked for an OAuth scope. This server issues none — an account-backed credential reaches exactly what
its account may read — so the request is refused rather than granted under a scope nobody honours. The
server log names the client that asked.

### I lost an MCP token

It cannot be recovered — only a hash is stored. Revoke it under **MCP access** and mint another.

---

## Getting more detail

```bash
docker compose logs -f                    # follow everything
docker compose logs --tail=200 contextator
curl -s http://localhost:3444/api/health | jq
```

For verbose per-request logging, set `LOG_LEVEL=debug` and restart. Useful log lines:
`embedding model ready`, `indexing started`, `indexing finished` (with counts),
`indexing finished with source errors`, `source sync failed`, `mcp session opened` / `closed`.

If you are stuck, open an issue with: the version from `/api/health`, the relevant log lines, the source
type involved, and what you expected to happen.
