Self-hosted Excalidraw with working live collaboration
and share links, deployed on Coolify at whiteboard.ciya.so.
Unlike the stock excalidraw/excalidraw image, no drawing data leaves this
server. Sharing and collaboration are backed by our own room and storage
services rather than Excalidraw's public infrastructure.
Everything is served from one origin. Coolify assigns a domain to gateway
only; the other four services stay on the internal Docker network.
whiteboard.ciya.so
└── gateway (nginx)
├── / → app the Excalidraw client
├── /socket.io/ → room live collaboration (websocket)
└── /api/v2/ → storage share links, room scenes, images
└── redis (persistence)
| Service | Image | Role |
|---|---|---|
gateway |
built from nginx/ |
Single public entrypoint, path routing |
app |
alswl/excalidraw:v0.18.1-fork-b2 |
Excalidraw client |
room |
excalidraw/excalidraw-room:sha-03ff435 |
Socket.io collaboration server |
storage |
alswl/excalidraw-storage-backend:v2023.11.11 |
Scene, room and image storage |
redis |
redis:7-alpine |
Durable store behind storage |
Two constraints push toward this shape rather than giving each service its own subdomain or path-based domain in the Coolify UI:
- Coolify adds a Traefik
stripprefixmiddleware to any domain whose path is not/. Routing/socket.iothat way would strip the prefix and break the handshake. - Keeping one origin means no CORS configuration anywhere, and the entire
routing table lives in
nginx/excalidraw.confin git rather than in UI toggles that are easy to lose.
The gateway is built from a one-line nginx/Dockerfile rather than using the
stock image with the config bind-mounted. Coolify rewrites relative bind sources
to a host path under /data/coolify/applications/<uuid>/ and pre-creates them
as directories, so mounting a single file fails with not a directory. Baking
the config into the image keeps it in its own file and touches no host path.
The official image bakes its Vite variables in at build time — .env.production
hardcodes VITE_APP_WS_SERVER_URL to oss-collab.excalidraw.com and
VITE_APP_BACKEND_V2_* to json.excalidraw.com, and no runtime environment
variable can change them. The alswl fork ships a launcher.py entrypoint that
rewrites the built assets on container start, so the same published image can be
pointed at our own services with plain environment variables and no build step.
Setting VITE_APP_FIREBASE_CONFIG={} together with VITE_APP_STORAGE_BACKEND=http
is what detaches the client from Excalidraw's Firebase, which normally holds
live collaboration scenes.
- Add a DNS
Arecord forwhiteboard.ciya.sopointing at the Coolify server. - In Coolify create a new resource, type Docker Compose, sourced from this repository.
- Set the environment variable
APP_URL=https://whiteboard.ciya.so. Coolify marks it required, and deployment fails fast if it is missing. - On the
gatewayservice, set the domain tohttps://whiteboard.ciya.so. Leaveapp,room,storageandrediswith no domain so they are not exposed. - Deploy. All five containers should report healthy.
APP_URL has to match the address people actually use. It is compiled into the
client as the origin for both the websocket and the storage API, so a mismatch
shows up as collaboration silently failing to connect.
The whiteboard is open to anyone who knows the URL. Coolify supports HTTP Basic Auth per resource under the service's settings, which is the quickest lock if you want one.
docker-compose.local.yml publishes the gateway on port 8080. It is not named
docker-compose.override.yml, so Coolify never merges it.
APP_URL=http://localhost:8080 docker compose \
-f docker-compose.yml -f docker-compose.local.yml up -dOpen http://localhost:8080. Collaboration works here without a certificate
because localhost is treated as a secure origin.
APP_URL=http://localhost:8080 docker compose \
-f docker-compose.yml -f docker-compose.local.yml downAdd -v to that last command to also discard the Redis volume.
These images are amd64-only. alswl/excalidraw,
alswl/excalidraw-storage-backend and excalidraw/excalidraw-room all publish
a single linux/amd64 manifest. On an ARM host they will only run under
emulation, if at all. To build natively instead, replace the image: line with
a git build context, for example:
app:
build:
context: https://github.com/alswl/excalidraw.git#feat/env-dynamic-in-docker-container-2025-12Expect a slow, memory-hungry first build, and the same treatment for room and
storage.
Redis persistence is required. The storage backend defaults to
STORAGE_URI="", an in-memory store that drops every share link on restart.
The redis://redis:6379 setting plus the redis-data volume is what makes
links durable.
Healthchecks use 127.0.0.1, not localhost. These containers listen on
IPv4 only, and inside them localhost resolves to ::1 first and is refused.
The app service's healthcheck is overridden here for exactly this reason,
since the one baked into the image targets localhost and never passes.
The library browser still calls out. VITE_APP_LIBRARY_URL and
VITE_APP_LIBRARY_BACKEND keep their defaults and point at
libraries.excalidraw.com. That is the public shape catalogue, reached only
when you open the library panel, and it carries none of your drawings. Point
them elsewhere or set them empty in docker-compose.yml if you want the
instance fully sealed.
excalidraw-room was last published in December 2023. It is stable rather
than abandoned — the socket protocol has not changed and it is what every
self-hosted Excalidraw setup uses — but it is not actively developed.