How to Self-Host Excalidraw and Share a Whiteboard on a Private URL
Updated October 27, 2026 .md
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 is a whiteboard that runs in the browser. Its self-hosting docs 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 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.
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#
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).
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:
# ~/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"# ~/excalidraw/Caddyfile
:80 {
reverse_proxy /socket.io/* room:80
reverse_proxy app:80
}# ~/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-stoppedThe 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:
# 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"sbx exec $id -i --timeout 600 "bash -s" < setup.sh/ 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#
sbx host $id 8080 --localhost --title Excalidraw{"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 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:
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#
sbx stop $id --wait
sbx resume $idThe 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#
More guides
For running AI-written code we rank Sandboxer, Freestyle and E2B first: each gives code its own VM with an API or SDK and can pause with memory. Pick Daytona for Windows or GPUs, Modal or Northflank for GPUs at scale, E2B or Northflank to run in your own cloud, and Vercel Sandbox if your app lives on Vercel.
For coding agents we rank Sandboxer first: a full Ubuntu VM with Claude Code and Codex preinstalled, Docker, and stop-and-resume that keeps the agent's session in memory. Freestyle and Amp Orbs also keep memory across pauses. Pick E2B for prebuilt agent templates on open source, Docker Sandboxes for free local runs, and Codespaces if you live in GitHub.
The best Daytona alternatives depend on why you're switching. For a full Linux microVM on every sandbox with memory-saving stop and Docker, we rank Sandboxer first. E2B suits open source and self-hosting, Modal massive short-job fleets, Northflank bring-your-own-cloud with GPUs, and Vercel Sandbox apps on Vercel.
If you're leaving E2B for longer sessions or a fuller machine, we rank Sandboxer first, then Freestyle: both give each sandbox a full Linux VM that keeps memory across pauses, with Docker and no short session cap. Pick Daytona for Windows or GPUs, Northflank for bring-your-own-cloud, and stay on E2B if you need open source.