# Self-hosting Knotebook ## Deployment prerequisites - **`PUBLIC_URL` is required.** The server refuses to start without it (or if it isn't a valid `http(s)://` URL). It is used to decide whether session cookies are marked `Secure` and to build each sign-in service's OIDC `redirect_uri` (`/api/auth/oidc/callback/`; the service imported from `OIDC_*` on upgrade keeps `/api/auth/oidc/callback`) — each service shows its exact callback URL in **Site admin → Sign-in**; see [Sign-in providers](#sign-in-providers) below. - **Two supported topologies.** Pick one: - **(a) Trusted-LAN plain http.** For a home/office network with no untrusted devices, you can run Knotebook without TLS, reachable by hostname/IP from other machines on the LAN: - In `docker-compose.yml`, comment out the default `"127.0.0.1:3000:3000"` port line and uncomment `"3000:3000"` instead (this binds `0.0.0.0`, i.e. every interface). - Set `PUBLIC_URL=http://:3000` (e.g. `http://192.168.1.50:3000`) in `.env`. - Understand the trade-off before enabling this: login credentials and the session cookie travel in **cleartext**; the session cookie has **no `Secure` flag**; **anyone on the same network segment can sniff or MITM the traffic**. The server will start and stay up, but it logs a prominent warning line at startup as a reminder — the server logs JSON (pino), so this arrives as one JSON log line with a `SECURITY WARNING: ...` `msg`, not a formatted banner. Only do this on a network where you trust every host, and accept that trade-off knowingly — it is not a substitute for topology (b) on any network you don't fully control. - **(b) Public — reverse proxy + TLS.** For anything reachable beyond a trusted LAN (the internet, a shared/untrusted network, etc.), terminate TLS in front of Knotebook and set `PUBLIC_URL` to the public `https://` address — see the next two bullets. - **Reverse proxy and TLS are bring-your-own (topology (b)).** Knotebook does not terminate TLS itself; put a reverse proxy (nginx, Caddy, Traefik, etc.) in front of it and point `PUBLIC_URL` at the public HTTPS address. - **Your reverse proxy must forward WebSocket upgrade requests on `/collab`** — real-time collaboration connects same-origin to `wss:///collab`, and a proxy that only forwards plain HTTP will silently break live editing while every other page keeps working. On nginx, the `/collab` location needs `proxy_http_version 1.1;` plus `proxy_set_header Upgrade $http_upgrade;` and `proxy_set_header Connection "upgrade";`; Caddy and Traefik forward WebSocket upgrades automatically with a plain reverse-proxy directive, no extra config needed. - **`TRUST_PROXY` decides whether `X-Forwarded-For` is believed, and it defaults to off.** Rate limiting and login lockout are keyed on the client address, so the server has to know whether it can trust the forwarding headers in front of it. With `TRUST_PROXY` unset, `X-Forwarded-For` is ignored and the socket's own address is used — the right default when clients can reach the app directly (the trusted-LAN topology, or a public deployment someone can route around). **If you run behind a reverse proxy, set it**, otherwise every visitor arrives as the proxy's address and shares one bucket: five bad passwords from anyone locks out everybody, and — if you use public share links — the public endpoints' per-address throttle collapses the same way, so one client sending garbage tokens rate-limits every anonymous reader at once (see [known limitations](./known-limitations.md)). The server logs a warning the first time it sees an `X-Forwarded-For` header while the setting is off. Accepted values: a comma-separated list of trusted proxy addresses — IPs, CIDR ranges, or the names `loopback`, `linklocal`, `uniquelocal` (e.g. `TRUST_PROXY=172.16.0.0/12`); a hop count (`1` for a single proxy); or `true`. A value that doesn't parse fails startup rather than every request. **The list form is the only one that is safe when clients can also reach the app directly** — a hop count and `true` both believe the header whoever sends it, so anyone who can bypass your proxy can forge their address either way. **Your proxy has to cooperate, in two ways.** It must send `X-Forwarded-For` — nginx does not do this by default (`proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`), and it is the only header Knotebook reads for the client address: `X-Real-IP` and RFC 7239 `Forwarded` are ignored, so a proxy that sends only those leaves you with the shared-bucket problem above even with `TRUST_PROXY` set. It should also pass the original `Host` through (`proxy_set_header Host $host;` — Caddy and Traefik already do): image uploads check the browser's `Origin` against the request host, so a proxy that rewrites `Host` to the upstream name and puts the real one in `X-Forwarded-Host` will make uploads fail with 403 unless `TRUST_PROXY` is set. ## Single-instance warning Only run a single `app` container. `docker compose up --scale app=N` is **not supported** and must not be used. In-memory state (login rate limiting, the collab-token/slug-change rate limiters, the password-hashing concurrency limiter, and the per-IP rate limiter on the OIDC login/callback endpoints) is already per-process, so scaling out would already produce inconsistent behavior across instances. On top of that, real-time collaboration state (Yjs/Hocuspocus) **now actually lives in server memory**: each note's live document and the set of connected clients are held in-process, with no cross-instance sync. Running multiple `app` instances behind a load balancer would let different clients editing the same note land on different instances, and their edits would **silently diverge** rather than merge or error. There is no clustering/coordination layer, so treat `app` as a single point of deployment, not a horizontally scaled service. ## Compose services and volumes `docker-compose.yml` (repo root) defines two services: - **`app`** — runs the prebuilt Docker image `ghcr.io/sw-willie-wu/knotebook`, or one built from `docker/Dockerfile` (see [The app image](#the-app-image)), reads its configuration from `.env` (`env_file: .env`), and by default publishes only on `127.0.0.1:3000` (see topology (a) above to expose it on the LAN instead). It waits for `db` to be healthy before starting — unless the bundled database is turned off (see [Using your own PostgreSQL](#using-your-own-postgresql)) — and has its own `/healthz`-based healthcheck. Inside it, the name `host.docker.internal` points at the machine running Docker. Uploaded images are persisted to the `uploads` named volume, mounted at `/app/uploads` — if you customize the compose file and drop that mount, uploaded images vanish on the next container rebuild. - **`db`** — the bundled PostgreSQL, `pgvector/pgvector:pg17`, with data persisted to the `db_data` named volume. It is started by default; to use a PostgreSQL server of your own instead, see [Using your own PostgreSQL](#using-your-own-postgresql). `POSTGRES_PASSWORD` can be overridden via `.env`, but Postgres only applies it while initializing a brand-new (empty) data directory — see the env var reference below. `docker compose down -v` discards *all* named volumes, not just `db` — it takes the `uploads` volume with it too, so every uploaded image is gone — and, with the bundled database, every note as well. ## The app image Each release from v0.11.1 on is published to the GitHub Container Registry as `ghcr.io/sw-willie-wu/knotebook`, for linux/amd64 and linux/arm64, tagged with its version (`0.11.1`). Two more tags move along: the minor line (`0.11`) always points at the highest patch release of that line, and `latest` at the highest release of all. There are two ways to get `docker-compose.yml` and `.env.example`: - **From a release (no clone needed).** Releases from v0.12.0 on carry both files as attachments (v0.11.1 and earlier have none; for those, use a clone — next item). The attachments are `docker-compose.yml`, which runs that release's image and can't build one, and `env.example`, the example environment file (GitHub doesn't keep a leading `.` in attachment names). In an empty directory: ```sh curl -fLO https://github.com/sw-willie-wu/knotebook/releases/latest/download/docker-compose.yml curl -fL -o .env.example https://github.com/sw-willie-wu/knotebook/releases/latest/download/env.example cp .env.example .env ``` For a given release, replace `latest/download` with `download/vX.Y.Z`. Nothing else from the repository is needed: the compose file mounts no files from it, only the `uploads` and `db_data` named volumes. **Keep your own changes out of `docker-compose.yml`**: upgrading replaces that file. Put them in a `docker-compose.override.yml` next to it instead — Compose reads that file automatically on every command when it is in the same directory, and a newer release's `docker-compose.yml` doesn't touch it. For example, the trusted-LAN mode of [Deployment prerequisites](#deployment-prerequisites) — read its trade-offs first: ```yaml # docker-compose.override.yml services: app: ports: !override - "3000:3000" ``` `!override` replaces the `127.0.0.1:3000:3000` mapping instead of adding a second one next to it (it needs Docker Compose v2.24.4 or newer); `docker compose config` shows the merged result. - **From a clone of the repository** — needed to build the image from source, or to run what is on `main`. Its `docker-compose.yml` runs `latest` by default and can also build the image (see below). Either file runs the tag named by `KNOTEBOOK_VERSION` in `.env` (written without a leading `v`) when it is set; unset, a release's `docker-compose.yml` runs that release, and the repository's runs `latest`. With the repository's file, pinning a version is recommended, so the version you run only changes when you change it. - `docker compose up -d` downloads the Docker image only if the machine doesn't have that tag yet. If the download fails, a release's `docker-compose.yml` stops with the registry's error (while the image isn't public, an authorization error such as `denied` or `failed to fetch anonymous token … 403 Forbidden`); the repository's builds the Docker image from source instead, which needs the whole repository. - `docker compose pull` downloads the tag again even if the machine already has it — that is how `latest` (or `0.11`) moves to a newer release. - **Building from source** (a clone only): `docker compose up -d --build` builds the Docker image from `docker/Dockerfile` in your checkout instead of downloading the published one (the build itself still downloads its Node.js base image and packages). The result is stored under the same name and tag, so a later `docker compose pull` replaces it with the published one. ## Using your own PostgreSQL By default `docker compose up -d` also starts the bundled PostgreSQL (the `db` service), and nothing in this section applies. To use a PostgreSQL server you run yourself instead — on the machine running Docker or on another host: 1. **Create a database and an account for Knotebook.** The server creates its tables itself — migrations run every time it starts, and they include a `drizzle` schema that records which ones have run — so the account in `DATABASE_URL` has to be able to create schemas and tables in that database. Making it the owner of the database is enough; it doesn't need to be a superuser, and the database needs no extensions: ```sql CREATE ROLE knotebook LOGIN PASSWORD '...'; CREATE DATABASE knotebook OWNER knotebook; ``` 2. **Point `DATABASE_URL` at it and turn the bundled database off.** In `.env`: ``` DATABASE_URL=postgres://knotebook:@db.example.com:5432/knotebook BUNDLED_DB=0 ``` Then run `docker compose up -d` as usual. With `BUNDLED_DB=0` the `db` container isn't created, `app` doesn't wait for it, and `docker compose ps` lists only `app` (Compose still creates an empty `db_data` volume, which nothing uses). `BUNDLED_DB` takes only `0` or `1`, the default; any other value makes `docker compose` fail. It needs Docker Compose v2.3.0 or newer. As in any URL, characters such as `@`, `:` or `/` in the password have to be percent-encoded. If the server can't reach the database, or the database refuses it, the `app` container keeps restarting; `docker compose logs app` shows the database's error. **PostgreSQL on the machine running Docker.** Inside the `app` container, `localhost` is the container itself, not your machine. Use `host.docker.internal` as the host instead — `docker-compose.yml` maps that name to the machine running Docker (`extra_hosts: host.docker.internal:host-gateway`); on Linux that is the address of Docker's `docker0` bridge, `172.17.0.1` unless your Docker daemon is configured otherwise: ``` DATABASE_URL=postgres://knotebook:@host.docker.internal:5432/knotebook ``` With Docker Engine on Linux, PostgreSQL's own defaults refuse this connection in two places (Docker Desktop forwards the connection differently and hasn't been tried): - **`listen_addresses`** defaults to `localhost`. Add the bridge address (`listen_addresses = 'localhost,172.17.0.1'`) or use `'*'`, then restart PostgreSQL. Until then the `app` log shows `ECONNREFUSED 172.17.0.1:5432`. PostgreSQL only warns about an address it can't listen on when it starts, so if it can start before Docker has created `docker0` (at boot, for example), it won't be listening there; `'*'` avoids that. - **`pg_hba.conf`** as `initdb` writes it only allows local and loopback connections. The connection comes from the `app` container's address on the Compose network — not from `172.17.0.1` — so allow that network's subnet, which `docker network inspect _default` shows (the project name is the directory name unless you set one), for example `host knotebook knotebook 172.16.0.0/12 scram-sha-256`, then reload (`SELECT pg_reload_conf();`). Until then PostgreSQL logs `no pg_hba.conf entry for host "172.x.x.x"`. Compose can give the network a different subnet when it's created again (after `docker compose down`, for example), so allow a range that covers where your Docker allocates networks rather than one address. A host firewall between the bridge and PostgreSQL has to let the connection through as well. **Moving an existing installation off the bundled database** needs PostgreSQL 17 or newer on your server (tried with 17): the bundled database is PostgreSQL 17, and its dump doesn't load into PostgreSQL 16 — it stops at `unrecognized configuration parameter "transaction_timeout"`. A new installation can use PostgreSQL 16 (see **Versions** below). 1. `docker compose stop app` 2. `docker compose exec -T db pg_dump -U knotebook --no-owner --no-privileges knotebook > knotebook.sql` — `--no-owner --no-privileges` lets the dump load under an account with a different name. 3. Load it into the empty database you created: `psql -v ON_ERROR_STOP=1 '' < knotebook.sql`. 4. Change `DATABASE_URL` in `.env`, add `BUNDLED_DB=0`, and run `docker compose up -d`. Compose removes the `db` container; the `db_data` volume stays until you remove it yourself (`docker volume rm _db_data`), so keep it until you've checked the new setup. Uploaded images stay in the `uploads` volume. **Backups.** The `docker compose exec -T db pg_dump …` commands elsewhere in this guide are for the bundled database. With your own PostgreSQL, run `pg_dump` against it directly, for example `pg_dump '' > knotebook-backup.sql`, with a `pg_dump` of the same major version as the server or newer — one from an older major version refuses (`aborting because of server version mismatch`). **Database logs.** When PostgreSQL rejects a statement, its own error log can quote the values involved: a unique violation writes the duplicate key in `DETAIL`, a check violation writes the whole row. `log_error_verbosity = terse` leaves `DETAIL` out of PostgreSQL's own log; it doesn't change what Knotebook logs. Set it if you ship that log to another system. It needs a superuser: put it in `postgresql.conf` or run `ALTER SYSTEM SET log_error_verbosity = 'terse';` and then `SELECT pg_reload_conf();`. **Versions.** From an empty database, every migration runs and the server starts and signs in on the stock `postgres:16` (16.15) and `postgres:17` (17.11) images, without pgvector; the bundled database and the test suites use PostgreSQL 17 (`pgvector/pgvector:pg17`). Older versions haven't been tried. ## Using `docker run` with your own PostgreSQL If you already run PostgreSQL and don't want Compose, the image runs on its own with `docker run`. To run the bundled database alongside it, use Compose instead (see [Compose services and volumes](#compose-services-and-volumes)) — it also gives you the healthcheck, the log size limit and the volumes below without writing them out yourself. The database needs what [Using your own PostgreSQL](#using-your-own-postgresql) describes: a database owned by the account in `DATABASE_URL`, no extensions, PostgreSQL 16 or 17 (see **Versions** there). In `.env` (start from `.env.example`), set at least the variables marked required in [Environment variables](#environment-variables): `DATABASE_URL`, `APP_SECRET`, `PUBLIC_URL`, and, for the first start on an empty database, `ADMIN_EMAIL` and `ADMIN_PASSWORD`. `docker run --env-file` takes each line literally, so don't put quotes around values. `BUNDLED_DB` and `KNOTEBOOK_VERSION` don't matter here. ```sh docker run -d --name knotebook \ -p 127.0.0.1:3000:3000 \ --env-file .env \ -v knotebook_uploads:/app/uploads \ --log-opt max-size=50m --log-opt max-file=5 \ --restart unless-stopped \ ghcr.io/sw-willie-wu/knotebook: ``` - `` is a release such as `0.11.1` (or `latest`). - `-p 127.0.0.1:3000:3000` publishes the app on this machine only, for a reverse proxy in front of it. To reach it directly from your network, use `-p 3000:3000` — read the trade-offs in [Deployment prerequisites](#deployment-prerequisites) first. - `knotebook_uploads` keeps uploaded images; without it they are lost when the container is removed. - `--log-opt` limits the log files, as `docker-compose.yml` does; Docker's default `json-file` log driver has no size limit. - **PostgreSQL on the same machine:** add `--add-host host.docker.internal:host-gateway` and use `host.docker.internal` as the host in `DATABASE_URL`. The `listen_addresses` and `pg_hba.conf` changes in [Using your own PostgreSQL](#using-your-own-postgresql) apply, except that the connection comes from Docker's default `bridge` network (`docker network inspect bridge` shows its subnet, usually `172.17.0.0/16`) rather than from a Compose network. - **Healthcheck:** the image doesn't define one; `docker-compose.yml` adds it. To have `docker ps` show the container's health, add the same check: ```sh --health-cmd "node -e \"fetch('http://127.0.0.1:3000/healthz').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))\"" \ --health-interval 5s --health-timeout 3s --health-retries 5 --health-start-period 10s \ ``` `docker logs knotebook` shows the server's log, including database connection errors; with `--restart unless-stopped`, a server that can't reach the database keeps restarting. **Upgrading.** Read the release's upgrade notes and back up the database when they say so. Then download the new version, remove the old container, and run the same `docker run` command with the new version; the `knotebook_uploads` volume is kept: ```sh docker pull ghcr.io/sw-willie-wu/knotebook: docker stop knotebook && docker rm knotebook docker run -d --name knotebook ... ghcr.io/sw-willie-wu/knotebook: ``` ## Environment variables Reference: `.env.example` (repo root) is the canonical source — copy it to `.env` and fill in the values below. | Variable | Required | Notes | |---|---|---| | `DATABASE_URL` | yes | Postgres connection string. With `docker compose` and the bundled database, use the `db` service name as host: `postgres://knotebook:knotebook@db:5432/knotebook`. With your own PostgreSQL, see [Using your own PostgreSQL](#using-your-own-postgresql). Outside docker, use `localhost` instead. | | `DATABASE_POOL_MAX` | no | Maximum number of database connections the server opens (default 10). | | `DATABASE_POOL_CONNECTION_TIMEOUT_MS` | no | How long a request waits for a free database connection before failing, in milliseconds (default 10000, maximum 2147483647). When every connection stays busy for that long the request fails with a server error and the server logs an error, instead of hanging. | | `APP_SECRET` | yes | 64+ hex characters (`openssl rand -hex 32`). Signs session cookies and collab tokens, and derives the keys used to encrypt stored AI provider credentials (see [AI quick actions](./ai.md)) and sign-in services' client secrets — rotating it invalidates all of those. | | `PUBLIC_URL` | yes | The externally-reachable base URL. Must be a valid `http(s)://` URL — the server refuses to start otherwise. It should be a bare origin (`scheme://host[:port]`, no path): API tokens and the OAuth endpoints that follow them derive their issuer from the origin only, and the OIDC redirect URIs are built from the origin too, so a sub-path deployment isn't supported. Anything other than a bare origin logs a warning at startup; a path, query, or fragment is dropped. **Changing it later means any app that cached the old issuer or resource has to register and authorize again** (issuer and resource identifier are derived from it) — a client that re-runs discovery keeps working: an app refreshing with the old value gets `invalid_target`; existing Bearer tokens keep working until they expire. See [Deployment prerequisites](#deployment-prerequisites) for the two supported topologies. | | `ADMIN_EMAIL` / `ADMIN_PASSWORD` | yes, on first boot (set both) | Creates the first (admin) account at startup — this is the **only** way to initialize a fresh instance; the server refuses to start on an empty/uninitialized database without both set. `ADMIN_PASSWORD` must be 12+ characters. **Only takes effect on first initialization** (empty database, not yet initialized) — once an instance is initialized, these are silently ignored on every subsequent start, with no error or warning (see [Known limitations](./known-limitations.md)). After first login, consider removing these two lines from `.env` — a plaintext password sitting there is one less secret to leak once the account already exists. | | `TRUST_PROXY` | no (**set it when you run behind a reverse proxy**) | Whether to believe `X-Forwarded-For`. Unset means the header is ignored and the socket address is used — the safe default when clients can reach the app directly. Behind a proxy, leaving it unset makes every visitor look like the proxy, so they all share one rate-limit and lockout bucket (this includes the per-IP limits on invalid API-token attempts and on the three OAuth endpoints — registration, authorization and token exchange — see [Known limitations](./known-limitations.md)). Accepts a list of trusted proxy addresses (IPs, CIDRs, or `loopback`/`linklocal`/`uniquelocal`), a hop count, or `true`. See [Deployment prerequisites](#deployment-prerequisites). | | `PASSWORD_LOGIN_FORCE_ENABLE` | no | Recovery switch, read when the server starts: `true` makes password sign-in work again while **Allow password sign-in** is off in Site admin, without changing that setting — see [SSO-only sign-in](#sso-only-sign-in). Only `true` or `false` (any letter case); empty counts as not set, and any other value stops the server from starting. Remove it once you're done. | | `PARSE_WORKERS` | no | How many parse workers read written Markdown (whole number from 1 to 32). Unset: the smaller of one less than the CPUs the server may use (at most 4) and half the memory it may use divided by `PARSE_WORKER_MEMORY_MB`, at least 1. Can also be set in Site admin → Site limits; when this variable is set, it wins and that field is locked on the page. See [Parse workers](#parse-workers). | | `PARSE_WORKER_MEMORY_MB` | no | Memory limit of each parse worker in MiB (128 to 16384, default 1024). Can also be set in Site admin → Site limits; when this variable is set, it wins and that field is locked on the page. See [Parse workers](#parse-workers). | | `PARSE_TIMEOUT_MS` | no | How long reading one write's Markdown may take, in milliseconds (100 to 2147483647, default 10000). Can also be set in Site admin → Site limits; when this variable is set, it wins and that field is locked on the page. See [Parse workers](#parse-workers). | | `PARSE_QUEUE_WAIT_MS` | no | How long a read or write may wait for a free parse worker, in milliseconds (100 to 2147483647; unset: 4 × `PARSE_TIMEOUT_MS` + 5000, which is 45000 with the default time limit). Can also be set in Site admin → Site limits; when this variable is set, it wins and that field is locked on the page. See [Parse workers](#parse-workers). | | `NOTE_MAX_BYTES` | no | The largest a note may grow to, in bytes of its stored state (a whole number from 1 to 1073741824, default 5242880 = 5 MiB). A change that would make a note larger than this, and larger than it was, is refused; notes already larger still open and read. Can also be set in Site admin → Site limits; when this variable is set, it wins and that field is locked on the page. See [Note size](#note-size). | | `BUNDLED_DB` (docker-compose.yml, not read by the server) | no | `1` (the default) starts the bundled `db` service; `0` leaves it out, for when `DATABASE_URL` points at your own PostgreSQL. Only `0` or `1`. See [Using your own PostgreSQL](#using-your-own-postgresql). | | `KNOTEBOOK_VERSION` (docker-compose.yml, not read by the server) | no | Which tag of `ghcr.io/sw-willie-wu/knotebook` the `app` service runs, written without a leading `v` (`0.11.1`, `0.11` or `latest`). Unset, a release's `docker-compose.yml` runs that release and the repository's runs `latest`. See [The app image](#the-app-image). | | `POSTGRES_PASSWORD` (docker-compose.yml, not `.env.example`) | no | Overrides the `db` service's Postgres password (default `knotebook`). Only applied by Postgres while initializing a brand-new (empty) data directory — see [Known limitations](./known-limitations.md) for how to change it after the `db` volume already exists. | Besides these five, the other limits that a site admin can adjust (upload size, request rates, how long a web sign-in lasts, API tokens per person) have no environment variable: they are set only in the browser, in Site admin → Site limits (the sign-in period and API tokens per person in Site admin → Sign-in → Session and tokens). See [Site limits](#site-limits). ## Site limits A site admin can adjust 17 limits in the browser, in **Site admin → Site limits** (15 of them) and **Site admin → Sign-in → Session and tokens** (2). A change takes effect right away, without restarting the server; nothing is cut or revoked because of it. Each row shows its default, its range and where its value comes from: the default, a value an admin saved, or — for the number of parse workers and the longest wait for a worker — an automatic value (a setting an environment variable sets shows as locked). Each section has one **Save** button that sends only the rows that changed, and a row's **Restore default** (for the two automatic settings, **Back to auto**) removes the saved value. The page also lists the 50 most recent changes: when, who (the account's email, or a note that the account no longer exists), which setting, and the old and new values. Apart from the number of parse workers, the memory per worker and the sign-in period, a limit can only be lowered: the largest value the page takes is the default (45 seconds for the longest wait for a worker). Values are whole numbers in the unit the page shows (MiB, seconds, hours, or a count). | Setting | Default | Range on the page | Applies | Where | |---|---|---|---|---| | Upload size limit | 10 MiB | 1–10 MiB | the next upload | Site limits | | Note size limit | 5 MiB | 1–5 MiB | the next write's check | Site limits | | Number of parse workers | automatic (see [Parse workers](#parse-workers)) | 1–4 | right away: workers start or stop, a busy one stops when its job is done | Site limits | | Memory per worker | 1024 MiB | 512–3072 MiB | all existing workers are replaced (an idle one at once, a busy one when its job is done) | Site limits | | Parse time limit | 10 seconds | 4–10 seconds | jobs handed to a worker afterwards | Site limits | | Longest wait for a worker | automatic: 4 × the time limit + 5 seconds (45 seconds by default) | 5–45 seconds | jobs that start waiting afterwards | Site limits | | API and MCP reads | 300 per minute | 30–300 | the next request | Site limits | | API and MCP writes | 60 per 10 minutes | 6–60 | the next request | Site limits | | Note content reads | 120 per minute | 12–120 | the next request | Site limits | | Note content writes | 30 per minute | 3–30 | the next request | Site limits | | Searches | 60 per minute | 6–60 | the next request | Site limits | | Uploads and copies | 120 per 10 minutes | 12–120 | the next request | Site limits | | AI actions | 30 per minute | 3–30 | the next request | Site limits | | Bulk downloads | 10 per hour | 1–10 | the next request | Site limits | | Bulk write-backs | 30 per minute | 3–30 | the next request | Site limits | | Web sign-in lasts | 7 days | 1 hour–30 days | sign-ins from then on | Sign-in | | Active API tokens per person | 20 | 1–20 | the next token or app authorization | Sign-in | - **Something already under way keeps the value it started with:** an upload that is already being received, a write already being checked (including a retry of it), a job already running (its time limit and its worker's memory), and how long a job already waiting for a worker may wait. The rate limits keep counting the window that is already open; lowering one to or below what someone has already used makes them wait for the rest of that window. - **Web sign-in lasts** changes only people who sign in afterwards (a changed or newly added password, and a password reset, also start a new sign-in); everyone already signed in keeps the end time they were given. **Active API tokens per person** does not revoke any token: someone who holds more than the new number just can't create another until they are below it. - **Lowering the parse time limit or the memory per worker can make content that used to be accepted fail.** The page's lowest parse time limit is 4 seconds (its highest is the default, 10: only `PARSE_TIMEOUT_MS` can raise it further), and its lowest memory per worker is 512 MiB (the 2 GiB row of the table in [Parse workers](#parse-workers)). Both can make content that used to be accepted fail, and the page says so next to them: densely formatted content (for example text broken up by many line breaks) written over MCP or the API can be refused, large notes may no longer be readable (reading and bulk downloads may take three times the time limit), and with less memory workers are also replaced more often. The environment variables keep their wider ranges (`PARSE_TIMEOUT_MS` from 100, `PARSE_WORKER_MEMORY_MB` from 128); a server that sets one outside the page's range uses that value as it is and the page shows it as outside the range it can set. - **Environment variables win.** `NOTE_MAX_BYTES`, `PARSE_WORKERS`, `PARSE_WORKER_MEMORY_MB`, `PARSE_TIMEOUT_MS` and `PARSE_QUEUE_WAIT_MS` each name the same setting as the page's row. While one is set, its row is locked and shows the value in force, and trying to change it through the API answers `409 limit_locked_by_env`. A value an admin saved earlier is kept but not applied; to manage that setting on the page, remove the variable from `.env` and restart the server. - **One server instance.** The page's values are kept in memory and read from the database when the server starts. A change made on one server is not passed on to another (see [Single-instance warning](#single-instance-warning)). - When the note size limit changes, the server logs how many notes are now over it, as it does when it starts (see [Note size](#note-size)). ## Parse workers Every write of Markdown — `edit_note` and `create_note` over MCP, `POST /api/notes` with `content`, `POST /api/notes/:id/edits`, and files sent back or created over MCP bulk transfer — is read into note content in a **parse worker**: a separate thread inside the same server process, with its own memory limit, so that a long or heavy read doesn't hold up other requests or live editing. The server starts the workers when it starts and keeps them running. All four settings below can be changed in Site admin → Site limits (see [Site limits](#site-limits)); an environment variable, when set, wins and locks its field on the page. The defaults and ranges in the table below are those of the environment variables; the page's ranges are narrower. Turning a note into Markdown and putting a write into a note happen in the parse workers too: reading a note (`GET /api/notes/:id/content`, MCP `read_note_outline` and `read_note_section`, the current content sent with a `409`, and notes fetched or downloaded over MCP bulk transfer), and putting the writes above, and reverts of them, into the note. Only small notes — whose stored state (see [Note size](#note-size)) is under 16 KiB together with what is being written — are turned around in the server's own thread: there that takes about 10–25 milliseconds, the same as the round trip to an idle worker, and it doesn't wait behind someone else's large job when the workers are busy. - A read that takes longer than `PARSE_TIMEOUT_MS`, or needs more memory than `PARSE_WORKER_MEMORY_MB`, is stopped: the worker is replaced with a new one and the write is refused with `400 unsupported_block`, its message saying the content is too complex. Nothing is written. - Turning a note into Markdown, or putting a write into a note, may take three times `PARSE_TIMEOUT_MS` (30 seconds by default) and the same memory. A read that runs out answers `500 too_complex` (over MCP, the tool error `too_complex`) — retrying won't help; putting a write or a revert into an existing note answers `500 too_complex` too (MCP: `too_complex`; a file sent back over bulk transfer: `500 too_complex`) — the note itself is too large or too complex, and retrying won't help; reading the Markdown of a write, or the content of a new note, that runs out answers `400 unsupported_block` saying the content is too complex. Nothing is written. - When every worker is busy, jobs wait in line. One person can use at most two workers at a time, and a second only while nobody else is waiting: a free worker goes to someone who has none before it goes to anyone's second job, so someone else waits at most for one job to finish. (Two, because the same person's browser download and assistant often work at once.) A job that waits longer than `PARSE_QUEUE_WAIT_MS`, or arrives when 100 are already waiting, answers `503 server_busy` with `Retry-After: 5` and writes nothing. - A write is worked out against the note as it is at that moment. If someone changes the note meanwhile, the write is still put in as long as the change didn't touch what the write depends on: the blocks of the section its `if_match` names, or — for an `if_match` of the whole note, or a file sent back — anything at all. A write without `if_match` (an `append`, a new note's content) is always put in. The result is merged with the other change: both stay. If the change did touch those blocks, the server works the write out again against the changed note, checking `if_match` again (so a changed section answers `409`); after three tries — which takes a section that keeps changing back to what it was — it answers `503 server_busy` and writes nothing — with `Retry-After: 5`, except for a file sent back, which gets no `Retry-After`. - A file sent back over MCP bulk transfer is read in several pieces (the parts it changed, and checks on where they join the rest); all of them together get at most three times `PARSE_TIMEOUT_MS`, and when that runs out — or any piece is too complex — the file is refused as too complex. - Each worker is replaced after 500 jobs, and after any job that leaves it using more than half its memory limit (a large note read and written in a row would otherwise run it out of memory). The values in effect are logged when the server starts (`parse worker settings in effect`), with where each came from (`from`, one entry for each of `workers`, `workerMemoryMb`, `timeoutMs` and `queueWaitMs`: `env`, `admin` for a value saved in Site admin → Site limits, `default`, or `auto` for the two automatic settings) — for the number of workers `workersSource` is `env`, `admin`, `cpu` or `memory` (the last two when it is automatic: which of the two limits decided) — and the CPUs and memory the server found, with where it found them (`cpuSource`, `memoryMb`, `memorySource`: `cgroup-v2`, `cgroup-v1` or `os`). The line is written after the saved values are read, so it shows what the server starts with; changes made later on the page are not logged here. Out-of-range environment values stop the server from starting. | Variable | Default | Range | |---|---|---| | `PARSE_WORKERS` | min(CPUs − 1 and at most 4, floor(memory × 0.5 ÷ `PARSE_WORKER_MEMORY_MB`)), at least 1 | 1–32 | | `PARSE_WORKER_MEMORY_MB` | 1024 | 128–16384 | | `PARSE_TIMEOUT_MS` | 10000 | 100–2147483647 | | `PARSE_QUEUE_WAIT_MS` | 4 × `PARSE_TIMEOUT_MS` + 5000 (45000) | 100–2147483647 | **How many CPUs.** The server takes the smaller of what Node.js reports (`os.availableParallelism()`) and the container's CPU quota (cgroup v2 `/sys/fs/cgroup/cpu.max`, or cgroup v1 `cpu.cfs_quota_us` / `cpu.cfs_period_us`), rounded up. The quota matters: in a container started with `--cpus=2` on a 24-core machine, Node.js 22 reports 24. Without a quota, or where there is no cgroup (not Linux), it uses what Node.js reports. **How much memory.** The container's memory limit (cgroup v2 `/sys/fs/cgroup/memory.max`, or cgroup v1 `memory.limit_in_bytes`; no limit counts as none), otherwise the machine's memory (`os.totalmem()`, which in a container is the host's). Without `PARSE_WORKERS`, the workers' memory limits together take at most half of it; with the default 1024 MiB per worker, a 2 GiB container gets one worker however many CPUs it has, and a 4 GiB one at most two. **Limiting the container.** `docker-compose.yml` has a commented-out `deploy.resources.limits` (`cpus`, `memory`) on the `app` service. Uncomment it to cap what the app may use; the defaults above then follow those limits instead of the host's. ### Choosing the values Measured on a reference machine of 2 vCPUs and 4 GiB (`docker run --cpus=2 -m 4g` with the `node:22-slim` image the app image is built on): - A worker is ready about 0.5–0.7 seconds after it starts and adds about 120 MB to the process's memory when idle, about 135 MB after its first read. - When a read uses the whole memory limit, the worker's memory reaches about the limit: with the default 1024 MiB, the process (server and one worker) peaked at about 1.2 GB. - Reading 256 KB of ordinary formatted Markdown (headings, paragraphs with bold, italic, code and links, lists, a code block, a table and a quote per section — about 1 500 blocks) took about 1.4 seconds and fits in 256 MiB; 50 KB took about 0.3 seconds, 10 KB about 0.09 seconds and 1 KB about 0.02 seconds. Going through a worker added up to about 0.08 seconds over reading in the server's own thread. - The slowest content measured that still fits the defaults is 256 KB of single words separated by `
` in one paragraph: about 7 seconds and about 640 MiB. 256 KB of alternating bold and italic words in one paragraph took about 3.7 seconds and about 768 MiB. - Turning a note into Markdown (measured on Linux, in a worker): a note of 1 MB of ordinary text took about 1 second, one of 10 MB about 10 seconds — inside the default 30 seconds — and a paragraph of 49 000 `
`-separated words (about 2 MB stored) about 4.5 seconds. Putting a 245 KB dense write into a note that already held one took about 1–3 seconds in a worker. While these ran, the server's own thread stopped for at most about 0.15 seconds for a read and 0.2–0.45 seconds for the heaviest writes, against 1–5 seconds and 0.6–2 seconds when it did this work itself. What remains in its own thread is linear work on the note's stored structure, and its cost depends on how many pieces that structure has, not only on its size — a note edited a lot, with text inserted here and there, has many more pieces than the same text typed straight through: encoding an open note to hand it to a worker (5.4 MB of stored state: about 14 ms typed straight through, about 173 ms heavily edited), for a note that wasn't open when the write started comparing it before putting the write in (0.6–80 ms for the same two), putting the result into the open note, and saving the note (which also walks it for the version history and the search index, about 30 ms per 1.5 MiB typed straight through). - Dense formatting inside quotes or `
` needs more memory the deeper it sits: 256 KB of bold words inside quotes nested 5 levels needed about 1.5 GiB; nested 20 levels, some forms needed about 3 GiB and others didn't fit in 3 GiB at all. Then: - **`PARSE_WORKERS`**: one less than the number of CPUs, leaving one for the server's own thread (requests and live editing), but no more than the memory formula below allows. More workers than CPUs don't read faster; they only share the CPUs. The default does this by itself: min(CPUs − 1 (at most 4), floor(memory × 0.5 ÷ `PARSE_WORKER_MEMORY_MB`)), at least 1. The 0.5 is a rough stand-in for the formula's other terms; the full formula below is the one to check. - **`PARSE_WORKER_MEMORY_MB`**: pick `N` (workers) and `M` (MiB) so that `N × (M + 150) + 500 (the server's own thread) + 500 (the bundled PostgreSQL, if it runs on the same machine) + 20 % ≤ the machine's memory`. The 150 is a worker's own memory (about 120–135 MB, above) with some to spare. The two 500s are allowances, not measurements: just after starting (on Linux, outside a container), the server measured about 340 MB with one worker, so about 215 MB for its own thread, and a fresh bundled PostgreSQL about 40 MiB; the rest leaves room for the notes open for live editing and for PostgreSQL's cache (128 MB by default) and connections. Use your own figures if you know them. - **`PARSE_TIMEOUT_MS`**: about 2–3 times the slowest read of a large note you want to accept, on your machine. The default of 10 seconds is about 2.7 times the 3.7 seconds of the dense bold and italic above, and still lets the 7-second `
` case through, so that what could be written before parse workers existed can still be written. Three times it — 30 seconds by default — is what turning a note into Markdown may take, about three times the 10 seconds a 10 MB note took above. It is also how long one person sending heavy content can make others' jobs wait. On a slower CPU, raise it with `PARSE_TIMEOUT_MS` (the Site limits page can only lower it). - **`PARSE_QUEUE_WAIT_MS`**: longer than the longest single job, so that a read or write waiting behind one that runs to its time limit still gets its turn. The longest job is a file sent back over bulk transfer — reading it may take three times `PARSE_TIMEOUT_MS`, plus one more for comparing the note — so unless you set it, it is 4 × `PARSE_TIMEOUT_MS` + 5 seconds (45 seconds by default) and follows `PARSE_TIMEOUT_MS`. Set it lower only if your clients give up sooner; with one worker, two jobs running to their limit ahead of a third still make the third wait out the limit. Worked out for a few container sizes (`deploy.resources.limits` in `docker-compose.yml`, or the machine itself), with the bundled PostgreSQL on the same machine: | Limits | Default `PARSE_WORKERS`: by CPU, by memory → chosen | Suggested `PARSE_WORKER_MEMORY_MB` | Memory by the formula | |---|---|---|---| | 2 CPUs, 2 GiB | 1, floor(2048 × 0.5 ÷ 1024) = 1 → **1** (`cpu`) | **512**: with the default 1024 the formula gives 1 × 1174 + 1000 = 2174 MB, + 20 % ≈ 2.6 GB, over 2 GiB | 1 × 662 + 1000 = 1662 MB, + 20 % ≈ 2.0 GB — tight; better with PostgreSQL elsewhere (1162 MB, + 20 % ≈ 1.4 GB). At 512 MiB the densest 256 KB writes above (640–768 MiB) are refused as too complex | | 2 CPUs, 4 GiB (reference) | 1, floor(4096 × 0.5 ÷ 1024) = 2 → **1** (`cpu`) | 1024 (the default) | 1 × 1174 + 1000 = 2174 MB, + 20 % ≈ 2.6 GB | | 4 CPUs, 8 GiB | 3, floor(8192 × 0.5 ÷ 1024) = 4 → **3** (`cpu`) | 1024 | 3 × 1174 + 1000 = 4522 MB, + 20 % ≈ 5.4 GB | | 8 CPUs, 16 GiB | 4, floor(16384 × 0.5 ÷ 1024) = 8 → **4** (`cpu`) | 1024, or 2048 (then by memory floor(16384 × 0.5 ÷ 2048) = 4 → still 4) | 1024: 4 × 1174 + 1000 = 5696 MB, + 20 % ≈ 6.8 GB; 2048: 4 × 2198 + 1000 = 9792 MB, + 20 % ≈ 11.8 GB | Memory decides when it is small for the CPUs: 8 CPUs and 4 GiB gives min(4, floor(4096 × 0.5 ÷ 1024) = 2) = **2** (`memory`). The CPU counts are what the server picks by itself in containers limited to those CPUs (checked with `--cpus=2`, `4` and `8`); the timings were measured on the reference machine only, so the time limits assume CPUs of similar speed. On the 8-CPU machine the formula leaves room for 2048 MiB per worker, which lets dense formatting inside quotes a few levels deep through. ## Note size A note can grow to at most 5 MiB of stored state by default. The limit is set in Site admin → Site limits (1 to 5 MiB, whole MiB; see [Site limits](#site-limits)); the `NOTE_MAX_BYTES` environment variable, when set, overrides it and locks that field (a whole number from 1 to 1073741824 bytes; out-of-range values stop the server from starting). A change applies to the next write's check. The size counted is the note's stored state — the bytes the server keeps for it in the database (`note_states.ydoc`), which hold its text, formatting and editing structure; it is larger than the note's text, and a note that was edited a lot is somewhat larger than the same text typed once. - **Only growth is refused, judged by the result.** The server applies the change to a copy of the note and refuses it when the note would be larger than the limit afterwards, larger than before, **and** either the content still in the note grew or the stored state grew by more than 4 KiB in that one change. Content is measured on the note after the change, not taken from what the change says it adds or removes: every piece of text, attribute (name and value), formatting mark, embed and other value still in the note, in the bytes the collaborative document stores it as. So deleting is always accepted, and so is replacing content with the same amount or less (shortening a paragraph, replacing a word with one of the same length or shorter) — even though each such replacement still grows the stored state by about 40 bytes of the collaborative document's bookkeeping, which the 4 KiB allowance lets through. A note already over the limit — one made before the limit existed, or before you lowered it — still opens, reads, downloads and exports as usual, and can be cut down or reworded at the same length or shorter; what it can't take is a change that adds content, including formatting text or changing an attribute to a longer value. Nothing is ever cut, moved or deleted automatically. - **Writes over MCP, the API and bulk transfer, reverts, applying a note version and copying a note** answer `413 note_too_large` (over MCP, the tool error `note_too_large`) with the limit as `limitBytes`, and write nothing. A note created with content over the limit is not created; a copy of a note over the limit is refused. - **In the browser** the limit is a soft check made by the editor; the server doesn't check the browser's live edits. Before it adds a large amount at once — pasting, dropping something in, applying the result of an AI action — the editor estimates how much the note would grow (the text being added plus a fixed amount for each paragraph, list item, table cell and other element, for each piece of formatting and for long attributes such as a diagram's source, minus what it replaces) and, if the note's current size plus that is over the limit, adds nothing and says so. The estimate is close but not exact (about 0.94–1.15 times the real growth for the shapes measured), so a note can end up modestly over the limit; typing is never blocked, and deleting always works. The current size is what the server would store: content the user deleted in this tab no longer counts, even though the editor keeps it for undo. A note that is over the limit shows a notice that editing may be slow and that deleting content shrinks it; the editor checks this when the note opens and again a few seconds after it changes. Someone with edit rights who writes their own collaboration client can make a note as large as they like (see [Known limitations](./known-limitations.md)). - **When the server starts**, it logs how many notes are already over the limit, and their ids (never their titles): `notes already over the note size limit`, or `note size limit in effect; no note is over it`. ## Sign-in providers Signing in through an identity provider (OpenID Connect) is optional; email-and-password sign-in works alongside it unless you turn it off (see [SSO-only sign-in](#sso-only-sign-in)). Admins manage sign-in services in **Site admin → Sign-in** (`/admin/login`; **Site admin** is in the user menu, shown to admins only). **Adding one:** 1. Press **Add sign-in service** and pick a template: **GitLab** (fills in `https://gitlab.com` — change it for a self-managed GitLab), **Google** (fills in `https://accounts.google.com`), or **Custom OIDC** (blank). Apart from the label shown on the service's row, a template only fills in the issuer URL and the display name, and decides the service's default icon (see **Icon** below). There is no Microsoft (Entra ID) template. **Client ID** and **Client secret** can be left empty for now. **Check issuer** reads the identity provider's configuration before you save. 2. Once it's added, the dialog shows the service's **callback URL**, `/api/auth/oidc/callback/`. Register exactly that URL on the identity provider when you create the application there — some identity providers, GitLab among them, only give you a client ID after that. (The service imported from `OIDC_*` when upgrading to v0.6 keeps the old `/api/auth/oidc/callback`.) 3. Back in **Site admin → Sign-in**, press **Edit** on the service and enter the client ID and client secret. 4. Press **Test connection**, then turn the service on. A new service starts turned off, and doesn't create accounts on first sign-in until you turn on **Creates accounts** in its row; a service without a client ID or client secret can't be turned on. When registering the client on the identity provider side: - **Grant the `email` and `profile` scopes.** Knotebook requests `openid email profile` on every sign-in — if the identity provider returns no `email` claim, neither in the ID token nor from its userinfo endpoint, nobody can sign in through it for the first time (`oidc_email_missing`). - **Make it a confidential client that allows `client_secret_post`.** Knotebook sends the client secret in the token request body, not as an `Authorization: Basic` header or a signed client assertion. - **Register the service's callback URL** exactly as shown under its row in **Site admin → Sign-in**. - **The identity provider must sign ID tokens with an asymmetric algorithm in the RS, ES or PS family** (RS256, ES256, PS256, etc.; an identity provider that lists only EdDSA isn't accepted). This is checked in two stages: if the identity provider's discovery metadata lists its ID token signing algorithms and none of them starts with RS, ES or PS (an empty list counts as none), the service is treated as unavailable before sign-in even starts (`oidc_unavailable`; **Test connection** reports `oidc_discovery_failed`); if the metadata claims asymmetric support but the ID token actually received back is signed some other way (e.g. HS256), signature verification fails during the code exchange instead (`oidc_exchange_failed`). Either way, an HS256-only identity provider isn't supported. **What Test connection checks.** It reads the identity provider's discovery document (`/.well-known/openid-configuration`, or the URL itself if it already contains `/.well-known/`) and checks that it names a signing-key location (`jwks_uri`) and, if it lists ID token signing algorithms, that at least one of them is in the RS, ES or PS family (an empty list fails); if the document can't be read or fails these checks, the test fails with `oidc_discovery_failed`. It also warns when the issuer is plain `http://`, when the identity provider doesn't list `client_secret_post`, when the service has no client ID yet, and when the saved client secret can't be decrypted (for example after `APP_SECRET` changed). It doesn't contact the token endpoint and doesn't send the client secret, so **it can't tell whether the client ID or secret is right** — a wrong one first shows up when someone tries to sign in, as an error on the identity provider's page or as `oidc_exchange_failed` when they come back. A successful test also records the issuer the identity provider reports, which the affected-account counts below rely on. **Editing.** The client secret is write-only: it's never shown again, and leaving the field blank keeps the saved one. A saved client ID can be changed but not cleared. **Changing a service's issuer clears its saved client secret and turns it off** (if you enter a new secret in the same edit, that secret is kept, but the service is still turned off): enter the secret again if needed, test, and turn it back on. If **Creates accounts** is on, changing the issuer asks whether to turn it off too. A change of issuer is logged as `登入服務的 issuer 被寫入`, with the old and new issuer reduced to scheme, host, port and path, the account that saved it, whether a secret remains, and whether **Creates accounts** is on after the change (the edit dialog sends the issuer only when you change it; through the API, every request that includes an issuer is logged, changed or not). Turning **Creates accounts** on or off is logged as `登入服務的自動建帳開關被變更`, with the account that changed it. Changing the issuer, client ID or secret makes a sign-in already in progress through that service fail when the person comes back from the identity provider (`oidc_unavailable`); changing only the display name or display order doesn't. **Icon.** A service can show an icon before its name on the sign-in buttons of the login page, on the **Link your sign-in** page, in **Settings → Sign-in → Sign-in methods**, and on its row in **Site admin → Sign-in**. Press the **Icon** button (the picture) on the row, pick one of **Template default** (the GitLab logo for the GitLab template, the Google logo for the Google template, a generic sign-in icon for Custom OIDC), **GitLab logo**, **Google logo**, **Upload an image** or **None**, and press **Save**. The service imported from `OIDC_*` uses the Custom OIDC template, so it shows the generic icon until you pick another one — choose **GitLab logo** if it's a GitLab. **Upload an image** takes a PNG, JPEG or WebP file of up to 5 MB, which the browser shrinks so its longer side is at most 128 px (a smaller image isn't enlarged) and saves as PNG; the server itself accepts only PNG, JPEG or WebP files of at most 256 KB, judged by their content. Saving any other option discards an uploaded image. A new service starts with **Template default**. Uploaded icons are served without signing in at `/api/auth/providers//icon`, also while the service is turned off. **Turning off and deleting.** Turning a service off removes its button from the login page, and a sign-in already in progress through it fails when the person comes back (`oidc_unavailable`). Accounts and the sign-ins linked to them are kept, people who are already signed in stay signed in, and turning it back on lets those people sign in with it again. Before it's turned off, Knotebook shows how many accounts (disabled ones aren't counted) are linked to it and how many of those would have no other way to sign in — no usable password (none, or **Allow password sign-in** is off) and no sign-in linked to another enabled service — and warns you when it is your own last sign-in linked to an enabled service; see [Known limitations](./known-limitations.md) for when these counts can be off. A service has to be turned off before it can be deleted. Deleting it also keeps the linked sign-ins: add a service with the same issuer again and those people can sign in with it again. Deleting the service imported from `OIDC_*` drops the old callback URL — a service you add afterwards gets its own callback URL, so the identity provider's registration has to change. **Registering the client — quick reference.** These are the identity providers' own screens, which they may rearrange; use the callback URL shown under the service's row in **Site admin → Sign-in**. - **GitLab** (gitlab.com or self-managed): create an application under **User Settings → Applications** (on a self-managed GitLab, an administrator can create an instance-wide one under **Admin Area → Applications**). Set **Redirect URI** to the callback URL, keep **Confidential** ticked, and select the scopes `openid`, `email` and `profile`. Use the **Application ID** as the client ID and the **Secret** as the client secret. The issuer URL is `https://gitlab.com`, or your GitLab's base URL. - **Google**: in the Google Cloud console, under **APIs & Services → Credentials**, create an **OAuth client ID** of type **Web application** and add the callback URL under **Authorized redirect URIs**. Use the client ID and client secret it shows — copy the client secret when it's shown, since Google may not show it in full again. The issuer URL is `https://accounts.google.com`. A couple of operational notes: - Knotebook discovers each identity provider's configuration (endpoints and supported algorithms; its signing keys are re-fetched on their own) the first time a sign-in needs it and caches it until one of that service's settings is changed in Site admin (turning it off or on counts), the service is deleted, or the server restarts; **Test connection** doesn't use or refresh this cache. If you change the identity provider's endpoints or algorithm support, turn the service off and on again, or **restart the `app` container**, to pick up the change. - An issuer URL must not contain a username or password (`https://user:pass@host`) — it is rejected in **Site admin → Sign-in**. The field is stored and shown as plain text. - Plain `http://` issuer URLs are accepted, for a trusted-internal identity provider only — the same spirit as the plain-http `PUBLIC_URL` topology above: tokens and claims travel in cleartext between Knotebook and the identity provider. A warning is shown under the service's row, and adding a service with one, or changing a service's issuer to one, logs a warning. - **Test connection** and **Check issuer** make the server send a request to the address you typed (for its discovery document). Apart from the issuer that a valid discovery document reports and whether it lists `client_secret_post` (shown as a warning), the response isn't shown, but whether the request succeeded is. **How SSO sign-in finds the account.** Knotebook recognizes a person by the identity provider's issuer and subject (`sub`) — not by email. On a first sign-in with an identity that isn't linked to any account yet: - if no account has that email (compared case-insensitively), a new account is created — unless **Creates accounts** is off for that sign-in service (see [Accounts](#accounts)), in which case the sign-in is refused; - if one account already has that email, nothing is merged automatically — the person is shown a **Link your sign-in** page and has to prove the account is theirs, either with that account's password or by signing in once more with a sign-in service already linked to it (one for a different identity provider). If the account has neither, the sign-in stops with an error asking them to contact the site administrator. Signing in through an identity provider never creates a second account for an email that already has one. The identity provider's `email_verified` claim is not used for anything. See [Known limitations](./known-limitations.md) for the full list of sign-in rough edges (no RP-initiated logout, switching identity providers, the per-IP rate limit on the sign-in endpoints, concurrent multi-tab sign-in, etc.). ## Accounts **Registering.** People can create their own account at `/register` with an email and a password, while password sign-in and **Allow self-registration with a password** are both on. There is no email verification: the account is created as soon as the form is sent, and the person is signed in. The login page shows a **Create an account** button only while that is available; when it isn't, `/register` sends the visitor back to the login page. Creating an account through a sign-in service doesn't go through `/register`: someone with no account just clicks the service's **Sign in with …** button, and the account is created on that first sign-in if the service has **Creates accounts** on. **Allow self-registration with a password** (**Site admin → Sign-in**) is on by default, including on instances upgraded from v0.5. It only governs the email-and-password form on `/register` and `POST /api/auth/register`: when it is off, `/register` sends visitors back to the login page (an invitation link still opens the invitation form) and `POST /api/auth/register` refuses (`registration_disabled`). It has no effect while password sign-in is off (**Allow password sign-in** off and `PASSWORD_LOGIN_FORCE_ENABLE` not `true`), but it can still be switched then. **Creates accounts** is a setting of each sign-in service, a column in the service table in **Site admin → Sign-in**. It decides whether someone whose email has no account yet gets one when they sign in through that service for the first time; when it is off, that first sign-in is refused (`registration_disabled`). A new service starts with it off — except a service imported from `OIDC_*` by v0.6 to v0.15, which took over the value of **Allow self-registration with a password** at the moment of the import — and when upgrading, each existing service took over the old **Allow registration** setting. Neither setting affects accounts that already exist, the account-linking page below, or invitations, and admins can still invite people while password sign-in is on. **When an email already has an account.** A first sign-in through a sign-in service whose email (compared case-insensitively) belongs to an existing account doesn't create a second account and isn't merged automatically. The person is shown **Link your sign-in** and proves the account is theirs with its password, or by signing in with another sign-in service already linked to it (see [How SSO sign-in finds the account](#sign-in-providers) for when neither is possible). Registering with a password doesn't open a separate account for that email either (`409 email_taken`). **Settings → Sign-in** has a **Sign-in methods** group that lists the sign-in services linked to the account. From there a person can: - link another sign-in service that is turned on — they sign in to it once; its email doesn't have to match; - unlink one, as long as the account keeps another way to sign in: another linked sign-in service that is turned on, or a password while **Allow password sign-in** is on (the `PASSWORD_LOGIN_FORCE_ENABLE` recovery switch doesn't count). While password sign-in is allowed, an account with no password also gets **Add a password** in **Settings → Sign-in**. Adding one doesn't ask them to sign in again; it signs out their other sessions, and their API tokens keep working. ### Resetting a password There is no email and no "forgot password" page: an admin makes a reset link and sends it to the person themselves. In **Site admin → Users**, press the key button in the person's row (**Actions** column; its name is **Reset password for …**), pick how long the link lasts (1 to 24 hours), and press **Create link**. Copy the link — it is shown once and cannot be read back. - The link works once. Creating another one for the same person replaces the previous one, and an unused link can be cancelled (**Cancel link**) from the same dialog; the dialog shows when the unused link expires. The person's current password keeps working until the link is used. - The person opens the link, sees the account's email, and chooses a new password. That signs them in and signs out their other browser sessions; their API tokens and connected apps keep working (revoke those in **Settings → Authorization** if they may have leaked). - An account with no password (SSO-only) and a disabled account can't get a link, and disabling an account revokes its unused link. - While **Allow password sign-in** is off, a link can't be created or used (`403 password_login_disabled`); a link that hasn't expired works again once it is turned back on. `PASSWORD_LOGIN_FORCE_ENABLE=true` counts as on. - The reset page asks anyone who is already signed in to sign out first, so an admin testing a link in their own browser doesn't become the other person. ### Inviting people There is no email: an admin makes an invitation link and sends it to the person themselves. In **Site admin → Users**, press **Create invitation**, pick how long the link lasts (1 to 7 days; 3 is preselected), optionally add a note (up to 100 characters; only admins see it), and press **Create link**. Copy the link — it is shown once and cannot be read back. - The link works once. The person opens it, enters their email and a password (and, if they want, a display name), and their account is created and signed in. The account is always a regular user — an invitation can't make an admin. The email isn't filled in or checked beforehand: anyone who has the link can use it with any email, so send it only to the person you are inviting. - Invitations don't depend on **Allow self-registration with a password**: they work whether it is on or off. - The **Invitations** table in **Site admin → Users** shows each one as Unused, Used (and by whom), Revoked or Expired. An unused invitation can be revoked there; a used one can't. Ones that ended (used, revoked or expired) are removed from the table 30 days after they ended; the cleanup runs when someone creates a reset link or an invitation, so it isn't exact. - While **Allow password sign-in** is off, an invitation can't be created or used (`403 password_login_disabled`), but one can still be revoked; unused invitations that haven't expired work again once it is turned back on. `PASSWORD_LOGIN_FORCE_ENABLE=true` counts as on. - Opened from an invitation link, the registration page asks anyone who is already signed in to sign out first. ### SSO-only sign-in **Allow password sign-in** (**Site admin → Sign-in**) is on by default, including on upgraded instances. Turn it off to have everyone, admins included, sign in through a sign-in service: - the login page has no email-and-password form — only the **Sign in with …** buttons (a first sign-in through a service that has **Creates accounts** on still creates the account) — there is no **Create an account** button, `/register` sends the visitor back to the login page (an invitation link says it can't be used), and the API refuses password sign-in, password registration and adding a password (`403 password_login_disabled`); - people who are already signed in stay signed in, and API tokens, connected apps and MCP clients keep working; - someone with no linked sign-in service can move over on their own: they sign in through a sign-in service with the same email for the first time and enter the account's password on the **Link your sign-in** page, which still accepts passwords; - changing a password still works, and an account that has to change its password still has to. Turning it off is refused unless at least one sign-in service is turned on and you yourself have a linked sign-in service that is turned on (`409 sso_provider_required` / `admin_sso_link_required`). While it is off, turning a sign-in service off — or changing its issuer, which turns it off — is checked the same way. Other admins aren't checked: make sure they have linked one too. **Recovery.** If password sign-in is off and nobody can sign in through SSO (the identity provider is down, the client secret expired, …): 1. Add `PASSWORD_LOGIN_FORCE_ENABLE=true` to `.env`. 2. Restart the server. Password sign-in is accepted again, for everyone, whatever the setting says — and if **Allow self-registration with a password** is on, so is registering with a password. The setting itself isn't changed, and Site admin shows a warning while the variable is `true`. 3. Sign in with your password. 4. Fix the sign-in service, or turn **Allow password sign-in** back on in Site admin. 5. Remove `PASSWORD_LOGIN_FORCE_ENABLE` from `.env`. 6. Restart again. `PASSWORD_LOGIN_FORCE_ENABLE` accepts only `true` or `false` (in any letter case); an empty value counts as not set, and any other value stops the server from starting. While it is `true`, the server logs a warning at startup. It also logs an error at startup when password sign-in is off, the variable isn't `true`, and no sign-in service is turned on. ### Adding members to a closed site When **Allow password sign-in** is off and no sign-in service creates accounts, nobody can create an account on their own and password sign-in is refused. Two ways to add someone: 1. Turn **Creates accounts** on, for a while, for the sign-in service they use; have them sign in with **Sign in with …** for the first time — that creates their account — and turn it off again. While it is on, anyone who can sign in to that identity provider with an email that has no account yet can create an account; for an identity provider only your people can sign in to, you can simply leave it on — but see [Someone can take a colleague's email](./known-limitations.md) if that identity provider lets people choose their own email. 2. First make sure **Allow self-registration with a password** is off (you can switch it while password sign-in is off) — otherwise the next step also lets anyone register with a password. Then turn **Allow password sign-in** on for a while and invite them from **Site admin → Users**. They create their account with their email and a password and link their sign-in service in **Settings → Sign-in**. Turn password sign-in off again; invitations still unused then stop working until it is back on. While it is on, everyone with a password can sign in with it. ## Storage quotas Every user's personal space and every group has a storage plan. A plan has a name and a limit in bytes (or no limit); a space's usage is the total size of the attachments in its notes, as recorded when they were uploaded or copied — note text doesn't count. A note's attachments are every file uploaded to it or copied into it, whether or not its content still shows them: removing an image from a note doesn't give its space back; permanently deleting the note does (moving it to the trash doesn't — see [Trash](#trash)). When a space is at or over its limit, no more attachments can be added to it: uploads into it are refused, and so are copies and moves into it, and group transfers to its owner, that would bring attachments. Nothing is deleted when a plan is lowered — a space that is over its new limit simply can't add attachments until it's under it again. A built-in plan named **Basic** (2 GiB) is created by the database migration, on fresh installs and upgrades alike; everyone and every group start on it. Basic can be renamed or resized like any other plan. Two defaults decide the plan of *newly created* users and groups (both Basic at first); changing a default doesn't move anyone who already exists. A plan that is a default, or that any user or group is on, can't be deleted. Site admins manage plans and the two defaults in **Site admin → Storage plans**, and pick each user's and each group's plan in **Site admin → Users** and **Site admin → Groups**, which also show how much of it is used; the same can be done through the admin API (`/api/admin/storage-plans`, `/api/admin/users/:id/storage-plan`, `/api/admin/groups/:id/storage-plan` — see [the API reference](api.md)). Everyone sees their own usage, by type, in **Settings → Storage**, and every member of a group sees the group's — by type and by member — in the group's panel (the group's `⋮` menu in the sidebar → **Members & settings**, or **View members** if your role can't manage members → **Storage**); both show a warning once the usage reaches the limit. If deleting a group with **transfer** is refused because the receiving admin's personal space has no room, give that person a larger plan, transfer to another admin, or delete the group's notes instead. If the `site_settings` row is ever removed by hand, creating users and groups fails until it's restored. ## Trash Deleting a note moves it to the trash, where it can be restored or permanently deleted. How long notes stay there is a site setting: 7 days by default, any whole number of days from 1 to 3650, or no limit (notes then stay until someone permanently deletes them). Site admins set it in **Site admin → Storage plans**, under **Trash** (or with `PATCH /api/admin/trash/settings`, see [the API reference](api.md)); a new value applies from the next clean-up pass. The server permanently deletes expired notes, with their attachments, in a background pass once an hour, the first one 5 minutes after it starts, at most 2,000 notes per pass (oldest first). A note usually outlasts its retention period by up to an hour; when more than 2,000 are due at once — after shortening the retention period, for example — it takes several passes to clear them all (see [Known limitations](./known-limitations.md)). Attachments of notes in the trash keep counting toward their space's storage plan until the notes are permanently deleted. Site admins have no special access to the trash: like everyone else, they see only their own personal notes there and the group notes that their role in a group can both edit and delete. ## Content Security Policy Knotebook serves its HTML with a Content-Security-Policy header (plus `Referrer-Policy: no-referrer` and `X-Content-Type-Options: nosniff`). It is not configurable — there is no environment variable for it. Three consequences for a self-hosted deployment: - **Scripts, stylesheets and fonts must come from Knotebook itself** (fonts may also be `data:` URLs). A reverse proxy that rewrites the page to inject an analytics snippet or a support widget (nginx `sub_filter`, Cloudflare "Rocket Loader"/apps, and similar) will have that script refused by the browser. The page still works; the injected part silently does not. - **The app's own assets cannot be moved to a separate CDN.** `/assets/*` (the JavaScript and CSS bundles) must be served from the same origin as the app. Uploaded images and other media are *not* restricted this way — `img-src`/`media-src` allow any host, so serving those from elsewhere is fine. - **Knotebook cannot be embedded in an iframe** (`frame-ancestors 'none'`), so it cannot be placed inside an intranet portal page. Images, audio and video that a note embeds by URL are deliberately *not* restricted, over both `http:` and `https:` — see [External images embedded by URL](known-limitations.md) for what that means for a reader's IP address. If one of the restrictions above blocks something you need, the escape hatch is your own reverse proxy: override or remove the `Content-Security-Policy` response header there (nginx: `proxy_hide_header Content-Security-Policy;` plus your own `add_header`). Removing it gives up the protection for every user of that instance. ## Upgrading and rolling back Database migrations run automatically at startup and are idempotent — upgrading needs no manual steps beyond these: 1. Get the new release's `docker-compose.yml`: - **with a release's files** — download the new release's `docker-compose.yml` over the old one (`curl -fLO` with the URL from [The app image](#the-app-image)). Any change you made in the old file itself is lost — move it to `docker-compose.override.yml` first (see [The app image](#the-app-image)). Keep your `.env`; download the new `env.example` too, and compare it with your `.env` for new settings. If your `.env` sets `KNOTEBOOK_VERSION`, change it to the new version or remove it. - **with a clone** — update it to the new release (for example `git pull`), and in `.env` set `KNOTEBOOK_VERSION` to the new version (for example `0.11.1`), or leave it unset to follow `latest`. 2. Run `docker compose pull`, then `docker compose up -d`. If you build from source (see [The app image](#the-app-image)), run `docker compose up -d --build` after updating your clone instead. Images are published from v0.11.1 on; the version sections below that say "`docker compose up -d --build`" predate them. To roll back to an earlier release that has an image, set `KNOTEBOOK_VERSION` to it and run the same two commands; for one before v0.11.1, check out its tag and run `docker compose up -d --build`. Rolling back to an older image after a migration has run is **schema-safe but not behavior-safe** (with one exception for the groups migration, below): newer migrations add columns with database-side defaults precisely so an older server still boots and works, but features introduced alongside the migration degrade in documented ways during the rollback window. Known degradations while running a rolled-back (pre-URL-revamp) server: - **Usernames**: accounts created during the window fall outside the username registry until the upgraded server's next startup backfills them — see the rolled-back-server username entry in [known limitations](./known-limitations.md). - **Note URLs**: the old server looks slugs up globally, so two users' notes sharing a slug (allowed after the upgrade) resolve unpredictably — an owner can 404 on their own note's URL. - **Custom slugs set during the window** are recorded without the "custom" marker, so after upgrading back, the next title change silently overwrites them with an automatic slug. - **Clearing a custom slug back to automatic breaks** during the window: the old server writes `NULL` where the new schema forbids it, returning a 500 until you upgrade again. The web UI no longer has a way to trigger this on its own — the Share dialog's custom-URL editor was removed (2026-09-17; see [Sharing](./sharing.md)) — so this degradation is now only reachable through a direct `PATCH` with `slug: null`. **The groups migration in this release (0012) can't be undone.** It changes data in ways that can't be reversed — which notes were read-only for their group, and who owned each group note, are gone. Back up the database before upgrading, for example `docker compose exec -T db pg_dump -U knotebook knotebook > knotebook-before-upgrade.sql` (with your own PostgreSQL, run `pg_dump` against it directly — see [Using your own PostgreSQL](#using-your-own-postgresql)). If you ran a build of the `main` branch from after #103 and before migration 0012 (a pre-release build with groups), that build still starts on the migrated database but can't serve notes any more — loading a note or the note list fails with a server error — because columns it reads are gone. To go back to it, restore the dump into an empty database and run that build against it: 1. `docker compose stop app` 2. `docker compose exec -T db psql -U knotebook -d postgres -c 'DROP DATABASE knotebook WITH (FORCE)' -c 'CREATE DATABASE knotebook OWNER knotebook'` 3. `docker compose exec -T db psql -v ON_ERROR_STOP=1 -U knotebook knotebook < knotebook-before-upgrade.sql` then start that build. Steps 2 and 3 are for the bundled database; with your own PostgreSQL, drop the database and create it again owned by the account in `DATABASE_URL` (`CREATE DATABASE knotebook OWNER knotebook`), then load the dump with `psql` against your server. If you're upgrading from 0.4.1 or earlier, there were no groups, so none of the data changes above apply to you. [Restoring note content from a backup](./backup-restore.md) is a different procedure: it puts one note's content back, not the whole database. **Upgrading to v0.6 (sign-in providers).** 0. **"Must change password" is now cleared only by changing the password.** An account an admin created for someone has to change its password on first sign-in. Signing in with SSO, or linking SSO to the account, no longer lets it skip that step. Accounts that already signed in with SSO before the upgrade aren't affected; accounts that haven't signed in yet, and accounts created from now on, still have to change the password once. 1. **`OIDC_*` were imported once, and are no longer read.** Versions 0.6 to 0.15 turned `OIDC_ISSUER_URL`/`OIDC_CLIENT_ID`/`OIDC_CLIENT_SECRET` on the first start into a sign-in provider named "SSO" with the same callback URL, so nothing had to change on the identity provider side. Later versions don't import them at all: the three variables are ignored (leftovers in `.env` do no harm and don't stop the server from starting; you can delete them). If you go from v0.5 or earlier straight to a version after 0.15, no "SSO" provider is created: upgrade to 0.15.x first so it is imported (That first start needs all three variables set, and valid: an issuer starting with lowercase `http://` or `https://`, without a username or password, and an issuer and client ID of at most 512 characters each (see the 0.15 documentation); otherwise nothing is imported, and they are never read again.), or set the identity provider up in **Site admin → Sign-in** (its callback URL is then `/api/auth/oidc/callback/`, which you register on the identity provider). A provider that was already imported keeps its old callback URL. 2. **Linking by verified email is gone.** Before, a first SSO sign-in whose email matched an existing account was linked to it automatically when the identity provider said the email was verified. Now identities that were already linked keep working, but a new one is never linked automatically: the person is asked to prove the account is theirs (its password, or another sign-in service already linked to it). 3. **Registration is open after the upgrade.** **Allow registration** starts on, upgraded instances included. A first sign-in through a sign-in service still creates an account, as before; what's new is a password registration page (`/register`) that anyone who can reach the site can use. If you don't want that, turn **Allow registration** off in **Site admin → Sign-in** — that also stops first sign-ins through a sign-in service from creating accounts. 4. **Allow password sign-in starts on**, so password sign-in works as before. To use sign-in services only, turn it off in **Site admin → Sign-in** — read [SSO-only sign-in](#sso-only-sign-in) and its recovery steps first. 5. **Rolling back to v0.5:** put the three `OIDC_*` variables back into `.env` first — v0.5 only knows SSO from them. Identities linked after the upgrade aren't visible to v0.5, and v0.5 links by verified email again while it runs; whatever it links is picked up again on the next upgrade. So an SSO identity created or linked after the upgrade can sign in under v0.5 only if the identity provider marks its email as verified. When you upgrade again, remove the `OIDC_*` variables you put back — they aren't read. **Body-text search.** The first start after upgrading to a version with body-text search indexes the existing notes in the background, after the server is already answering requests. The `app` log shows `全文索引回填開始` when that starts and `全文索引回填完成` when it is done; until then, `search_notes` finds a note it hasn't got to yet by its title only. Rolling back is harmless: an older server ignores the two tables that hold the index, `note_search_sections` and `note_search_state`. When you upgrade again, the first start indexes again whatever was edited or created while the older server ran, because the index has no entry for them, or one built from an older version. **The trash (migration 0033).** 1. **Migration 0033 only adds columns, an index and constraints** — who moved a note to the trash, and the site's retention period for the trash (7 days to start with); existing notes and settings are left as they are. Back up the database first as usual, for example `docker compose exec -T db pg_dump -U knotebook knotebook > knotebook-before-upgrade.sql`. 2. **Rolling back:** the previous version doesn't know about the trash, so notes in the trash reappear as ordinary notes — their shares and public links work again — but they keep their trash marks (`deleted_at` and `deleted_by`). Pick one of two ways: - **To keep them as ordinary notes**, clear the marks after rolling back and **before upgrading again**: ```sh docker compose exec -T db psql -U knotebook knotebook -c "UPDATE notes SET deleted_at = NULL, deleted_by = NULL WHERE deleted_at IS NOT NULL" ``` - **To get rid of them**, do it **before rolling back**: each person empties their own trash (a site admin can't see anyone else's), or you delete them in the database. In that case, first list their attachments, which are files named by their id in the `uploads` volume (`/app/uploads` in the `app` container) and have to be removed from it separately, then delete the notes: ```sh docker compose exec -T db psql -U knotebook knotebook -c "SELECT u.id FROM uploads u JOIN notes n ON n.id = u.note_id WHERE n.deleted_at IS NOT NULL" docker compose exec -T db psql -U knotebook knotebook -c "DELETE FROM notes WHERE deleted_at IS NOT NULL" ``` With your own PostgreSQL, run the same SQL with `psql` against it directly. 3. ⚠ **If you do neither and upgrade again**, every note that still has `deleted_at` set — including ones opened and edited while the previous version ran — goes back into the trash. Those whose original deletion time is already past the retention period are permanently deleted, with their attachments, starting with the first clean-up pass, 5 minutes after the server starts (at most 2,000 per pass, then one pass an hour); edits made to them while rolled back are lost without warning. **Folders (migration 0034).** 1. **Migration 0034 only adds tables, columns, indexes, triggers and the functions they run**: the `folders` table, the `note_placements` table (where each person filed the notes shared with them), two columns on `notes` that can be empty — `folder_id` (the folder a note is in) and `trash_folder_path` (the folder path kept for a note in the trash) — with their indexes and constraints, and three triggers, each with its own function, that keep notes, folders and filed shared notes in the right space. Nothing is filled in: every existing note starts at the top level. Back up the database first as usual, for example `docker compose exec -T db pg_dump -U knotebook knotebook > knotebook-before-upgrade.sql`. 2. **Rolling back:** the previous version doesn't know about folders, so it lists every note without them. The folders, and where notes are filed, stay in the database and are back after upgrading again — except for what the previous version changes while it runs: - a note moved into a group is taken out of its folder, and is at the group's top level after upgrading again; - deleting a group, with **Give them to an admin** or **Delete everything**, deletes the group's folders too — with **Give them to an admin**, its notes are at the top level of that person's notes after upgrading again; - removing a share, or moving a note into a group, removes where the people it was shared with had filed it; - notes created or copied while rolled back are at the top level. A note taken out of the trash while rolled back — by clearing its trash marks as described under **The trash** above — is in its folder after upgrading again if that folder still exists, otherwise at the top level. 3. **The triggers and their functions stay in the database while rolled back** and don't make any of the previous version's writes fail: when it moves a note to another space (into a group, or in a group transfer), they only take the note out of its folder, as described above. **The `notes:delete` scope (migration 0035).** 1. **Migration 0035 only widens the `scope` checks** on `api_tokens`, `oauth_requests` and `oauth_codes`, so they also allow `notes:read notes:write notes:delete` and `notes:read notes:write notes:move notes:delete`. It changes no stored value, so no existing token or app gets `notes:delete`. Back up the database first as usual. 2. **MCP clients:** after an MCP client is restarted, every credential's tool list has `list_trash`; `delete_note` and `restore_note` appear only for a credential with `notes:delete`. To allow them, turn on "Move notes to the trash and restore them" (it needs "Create and edit notes" on as well) for the token or app with the pencil on its row in **Settings → Authorization → API tokens**, then restart the MCP client; or authorize the app again and tick it (the box appears only if the app asks for `notes:delete`; an MCP client that follows the `/api/mcp` challenge does). 3. **Rolling back:** the wider checks stay, and the previous version doesn't mind them — it only stores the three scopes it knows. It treats a credential with `notes:delete` as if it didn't have it: `notes:read notes:write notes:delete` as read-write, and `notes:read notes:write notes:move notes:delete` as read-write with `notes:move`. Changing such a credential's access while the previous version runs drops `notes:delete`. An app authorization that asked for `notes:delete` and is still pending when you roll back (a request lasts 10 minutes) is granted only `notes:read` if it is approved on the previous version; authorize the app again to give it more. Upgrading again doesn't run 0035 a second time; credentials keep the scope they have. **MCP bulk transfer (migrations 0036 and 0037).** 1. **Migration 0036 only adds tables and columns and changes checks.** `transfer_tokens` gets two columns, `access` and `range`, its `note_id` may now be empty, and its checks allow the new purpose `bulk` and tie those three columns to it. Two tables are new: `transfer_bases`, which holds what the server keeps of each copy of a note someone downloaded as a file — hashes of its lines and of its blocks, not its text — to tell what a file sent back changed and whether the note has changed since, and `transfer_creates`, which records which file created which note, to recognise the same file sent again. The checks on the AI edit history (`note_ai_edits`) allow the new `op` `file_sync`. Existing rows are left as they are. Back up the database first as usual, for example `docker compose exec -T db pg_dump -U knotebook knotebook > knotebook-before-upgrade.sql`. 2. **Migration 0037 only adds one column.** `transfer_bases` gets `applied_edit_ids`, a list of AI edit history row ids that can be empty: the rows a downloaded copy's file wrote when it was sent back, so that sending the same file again after one of them was reverted answers `409 base_unavailable` with `reason: "reverted"`. It fills nothing in for existing rows; for them the column is empty, which counts as nothing reverted. 3. **MCP clients:** after an MCP client is restarted, a token or app credential on a deployment with the collaboration component gets `open_notes_as_files` (see [MCP](mcp.md#open_notes_as_files)). No credential's scope changes. In v0.15 the tool was called `create_bulk_transfer_token`: prompts or scripts that name it must use `open_notes_as_files` (see the [CHANGELOG](../CHANGELOG.md)). 4. **Rolling back to v0.14:** the new columns, tables and checks of both migrations stay, and v0.14 doesn't read them. It has no `/api/transfer/*` endpoints, and it refuses a bulk transfer token on its upload and download endpoints with `403`. - ⚠ **Don't revert the AI edit history rows of files sent back over MCP while v0.14 runs.** In v0.14, a `file_sync` row for a part a file rewrote or added can show a **Revert** button, and reverting it reports success but puts nothing back; the row is then marked as reverted, so it can't be reverted after upgrading again either. To undo such a write, upgrade again first, then revert it. (In the web app the revert shows "Edit reverted."; over the API it answers `201`.) A `file_sync` row for a part a file only removed shows as not revertable in v0.14, and reverting it over the API answers `409 stale` without changing anything. The same warning is in [Known limitations](./known-limitations.md#sending-notes-back-as-files-mcp-bulk-transfer) and the [CHANGELOG](../CHANGELOG.md). **Server-side sessions (migration 0038).** 1. **Everyone has to sign in again once.** Signing in now records a session on the server (one row in the new `sessions` table each time someone signs in), and the session cookie names it. Cookies from before the upgrade don't name one, so the server treats them as signed out. API tokens and connected apps keep working. 2. **Migration 0038 only adds the `sessions` table** (who, when it was created, when it expires) with two indexes; a row is deleted with its account. It fills nothing in. Back up the database first as usual, for example `docker compose exec -T db pg_dump -U knotebook knotebook > knotebook-before-upgrade.sql`. 3. **Signing out signs out that browser only.** It deletes that browser's session, so its cookie stops working even if it was copied; the account's other browsers stay signed in, and a note that browser had open stops accepting its edits. Signing in again to the same account in a browser that is still signed in ends the session it had before too. Notes that browser already has open are checked again about 10 seconds later, by when the browser has the new cookie, so they stay open on the new sign-in; a note opened elsewhere with a copy of the old cookie stops accepting its edits within about 15 seconds. Changing or adding a password still signs out every other browser, and disabling an account deletes all of its sessions. A session lasts 7 days from signing in by default, as before (a site admin can change it, from 1 hour to 30 days, for sign-ins from then on; see [Site limits](#site-limits)); expired rows are deleted as people sign in. 4. **Rolling back:** the `sessions` table stays and the previous version doesn't read it, so cookies made after the upgrade keep working there — including copies of cookies that were signed out after the upgrade, which work again on the previous version until they expire — and signing out goes back to only clearing the cookie. After upgrading again, cookies made while rolled back count as signed out, so everyone signs in once more. **Site limits (migration 0039).** 1. **Migration 0039 only adds two tables**, `site_limits` (the values saved in Site admin → Site limits) and `site_limit_changes` (the list of changes). It fills nothing in and changes no existing row. Back up the database first as usual. 2. **Nothing changes until an admin changes it.** Every limit starts at the value it had before, and the five environment variables (`NOTE_MAX_BYTES`, `PARSE_WORKERS`, `PARSE_WORKER_MEMORY_MB`, `PARSE_TIMEOUT_MS`, `PARSE_QUEUE_WAIT_MS`) still work and still win: each one locks its row on the page. To manage one of them on the page instead, remove it from `.env` and restart the server. See [Site limits](#site-limits). 3. **The parse worker startup log line** (`parse worker settings in effect`) is now written after the saved values are read; its `from` entries can also be `admin`, and the number of workers and the longest wait for a worker now report `auto` instead of `default` when they are automatic; `workersSource` can also be `admin`. 4. **Rolling back:** the previous version doesn't read the two tables, so its limits go back to the environment variables and its built-in values; the tables stay, and after upgrading again the saved values apply again. **Upgrading to v0.15 (MCP bulk transfer, parse workers, a note size limit).** 1. **Back up the database first.** Migrations 0036 and 0037 run automatically when the new server starts. They only add tables and columns, change checks and let `transfer_tokens.note_id` be empty, and leave existing rows as they are (see **MCP bulk transfer** above), but take a dump before you start the new version anyway, for example `docker compose exec -T db pg_dump -U knotebook knotebook > knotebook-before-upgrade.sql` (with your own PostgreSQL, run `pg_dump` against it directly — see [Using your own PostgreSQL](#using-your-own-postgresql)). Coming from v0.13 or earlier, read the sections for the versions in between as well. 2. **Check the memory for parse workers.** The server process now runs parse workers, which use memory of their own: about 120 MB each when idle, and up to about `PARSE_WORKER_MEMORY_MB` (1024 MiB by default) while reading heavy Markdown. On a 2 GiB machine, set `PARSE_WORKER_MEMORY_MB` to 512. [Parse workers](#parse-workers) has the formula and worked values; the new settings are in `.env.example` (`env.example` among a release's files). 3. **A note can grow to at most `NOTE_MAX_BYTES`** (5 MiB of stored state by default). Notes already larger still open, read, download and export, and nothing is cut; when it starts, the server logs how many notes are already over the limit, and their ids. See [Note size](#note-size). 4. **Restart your MCP clients** so they list `open_notes_as_files` (see item 3 of **MCP bulk transfer** above). 5. **Scripts that read or write notes over MCP or the REST API:** `edit_note`'s `rename` now needs `if_match`; Markdown with some HTML elements (`