# On-premise installation

This is the install guide for the **on-premise edition**: the
jodit-collaboration server running as a Docker container inside your own
infrastructure. Your documents, patch logs and comments never leave your
network. (For the hosted alternative see
[Licensing & purchasing](licensing.md).)

## Prerequisites

| Requirement                     | Notes                                                                                                                                                                                                                |
| ------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Docker (20.10+) with Compose    | The server ships as a single container image.                                                                                                                                                                        |
| PostgreSQL 14+                  | For persistence. You can use your existing cluster, **or** the bundled Compose file which starts `postgres:16-alpine` next to the server. In-memory storage works but loses everything on restart (evaluation only). |
| A TLS-terminating reverse proxy | nginx/Traefik/your ingress; browsers need `wss://`. A ready nginx template is in [Deployment](deployment.md#behind-nginx-websocket-proxying).                                                                        |
| An identity source              | Anything that can mint a signed token for your users (JWT from your app backend is the typical choice). For a trial you can skip this and run in [anonymous demo mode](#step-2-first-run-demo-mode).                 |
| Node.js ≥ 20                    | Only if you prefer running the server as an npm package instead of Docker.                                                                                                                                           |

## Step 1: get the image

Pull the published server image from Docker Hub:

```bash
docker pull xdsoft/jodit-collaboration:latest
```

**Air-gapped / offline install?** Load the tarball from your licensed
distribution instead; no registry access is required:

```bash
docker load -i jodit-collaboration-<version>.tar
```

## Step 2: first run (demo mode)

Verify the installation before wiring in real auth. The bundled
`docker-compose.yml` starts the server plus Postgres with a data volume and
health checks:

```bash
docker compose up -d
curl http://localhost:8083/collab/health
# → {"success":true,"protocol":1}
```

Open <http://localhost:8083> to see the demo page with two live editors,
server-assigned guest identities and the in-editor comments panel.

> **Warning: Demo mode is not production**
>
> The image defaults to `ALLOW_ANONYMOUS=true`: anyone who can reach the
> port gets a generated guest identity with write access. Complete
> Step 3 before exposing the server to real users, or at minimum restrict
> guests with `GUEST_ROLE=reader`.

## Step 3: configure authentication

Production authentication is a **code hook**, not an env variable: you give
the server a `checkAuthentication` function that turns your token into an
identity (this is what makes identity unforgeable; see
[Authentication](authentication.md)). The published image bakes a
self-contained production entry file at `examples/server-prod.mjs` that reads
its config from the environment and **inlines** a dependency-free HS256 JWT
verifier, so there is nothing extra to build or mount. Its core is the
`checkAuthentication` hook:

```js title="server-prod.mjs (the auth hook)"
import { start } from '@jodit/collab-server';
// verifyJwtHS256 is inlined in the shipped file; no external import needed.

await start({
	auth: {
		// No allowAnonymous: connections without a valid JWT are rejected.
		checkAuthentication(token) {
			const claims =
				token && verifyJwtHS256(token, process.env.JWT_SECRET);
			if (!claims || typeof claims.sub !== 'string') {
				return null; // → auth_failed, connection closed
			}
			// Identity comes from the token CLAIMS your backend signed;
			// the page cannot invent a name, color or role.
			return {
				userId: claims.sub,
				name: claims.name ?? claims.sub,
				color: claims.color ?? '#2196f3',
				role: ['owner', 'writer', 'commenter', 'reader'].includes(
					claims.role
				)
					? claims.role
					: 'reader'
			};
		}
	}
});
```

Your application backend mints the token when it renders the editor page,
e.g. with claims:

```json
{
	"sub": "u1",
	"name": "Alice Product",
	"color": "#e91e63",
	"role": "writer",
	"exp": 1751888400
}
```

### Run it

The published image already contains `examples/server-prod.mjs`, so a
production stack is just a Compose file that overrides the container command to
run it. Save this as `docker-compose.yml` and set a real `JWT_SECRET`; there is
nothing to build or mount:

```yaml title="docker-compose.yml"
services:
    collab:
        image: xdsoft/jodit-collaboration:latest
        restart: unless-stopped
        command: ['node', 'examples/server-prod.mjs'] # JWT auth entry baked into the image
        environment:
            PORT: 8083
            STORAGE: postgres
            DATABASE_URL: postgres://collab:collab@postgres:5432/collab
            JWT_SECRET: 'change-me-to-a-long-random-secret' # set a real secret
            # ALLOW_ANONYMOUS is intentionally unset → anonymous connections are rejected
        ports: ['8083:8083']
        depends_on: [postgres]
    postgres:
        image: postgres:16-alpine
        environment:
            POSTGRES_USER: collab
            POSTGRES_PASSWORD: collab
            POSTGRES_DB: collab
        volumes: ['collab-db:/var/lib/postgresql/data']
volumes:
    collab-db:
```

```bash
docker compose up -d
```

The `server-prod.mjs` entry refuses to start unless `JWT_SECRET` is set to a
real value and rejects anonymous/invalid tokens, so leaving `ALLOW_ANONYMOUS`
unset is all that is needed to close the door on guests.

**Prefer plain Node.js instead of Docker?** The `examples/` folder is not part
of the npm package, so save the entry file yourself as `server-prod.mjs` next
to your `package.json`:

```js title="server-prod.mjs"
/**
 * Production entry point: JWT authentication, no anonymous access.
 * Your backend mints a token signing { sub, name, color, role, exp } with the
 * SAME JWT_SECRET, so identity comes from your backend, never the browser.
 *
 *   JWT_SECRET=... STORAGE=postgres DATABASE_URL=... node server-prod.mjs
 */
import { createHmac, timingSafeEqual } from 'node:crypto';
import { start } from '@jodit/collab-server';

const SECRET = process.env.JWT_SECRET;
if (!SECRET || SECRET === 'change-me-to-a-long-random-secret') {
	console.error('Refusing to start: set JWT_SECRET to a real secret.');
	process.exit(1);
}

const ROLES = ['owner', 'writer', 'commenter', 'reader'];

function verifyJwtHS256(token, secret) {
	const parts = String(token).split('.');
	if (parts.length !== 3) return null;
	const [header, payload, signature] = parts;
	const expected = createHmac('sha256', secret)
		.update(`${header}.${payload}`)
		.digest();
	let actual;
	try {
		actual = Buffer.from(signature, 'base64url');
	} catch {
		return null;
	}
	if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
		return null;
	const claims = JSON.parse(Buffer.from(payload, 'base64url').toString());
	if (typeof claims.exp === 'number' && claims.exp * 1000 < Date.now())
		return null;
	return claims;
}

const server = await start({
	auth: {
		checkAuthentication(token) {
			const claims = token && verifyJwtHS256(token, SECRET);
			if (!claims || typeof claims.sub !== 'string') return null;
			return {
				userId: claims.sub,
				name: claims.name ?? claims.sub,
				color: claims.color ?? '#2196f3',
				role: ROLES.includes(claims.role) ? claims.role : 'reader'
			};
		}
	}
});
console.log(`jodit-collaboration (JWT auth) listening on :${server.port}`);
for (const signal of ['SIGINT', 'SIGTERM']) {
	process.on(signal, () => void server.stop().then(() => process.exit(0)));
}
```

Then install the server package and run your file:

```bash
npm install @jodit/collab-server   # Node.js ≥ 20
STORAGE=postgres DATABASE_URL=postgres://… JWT_SECRET=… \
  node server-prod.mjs
```

## Step 4: connect the editor plugin

On your frontend, register the `@jodit/collab-plugin` plugin and turn it on per
editor with the `collab` option. Pass the token as a **provider function** so
reconnects always use a fresh one:

```ts
import { Jodit } from 'jodit'; // or 'jodit-pro'
import collab from '@jodit/collab-plugin';

Jodit.plugins.add('collab', collab);

Jodit.make('#editor', {
	collab: {
		url: 'wss://collab.your-company.com/collab/ws',
		docId: 'doc-123',
		// Called on every (re)connect; expired JWTs heal automatically.
		token: async () => (await fetch('/api/collab-token')).text(),
		onError: (code, message, context) => {
			if (context === 'comment') showNotice(message); // submit errors auto-resync
		}
	}
});
```

That is the whole integration. The plugin adds its toolbar button and wires
capture, sync, cursors and comments. The optional host-UI hooks (presence,
status, log) are in
[Getting started](getting-started.md#connecting-the-editor-plugin).

## Step 5: verify

```bash
# Health (no auth required; safe for LB probes):
curl https://collab.your-company.com/collab/health
# → {"success":true,"protocol":1}

# Auth is fail-closed; a bogus token must be rejected:
curl -H 'Authorization: Bearer nonsense' \
  'https://collab.your-company.com/collab/docs/doc-123/history?from=0&to=10'
# → 401 {"success":false,"error":{"code":"auth_failed"}}
```

Then open your editor page in two browsers: both should show the
server-assigned names from your JWT claims, live cursors, and a shared
comments panel.

## Configuration reference

All environment variables (`GUEST_ROLE`, `SANITIZE_ENABLED`,
`SESSION_WINDOW`, `RETENTION_SNAPSHOTS`, `RATE_LIMIT_*`, storage, ports…)
are documented in [Configuration](configuration.md). Security-relevant
defaults are explained in [Security](security.md).

## Upgrades

1. Read the release notes shipped with the new version.
2. Bump the image tag (or `docker load` the new archive) and restart:

    ```bash
    docker compose pull collab   # or: docker load -i jodit-collaboration-<new>.tar
    docker compose up -d collab
    ```

There is no separate migration step: the server creates and evolves its
Postgres schema **idempotently on startup** (`CREATE TABLE IF NOT EXISTS`
/ `CREATE INDEX IF NOT EXISTS` in the storage adapter's `init()`).

Downtime is a brief reconnect blip for editors: every sequenced entry is
persisted _before_ it is broadcast, so no acknowledged edit is lost;
clients reconnect automatically and resume via the `sinceSeq` delta.

> **Note: One node per document**
>
> The sequencer is single-node per document: never run two server
> instances against the same documents at the same time. Blue/green
> deploys should switch traffic, not overlap writers. (The
> `(doc_id, seq)` primary key makes an accidental second writer fail
> loudly rather than fork history.) See
> [Deployment: scaling caveats](deployment.md#scaling-caveats).

## Backup

All persistent state lives in four Postgres tables. Back them up with your
normal tooling:

```bash
pg_dump --dbname="$DATABASE_URL" \
  --table=collab_documents \
  --table=collab_entries \
  --table=collab_snapshots \
  --table=collab_comments \
  --format=custom --file=collab-$(date +%F).dump
```

- `collab_documents`: the document registry;
- `collab_entries`: the append-only patch log (who changed what, when);
- `collab_snapshots`: periodic full-document snapshots;
- `collab_comments`: comment threads (`data` JSONB per thread).

A dump taken while the server is running is consistent (Postgres snapshot
isolation); restoring it and starting the server reconstructs every room
from the latest snapshot plus the entry tail.

## Sizing

The binding constraint is memory per **active** room, and it is dominated
by the sliding OT window: the session keeps up to `SESSION_WINDOW`
(default 512) recent entries _and the document state after each of them_.

```text
memory per active room ≈ SESSION_WINDOW × (average document JSON size)
                         + the window's patch entries
```

Rules of thumb:

- a 100 KB document with the default window costs on the order of tens of
  MB per active room while it is being edited hard; idle rooms with no
  participants are unloaded after `EMPTY_ROOM_TTL_MS` (default 10 min);
- **`SESSION_WINDOW`**: lower it (e.g. 128) for very large documents to cut
  memory; the cost is that clients lagging more than that many entries get
  `base_seq_too_old` and reload a snapshot (invisible but bandwidth-y);
- **`SNAPSHOT_EVERY`** (default 200): lower it for faster room loads and
  cheaper history queries at the cost of more snapshot rows; keep it below
  or near `SESSION_WINDOW` so a freshly loaded room replays a short tail;
- **`RETENTION_SNAPSHOTS`** (default 0 = keep everything): set e.g. `20`
  to prune the patch log to the last 20 snapshots' worth of history and
  bound database growth; older history/replay is gone after pruning, so
  leave it at 0 if you need a full audit trail.

Start with the defaults, watch container RSS and the `collab_entries`
table size, and tune from there.
