Self-Host Excalidraw with Real-Time Collaboration
A clean Docker Compose deployment with Caddy auto-TLS — including the runtime patch for the hardcoded oss-collab.excalidraw.com URL that breaks vanilla self-hosted setups.
At a Glance
| Project | excalidraw + excalidraw-room |
| License | MIT |
| Recommended Plan | RamNode KVM 1 GB+ (huge headroom) |
| OS | Ubuntu 24.04 LTS / Debian 12 |
| Stack | Docker + Caddy 2 (auto-TLS) |
| Subdomains needed | 2 (frontend + collab room) |
Why the default setup doesn't work
The Excalidraw frontend is a Vite SPA — VITE_APP_WS_SERVER_URL is inlined at build time. The published Docker image bakes in https://oss-collab.excalidraw.com, so setting an env var on the running container does nothing. Click "Live collaboration" and you'll connect to Excalidraw's public server instead of yours. This guide patches the bundle at startup with sed so you keep using the official image.
Initial Server Preparation
sudo apt update && sudo apt upgrade -y
sudo apt install -y ca-certificates curl gnupg ufwsudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enableInstall Docker + Compose
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | \
sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt update
sudo apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER"For Debian 12, swap ubuntu for debian in both URLs. Log out + back in for the docker group change.
Configure DNS
draw.example.com. A 192.0.2.10
collab.example.com. A 192.0.2.10Verify with dig +short draw.example.com before moving on — Caddy can't get a cert until both names resolve.
Project Layout
mkdir -p ~/excalidraw && cd ~/excalidraw
mkdir caddy_data caddy_configThe two empty dirs persist Caddy's certs across restarts.
Docker Compose Stack
services:
excalidraw:
image: excalidraw/excalidraw:latest
container_name: excalidraw
restart: unless-stopped
expose:
- "80"
environment:
- NODE_ENV=production
- VITE_APP_WS_SERVER_URL=https://collab.example.com
entrypoint: /bin/sh
command:
- -c
- |
echo "Patching hardcoded collab URL with: $VITE_APP_WS_SERVER_URL"
find /usr/share/nginx/html/assets -type f -name "*.js" \
-exec sed -i "s|https://oss-collab\.excalidraw\.com|$VITE_APP_WS_SERVER_URL|g" {} +
echo "Starting nginx..."
nginx -g 'daemon off;'
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:80/ >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
security_opt:
- no-new-privileges:true
networks:
- excalidraw_net
excalidraw-room:
image: excalidraw/excalidraw-room:latest
container_name: excalidraw-room
restart: unless-stopped
expose:
- "80"
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:80/ >/dev/null 2>&1 || exit 1"]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
security_opt:
- no-new-privileges:true
networks:
- excalidraw_net
caddy:
image: caddy:2-alpine
container_name: excalidraw-caddy
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- ./caddy_data:/data
- ./caddy_config:/config
networks:
- excalidraw_net
depends_on:
- excalidraw
- excalidraw-room
networks:
excalidraw_net:
driver: bridgeThe double dollar signs ($VITE_APP_WS_SERVER_URL) are required — Compose interpolates $VAR itself, so we escape it so the shell inside the container sees a single $.
Caddyfile
draw.example.com {
encode zstd gzip
reverse_proxy excalidraw:80
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
Referrer-Policy "no-referrer"
Permissions-Policy "interest-cohort=()"
X-Frame-Options "SAMEORIGIN"
}
}
collab.example.com {
encode zstd gzip
reverse_proxy excalidraw-room:80
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options "nosniff"
}
}Caddy upgrades WebSockets transparently — no extra config needed for collab.example.com. This is the single biggest reason to prefer Caddy over plain Nginx for this stack.
Bring the Stack Up
docker compose pull
docker compose up -d
docker compose logs -fLook for Patching hardcoded collab URL with: https://collab.example.com on the excalidraw container, and Caddy obtaining certs from Let's Encrypt for both subdomains. Cert failures are almost always DNS not yet resolving or port 80 blocked.
Verify Real-Time Collaboration
Open https://draw.example.com in two different browsers (or one regular + one incognito so localStorage is separate). In window 1: hamburger → "Live collaboration" → Start session → copy share link. Paste into window 2.
Open DevTools → Network → filter WS. You should see an open socket to collab.example.com — not oss-collab.excalidraw.com. That's the test that proves the patch worked. If it's wrong: hard refresh (Ctrl+Shift+R), then check container logs for the patch line.
Hardening
Basic auth for a private instance — add inside the draw.example.com block:
basic_auth {
yourusername JDJhJDE0JE9...
}Generate the hash with caddy hash-password. Note this protects the frontend, but anyone with a live share link can still join the room — the room server has no auth (fine for small trusted groups).
Other layers: fail2ban or CrowdSec on SSH, memory caps via deploy.resources.limits.memory.
Updates (and Backups)
cd ~/excalidraw
docker compose pull
docker compose up -dThe patch entrypoint reapplies on every start, so frontend updates pick up cleanly. Backups: there is genuinely nothing to back up server-side. Drawings live in browser localStorage / IndexedDB; the room server is stateless. If you need server persistence, look at excalidraw-storage-backend or alswl/excalidraw-collaboration.
Troubleshooting
- "Cannot read properties of undefined (reading 'generateKey')": page being served over HTTP — WebCrypto only works on secure contexts. Always use
https://. - Spinner forever, no WS connection: patch didn't run or browser cached old bundle. Hard refresh, then check
docker compose logs excalidrawfor the patch line. If missing, your Compose YAML indentation broke the entrypoint. - WS opens but no updates propagate: ensure both clients are on the same room (URL hash matches). Restart with
docker compose restart excalidraw-room. - Caddy ACME timeouts: DNS or firewall. Test
curl -v http://draw.example.com/.well-known/acme-challenge/testfrom a separate machine.
