What works on your own copy, and what does not
Drawing, the shape tools, image support, dark mode and export to PNG, SVG or an .excalidraw file all work, because the editor is a web app that runs in the browser. Saving works too, but locally: the app keeps the current scene in the browser's storage, and your personal library of shapes lives there as well (the app's LocalData.ts file).
Three things lean on services you do not get. Read-only share links upload an encrypted copy of the drawing to json.excalidraw.com. Live collaboration talks to a relay at oss-collab.excalidraw.com and stores room data in a Firebase project that belongs to Excalidraw. The public library browser opens libraries.excalidraw.com. All of these addresses are written into the app's .env.production file and fixed when the image is built. The development guide on docs.excalidraw.com puts it in one line: for now, a self-hosted copy has no sharing and no collaboration.
- Works on your server: the editor, browser autosave, file export, your personal library.
- Our own experiment: live sessions through your own excalidraw-room relay, with a rebuilt image (step 4 below). We have not tested it end to end, the Excalidraw docs do not describe it, and it changes only the relay address.
- Not on your server: read-only share links, and the room and image storage that live sessions use.
What this means for privacy, in plain words: the stock image and a rebuilt one both still talk to Excalidraw's services. A live session saves every scene and the images pasted into it to a Firebase project that Excalidraw runs (the VITE_APP_FIREBASE_CONFIG line of .env.production names it), a share link uploads the drawing to json.excalidraw.com, and the library browser opens libraries.excalidraw.com. Your browser encrypts the drawing before it goes, yet it does leave your server. If your rule is that nothing leaves the machine, use the board without live sessions and without share links. Your own Firebase project on that same line moves room data off Excalidraw's account and onto Google's servers under your name; share links would still upload to json.excalidraw.com, and nobody here has tried that setup.
Is the drawing private when you work together?
Yes, in the sense the project means by end-to-end encrypted. When you start a live session, your browser creates a random 128-bit key and encrypts every change with AES-GCM before it leaves your machine (encryption.ts). The key rides in the part of the link after the #, which browsers never send to a server. The relay in the excalidraw-room repository only passes the encrypted bytes and their IV between the people in a room; its src/index.ts file has no way to read them. The flip side: whoever has the whole link can open the drawing, so share it the way you would share a password.
The server that fits
The running whiteboard is light: the image is nginx serving the built app, and the relay is one small Node.js process. The heavier moment is building the image yourself for step 4, which installs all of the project's packages and compiles the app. Excalidraw publishes no hardware figures, so give the build some spare memory rather than aiming for a number.
One of the small cartridge servers on our dedicated server page is plenty for a team board, as long as it is an x86 model. Want a console screen and power button for the day a firewall mistake locks you out? Rack servers and blades include one, as do two cartridge models, the m510 and the m710x; every other cartridge can only be powered on and off remotely. Skip the X-Gene cartridge, which stops at Ubuntu 18.04. The Ampere Altra is a maybe we cannot vouch for: the published board image has an arm64 version, but these steps were written for x86-64 machines on Ubuntu 24.04, and nobody here has run them on an Altra. When you order, check that Ubuntu 24.04 appears among the systems you can pick for that server. A ready server is yours within 30 minutes of approval; builds to order take longer.
Up and running in an evening
- Get Docker Engine (version 28 or newer) plus its compose plugin, using Docker's apt packages, and become root via
sudo -i. - Set up the firewall in a safe order: switch IPv6 rules on, let SSH in, enable ufw, and only then open the web ports Caddy needs. The container is published on 127.0.0.1 only, so it is unreachable from outside. Leave the address out and Docker publishes the port on every address the host has, 0.0.0.0 and [::]. On IPv4 that traffic slips past ufw; IPv6 traffic slips past only if you have turned on Docker's own IPv6 networking, because otherwise docker-proxy answers on [::] and ufw filters it. Binding to 127.0.0.1 keeps the port private only from Docker Engine 28 onward, which is why the line after the compose file tests your engine version first.
- Point DNS for
board.example.comat the server, then install Caddy (Ubuntu 24.04 ships it as a regular package) and send that name to the container. Caddy fetches the HTTPS certificate on its own. - Optional, and our own experiment rather than anything the Excalidraw docs describe: rebuild the image so it talks to your own relay, and run the relay next to it.
sed -i 's/^IPV6=.*/IPV6=yes/' /etc/default/ufw
ufw allow OpenSSH && ufw --force enable && ufw allow 80,443/tcp
mkdir -p /srv/whiteboard
cat > /srv/whiteboard/compose.yaml <<'EOF'
services:
board:
image: excalidraw/excalidraw:latest
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
EOF
v=$(docker version -f '{{.Server.Version}}')
if [ "${v%%.*}" -ge 28 ] 2>/dev/null; then
docker compose -f /srv/whiteboard/compose.yaml up -d && sleep 5 &&
curl -fsS -o /dev/null http://127.0.0.1:8080/ && echo "board ok" &&
apt-get update && apt-get install -y caddy &&
cat > /etc/caddy/Caddyfile <<'EOF' && caddy validate --config /etc/caddy/Caddyfile && systemctl reload caddy
board.example.com {
reverse_proxy 127.0.0.1:8080
}
EOF
else echo "STOP: need Docker Engine 28+, found $v"; fi
For step 4, point DNS for live.example.com at the server too. We have not tested this end to end; it changes the relay address and nothing else, so live sessions still store their data with Excalidraw as described above. The relay address has to go into .env.production before the build, because the app's vite.config.mts reads it at build time. Editing a .env.production.local file will not work: of the .env files, the repository's .dockerignore lets only .env.development and .env.production into the build. The block runs in a subshell with set -e, so it halts on the first command that fails, a failed edit included, and it refuses to run if the clones already exist.
(
set -e
cd /srv/whiteboard
[ ! -e excalidraw ] || { echo "STOP: the clones already exist"; exit 1; }
git clone --depth 1 --branch release https://github.com/excalidraw/excalidraw.git
git clone --depth 1 https://github.com/excalidraw/excalidraw-room.git
sed -i 's|^VITE_APP_WS_SERVER_URL=.*|VITE_APP_WS_SERVER_URL=https://live.example.com|' excalidraw/.env.production
grep -qx 'VITE_APP_WS_SERVER_URL=https://live.example.com' excalidraw/.env.production || { echo "STOP: relay address edit failed"; exit 1; }
cat > compose.override.yaml <<'EOF'
services:
board:
build: ./excalidraw
image: whiteboard-local
relay:
build: ./excalidraw-room
restart: unless-stopped
environment:
- CORS_ORIGIN=https://board.example.com
ports:
- "127.0.0.1:3002:80"
EOF
docker compose -f compose.yaml -f compose.override.yaml up -d --build
sleep 5; curl -fsS http://127.0.0.1:3002/; echo
cat >> /etc/caddy/Caddyfile <<'EOF'
live.example.com {
reverse_proxy 127.0.0.1:3002
}
EOF
caddy validate --config /etc/caddy/Caddyfile
systemctl reload caddy
)
There are no user accounts in Excalidraw. As soon as Caddy serves the site, anybody who finds board.example.com can draw there, though each person only sees their own browser's drawings unless they join a session. CORS_ORIGIN only touches socket.io's long-polling fallback: WebSocket connections are not subject to CORS in a browser, so it cannot keep other sites away from the relay, and it is not a password.
Caddy's reverse_proxy hands the relay's WebSocket connection through untouched, and if WebSockets are blocked somewhere the app drops back to long-polling (Collab.tsx). One warning: the first block writes a fresh Caddyfile. If you run it again after step 4, the live.example.com entry is gone; add it back with the last lines of the step-4 block.
Keeping it safe and current
Everything here runs as root (sudo -i). Your server holds almost nothing worth backing up, and it is better you hear that now. The board image keeps no data, and the relay holds rooms in memory only. Your team's drawings are in their browsers, so clearing site data wipes them. The habit that protects work is saving important boards as .excalidraw files (plain JSON, an open format according to the README). You manage the server; we keep the hardware and the network running.
- Copy your setup off the machine:
tar -czf /root/whiteboard-$(date +%F).tgz /srv/whiteboard/compose.yaml /etc/caddy/Caddyfile $(ls /srv/whiteboard/compose.override.yaml 2>/dev/null), then move the archive somewhere else. - Update, only if you skipped step 4:
cd /srv/whiteboard && if [ -e compose.override.yaml ]; then echo "STOP: step-4 build here, use the block below"; else docker compose pull board && docker compose up -d board; fi. The check keeps it from swapping your own build for the stock image, and tells you when it refuses.latest(amd64, arm64 and arm/v7) is the one tag that still gets pushed or refreshed; thesha-tags you may see on Docker Hub are from 2021 and stale, and when we looked in October 2026latesthad last been updated in May 2026. - Update your own build (step 4): run the block below. It also re-clones anything missing, so it doubles as the rebuild step of a restore.
- Watch the relay's age: it is built on
node:12-alpine, and nobody has committed to its repository since December 2023.
(
set -e
[ -e /srv/whiteboard/compose.override.yaml ] || { echo "STOP: no step-4 build here"; exit 1; }
v=$(docker version -f '{{.Server.Version}}'); [ "${v%%.*}" -ge 28 ] || { echo "STOP: need Engine 28+, found $v"; exit 1; }
for r in excalidraw:release excalidraw-room:master; do
n=${r%%:*}; b=${r##*:}; d=/srv/whiteboard/$n
[ -d "$d" ] || git clone --depth 1 -b "$b" "https://github.com/excalidraw/$n.git" "$d"
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=https://live.example.com|' /srv/whiteboard/excalidraw/.env.production
grep -qx 'VITE_APP_WS_SERVER_URL=https://live.example.com' /srv/whiteboard/excalidraw/.env.production || { echo "STOP: relay address edit failed"; exit 1; }
docker compose -f /srv/whiteboard/compose.yaml -f /srv/whiteboard/compose.override.yaml up -d --build
)
Moving to a fresh server, as root, in this order:
- Install Ubuntu 24.04, then Docker (Engine version 28+) plus compose and
git, and copy your archive into /root. - Firewall first: reuse the firewall lines at the top of the first setup block, unchanged.
- Add Caddy before unpacking anything, so the package installer does not stop to ask about an existing Caddyfile: refresh the package lists, then
apt install caddy. - Unpack:
A=$(ls -1 /root/whiteboard-*.tgz | tail -n 1) && [ -n "$A" ] && tar -tzf "$A" >/dev/null && tar -xzf "$A" -C /. - Start it: with step 4, run the update block above. Without it:
v=$(docker version | grep -A3 '^Server' | grep -m1 'Version:' | tr -dc '0-9.'); if test "${v%%.*}" -ge 28; then docker compose -f /srv/whiteboard/compose.yaml up -d; else echo "STOP: Engine 28+ needed, this one is $v"; fi. - Point DNS for board.example.com, and live.example.com if you use it, at the new server. Then test the restored proxy file with
caddy validate --adapter caddyfile --config /etc/caddy/Caddyfile, then load it:systemctl reload caddy.
The licence, and when to pick something else
Excalidraw and excalidraw-room both use the MIT licence, according to the LICENSE file in each repository. This is how we read the licence, in general terms and not as legal advice: if you pass copies on, keep the copyright line and the licence text with them; you do not have to publish your source code or your changes.
If your team needs sign-in, shared folders and boards stored on the server, this is not that product. Excalidraw+ at plus.excalidraw.com is the team's separate hosted service, and it is not in the open-source code. For a self-hosted tool with accounts, look at Penpot for your design team or Docmost for a shared wiki.
Questions people ask
- Which Docker image should I use?
- Use excalidraw/excalidraw, which the docs name as the official client image. Only latest still receives new pushes, built for three platforms: amd64, arm64 and arm/v7; the old sha- tags date from 2021. Build your own from the repository only if you need to change a build-time setting such as the relay address.
- Can my team edit the same board at the same time?
- Not with the stock image on your server: the docs say self-hosting does not support collaboration. Step 4 is our untested experiment: session traffic goes through your relay, but the code still stores rooms and pasted images, encrypted, in Excalidraw's Firebase project. Try it before you count on it.
- What happens to our drawings if the server goes down?
- Nothing, because they are not on it. Each drawing lives in the browser where it was made. When the server is back, people open the board and carry on.
- Can I pass the relay address as an environment variable when the container starts?
- No. The build compiles VITE_APP_WS_SERVER_URL into the app's files. Change it in .env.production and rebuild.
- Do I need a server just for the relay?
- No. The relay is a single small Node.js process that keeps nothing on disk, so it runs comfortably next to the board on one machine, which is how the steps above set it up.