Skip to content
Go back

Self-Hosted Excalidraw, Minus the Cloud

By KingPin 16 min read
Self-Hosted Excalidraw, Minus the Cloud
Contents

You Self-Hosted a Whiteboard and It Still Phones Home

You spun up Excalidraw on your homelab box, felt good about it, and clicked “Live collaboration” to draw an architecture diagram with a coworker. Your browser then opened a socket to oss-collab.excalidraw.com. Your server never saw a byte of it.

Solo self-hosting is one container and about two minutes, but even solo, the stock page pulls its fonts from Excalidraw’s CDN and sends an analytics pageview on every load. Collaboration is a bigger leak: the stock image has the collab, share-link, and AI URLs compiled into its JavaScript, and no environment variable changes them. Real self-hosting means rebuilding the frontend with your own room server URL and stripping those page-load calls. With Firebase switched off, you also trade away room persistence and image sync. I tested this on one machine on 2026-10-31, and the notes below come from that run.

Full example: Clone the working files at github.com/KingPin/sumguy-examples/self-hosting/excalidraw-self-hosted

Mode One: Solo Drawing in Two Minutes

If you draw alone and can live with a font CDN and a pageview counter, you are done after this section. The official image is nginx serving static files. It weighs 94 MB and listens on port 80.

The project publishes only :latest on Docker Hub, plus hundreds of old sha-* tags from 2020 and 2021. There are no version tags. The v0.18.1 GitHub release (April 2026) is the npm library version, and the app itself ships from master. It moves rarely (the last push was May 2026), but when it moves, it moves with no version number. So pin by digest.

compose.solo.yaml
# Solo Excalidraw: the stock image, nothing else.
# Drawings live in your browser's localStorage/IndexedDB. Every page load still
# fetches fonts from Excalidraw's DigitalOcean CDN and sends a Simple Analytics
# pageview. "Live collaboration", "Shareable link", the library and the AI tools
# call excalidraw.com's servers (the URLs are baked in at build time).
services:
excalidraw:
# The project publishes only :latest plus sha-* tags, so pin the digest.
image: excalidraw/excalidraw:latest@sha256:f7ee194addd607bf831d2af0f0a34463dd4225e426cf35199ef0b12a803398e9
ports:
- "8080:80"
restart: unless-stopped
Terminal window
docker compose -f compose.solo.yaml up -d
curl -sI http://localhost:8080 | head -n 1

That returned HTTP/1.1 200 OK on my machine. Open http://localhost:8080 and draw.

Where do your drawings live? In your browser. Excalidraw autosaves the scene to localStorage and IndexedDB, so the server stores nothing. Clear your browser data and the drawings go with it. Press Ctrl+S to save a .excalidraw file, and put those files in git or a synced folder. Your 2 AM self will appreciate having a copy that does not depend on one browser profile.

The drawing font files ship inside the image too (Virgil, Excalifont, Cascadia Code and the rest live under /fonts). The page still asks a DigitalOcean CDN for them first and only falls back to your nginx if that fails.

What the Stock Image Calls Home To

Your drawings never leave the browser in solo mode. The page itself does. I loaded the stock image in Chromium and counted requests to other hosts with zero clicks: seven font fetches from excalidraw.nyc3.cdn.digitaloceanspaces.com, the Simple Analytics script from scripts.simpleanalyticscdn.com, and a pageview beacon to queue.simpleanalyticscdn.com (hostname, path, browser, timezone, screen size). Those come from index.html and the CSS:

Terminal window
docker run --rm --entrypoint sh excalidraw/excalidraw:latest \
-c 'grep -ohE "https://[a-z0-9.-]+" /usr/share/nginx/html/index.html /usr/share/nginx/html/assets/*.css | sort -u'

Upstream’s index.html wraps the analytics and font blocks in a production-build check, so every production build has them, and no env var turns them off.

The heavier calls fire when you click Live collaboration, Shareable link, the library browser, or the AI tools. You can see those compiled-in destinations by grepping the built JavaScript:

Terminal window
docker run --rm --entrypoint sh excalidraw/excalidraw:latest \
-c 'grep -oh "https://[a-z0-9.-]*excalidraw[a-z0-9.-]*" /usr/share/nginx/html/assets/*.js | sort -u'

On the image I pulled, that prints nine URLs. The ones that matter:

FeatureDestination
Live collaboration sockethttps://oss-collab.excalidraw.com
“Shareable link” exporthttps://json.excalidraw.com/api/v2/
Collab scene persistence and image filesFirebase project excalidraw-room-persistence
Library browsing and publishinghttps://libraries.excalidraw.com plus a cloudfunctions URL
Text-to-diagram and diagram-to-codehttps://oss-ai.excalidraw.com

