docs/self-hosting.md
Rama main · réplica de lectura del repositorio original.
# 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` (`<PUBLIC_URL>/api/auth/oidc/callback/<service id>`; the service imported from `OIDC_*` on upgrade keeps `<PUBLIC_URL>/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://<lan-ip-or-hostname>: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://<your-domain>/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:<password>@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:<password>@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 <project>_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 '<your DATABASE_URL>' < 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 <project>_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 '<your DATABASE_URL>' > 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:<version>
```
- `<version>` 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:<new version>
docker stop knotebook && docker rm knotebook
docker run -d --name knotebook ... ghcr.io/sw-willie-wu/knotebook:<new version>
```
## 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 `<br>` 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 `<br>`-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 `<details>` 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 `<br>` 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**, `<PUBLIC_URL>/api/auth/oidc/callback/<service id>`. 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 `<PUBLIC_URL>/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 (`<issuer>/.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/<service id>/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 `<PUBLIC_URL>/api/auth/oidc/callback/<service id>`, 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 (`<script>`, `<style>`, `<iframe>` and others), with a paragraph inside another paragraph, nested more than 128 HTML elements deep, or that takes longer than `PARSE_TIMEOUT_MS` or more memory than `PARSE_WORKER_MEMORY_MB` to read is refused; reading a note can now fail with `too_complex` or `server_busy`, and gives slightly different Markdown; and the `message` of the HTTP API's errors is now in English (check `code` instead). The upgrade notes in the [CHANGELOG](../CHANGELOG.md) list them; see also [AI editing](ai-editing.md#markdown-round-trip).
6. **Rolling back** to v0.14.1 (set `KNOTEBOOK_VERSION=0.14.1` and run `docker compose pull`, then `docker compose up -d`): the columns, tables and checks of both migrations stay, and v0.14 doesn't read them. ⚠ Read item 4 of **MCP bulk transfer** above first: don't revert the AI edit history rows of files sent back over MCP while v0.14 runs.
**Upgrading to v0.14 (folders in the web app, folder tools over MCP, side-by-side columns and sizes over MCP).**
1. **There is no database migration.** The newest migration is still 0035, from v0.13.0, so nothing in the database changes. Backing up first is still a good habit, 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.12 or earlier, read the sections for the versions in between as well.
2. **Restart your MCP clients** so they list the new folder tools: every credential gets `list_folders`, a credential with `notes:write` also gets `create_folder`, `rename_folder`, `move_folder` and `move_note_to_folder`, and one with `notes:delete` also gets `delete_folder` (see [MCP](mcp.md#list_folders)). No credential's scope changes.
3. **Scripts that read or write notes over MCP or the REST API:** a note with side-by-side columns, a resized image or table column widths reads differently, and some writes that used to be accepted are now refused; `DELETE /api/folders/:id` also refuses some bodies it used to ignore. The upgrade notes in the [CHANGELOG](../CHANGELOG.md) list them; see also [Columns](ai-editing.md#columns) and [Sizes](ai-editing.md#sizes).
4. **Rolling back** to v0.13.0 (set `KNOTEBOOK_VERSION=0.13.0` and run `docker compose pull`, then `docker compose up -d`) is harmless for the database, since nothing in it changed. Folders, and where notes are filed, stay; v0.13.0's web app doesn't show them and its MCP has no folder tools, so while it runs they are reachable only through the API (see [the API reference](api.md)).
**Upgrading to v0.13 (the trash, folders, invitations and password reset links, a per-service Creates accounts setting).**
1. **Back up the database first.** Nine migrations, 0027 to 0035, run automatically, in that order, when the new server starts; take a dump before you start the new version, 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.11 or earlier, read the sections for the versions in between as well. What each one does:
- **0027** adds a table for username renames (so the limit of 5 per rolling 24 hours is counted there), records in it one rename for each name released in the last day, and deletes every released name — a renamed-away username can now be taken by anyone, you included.
- **0028** adds a public page width to notes (Standard, Wide or Full width; Standard for every existing note).
- **0029** lets a sign-in service have no client ID yet; a service without one can't be turned on.
- **0030** adds the `one_time_links` table, for password reset links.
- **0031** adds each sign-in service's **Creates accounts** setting and sets it, for every existing service, to the value **Allow registration** had before the upgrade, so sign-in behaves as before. Services added afterwards start with it off (a service imported from `OIDC_*` takes over **Allow self-registration with a password** instead).
- **0032** extends `one_time_links` to invitations (see [Inviting people](#inviting-people)).
- **0033** adds the trash: a column recording who moved a note to the trash, a setting for how many days notes stay there (7 by default), and their constraints and index. Existing notes stay as they are.
- **0034** adds folders: the `folders` and `note_placements` tables, two nullable columns on `notes` (`folder_id`, `trash_folder_path`), their indexes and constraints, and three triggers. Existing notes stay at the top level. The web app doesn't show folders yet; they are reachable through the API (see [the API reference](api.md)).
- **0035** widens the scope checks on API tokens and OAuth requests and codes so they also allow `notes:delete`. It changes no stored value.
2. **Reload every open tab.** Tabs opened before the upgrade ask to be reloaded; edits in them that hadn't synced are lost on reload.
3. **Existing tokens and apps don't get the new `notes:delete` scope** (move notes to the trash and restore them over MCP). Turn it on with the pencil on the token's or app's row in **Settings → Authorization → API tokens**, or authorize the app again and tick it; it can only be turned on together with `notes:write`. Then restart the MCP client so it lists `delete_note` and `restore_note`.
4. **Deleting a note now moves it to the trash**, where it stays for 7 days unless a site admin changes it under **Site admin → Storage plans → Trash** (or turns automatic deletion off); see [Trash](#trash). Notes in the trash still count toward storage until they are permanently deleted.
5. **Sign-in services and accounts changed.** **Allow registration** is now **Allow self-registration with a password** and only governs registering with an email and a password; whether a sign-in service creates an account on someone's first sign-in is now each service's own **Creates accounts** setting (see [Sign-in providers](#sign-in-providers)). **Site admin → Users → Create user** (an account with a temporary password) is gone: use an invitation ([Inviting people](#inviting-people)), or a reset link for an existing account ([Resetting a password](#resetting-a-password)).
6. **Rolling back** to v0.12.0 (set `KNOTEBOOK_VERSION=0.12.0` and run `docker compose pull`, then `docker compose up -d`) has consequences — back up first:
- **Side-by-side columns are deleted**, together with what is in them, the first time a note that has them is opened on the old version.
- **Trash (0033):** the old version doesn't know about the trash, so notes in it reappear as ordinary notes (with their shares and public links working again) but keep their trash marks. Before upgrading again, either run `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 keep them, or empty the trash before rolling back. Otherwise, after upgrading again they go back to the trash, and those past the retention period are permanently deleted — with their attachments and any edits made while rolled back — starting with the first clean-up pass about five minutes after the server starts (up to 2,000 notes per pass, one pass an hour).
- **Folders (0034):** the old version shows every note without folders; folders and filed notes stay in the database and come back after upgrading again. While rolled back, moving a note into a group puts it at the group's top level; deleting a group, with **Give them to an admin** or **Delete everything**, also deletes the group's folders, and with **Give them to an admin** its notes end up at the top level of that person's notes; and removing a share removes where that person had filed the note.
- **Sign-in services (0031):** the old version doesn't read **Creates accounts**; there, a first sign-in through a sign-in service creates accounts according to **Allow registration** for every enabled service at once, which can be more permissive than after the upgrade. If **Allow self-registration with a password** is on and **Creates accounts** is off for some open identity provider, turn **Allow registration** off on the old version first.
- **Invitations (0032):** an unused invitation can't be used while rolled back, and **Site admin → Users** creates accounts with a temporary password again; accounts already made from an invitation keep working.
- **Scopes (0035):** the wider checks stay and the old version doesn't mind them, but it treats a credential with `notes:delete` as if it didn't have it, and changing such a credential's access there drops `notes:delete`. An app authorization that asked for `notes:delete` and is still pending when you roll back is granted only read access if approved on the old version; authorize the app again.
7. **Upgrading again after a rollback:** check **Creates accounts** on each sign-in service — especially any whose issuer was changed during the rollback — remember that services added during the rollback start with it off, and check whether **Allow self-registration with a password** needs to be turned back on. Invitations that haven't expired work again.
**Upgrading to v0.12 (prebuilt images).**
1. **There is no database migration.** The newest migration is still 0026, from v0.11.0, so nothing in the database changes. Backing up first is still a good habit, 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.10 or earlier, read the sections for the versions in between as well.
2. **In a clone, `docker compose up -d` now runs the published image instead of building one.** The repository's `docker-compose.yml` now names the `app` image `ghcr.io/sw-willie-wu/knotebook`, with the tag set by `KNOTEBOOK_VERSION` in `.env`, `latest` if unset (see [The app image](#the-app-image)). After updating your clone (for example `git pull`), set `KNOTEBOOK_VERSION=0.12.0` in `.env` — recommended, so the version you run only changes when you change it — or leave it unset to follow `latest`, then run `docker compose pull` and `docker compose up -d`. The `app` container is re-created from the published image; the `db_data` and `uploads` volumes are kept. If you follow `latest`, run `docker compose pull` on every later upgrade too: `docker compose up -d` alone keeps the `latest` the machine already has. The image earlier versions built in your clone — named after the project, the directory name unless you set one, followed by `-app` (for example `knotebook-app`) — is then no longer used (unless you keep the old setup, item 4); `docker image rm <project>-app` (for example `docker image rm knotebook-app`) removes it.
3. **To keep building from source**, run `docker compose up -d --build` after updating your clone, as before. The image it builds is now stored as `ghcr.io/sw-willie-wu/knotebook` with the `KNOTEBOOK_VERSION` tag (or `latest`) instead of the name earlier versions gave it, so don't run `docker compose pull`: it replaces your build with the published image. And `docker compose up -d` without `--build`, on a machine that doesn't have that tag yet (after you change `KNOTEBOOK_VERSION`, for example), downloads the published image instead of building one. Two clones on one machine that both build from source with the same `KNOTEBOOK_VERSION` (or both without one) now store their builds under the same name and tag, so a build in one replaces the other's; before v0.12 each clone's build had its own name (`<project>-app`). To keep them apart, use `image: !reset null` (item 4) in each.
4. **If you have your own override file** (`docker-compose.override.yml`, or a file you pass with `-f`): Compose merges it over `docker-compose.yml`, so unless it sets `image:` itself the new `image:` applies with it too — an override that only changes ports, environment or volumes now runs the published image, as in item 2. An override that sets its own `image:` replaces the new one, and `app` keeps using the name your override gives. To keep the setup of v0.11.1 and earlier — the image built in your clone under the project's name — add `image: !reset null` to the `app` service in your override:
```yaml
# docker-compose.override.yml
services:
app:
image: !reset null
```
With it, the merged configuration is the same as with v0.11.1's `docker-compose.yml`; build with `docker compose up -d --build`, as before. `!reset` needs Docker Compose v2.18.0 or newer (`!override`, above, needs v2.24.4); `docker compose config --images` shows which image `app` will run — with this override, `<project>-app`.
5. **Open tabs keep working without a reload** when you upgrade from v0.11.0 or v0.11.1: the server's code is unchanged since v0.11.0 apart from its version number, and so is the way notes are stored, so the editor in a tab opened before the upgrade reconnects by itself. The web app did change, though: such a tab keeps the interface of the version it was opened on — for example the code block and diagram controls in their old place, and, coming from v0.11.0, the old way links behave in the editor and the version panel's resize handle inside the panel — until you reload it, and the first time it needs a part of the page it hasn't loaded yet, it can reload by itself once or leave code blocks without syntax highlighting — see [Known limitations](./known-limitations.md).
6. **Moving from a clone to a release's files** (see [The app image](#the-app-image)) is optional. If you do:
- Copy your existing `.env` — and `docker-compose.override.yml`, if you have one — into the new directory instead of starting again from `env.example`. A new `APP_SECRET` signs everyone out and makes the stored AI provider keys and sign-in services' client secrets unreadable (see [Environment variables](#environment-variables)), and with the bundled database, `DATABASE_URL` has to keep the password the database was created with: `POSTGRES_PASSWORD` only applies to an empty data directory.
- Run Compose under the same project name as before — by default the name of the directory holding `docker-compose.yml` — or it starts with new, empty `db_data` and `uploads` volumes: no uploaded images and, with the bundled database, an empty database.
7. **Rolling back** to v0.11.1 is harmless, since nothing in the database changed: set `KNOTEBOOK_VERSION=0.11.1` and run `docker compose pull`, then `docker compose up -d`. `KNOTEBOOK_VERSION` doesn't apply when your override sets `image:` itself or uses `image: !reset null` (item 4). If your override's `image:` names a published image (pinned to `ghcr.io/sw-willie-wu/knotebook:0.12.0`, for example), change its tag to `0.11.1` and run `docker compose pull`, then `docker compose up -d` — don't build, which would store v0.11.1's source under the `0.12.0` name. Otherwise check out `v0.11.1` in your clone and run `docker compose up -d --build`.
**Upgrading to v0.11 (links to notes, the Instruct AI action).**
1. **Back up the database first.** Migrations 0024, 0025 and 0026 run automatically, in that order, when the new server starts; 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)). 0024 and 0025 are described in the [CHANGELOG](../CHANGELOG.md); 0026 below.
2. **Reload every open tab.** Tabs opened before the upgrade can't connect to the new server and keep retrying ("Connecting…", then an offline warning); edits in them that hadn't synced are lost on reload.
3. **The Instruct AI action is on after upgrading.** The first start adds it, enabled, with no model of its own, so on a site where AI actions already work every editor can send free-form instructions right away; turn it off in **Site admin → AI agents** if you don't want that.
4. **A one-time conversion runs in the background.** Migration 0026 records every note that exists at that moment. About 30 seconds after the server starts, it goes through them one at a time and turns ordinary links to notes on this site into links to those notes, with the note owner's permissions (in a group note: only links to notes in the same group). Before changing a note, it saves a version named `連結轉換前` — or, if the note's content is already its base version, turns that version into a manual one, keeping its name if it has one (see [Versions](./versions.md)). The `app` log shows `站內連結轉換:開始` when it starts and `站內連結轉換:結束` with counts (`scanned`, `notesConverted`, `linksConverted`, `unresolved`, `retry`, `failed`, `ms`) when it is done; a note that fails is logged as `站內連結轉換:單篇失敗` with the note's id, the step and the error's kind (and its code, if it has one) — not its message, so not the note's links or text. Stopping the server partway is safe: the next start carries on. A note that fails is tried again on later starts, up to three attempts in all; after the third, a `站內連結轉換:第 3 次失敗` line is logged at error level and the note isn't tried again by itself.
5. **To run it again for one note**, mark it pending — `docker compose exec -T db psql -U knotebook knotebook -c "UPDATE note_link_conversion SET status = 'pending', attempts = 0 WHERE note_id = '<note id>'"` — then restart the `app` container with `docker compose restart app`. To list the notes it hasn't finished: `SELECT note_id, status, attempts FROM note_link_conversion WHERE status <> 'done'`. With your own PostgreSQL, run the same SQL with `psql` against it.
6. **Before rolling back, disable the Instruct action** in **Site admin → AI agents**: v0.10 lists it as an ordinary quick action that only fills in `{{text}}`, so the literal `{{instruction}}` and `{{focus}}` would reach the model, without the guard. Rolling back to v0.10 is harmless for the database — v0.10 doesn't read `note_link_conversion` — but v0.10 doesn't know a link's own text or the paragraph it points to. On v0.10, moving or splitting a block that holds such a link (or moving one next to it), or copying and pasting the link, can drop them, and MCP and the API read the link as `[[…]]` with the title saved in the link, so writing that back binds it again by title alone, if at all; v0.10 also doesn't ask tabs opened on v0.11 to reload, so they can still connect and edit. When you upgrade again, 0026 doesn't run a second time and the conversion only picks up notes still waiting for it (`pending` or `retry`), so it doesn't convert ordinary links to notes added on v0.10 in any other note, including notes created on v0.10.
**Upgrading to v0.10 (note ids in any letter case, stricter MCP input, a separate scope for moving notes into groups, last sign-in time, group storage counted by who brought files in).**
1. **Back up the database first.** The migrations below run automatically when the new server starts; 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)).
- **0020 (drop note self-links)** deletes rows of `note_links` that record a note as linking to itself. A note id written with uppercase letters could create them; they were never correct.
- **0021 (`notes:move` scope)** replaces the `scope` checks on `api_tokens`, `oauth_requests` and `oauth_codes` with wider ones that also allow `notes:read notes:write notes:move`. It changes no stored value, so no existing credential gets `notes:move`.
- **0022 (last sign-in time)** adds a column to `users` that records when each account last signed in (best effort: a sign-in that races with another change to the same `users` row can go unrecorded). Nothing is filled in for existing accounts: until someone signs in again, `GET /api/admin/users` reports `lastLoginAt: null` for them, the same as for an account that has never signed in. In **Site admin → Users** the **Last sign-in** column shows **Never** for them until then.
- **0023 (who takes up group space)** adds a column to `uploads` that records who brought each file into its space — whoever uploaded it, or whoever moved or copied its note into a group; a group's **Storage → By member** counts by it. It is filled in with whoever uploaded each existing file. Moves made before the upgrade weren't recorded, so files of notes moved into a group before then still count for the people who uploaded them; copies made before the upgrade already count for the person who copied them.
2. **MCP clients and scripts:** the seven tools that used to ignore an argument they don't know (all but `create_note`, `move_note_to_group` and `copy_note`) now reject it, so a call with an extra or misspelled argument fails the input check and does nothing — fix the argument's name.
3. **Tokens and apps:**
- **Moving and copying notes into groups need the new `notes:move` scope.** Existing tokens and apps no longer get `move_note_to_group`, and their `copy_note` refuses `group_id`. To allow it, turn on "Move or copy notes into groups" (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 so it picks up `move_note_to_group`; or authorize the app again and tick both (the second box appears only if the app asks for `notes:move`; an MCP client that follows the `/api/mcp` challenge does).
- **Apps you authorize for the first time after the upgrade are read-only** unless you tick "Create and edit notes" on the consent page; re-authorizing an app starts from the access it already has, never more than it is asking for this time.
- Restart an MCP client after changing its credential's access, so that it lists the right tools. A consent page left open from before the upgrade fails when you press Allow; reload it and choose again.
4. **The bundled database container is re-created once.** Its healthcheck changed, so the first `docker compose up -d` with the new `docker-compose.yml` replaces the `db` container; the data in the `db_data` volume is kept. Using your own PostgreSQL instead is new in this version — see [Using your own PostgreSQL](#using-your-own-postgresql).
5. **Rolling back** to v0.9:
- **0020:** harmless — no table or column changed, and the deleted rows were wrong. While v0.9 runs it can store such self-links again; upgrading again doesn't run 0020 a second time, so those stay until that note's set of links is next saved, which replaces the whole set.
- **0021:** the wider checks stay, and v0.9 doesn't mind them — it only stores the two scopes it knows. v0.9 has no `notes:move`, so while it runs every credential with `notes:write` can move notes into groups and copy them there again, and a credential that has `notes:move` works as an ordinary read-write one. Access you lowered with `PATCH /api/auth/tokens/:id` stays lowered. Upgrading again doesn't run 0021 a second time; credentials keep the scope they have.
- **0022:** harmless — v0.9 doesn't read or write the new column. Sign-ins while v0.9 runs aren't recorded in it, and upgrading again doesn't run 0022 a second time, so those accounts keep the time of their last sign-in before the rollback until they next sign in.
- **0023:** v0.9 doesn't read or write the new column. Files added while v0.9 runs — uploaded, or copied with a note — have no one recorded, and upgrading again doesn't run 0023 a second time, so in a group's **By member** they count under **Deleted user** until their note is next moved into a group, which counts every file of the note for the person who moves it. Notes moved into a group while v0.9 runs don't change it: their files keep counting for whoever they counted for before (usually the person who uploaded them), as for moves made before the upgrade.
- **`docker-compose.yml`:** going back to v0.9's file re-creates the bundled `db` container once more (its healthcheck changes back), keeping the data in `db_data`. v0.9's file doesn't know `BUNDLED_DB`: if you use your own PostgreSQL, it starts the bundled one again — from the `db_data` volume if it's still there, otherwise as a new empty database — and `app` waits for it to be healthy. Nothing uses it: `app` still connects to `DATABASE_URL`, so your data isn't affected.
**Upgrading to v0.9 (MCP tools).**
1. **There is no database migration.** Upgrading is pulling the new version and running `docker compose up -d --build`.
2. **Restart your MCP clients** so they list the new tools (`move_note_to_group`, `copy_note`, `read_note_image`) and `edit_note`'s `rename` operation. Scripts that call `create_note` must change `groupId` to `group_id`: a call with the old key is rejected by the input check and creates nothing.
3. **Rolling back** to v0.8 is harmless, since nothing in the database changed; those tools and the `rename` operation are simply gone.
**Upgrading to v0.8 (note versions).**
1. **Back up the database first.** Migration 0019 runs automatically when the new server starts. It only adds a table, columns, indexes and constraints, 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)).
2. **Automatic note versions are on after upgrading**, for the whole site and for every user and group. Each version is a full copy of a note's content, kept in the database, and versions don't count towards storage plans. To turn automatic saving off for the whole site, use **Site admin → Storage plans** (or `PATCH /api/admin/versions/settings`); people turn it off for their own notes in **Settings → Storage**, and, for a group's notes, on the **Storage** page of the group's panel — every member sees the switch, and members whose role can manage roles and the group change it (see [Version history](versions.md#turning-automatic-versions-off)).
3. **Rolling back** to the previous image means no versions are saved while it runs: the new table and columns stay in the database and the old server doesn't read them, and the versions already saved remain. Prefer rolling forward; if you must roll back, treat it as a temporary state.
**Upgrading to v0.7 (storage quotas).**
1. **Everyone starts on a 2 GiB plan.** The storage-quota migration puts every existing user and group on the 2 GiB Basic plan. Spaces already over 2 GiB keep everything but can't add attachments: after upgrading, check usage in **Site admin → Users** and **Site admin → Groups** (or with `GET /api/admin/users` and `GET /api/admin/groups`) and assign larger plans there where needed (see [Storage quotas](#storage-quotas)).
2. **Rolling back** to the previous image means no quota is enforced while it runs: the new table and columns stay in the database and the old server doesn't read them; users and groups it creates still get the default plan.
Prefer rolling forward; if you must roll back, treat it as a temporary state.
## Troubleshooting
- **Server crash-loops on first boot with a database error mentioning `users_email_unique`.** This means `instance_setup` is still empty (the instance believes it hasn't been initialized yet) but a `users` row already exists whose stored `email` is exactly the lowercase form of `ADMIN_EMAIL` — `users.email` has a plain unique constraint on the raw column, not a `lower()` index, so this only collides when the existing row happens to already be lowercase (a mixed-case existing row wouldn't collide here, but would instead become one of the pre-v0.1 duplicate-row cases in [Known limitations](./known-limitations.md) if a fresh lowercase row got created too). This typically happens after restoring a partial backup, or after the `instance_setup` row was removed manually. `initializeInstance` retries the same failing insert on every restart, so the container keeps crashing identically. Fix it one of two ways: change `ADMIN_EMAIL` in `.env` to an address not already present in `users`, or, if that existing row is meant to be the admin account, mark the instance initialized directly instead of relying on the env bootstrap (`INSERT INTO instance_setup (singleton) VALUES (true) ON CONFLICT DO NOTHING;` and `UPDATE users SET is_admin = true WHERE email = '<lowercased-admin-email>';`), then restart — `ADMIN_EMAIL`/`ADMIN_PASSWORD` are ignored once `instance_setup` has a row.
- **Everyone gets `429 too_many_attempts` on login, including people typing the right password, and it does not clear on its own.** The per-IP half of the login lockout is keyed on `request.ip`. Behind a reverse proxy with `TRUST_PROXY` unset (or with a proxy that does not send `X-Forwarded-For`), that is the proxy's address for **every** visitor — so one person's failed attempts count against everybody, and the backoff window keeps being extended by the next failure from anyone. Fix it by setting `TRUST_PROXY` (and making sure the proxy sends `X-Forwarded-For`, see [Deployment prerequisites](#deployment-prerequisites)), then **restart the `app` container**: the counters live in process memory, so without a restart you still have to wait out a full 15-minute window with no failed login from anyone. The server logs a `SECURITY WARNING` at startup when `PUBLIC_URL` is https and `TRUST_PROXY` is unset, and warns once at runtime when it sees a forwarding header it was told not to trust.
- **Someone reports being kicked out of a note ("You no longer have access"), or a tab that shows "Connecting…" and then a "Not loaded — offline" / "Editing offline" warning and never recovers.** Refused collaboration connections are logged by the `app` container with structured fields `phase`, `noteId`, `userId`, `cause` and `reason` — `docker compose logs app | grep '"cause"'` shows them, and every line at the default level names the account it refused. `phase` says when it happened: `handshake` (refused while connecting), `reverify` (the connection was open and was closed after a permission re-check — this is what a revoked share looks like), or `deadline` (the connection was closed because the client did not answer a re-check within five seconds, which is how a legitimate user gets ejected when their own token fetch is stuck). The `cause`:
- `no-role` — the account genuinely has no role on that note. **This is the one that means what the "You no longer have access" notice says.**
- `note-deleting` — the note was deleted, or is being deleted right now. The tab shows "note deleted" and returns to the list. Only people who could see the note when the delete started, or who still hold a role on it, are told this; to anyone else it is refused with the same `forbidden` reason as a note that never existed. The window is bounded (see `DELETING_GATE_TTL_MS`, currently two minutes — and it lives in process memory, so restarting the `app` container ends it early); a tab reconnecting after that gets `no-role` instead: to the live-editing server, a note in the trash (like one permanently deleted) is simply not yours.
- `session-revoked` — the account was disabled or deleted, its sessions were invalidated (a password change), or that browser signed out (its row in `sessions` is gone). This is **not** an access decision about the note: the tab fetches a new token within seconds, that request 401s, and the tab signs itself out. Check `users.disabled_at`, `users.token_version` and the account's rows in `sessions`.
- `no-reverify` (`phase: deadline`) — the client did not re-present its token in time. The person is ejected exactly as if access were revoked, so if they insist they still have access, this is the line that says otherwise.
- `bad-token` — the token was missing, failed to verify, or was bound to a different note. **In `phase: reverify` this ejects the person and is logged at the default level. In `phase: handshake` it is logged at `debug`, which this server does not emit at all**: it is the only refusal reachable before the token identifies anyone, and `/collab` accepts WebSocket upgrades without a session, so logging it would let an anonymous client decide how much disk your logs consume. A handshake stuck on `bad-token` therefore looks, from the browser, like a tab that shows "Connecting…" and then (after a few seconds) "Not loaded — offline", with nothing in the log.
- `client-outdated` — the tab was built for a different version of the editor than the server runs, usually because it was opened before an upgrade (#269). It is logged at the default level. A tab from 0.11 on shows a notice asking to reload the page; a tab opened before 0.11 doesn't know this refusal and keeps retrying ("Connecting…", then an offline warning) until it is reloaded.
- `server-error` — the handshake hit an unexpected error, logged at error level with the stack trace and the same `cause` field. This is the usual reason a tab retries indefinitely (badge stuck on "Connecting…" → "Not loaded — offline") with something to find; the usual culprit is the database being unreachable.
Refusals decided by the handshake and by a re-check are capped per account (ten a minute, with errors on their own budget); when the cap bites, one `collab 拒連日誌已達每分鐘上限` warning is emitted per account per minute so an empty grep is never ambiguous. The cap exists because a single valid token can be replayed on one WebSocket indefinitely. `phase: deadline` lines are not capped; they are bounded only by how many connections a re-check covers, and one is written per connection actually closed that way. One further log-volume path is intercepted: message types the collaboration library reacts to with an unstructured error line and an open connection — unknown types, the enum-listed-but-unhandled `SyncStatus`/`Ping`/`Pong`, and client-sent authentication messages of kinds only servers send — are dropped before they reach the library, and a connection that accumulates ten of them is forcibly disconnected (`collab 連線持續送出無法處理的訊息型別,強制斷線`, capped process-wide at ten lines per minute). The compose file also caps the `app` container's log size (50 MB × 5 rotated files) as a backstop. None of this makes the collab endpoint immune to a signed-in account generating log volume by other means.
- **AI quick actions stopped working right after someone edited a provider's Base URL.** Changing the Base URL clears that provider's stored API key on purpose — the key is sent to whatever host the field names, so it is not carried over to a new one (see [AI setup](./ai.md)). Fix it by entering the API key again in **Site admin → AI agents** (`/admin/ai`); the text under the provider's row says "No API key stored" until you do. If nobody meant to change the URL, `docker compose logs app | grep 'base URL'` shows every edit that carried one — the provider edit form always sends the field, so a line appears even when the value did not change; compare `from` and `to` — along with the `userId` that made it (a uuid — look it up in the `users` table to put a name to it).
- **The log shows `筆記內容含非 http(s) scheme 的媒體 URL`.** A collaborator with edit access wrote a media URL with a scheme like `javascript:` or `data:` directly into a note's collaborative document (the input field refuses these, but the document itself is writable by any editor). The URL is harmless inside Knotebook — rendering and export replace it before it reaches anything (the one exception is the app-private `blocknote/html` clipboard format, which only another BlockNote can read and which re-enters the render guard on paste) — but the string itself stays in the document until someone deletes that block, and this line is how you find out it exists. The fields name the note (`noteId`), how many were found (`findings`), the schemes and the block types; the URL itself is deliberately not logged. A note that stays open logs one line however often it saves; closing and reopening it (or restarting the server) produces one more — so a collaborator reconnecting in a loop can still generate lines at the rate of their reconnects. Unlike the refusal lines above, this line has no per-account budget; each line's size is bounded (block names truncated, at most ten distinct schemes/blocks listed), but the line count under reconnect churn is not. The line keeps reappearing until the offending block is removed. To locate the block, open the note and look for a broken image/player or a file link — those are the guarded renderings of such URLs.
## See also
- [Known limitations](./known-limitations.md) — the full list, including several deployment/env-var gotchas referenced above.
- [Restoring note content from a backup](./backup-restore.md).
- [API contract summary](./api.md).