Excalidraw на VPS в Docker: своя доска для схем и архитектуры
Excalidraw — та самая доска, где схему рисуешь от руки за минуту: прямоугольники, стрелки, кривые, экспорт в PNG и SVG. Публичный excalidraw.com удобен ровно до момента, когда на доске появляется архитектура вашей инфраструктуры или чужой проект под NDA. Тогда хочется своего домена, своего TLS и полного контроля над тем, куда уезжают данные.
Хорошая новость: приложение лёгкое и ставится на минимальный сервер. Плохая (и её лучше узнать до, а не после): официальный Docker-образ из коробки гоняет совместное редактирование через инфраструктуру самого Excalidraw, а не через ваш сервер. Чтобы получить по-настоящему изолированную установку, фронтенд придётся собрать самому. Ниже — как это сделать без сюрпризов.
Что понадобится
- VPS на Ubuntu 24.04, 1–2 vCPU и 1–2 ГБ RAM. Сервис нетяжёлый: статика плюс тонкий WebSocket-релей, базы данных нет. Это ориентир, а не гарантия — реальную нагрузку смотрите по своей команде.
- Домен (в примерах — draw.example.com) с A-записью на IP сервера.
- Root-доступ, Docker с плагином Compose, git.
- Понимание одной особенности сборки: адрес room-сервера зашивается во фронтенд на этапе
vite build, а не читается в рантайме. Менять его «через .env и перезапуск» не получится.
Если сервера ещё нет, подойдёт любой VPS с 2 ГБ RAM — VPS для этого проекта, а если хочется сразу с готовым Docker, посмотрите подборку Docker VPS.
Как устроен self-hosted Excalidraw
Это два независимых компонента, и путать их — источник половины проблем.
excalidraw — статический React-бандл, собранный Vite и отданный через nginx. Редактор полностью самодостаточен: доска, фигуры, экспорт работают офлайн, без единого запроса на сервер. Сцена живёт в localStorage/IndexedDB браузера.
excalidraw-room — небольшой Node.js-сервер на socket.io. Он только ретранслирует события совместного редактирования между участниками одной комнаты. Содержимое доски он не хранит и не видит: данные шифруются на клиенте AES-ключом, который лежит во фрагменте URL (#room=...,key=...), а сервер гоняет между сокетами зашифрованные блобы.
Отдельно про кнопку «Share»: открытого self-hosted бэкенда для публичных ссылок на снапшот у Excalidraw нет, такие запросы уходят на json.excalidraw.com. Это ограничение проекта, а не ошибка вашей конфигурации.
Установка пошагово
Шаг 1. Базовая подготовка и Docker.
apt update && apt upgrade -y
apt install -y ca-certificates curl git ufw
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo "$VERSION_CODENAME") stable" > /etc/apt/sources.list.d/docker.list
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
Наружу должны смотреть только 80 и 443. Держите в уме особенность Docker: он сам пишет правила в iptables при публикации портов и по умолчанию обходит ufw. Пока вы пробрасываете только локальные адреса — проблемы нет, но если позже добавите сервисы с портами наружу, для дружбы ufw с Docker понадобится отдельная настройка (например, ufw-docker).
Шаг 2. Клонируем оба репозитория.
mkdir -p /opt/excalidraw && cd /opt/excalidraw
git clone https://github.com/excalidraw/excalidraw.git app
git clone https://github.com/excalidraw/excalidraw-room.git room
Шаг 3. Патчим Dockerfile фронтенда. Готовый Dockerfile не пробрасывает переменные окружения в сборку как build-arg. Откройте app/Dockerfile и сразу после строки FROM node:... AS builder (до RUN yarn build) добавьте:
ARG VITE_APP_WS_SERVER_URL
ENV VITE_APP_WS_SERVER_URL=$VITE_APP_WS_SERVER_URL
Так Vite подставит ваш адрес вместо oss-collab.excalidraw.com из .env.production.
Шаг 4. Собираем образы.
cd /opt/excalidraw/app
docker build --build-arg VITE_APP_WS_SERVER_URL=https://draw.example.com -t excalidraw-self:latest .
cd /opt/excalidraw/room
docker build -t excalidraw-room-self:latest .
У room-сервера CORS_ORIGIN и PORT читаются уже в рантайме (это обычный Node-процесс, а не собранная статика), поэтому Dockerfile трогать не нужно. Единственное, на что стоит посмотреть: репозиторий собирался на образе node:12-alpine, который давно вне поддержки. Замена первой строки FROM на актуальный node:20-alpine на процесс сборки не влияет, но убирает лишний риск.
Docker Compose
Оба контейнера слушают только localhost, наружу их отдаёт nginx. Файл /opt/excalidraw/docker-compose.yml:
services:
excalidraw:
image: excalidraw-self:latest
container_name: excalidraw
restart: unless-stopped
ports:
- "127.0.0.1:5000:80"
excalidraw-room:
image: excalidraw-room-self:latest
container_name: excalidraw-room
restart: unless-stopped
environment:
- NODE_ENV=production
- PORT=80
- CORS_ORIGIN=https://draw.example.com
ports:
- "127.0.0.1:3002:80"
Запуск: docker compose up -d.
Nginx перед room-сервером. Это место, где ошибаются чаще всего: без явного проброса заголовков апгрейда WebSocket не устанавливается, и socket.io тихо падает обратно на long-polling.
location /socket.io/ {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# socket.io держит соединение подолгу — иначе nginx рвёт его через 60 секунд
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
Caddy проксирует WebSocket из коробки, но таймауты стоит выставить явно — грабли те же.
Частые ошибки и решения
«Live collaboration» висит на «Connecting…». Либо room-сервер не поднят вовсе, либо прокси не пропускает Upgrade. Проверьте релей до всякого фронтенда:
curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
-H "Sec-WebSocket-Version: 13" \
-H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
"https://draw.example.com/socket.io/?EIO=4&transport=websocket"
Ответ 101 Switching Protocols значит, что прокси и room-сервер настроены верно, и проблема во фронтенд-сборке (неверный VITE_APP_WS_SERVER_URL).
Участники видят друг друга, но правки не долетают. Смотрите консоль браузера. Ошибка blocked by CORS policy — это несовпадение origin: фронтенд на одном адресе, room на другом, а CORS_ORIGIN при сборке excalidraw-room не разрешает нужный. Сеть тут ни при чём.
Правки environment: в compose ничего не меняют. Для готового публичного образа это ожидаемо: переменные VITE_APP_* читаются на этапе сборки. Лечится только пересборкой с build-arg (шаг 3) либо форком, который читает конфиг из window-объекта, инжектируемого entrypoint-скриптом.
Белый экран или доски пропали после перезагрузки страницы. Отдельного хранилища сцен на сервере нет: доска живёт в localStorage конкретного браузера и исчезает при чистке кеша или смене устройства. Единственное «настоящее» сохранение — явный экспорт в .excalidraw, PNG или SVG.
Бэкап и обновление
Тут придётся принять неприятное: в типовой связке «фронтенд + room» бэкапить состояние приложения нечего — room держит доски только в памяти процесса, пока идёт сессия. Зато нужно защитить три вещи: папку с экспортами команды, конфигурацию развёртывания и nginx с сертификатами.
mkdir -p /backup/excalidraw
tar -czf /backup/excalidraw/exports_$(date +%F).tar.gz -C /srv/team-exports excalidraw
tar -czf /backup/excalidraw/config_$(date +%F).tar.gz -C /opt/excalidraw docker-compose.yml
tar -czf /backup/excalidraw/nginx_$(date +%F).tar.gz /etc/nginx/sites-available/excalidraw
Если у вас (или до вас) развёрнут кастомный backend с Postgres и S3-совместимым хранилищем, бэкап становится обычным: дамп базы в custom-формате и синхронизация бакета.
docker exec excalidraw-postgres pg_dump -U excalidraw -Fc excalidraw > /backup/excalidraw/db_$(date +%F).dump
rclone sync s3-excalidraw:excalidraw-scenes /backup/excalidraw/scenes
Важный нюанс консистентности: если Postgres хранит ссылки на объекты в S3, дамп базы и синхронизация хранилища должны идти в одном окне, иначе восстановите базу с битыми ссылками. И не держите копии рядом с оригиналом — увозите офсайт.
Обновление — это повторная сборка, а не docker pull:
cd /opt/excalidraw/app && git pull && docker build --build-arg VITE_APP_WS_SERVER_URL=https://draw.example.com -t excalidraw-self:latest .
cd /opt/excalidraw/room && git pull && docker build -t excalidraw-room-self:latest .
cd /opt/excalidraw && docker compose up -d
Перед обновлением сделайте экспорт важных досок — вы помните, что на сервере их нет.
Итог
У вас своя доска на своём домене: статика, релей и прокси, без единой базы и без обращений к чужим облакам. Дальше стоит закрыть три вещи: регулярный экспорт досок в общую папку (иначе бэкапить нечего), краткий регламент для команды — «важное сохраняем в .excalidraw, а не только в браузере», и проверку 101 после каждого изменения конфига nginx. Если планируете рисовать вместе с подрядчиками, заранее решите, как выдаёте доступ к комнате: ключ лежит в ссылке, и она равносильна приглашению.