The share-link payload is stored encrypted, and collab scenes are end-to-end encrypted in the browser. The AES key sits in the URL fragment after #room=<id>,<key>, so it never goes to any server. That is a decent design. It still means your “self-hosted” whiteboard depends on someone else’s servers the moment two people want to draw together.

These are Vite VITE_APP_* variables compiled in at build time. The stock image has no runtime switch for them. If you want your own collab server, you rebuild.

The Collab Server: excalidraw-room

The official collaboration server is excalidraw/excalidraw-room. It is a small Express and Socket.IO relay that forwards encrypted messages between browsers in the same room. The decryption key never reaches it.

Some facts to weigh before you run it:

  1. License is MIT, about 540 GitHub stars.
  2. The last commit on its default branch (master) and the Docker image are both from December 2023.
  3. The image is 330 MB and runs Node v12.22.12. Node 12 went end-of-life in April 2022.
  4. The image is amd64 only. There is no ARM build, so a Raspberry Pi cannot run collab mode with it.
  5. It listens on port 80 in production. The PORT variable overrides that.
  6. CORS_ORIGIN defaults to *.
  7. Idle memory was 39 to 57 MiB across my runs.

Running a relay on an end-of-life runtime sounds alarming, and I would not expose it bare. What takes the edge off: the server only relays opaque encrypted blobs, and the compose file below keeps it behind Caddy on the same origin as the app. CORS_ORIGIN is set too, but it only limits browser polling requests, not WebSocket connections, so it does not control who can connect. Keep it on a network you control and pin the image by digest. If a stale Node 12 relay bothers you more than that, skip collab and stay in solo mode.

A quick health check shows it is alive:

Terminal window
docker run --rm -d --name room-test -p 3002:80 excalidraw/excalidraw-room:latest
curl -s http://localhost:3002
docker rm -f room-test

The response is Excalidraw collaboration server is up :).

Mode Two: Rebuilding the Frontend for Your Own Collab

