Use case · Whiteboard

Excalidraw self hosted: the whiteboard, its collaboration server, and the parts that stay upstream

Excalidraw self hosted means serving the MIT-licensed whiteboard from your own machine, either with the published excalidraw/excalidraw Docker image or with an image you build so its settings point where you choose. The editor, browser autosave and file export run entirely on your side. Excalidraw's own documentation says a self-hosted instance does not support sharing or collaboration, and the source shows why: those features call services the repositories do not contain. This page sets out what you get, what you can add with the excalidraw-room server, and what stays out of reach.

See the machines

What runs where

The client is a static web app. The Dockerfile in the excalidraw repository builds it with Node and Yarn (yarn build:app:docker), then copies the output into an nginx:stable-alpine-slim image that serves it on port 80. There is no database and no application server in that image. The development guide on docs.excalidraw.com names the published image, excalidraw/excalidraw, says it carries no analytics or tracking libraries, and states that self-hosting does not yet support sharing or collaboration.

The repository's publish-docker.yml pushes one tag only, excalidraw/excalidraw:latest, on each push to the release branch, for linux/amd64, linux/arm64 and linux/arm/v7. It is the only tag still published or updated; the sha- tags still listed on Docker Hub date from 2021 and are stale. When we checked in October 2026, Docker Hub showed latest last updated in May 2026, so look at its date before you rely on it. Pinning a release means building it yourself.

Live collaboration needs a second process. The excalidraw-room repository describes itself as an example collaboration server: Express plus socket.io in src/index.ts. It joins sockets to rooms and forwards each encrypted update and its IV to the other members. It keeps nothing on disk and listens on PORT, or 80 outside development mode. Its CORS_ORIGIN setting (default: any origin) only matters to socket.io's long-polling fallback. Browsers do not apply CORS to WebSocket connections, so it does not stop other sites from connecting, and it is not authentication.

Feature by feature, once it is on your box

Every outside endpoint comes from .env.production, and vite.config.mts loads that file when the app is built. The values end up inside the static files. Changing one means a rebuild. The table is our reading of that file and of Collab.tsx, data/index.ts and LocalData.ts.

FeaturePublished imageYour build plus excalidraw-room (our experiment)
Drawing, autosave to the browserWorksWorks
Export to PNG, SVG, .excalidraw fileWorksWorks
Live collaborationNot supported self-hosted, per the docs; the build points at oss-collab.excalidraw.comRelayed by your room server; Collab.tsx still saves the room scene and pasted images to the Firebase project in VITE_APP_FIREBASE_CONFIG
Read-only share linkUploads to json.excalidraw.com; neither repo contains that backendSame
Personal libraryKept in the browser (IndexedDB)Same
Browse public librariesOpens libraries.excalidraw.comSame

Put plainly, neither image is self-contained. Both are built from the same .env.production. A collaboration session saves each scene and its pasted images, encrypted, to the Excalidraw team's Firebase project named in VITE_APP_FIREBASE_CONFIG; share links upload to json.excalidraw.com; the library browser opens libraries.excalidraw.com. The browser encrypts that data first, but it still leaves your server. If nothing may leave the server, use the board without collaboration and without share links. Pointing VITE_APP_FIREBASE_CONFIG at a Firebase project of your own keeps room data away from Excalidraw, but it then sits on Google's servers, share links still go to json.excalidraw.com, and we have not tried it.

The room-server build below is our own experiment: not tested end to end and not described in the Excalidraw docs. It changes one value, the relay address, and leaves every other endpoint as shipped.

End-to-end encryption

A collaboration link has the form #room=ROOMID,KEY (data/index.ts). The key sits after the #, in the URL fragment, which browsers keep to themselves rather than send with the request. Scene data is encrypted in the browser with AES-GCM (data/encryption.ts) using a 128-bit key (ENCRYPTION_KEY_BITS in the common constants). The room server only ever handles the ciphertext and the IV. Share links use #json=ID,KEY, where ID names the encrypted document uploaded to json.excalidraw.com, not a room. Anyone holding the full link can read the drawing, so treat links as secrets.

Machine and prerequisites

At runtime this is nginx serving files and a small Node process relaying messages. The build is the heavy step: the Dockerfile installs the full dependency tree and runs a Vite production build. The docs give no hardware figures, so leave memory headroom for builds rather than size to a number.

