# How to Self-Host Excalidraw and Share a Whiteboard on a Private URL

> Run the excalidraw/excalidraw Docker image and the excalidraw-room server behind one Caddy port in a small sandbox, point the client at your room server, and host the port with sbx host --localhost. Drawings stay in each browser; the room server relays encrypted updates, but the stock image still saves encrypted rooms to Excalidraw's Firebase.

Source: https://sandboxer.com/guides/run-excalidraw-in-a-sandbox. Updated 2026-10-27.

**Short answer:** Run the excalidraw/excalidraw Docker image and the excalidraw-room server behind one Caddy port in a small sandbox, point the client at your room server, and host the port with sbx host --localhost. Drawings stay in each browser; the room server relays encrypted updates, but the stock image still saves encrypted rooms to Excalidraw's Firebase.

[Excalidraw](https://github.com/excalidraw/excalidraw) is a whiteboard that runs in the browser. Its [self-hosting docs](https://docs.excalidraw.com/docs/introduction/development#self-hosting) offer a Docker image of the client and say that self-hosting doesn't support sharing or collaboration yet. This page runs that image, adds Excalidraw's own [collaboration server](https://github.com/excalidraw/excalidraw-room) as a workaround, and shows what each piece stores.

## What we ran

Everything below ran on prod on 2026-10-27, on one `small` sandbox (2 vCPU, 4 GB, $0.018/hour), with two headless Chromium browsers (Playwright) on our test machine as the teammates. Tokens are replaced with `<token>`.

| Step | Result |
| - | - |
| `sbx new --type small` | 3.2 s to ready |
| Pull `excalidraw/excalidraw` (94 MB) and `excalidraw/excalidraw-room` (330 MB) | 3.3 s and 8.2 s |
| `docker compose up -d --build` (client, room server, Caddy) | 3.9 s |
| Page through the hosted URL, with the cookie | `200` in 0.19 s |
| Token link to a drawable canvas in a fresh browser | 1.3 s |
| Rectangle drawn in browser A, seen in browser B | within 1 s (our first check) |
| Memory | Caddy 12.6 MiB, client (nginx) 3.7 MiB, room server 38.6 MiB |
| `sbx stop --wait`, then `sbx resume` | 2.2 s and 1.3 s, `memoryRestored: true`, containers kept running |

## Where your drawings live

This is the part to get right before you share the link.

| What | Where it goes | Who can read it |
| - | - | - |
| A drawing nobody shares | The browser's `localStorage` (`excalidraw`, `excalidraw-state`) and IndexedDB (`files-db` for images, `excalidraw-library-db`) | Only that browser. The server just sends static files. A teammate who opens the same URL gets their own blank canvas. |
| Live collaboration updates | Your room server, through the hosted URL | The room server sees the room ID and encrypted binary messages. The key is in the link's `#room=<id>,<key>` fragment, which browsers don't send to servers. |
| Saved copy of a collaboration room | `firestore.googleapis.com`, project `excalidraw-room-persistence` (Excalidraw's Firebase), with the stock image | Encrypted with the same room key. It still leaves your sandbox. |
| "Shareable link" export | `json.excalidraw.com`, per the image's built-in config (we didn't click it) | Excalidraw's backend, encrypted |
| Fonts | `excalidraw.nyc3.cdn.digitaloceanspaces.com` | Public font files, no drawing data |

We checked the first three rows in the browsers. Browser A's storage held one `rectangle` element after we drew it. A sent 41 WebSocket frames during the session, and none contained the text `rectangle`. A posted `documents:commit` to the Firestore project above, and B opened a Firestore listen channel. The image's own config lists the other hosts: `json.excalidraw.com` for share links, `oss-ai.excalidraw.com` for AI features, `libraries.excalidraw.com` for libraries. They come from Excalidraw's [`.env.production`](https://github.com/excalidraw/excalidraw/blob/master/.env.production).

In our test on October 27, 2026, each browser also requested `scripts.simpleanalyticscdn.com` and `queue.simpleanalyticscdn.com`. If you need the page to make no third-party requests, check your browser's network tab with the image version you run.

If the Firebase row is a problem, don't start collaboration sessions on the stock image. Use solo drawing plus **Save to file**, or build the client from source with your own `VITE_APP_FIREBASE_CONFIG`. We didn't test a source build.

## 1. Pull the images

```bash
sbx new --name excalidraw --type small
id=sb_46jcxglid2    # the id sbx new printed; its subdomain was plucky-dense-cliff
sbx exec $id --timeout 600 "docker pull -q excalidraw/excalidraw:latest && docker pull -q excalidraw/excalidraw-room:latest"
```

`sbx exec` stops a command after 30 s by default, so raise `--timeout` for pulls. Docker is already installed in the sandbox ([Docker inside a sandbox](/guides/docker-and-kvm-inside-an-agent-sandbox)).

Both images listen on port 80. `excalidraw-room` is a small Socket.IO relay. The image on Docker Hub was built about two years before our test, on Node.js 12.22.

## 2. One port for the app and the room server

The browser has a cookie for one hosted name, `plucky-dense-cliff-8080.box.sandboxer.com`. A second hosted port would be a second name with its own token. So put Caddy in front and route `/socket.io/` to the room server. Everything else goes to the client.

The client has the collaboration server URL baked in at build time (`https://oss-collab.excalidraw.com`). A two-line `Dockerfile` swaps it for your hosted URL:

```dockerfile
# ~/excalidraw/Dockerfile
FROM excalidraw/excalidraw:latest
ARG COLLAB_URL
# Point the app's collaboration client at our own room server instead of oss-collab.excalidraw.com.
RUN grep -rl "https://oss-collab.excalidraw.com" /usr/share/nginx/html \
  | xargs sed -i "s#https://oss-collab.excalidraw.com#${COLLAB_URL}#g"
```

```text
# ~/excalidraw/Caddyfile
:80 {
	reverse_proxy /socket.io/* room:80
	reverse_proxy app:80
}
```

```yaml
# ~/excalidraw/compose.yaml
services:
  app:
    build:
      context: .
      args:
        COLLAB_URL: https://plucky-dense-cliff-8080.box.sandboxer.com
    restart: unless-stopped
  room:
    image: excalidraw/excalidraw-room:latest
    restart: unless-stopped
  front:
    image: caddy:2
    ports:
      - "127.0.0.1:8080:80"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
    restart: unless-stopped
```

The sandbox has a public IPv6 address, so publish only on `127.0.0.1`. The room server has no port of its own; only Caddy reaches it. We put the three files in a local `setup.sh` (each written with a heredoc into `~/excalidraw`), ending with these lines, and piped it in with `sbx exec -i`:

```bash
# last lines of setup.sh
cd ~/excalidraw && docker compose up -d --build --quiet-pull
for i in $(seq 1 50); do curl -fs -o /dev/null localhost:8080 && break; sleep 0.2; done
curl -s -o /dev/null -w "/ %{http_code}\n" localhost:8080/
curl -s "localhost:8080/socket.io/?EIO=4&transport=polling"
```

```bash
sbx exec $id -i --timeout 600 "bash -s" < setup.sh
```

```text
/ 200
0{"sid":"hDrdqsi_Bl-AJ0jVAAAA","upgrades":["websocket"],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}
```

The second line is the room server answering a Socket.IO handshake through Caddy.

## 3. Host it on a private URL

```bash
sbx host $id 8080 --localhost --title Excalidraw
```

```text
{"ok":true,"type":"port.hosted","sandboxId":"sb_46jcxglid2","port":8080,"url":"https://plucky-dense-cliff-8080.box.sandboxer.com?_token=<token>","access":"private",…,"localhost":true}
```

`--localhost` makes the sandbox relay to `127.0.0.1:8080`. [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url) covers tokens and cookies in general. Here is what the gate does for Excalidraw:

| Request | Result |
| - | - |
| `/`, no token | `401` from the Sandboxer proxy |
| `/socket.io/…`, no token | `401` "Access token required"; the room server never sees it |
| `/?_token=<token>` | `302` to `/`, sets the cookie |
| `/` with the cookie | `200` in 0.19 s |

So only people with the link can load the app or join a room on your server.

## 4. Draw together

Send teammates the full `?_token=` link. Each of them opens it once; the cookie lasts 30 days. Then one person clicks **Live collaboration…** and **Start session**. The address changes to `https://plucky-dense-cliff-8080.box.sandboxer.com/#room=<id>,<key>`. Send that room link too.

In our test, browser B opened the token link and then the room link. It showed browser A's rectangle and a collaborator count of 2. A drew a second rectangle. B showed it, with A's labeled cursor, at our first check 1 s later. A's WebSocket went to `wss://plucky-dense-cliff-8080.box.sandboxer.com/socket.io/`, not to `oss-collab.excalidraw.com`. Its text frames looked like this:

```text
42["join-room","0da1fa6c7e8306d51203"]
452-["server-broadcast","0da1fa6c7e8306d51203",{"_placeholder":true,"num":0},…]
```

The scene travels as binary attachments (`_placeholder`), encrypted in the browser.

## 5. Stop and resume

```bash
sbx stop $id --wait
sbx resume $id
```

The stop took 2.2 s and the resume 1.3 s. `sbx list` reported `"memoryRestored": true`. The three containers kept their uptime, the page answered `200` on the first request, and the room server answered its handshake. Drawings aren't on the sandbox, so a stop doesn't touch them. Teammates' browsers keep their own copies.

Memory comes back only when the resume lands on a machine with the same CPU type. Otherwise the sandbox boots from disk with files, routes and tokens kept. Docker starts again at boot, and `restart: unless-stopped` brings the containers back. We didn't force a cold boot here. The room server stores nothing, so a restart loses no drawings.

## FAQ

### Is my whiteboard stored on the sandbox?

No. The client container only serves static files. Solo drawings live in each browser's `localStorage` and IndexedDB. In collaboration the room server relays encrypted messages and keeps nothing. Use **Save to file** for a copy you control.

### Does self-hosted Excalidraw send data to excalidraw.com?

The stock image does in some cases. Collaboration rooms are saved, encrypted, to Excalidraw's Firebase project. Share links go to `json.excalidraw.com`. Fonts load from Excalidraw's CDN. Before we started a session, browser A made no Firestore or `json.excalidraw.com` requests.

### Do teammates need a Sandboxer account?

No. Anyone with the full `?_token=` link can open the board. Treat it like a password. To cut everyone off, run `sbx host <id> 8080 --public` and then `--private` again (keep `--localhost`). That makes a new token. Or run `sbx host hide <id> 8080`.

### Can I make the board public?

You can host the port with `--public`. Then anyone who finds the URL can load the app and use your room server, though they still need a room's key to read a room. Private is the safer default.

## Related

- [Give a sandbox port an HTTPS URL](/guides/give-a-sandbox-port-an-https-url)
- [Run Docker Compose with a URL per service](/guides/run-docker-compose-in-a-sandbox-with-a-url-per-service)
- [Run Storybook with a shareable URL](/guides/run-storybook-with-a-shareable-url)
- [Hosting](/docs/hosting)
