Whiteboard
    E2E Encrypted

    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

    Projectexcalidraw + excalidraw-room
    LicenseMIT
    Recommended PlanRamNode KVM 1 GB+ (huge headroom)
    OSUbuntu 24.04 LTS / Debian 12
    StackDocker + Caddy 2 (auto-TLS)
    Subdomains needed2 (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.

    1

    Initial Server Preparation

    Update + baseline tools
    sudo apt update && sudo apt upgrade -y
    sudo apt install -y ca-certificates curl gnupg ufw
    Firewall
    sudo 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 enable
    2

    Install Docker + Compose

    Docker's official APT repo
    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.

    3

    Configure DNS

    Two A records to your VPS IPv4
    draw.example.com.    A    192.0.2.10
    collab.example.com.  A    192.0.2.10

    Verify with dig +short draw.example.com before moving on — Caddy can't get a cert until both names resolve.

    4

    Project Layout

    Working directory
    mkdir -p ~/excalidraw && cd ~/excalidraw
    mkdir caddy_data caddy_config

    The two empty dirs persist Caddy's certs across restarts.

    5

    Docker Compose Stack

    ~/excalidraw/docker-compose.yml
    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: bridge

    The 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 $.

    6

    Caddyfile

    ~/excalidraw/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.

    7

    Bring the Stack Up

    Pull + start
    docker compose pull
    docker compose up -d
    docker compose logs -f

    Look 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.

    8

    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.comnot 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.

    9

    Hardening

    Basic auth for a private instance — add inside the draw.example.com block:

    Caddyfile addition
    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.

    10

    Updates (and Backups)

    One-liner update
    cd ~/excalidraw
    docker compose pull
    docker compose up -d

    The 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 excalidraw for 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/test from a separate machine.