Here is the plan. Caddy sits in front on one origin. Requests to /socket.io/* go to the room server. Everything else goes to a frontend you build yourself with your room URL compiled in.

Because the frontend and the socket share one origin, PUBLIC_URL is both the app address and the collab server address. You set it once.

compose.yaml
# Excalidraw + your own collaboration server, on one origin.
#
# browser -> caddy :8080 -> /socket.io/* -> room (excalidraw-room)
# -> everything else -> excalidraw (rebuilt frontend)
#
# The collab server URL is compiled into the frontend JavaScript, so the
# frontend is rebuilt from source with your URL. Set PUBLIC_URL to the exact
# origin your browser uses (scheme + host + port, no trailing slash).
#
# PUBLIC_URL=http://localhost:8080 docker compose up -d --build
#
# The first build takes several minutes (yarn install + vite build).
services:
excalidraw:
build:
# Upstream master at the commit this kit was tested against (2026-10-31).
context: https://github.com/excalidraw/excalidraw.git#8bcdca4526867433f99fdfcb55a063668db0c9dc
dockerfile_inline: |
FROM node:24 AS build
WORKDIR /opt/node_app
COPY . .
RUN yarn --frozen-lockfile --network-timeout 600000
# Vite gives variables already in the environment priority over
# .env.production, so build args override the excalidraw.com defaults.
ARG VITE_APP_WS_SERVER_URL
# '{}' = no Firebase. Live sync still works peer-to-peer through the
# room server; room persistence and image sync do not (see README).
ARG VITE_APP_FIREBASE_CONFIG={}
RUN test -n "$$VITE_APP_WS_SERVER_URL" && yarn build:app:docker
# Every production build loads fonts from Excalidraw's DigitalOcean CDN
# and a Simple Analytics script on each page view. No env var turns
# that off, so strip it from the built files. Fonts then load from this
# image; the UI font (Assistant) is not bundled and falls back to a
# system font.
RUN cd excalidraw-app/build \
&& perl -0pi -e 's#"https://excalidraw\.nyc3\.cdn\.digitaloceanspaces\.com/oss/",\s*##g; s#<link rel="(?:preload|preconnect)" href="https://[^>]*>##g; s#<script>// need to load this script dynamically.*?</script>##s' index.html \
&& perl -pi -e 's#url\(https://excalidraw\.nyc3\.cdn\.digitaloceanspaces\.com/[^)]*\) format\("woff2"\),##g' assets/*.css \
&& ! grep -rqE 'simpleanalyticscdn|digitaloceanspaces|fonts\.googleapis' index.html assets/*.css
FROM nginx:stable-alpine-slim
COPY --from=build /opt/node_app/excalidraw-app/build /usr/share/nginx/html
args:
VITE_APP_WS_SERVER_URL: ${PUBLIC_URL:?set PUBLIC_URL, e.g. http://localhost:8080}
image: excalidraw-selfhosted:local
restart: unless-stopped
room:
image: excalidraw/excalidraw-room:latest@sha256:2fe999f9be4379e3ee282fc45d75d84a691a6383dde33544514cc395287c7a70
environment:
# Default is "*". This only limits browser polling requests; it does not
# stop a WebSocket client from another origin connecting.
CORS_ORIGIN: ${PUBLIC_URL}
restart: unless-stopped
caddy:
image: caddy:2
ports:
- "8080:8080"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
# Keeps issued certificates across container re-creates.
- caddy_data:/data
depends_on: [excalidraw, room]
restart: unless-stopped
volumes:
caddy_data:
Caddyfile
# Local test: plain HTTP on :8080. Browsers treat http://localhost as a secure
# context, so the Web Crypto API that collab rooms need still works.
# For a real deployment, replace ":8080" with your hostname (draw.example.com),
# publish 80/443 instead, and set PUBLIC_URL=https://draw.example.com.
:8080 {
handle /socket.io/* {
reverse_proxy room:80
}
handle {
reverse_proxy excalidraw:80
}
}
Terminal window
PUBLIC_URL=http://localhost:8080 docker compose up -d --build

The first build takes several minutes, because it runs yarn install and a full Vite build. The rebuilt frontend image came out at 50.5 MB, against 94 MB for the stock one.

Three build gotchas cost me time, so you can skip them.

Do not build the release tag. The v0.18.1 Dockerfile uses node:18, and the v0.18.1 lockfile pins [email protected] (pulled in by mermaid), which wants Node 20 or newer. The build dies with The engine "node" is incompatible with this module. Expected version ">= 20". Got "18.20.8". Master’s Dockerfile uses node:24 and builds fine. That is why the compose file pins a master commit instead of a tag.

Build args beat the defaults. Vite gives variables already present in the process environment priority over .env.production. A Docker build ARG therefore overrides the excalidraw.com defaults, and you do not edit any files in the checkout.

The trackers live in the HTML. The font CDN and the Simple Analytics script come from index.html and the CSS, behind a production-build check, so a build arg cannot remove them. The second RUN in the compose file deletes them from the built files with two perl substitutions, then fails the build if any of simpleanalyticscdn, digitaloceanspaces, or fonts.googleapis survives. The cost: the UI font (Assistant) is not bundled, so menus fall back to a system font. Drawing fonts still load from your own server. With the patch, Chromium made zero requests outside my host during a page load and a drawing.

To prove the override worked, grep your rebuilt image the same way as before:

Terminal window
docker run --rm --entrypoint sh excalidraw-selfhosted:local \
-c 'grep -oh "https://[a-z0-9.-]*excalidraw[a-z0-9.-]*" /usr/share/nginx/html/assets/*.js | sort -u'

In my build, oss-collab.excalidraw.com and the Firebase database URL were gone, and my PUBLIC_URL was in the bundle instead. The build args swap the collab socket and Firebase, and the patch step removes the page-load calls. Click-triggered features stay as they were. Shareable links still post to json.excalidraw.com, the library browser still calls libraries.excalidraw.com and a cloudfunctions URL, and the AI tools still call oss-ai.excalidraw.com. If that matters to you, do not click those buttons.

HTTPS Is Not Optional for Collab

Collaboration encrypts scenes with the Web Crypto API, and browsers expose crypto.subtle only in a secure context. That means HTTPS, or http://localhost.

I checked this in Chromium. On http://localhost, collab works. On http://<LAN IP>:<port>, isSecureContext was false and crypto.subtle was undefined. So the plain http://192.168.1.50:8080 setup that works fine for solo drawing breaks collab.

The fix is a real hostname with a certificate. Change the Caddyfile for a deployment, and Caddy fetches the certificate itself if the host is public. For an internal-only name, use your usual DNS challenge setup.

Caddyfile
draw.example.com {
handle /socket.io/* {
reverse_proxy room:80
}
handle {
reverse_proxy excalidraw:80
}
}

Then publish 80 and 443 on the caddy service instead of 8080, and rebuild with the matching URL:

Terminal window
PUBLIC_URL=https://draw.example.com docker compose up -d --build

PUBLIC_URL must match the origin in your browser bar exactly: scheme, host, port, no trailing slash. The frontend connects its socket to that compiled-in address, and the room server’s CORS_ORIGIN is set from the same value. If you change the URL later, you rebuild.

What Works With Firebase Off (Tested)

The compose file sets VITE_APP_FIREBASE_CONFIG={}, so no Firebase project is involved. I ran this with two and then three Chromium sessions on localhost. Here is what happened:

  1. Browser A started a session. The room link looked like http://localhost:<port>/#room=<20-hex-id>,<22-char-key>.
  2. Browser B opened the link. B saw A’s rectangle and A’s live cursor, labelled with a random collaborator name. The badge showed 2.
  3. Each browser in the session showed a red warning triangle and, once, an error dialog: “Couldn’t save to the backend database. If problems persist, you should save your file locally to ensure you don’t lose your work.”
  4. Browser C joined late while A was still open. C received the full scene from A over the socket.
  5. I closed every tab. A fresh browser opening the same room link got an empty canvas.

Read that last result twice. With Firebase off, the room exists only in open tabs. Scene updates travel peer to peer through your relay. Firebase normally persists the room scene, so a late joiner can load it when no peer is online, and it stores every pasted image, which all participants download from there. Without it, the save call fails, you get that warning, and images do not sync.

Excalidraw also pauses its own localStorage autosave during a collab session. So the one place your work is not saved is the session you most want saved. Press Ctrl+S to export a .excalidraw file before anyone closes a tab.

Idle memory in my run was 13 MiB for the nginx frontend, 39 MiB for the room server, and 54 MiB for Caddy: about 106 MiB for the whole stack.

Who Should Run Which Mode

Pick by how many people draw at once.

Solo mode is for you if you draw alone or share finished diagrams as exported PNG, SVG, or .excalidraw files, and a font CDN plus a cookieless pageview counter does not bother you. It is one container. Run it, pin the digest, back up your exports. This covers most homelab users. If the pageview does bother you, build only the patched frontend with PUBLIC_URL=http://localhost:8080 docker compose build excalidraw, then put image: excalidraw-selfhosted:local in compose.solo.yaml in place of the stock image.

Rebuilt collab is for you if a small team brainstorms live in the same session, you have HTTPS, and you accept that the session is ephemeral. Treat the room as a meeting, not a document. Save before you leave.

If you want share links and image sync as well, the community project alswl/excalidraw-collaboration (MIT, about 630 stars, latest release v0.18.1-fork-b3 on 2026-08-08) is the route to look at. It patches the frontend to read config at runtime and adds an HTTP storage backend that replaces Firebase and json.excalidraw.com. It also has a Go room server (alswl/excalidraw-room-go). Its README says HTTPS is required for collab crypto too. We did not test it, so I cannot tell you it works. Read its README and try it on a throwaway box first.

Skip the rebuild if the only reason is “self-hosted feels cleaner.” You are swapping a one-line compose file for a multi-minute build, a pinned upstream commit to babysit, and an end-of-life Node runtime. That is a forklift to move a couch. Technically it works, but your neighbors will have questions.

Keeping It Alive

Upgrades in solo mode mean picking a new digest. Check the :latest tag on Docker Hub, copy the new sha256, and update the compose file. The upstream app tracks master, so a new digest can bring UI changes with no version number to read.

Upgrades in collab mode mean bumping the commit hash in the context: line and rebuilding. Test the build before you trust it, since a Dockerfile or dependency change upstream can break it the way it broke the v0.18.1 tag.

Terminal window
git ls-remote https://github.com/excalidraw/excalidraw.git HEAD

That prints the current master commit. Paste it into context:, run the build, open two browsers, and confirm a cursor shows up in each. That two-minute check beats finding out during the meeting.

Common Questions

Does self-hosted Excalidraw send data to excalidraw.com?

Not to excalidraw.com itself in solo mode, but the stock Excalidraw page still contacts third parties on every visit: it loads fonts from a DigitalOcean CDN and sends a Simple Analytics pageview. Collaboration, shareable links, the library and AI tools call excalidraw.com servers. Rebuilding and patching the frontend removes the page-load calls.

Why does Excalidraw collaboration not work over plain HTTP on my LAN?

Plain HTTP on a LAN address is not a secure context, so the browser hides crypto.subtle, the Web Crypto API that Excalidraw collaboration uses to encrypt rooms. http://192.168.1.50:8080 fails, while http://localhost and HTTPS both count as secure. Put the stack behind a reverse proxy with a real certificate.

Can I change the Excalidraw collaboration server URL with an environment variable?

No, not on the stock image. The collaboration URL is a Vite VITE_APP_WS_SERVER_URL variable compiled into the JavaScript at build time, so a runtime variable changes nothing. Pass it as a Docker build argument and rebuild the frontend. The community alswl/excalidraw-collaboration project patches Excalidraw to read config at runtime, but we did not test it.

Where are drawings stored in self-hosted Excalidraw?

Drawings live in your browser’s localStorage and IndexedDB, not on the server. Clearing browser data deletes them. Export with Ctrl+S to get a .excalidraw file you can back up. During a collaboration session, Excalidraw pauses that autosave, so export before closing the last tab.


Share this post on:

Send a Webmention

Written about this post on your own site? Send a webmention and it'll show up above once verified.


Next Post
Docker's Embedded DNS: Inside 127.0.0.11

Discussion

Powered by Garrul . Sign in with GitHub or Google, or post anonymously.

Related Posts