Self-hosted Excalidraw with a runtime-configurable collaboration server and a persistent shared room — everyone who opens the app lands in the same board, no room links required.
- Container runtime (Docker (recommended), Colima, ...)
- Git with submodule support
| Name | Description |
|---|---|
| excalidraw (submodule) | Upstream Excalidraw client, built from source |
| excalidraw-room (submodule) | Upstream collaboration server, built from source |
Both are built from source via Dockerfile and Dockerfile.room respectively — no prebuilt images are pulled, so the stack works on any host architecture.
- Clone the repository including submodules:
git clone --recurse-submodules git@github.com:<your-username>/excalidraw-docker.git - Navigate to the root:
cd excalidraw-docker - Copy
.env.exampleto.envand adjust the values accordingly (see Configuration). - Leave
ROOM_HASHempty for now — you generate it on first run (see Generating the shared room hash).
docker compose up -d --buildThis builds and starts both services on an internal Docker network. docker-compose.override.yml is picked up automatically and exposes them locally:
| Service | URL |
|---|---|
excalidraw |
http://localhost:3000 |
excalidraw-collab-server |
http://localhost:3002 |
Check both are up — the collab server reports service running on its root path and has a healthcheck wired to it:
docker compose psTo verify collaboration works, open http://localhost:3000 in two different browsers (or one normal and one incognito window). With ROOM_HASH set, both should land in the same board automatically and see each other draw in real time.
The shared room is identified by a room ID and a client-side encryption key, which Excalidraw generates when a collaboration session first starts. You create one once, then pin it via ROOM_HASH:
- Start the stack with
ROOM_HASHempty. - Open the app, then Share → Live collaboration to start a session.
- Copy the part of the URL after
#room=— e.g.1f62e10b0baae8414d16,57nradTETCWtSF0aQrlkog. - Set it as
ROOM_HASHin.env(without theroom=prefix) and restart:docker compose up -d --build.
From then on, every visitor hitting the root URL is redirected into that room automatically. Changing rooms later is just a matter of changing the variable — nothing is baked into the image.
Note: the collaboration server is a stateless relay — it stores nothing. The board only lives in each participant's
localStorage, which browsers evict over time. For anything long-lived, treat the exported.excalidrawfile as the source of truth: import at the start of a session, export at the end.
| Variable | Used by | Description |
|---|---|---|
ROOM_HASH |
excalidraw |
Fixed <room-id>,<key> value, auto-injected into index.html so every visitor lands in the same shared room |
APP_WS_OLD_SERVER_URL |
excalidraw |
The hardcoded upstream collab URL to replace (rarely needs changing) |
APP_WS_NEW_SERVER_URL |
excalidraw |
URL the client connects to for collaboration — http://localhost:3002 locally, your public domain in production |
Variables are read from .env in the repository root and passed into the client container via env_file. The collab server needs no configuration.
This setup is self-hosted — deploy it on your own infrastructure, at whatever domain you choose. Neither service publishes ports in docker-compose.yml; both listen on port 80 inside the container and are meant to sit behind a reverse proxy.
Both services can share a single domain: route the /socket.io path to the collab server and everything else to the client. Excalidraw's Socket.IO client hardcodes that path, so it cannot be moved to a custom one like /ws. A separate subdomain for the collab server works just as well if you prefer — just point APP_WS_NEW_SERVER_URL at it.
- Create a new resource of type Docker Compose and point it at this repository.
- Under Domains, set:
excalidraw→https://draw.example.comexcalidraw-collab-server→https://draw.example.com/socket.io
- Turn off Strip Prefix on the collab server's domain. Socket.IO needs the
/socket.iopath to reach the server intact; with stripping enabled the request arrives as/and the handshake fails. - Under Environment Variables, add the values from Configuration and tick Is Literal? on each. Leave
ROOM_HASHempty for the first deploy. - Deploy, then generate the room hash against the live instance (see Generating the shared room hash), set
ROOM_HASH, and redeploy.
Do not put Traefik labels in the compose file — Coolify manages routing itself, and it escapes $ in labels, so ${VAR} interpolation there silently does not work.
If the domain sits behind Cloudflare Access, add a bypass policy for the /socket.io path. Access intercepts the WebSocket handshake and the browser fails to connect, even though the app itself loads fine from an already-authenticated session.
To debug a failing connection, check the handshake directly — a working collab server answers with a Socket.IO payload, not HTML:
curl -i "https://draw.example.com/socket.io/?EIO=4&transport=polling"Route /socket.io to the collab server and everything else to the client, both on port 80 in-container. Pass the path through unmodified, and make sure WebSocket upgrades are forwarded — this works out of the box with Caddy and Cloudflare Tunnel, and with nginx once the usual Upgrade/Connection headers are set.
This repository is a fork of pmoscode-helm/excalidraw-docker, built on top of Excalidraw and excalidraw-room. Licensed under the MIT License.