Any x86 line in the dedicated server catalogue will carry it, a Moonshot cartridge included. For an out-of-band console on the day a firewall rule locks you out, take any racked single-, dual- or quad-socket server, or one of the m510 and m710x cartridges; the other cartridges have power control only. Leave the X-Gene ARM cartridge aside: its newest bootable release is Ubuntu 18.04. On the Ampere Altra, the published board image includes arm64, but we wrote these steps for x86-64 with Ubuntu 24.04 and have not tried them on Altra, so this page does not recommend it. Ubuntu 24.04 is also where the caddy package is in the archive; confirm 24.04 is in the operating-system list when you configure the line. Compare Xeon lines on the Intel servers page. A racked line is live about 30 minutes from payment approval; a built-to-order line takes 4 to 24 hours.

Beyond the OS: Docker at Engine version 28 or later with compose, both taken from Docker's apt repo, plus git, and two DNS names: one for the board, one for the room server. Below they are draw.example.com and collab.example.com.

Setting it up

Run everything as root (sudo -i). The block sets up the firewall in the order that keeps IPv6 covered: IPV6=yes, allow SSH, enable, then open the web ports for Caddy, which runs on the host. A mapping without an address publishes the port on every host address, 0.0.0.0 as well as [::]. On IPv4 that published traffic bypasses ufw's rules; the IPv6 side bypasses them too, but only when Docker's own IPv6 networking is enabled, since otherwise docker-proxy holds the [::] listener and ufw filters it. So both containers publish on 127.0.0.1 only, which is local-only on Docker Engine 28 and later (moby#45610); the build script checks the version before it starts anything.

The build script is ours. It clones or refreshes the release branch of the client and master of the room server, writes your room URL into .env.production, stops if the edit did not land, and rebuilds. The edit has to go into .env.production itself: of the .env files, .dockerignore admits only .env.development and .env.production, so a .env.production.local never reaches the build. The script runs git reset --hard on both clones, so keep no other local changes there.

sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH && ufw --force enable && ufw allow 80,443/tcp
mkdir -p /opt/excalidraw
cat > /opt/excalidraw/compose.yaml <<'EOF'
services:
  excalidraw:
    build: ./excalidraw
    image: excalidraw-local
    restart: unless-stopped
    ports: ["127.0.0.1:8080:80"]
  room:
    build: ./excalidraw-room
    image: excalidraw-room-local
    restart: unless-stopped
    environment: ["CORS_ORIGIN=https://draw.example.com"]
    ports: ["127.0.0.1:3002:80"]
EOF
cat > /opt/excalidraw/build.sh <<'EOF'
set -eu
cd /opt/excalidraw
WS=https://collab.example.com
v=$(docker version -f '{{.Server.Version}}')
[ "${v%%.*}" -ge 28 ] || { echo "need Engine 28+, found $v"; exit 1; }
for pair in excalidraw:release excalidraw-room:master; do
  d=${pair%%:*}; b=${pair##*:}
  [ -d "$d" ] || git clone --depth 1 -b "$b" "https://github.com/excalidraw/$d.git"
  git -C "$d" fetch --depth 1 origin "$b"
  git -C "$d" reset --hard FETCH_HEAD
done
sed -i "s|^VITE_APP_WS_SERVER_URL=.*|VITE_APP_WS_SERVER_URL=$WS|" excalidraw/.env.production
grep -qx "VITE_APP_WS_SERVER_URL=$WS" excalidraw/.env.production || { echo "room URL edit failed"; exit 1; }
docker compose -f /opt/excalidraw/compose.yaml up -d --build
EOF
bash /opt/excalidraw/build.sh && sleep 5 &&
curl -fsS http://127.0.0.1:3002/ && echo &&
curl -fsSI http://127.0.0.1:8080/ | head -n 1

The room server answers its root path with "Excalidraw collaboration server is up :)" (src/index.ts). Only once both checks pass, point the two DNS names at the server and put Caddy in front. Caddy obtains the certificates and passes the WebSocket upgrade through reverse_proxy without extra directives; the client opens socket.io with WebSocket first and long-polling as the fallback (Collab.tsx). This block rewrites the whole Caddyfile, so re-running it later drops any other site you have added to that file.

apt-get update && apt-get install -y caddy &&
cat > /etc/caddy/Caddyfile <<'EOF'
draw.example.com {
	reverse_proxy 127.0.0.1:8080
}
collab.example.com {
	reverse_proxy 127.0.0.1:3002
}
EOF
caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy

Excalidraw has no accounts or sign-in. Once Caddy is live, anyone who reaches draw.example.com can use the board. Drop the room service and the collab site if you only want the whiteboard.

To run the published image instead of building, paste only the first two lines of the setup block (the firewall), skip compose.yaml and build.sh entirely, and run: v=$(docker version | sed -n '/^Server/,$ s/^ *Version: *//p' | head -n 1); if [ "${v%%.*}" -ge 28 ] 2>/dev/null; then docker run -d --name excalidraw --restart unless-stopped -p 127.0.0.1:8080:80 excalidraw/excalidraw:latest; else echo "need Engine 28+, found $v"; fi. A leftover build.sh would block the update step below. This image keeps every endpoint as shipped, collaboration relay included.

Backups, restore and updates

As root (sudo -i). There is very little to back up on the server, and that is the honest answer. Drawings and personal libraries sit in each user's browser storage (LocalData.ts), the room server holds rooms in memory only, and both images can be rebuilt from Git. Clearing site data in a browser deletes that user's drawings, so the real backup is people saving .excalidraw files, the open JSON format the README describes.

  1. Archive the three files that define the install: tar -czf /root/excalidraw-config-$(date +%F).tgz /opt/excalidraw/compose.yaml /opt/excalidraw/build.sh /etc/caddy/Caddyfile, then copy it off the machine yourself.
  2. Published-image install: archive only the Caddyfile (tar -czf /root/excalidraw-config-$(date +%F).tgz /etc/caddy/Caddyfile), since there is no compose.yaml or build.sh to keep.
  3. Update a build.sh install by running it again: bash /opt/excalidraw/build.sh. It fetches the latest release and master, re-applies the room URL, stops if the edit fails, and recreates both containers.
  4. Only if you ran the published image instead of build.sh: `if [ -e /opt/excalidraw/build.sh ]; then echo "build.sh install found: run build.sh instead"; else docker pull excalidraw/excalidraw:latest && docker rm -f excalidraw && docker run -d --name excalidraw --restart unless-stopped -p 127.0.0.1:8080:80 excalidraw/excalidraw:latest; fi`. The guard stops it from replacing your own build and says so.
  5. Old images pile up after rebuilds; docker image prune removes the dangling ones.
  6. The room server's Dockerfile still starts from node:12-alpine, and the repository's last commit dates from December 2023. Weigh that before exposing it, and watch the repo.

Restoring onto a fresh Ubuntu 24.04 server, as root: first set up Docker (Engine 28+) with compose from Docker's apt repo, copy the archive to /root, then run the block. Caddy is installed before the archive is unpacked, because a Caddyfile already in place makes the package installer stop and ask whether to keep it. build.sh re-clones both repositories and rebuilds. Point draw.example.com and collab.example.com at the new server before the block reaches its last line, the Caddy reload, so the certificates can be issued there. For a published-image install, replace the bash /opt/excalidraw/build.sh && line with the gated docker run from the setup section.

sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH && ufw --force enable && ufw allow 80,443/tcp
apt-get update && apt-get install -y caddy git &&
A=$(ls -1 /root/excalidraw-config-*.tgz | tail -n 1) && [ -n "$A" ] &&
tar -tzf "$A" >/dev/null && tar -xzf "$A" -C / &&
bash /opt/excalidraw/build.sh &&
caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy

Licence and when not to do this

Both repositories ship an MIT LICENSE (copyright 2020 Excalidraw). Our general reading, not legal advice: MIT requires that distributed copies carry the MIT copyright line and its permission text; it creates no obligation to publish source code or modifications. Bundled dependencies carry their own licences.

If your team needs persistent shared workspaces, sign-in and stored scenes on a server, the open-source repositories do not provide them. Excalidraw+ at plus.excalidraw.com is the project's separate hosted product. For a self-hosted design tool with accounts and a database, see Penpot on a dedicated server; for collaborative Markdown notes, HedgeDoc.

Questions

Is there an official Excalidraw Docker image?

Yes. The docs name excalidraw/excalidraw. The repository's publish workflow pushes it on each release-branch push. Latest is the only tag still published or updated; the old sha- tags are from 2021. It is built for amd64, arm64 and arm/v7. Its outside endpoints are fixed at build time.

Can I change the collaboration server URL with an environment variable at docker run?

No. VITE_APP_WS_SERVER_URL is read by Vite during the build and compiled into the static files. Set it in .env.production and rebuild the image. That is what the build script on this page does.

Where are drawings stored on a self-hosted instance?

In each user's browser, in localStorage and IndexedDB. The server keeps no copy. Ask users to save .excalidraw files for anything they care about.

Does the room server see the content of a drawing?

No. The browser encrypts updates with AES-GCM before sending them, and the key travels in the link's fragment, which the browser does not send to the server. The room server forwards ciphertext and an IV.

Do share links work when I self-host?

Not on your own infrastructure. The share-link upload goes to the URL in VITE_APP_BACKEND_V2_POST_URL, json.excalidraw.com in the stock build, and neither repository contains that backend.

Put the board on your own hardware

Pick an x86 line, take root, and run the build script on a machine nobody else shares.

See the machines

Related on Server Room

Other use cases