MAATRIX / Блог / Excalidraw на сервере: частые ошибки и решения

Excalidraw на сервере: частые ошибки и решения

MAATRIX

Excalidraw — приятный инструмент для набросков схем от руки, и соблазн поднять его на своём сервере понятен: не зависеть от публичного excalidraw.com, держать доски в своей инфраструктуре, дать команде совместное рисование без чужих облаков. На практике self-hosted установка почти всегда упирается в одну из трёх проблем: совместная работа не коннектится, доски пропадают после перезагрузки страницы, или на HTTPS всё падает с непонятной ошибкой в консоли. Ниже — разбор причин и рабочие решения для каждой.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →

Архитектура self-hosted Excalidraw и откуда берутся проблемы

Официальный образ excalidraw/excalidraw — это просто статичный SPA-билд на nginx: рисует, экспортирует в PNG/SVG, хранит сцену в localStorage браузера. Но запустить только этот один контейнер и ожидать совместного редактирования в реальном времени не выйдет: за live-collab отвечает отдельный сервис, excalidraw/excalidraw-room — WebSocket-релей на socket.io, который перебрасывает изменения между клиентами одной комнаты и ничего не хранит на диске.

Отсюда и типичный набор ошибок при самостоятельном хостинге:

  • фронтенд поднят один, без room-сервера — кнопка «Live collaboration» либо не работает, либо висит на «Connecting…»;
  • room-сервер поднят, но за обратным прокси, который не пробрасывает Upgrade/Connection заголовки — WebSocket не устанавливается;
  • URL room-сервера зашит в билд фронтенда как build-time переменная, а не runtime — смена .env после сборки ничего не меняет;
  • нет отдельного хранилища сцен — доски живут только в localStorage конкретного браузера и теряются при чистке кеша или смене устройства.

Разберём каждую по порядку, начиная с правильного docker-compose.

Базовый docker-compose: фронтенд + комната для совместной работы

Минимальный рабочий стек — два контейнера: сам Excalidraw и excalidraw-room для realtime-коллаборации.

# docker-compose.yml
services:
  excalidraw:
    image: excalidraw/excalidraw:latest
    container_name: excalidraw
    restart: unless-stopped
    ports:
      - "127.0.0.1:5000:80"
    environment:
      - VITE_APP_WS_SERVER_URL=https://draw-ws.example.com

  excalidraw-room:
    image: excalidraw/excalidraw-room:latest
    container_name: excalidraw-room
    restart: unless-stopped
    ports:
      - "127.0.0.1:3002:80"

Важный нюанс: официальный образ excalidraw/excalidraw собирается с уже зашитыми переменными окружения (VITE_APP_WS_SERVER_URL и другими VITE_APP_*) — они читаются на этапе vite build, а не в рантайме контейнера. Строчка environment: в compose-файле для готового публичного образа чаще всего просто не сработает — фронтенд продолжит стучаться туда, куда его собрали в официальном Dockerfile (по умолчанию — публичный WebSocket-сервер excalidraw.com, если вы вообще не трогали билд).

Рабочий путь — собрать образ самостоятельно с нужными build-args:

git clone https://github.com/excalidraw/excalidraw.git
cd excalidraw
docker build \
  --build-arg VITE_APP_WS_SERVER_URL=https://draw-ws.example.com \
  --build-arg VITE_APP_DISABLE_TRACKING=true \
  -t excalidraw-custom .

Да, это неудобнее, чем «поменял .env — перезапустил контейнер». Но это цена статичной SPA-сборки: переключить URL room-сервера без пересборки не выйдет — придётся либо держать fork с чтением конфига из window-объекта, инжектируемого entrypoint-скриптом nginx, либо смириться с пересборкой при смене адреса. Для одного сервера с постоянным доменом это разовая настройка.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Совместная работа не подключается: ошибки WebSocket за прокси

Самая частая жалоба — кнопка Share/Live collaboration либо ничего не делает, либо участники видят друг друга, но правки не долетают. В 90% случаев причина в обратном прокси перед excalidraw-room: он не проксирует апгрейд соединения до WebSocket, и socket.io тихо падает обратно на long-polling или вообще обрывается.

Nginx перед room-сервером должен явно пробрасывать заголовки апгрейда:

server {
    listen 443 ssl http2;
    server_name draw-ws.example.com;

    ssl_certificate     /etc/letsencrypt/live/draw-ws.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/draw-ws.example.com/privkey.pem;

    location / {
        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 из коробки, но таймаут стоит выставить явно:

draw-ws.example.com {
    reverse_proxy 127.0.0.1:3002 {
        transport http {
            read_timeout 3600s
        }
    }
}

Если Caddy — ваш выбор для всего проекта, автонастройку HTTPS и типовые проблемы конфигурации разбирали в статье про Caddy на сервере — те же грабли с таймаутами и апгрейдом соединений всплывают и в других realtime-приложениях.

Второй частый источник обрыва — CORS. Если фронтенд на draw.example.com, а room-сервер на draw-ws.example.com, socket.io должен явно разрешать этот origin (переменная CORS_ORIGIN при сборке excalidraw-room) — ошибка blocked by CORS policy в консоли однозначно указывает на несовпадение origin, а не на сеть.

Проверить, что WebSocket вообще жив, можно до всякого фронтенда:

curl -i -N -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  https://draw-ws.example.com/socket.io/?EIO=4&transport=websocket

Ответ 101 Switching Protocols значит, что прокси и room-сервер настроены верно, и проблему нужно искать во фронтенд-сборке (неверный VITE_APP_WS_SERVER_URL) или в firewall между хостами.

Белый экран и потерянные доски после обновления страницы

Вторая по частоте жалоба: доска рисовалась, всё было хорошо, обновили страницу — и холст пуст. Или того хуже — белый экран без единой ошибки на вид.

Причины делятся на две группы.

Белый экран сразу после установки почти всегда значит, что фронтенд отдаётся не с корня домена. Excalidraw ожидает статику с /, а не с поддиректории вроде example.com/excalidraw/. Нужен путь-префикс — пересобирайте с соответствующим --build-arg или настраивайте rewrite в nginx так, чтобы приложение видело себя как корневое, иначе относительные пути до JS-чанков ломаются и React не монтируется. Смотрите вкладку Network в devtools: 404 на /assets/*.js — это оно.

Пропажа доски после обновления страницы — это не баг, а ожидаемое поведение архитектуры. Без совместной сессии Excalidraw хранит текущую сцену в localStorage браузера конкретного устройства. Это значит:

  • очистка кеша браузера или режим инкогнито = потеря несохранённой доски;
  • открыли с телефона — увидите пустой холст, доски с ноутбука там нет;
  • домен сменился (например, httphttps или поддомен) — localStorage привязан к origin, старые данные останутся недоступны с нового адреса.

Рабочая привычка — регулярно жать «Save to disk» (файл .excalidraw, это обычный JSON) или полагаться на функцию Live collaboration, где сцена держится в памяти комнаты, пока в ней есть хотя бы один участник. Полноценное серверное хранилище — отдельная история, разберём её в следующем разделе.

HTTPS, Mixed Content и wss за Caddy/nginx

Если фронтенд открывается по https://, а VITE_APP_WS_SERVER_URL в билде указывает на ws:// (не wss://) или на голый IP без сертификата, браузер молча блокирует соединение как небезопасный mixed content — в консоли будет Mixed Content: ... attempted to connect to the insecure WebSocket endpoint. Кнопка коллаборации при этом просто не реагирует, без внятной ошибки на экране.

Решение — всегда закрывать room-сервер отдельным сертификатом и указывать в build-args именно https://:

VITE_APP_WS_SERVER_URL=https://draw-ws.example.com

socket.io сам поднимет wss://, если видит, что страница и WS-эндпоинт оба на HTTPS — руками указывать протокол wss:// в этой переменной не нужно, socket.io-client определяет его исходя из схемы переданного URL.

Отдельно проверьте, что у фронтенда сертификат валиден и не самоподписан: браузер заблокирует запросы к другому origin с невалидным сертификатом, даже если вы вручную добавили исключение для основного домена. Если получаете SSL_ERROR или сертификат не обновляется автоматически, стоит свериться со статьёй про ошибки Let's Encrypt.

Постоянное хранение досок и бэкапы

У официального self-hosted стека Excalidraw нет встроенного бэкенда для хранения сцен — в отличие от excalidraw.com с его закрытой инфраструктурой. excalidraw-room — только realtime-релей, он ничего не пишет на диск. Функция «поделиться ссылкой», где сцена шифруется на клиенте и кладётся на сервер, из коробки в open-source версии не работает без дополнительного бэкенда для хранения зашифрованных blob'ов.

Практические варианты для тех, кому нужно постоянное хранилище:

  1. Не бороться с архитектурой — принять, что Excalidraw self-hosted это инструмент для рисования и совместной сессии здесь-и-сейчас, а persistence обеспечивать вручную: экспорт в .excalidraw (JSON) или SVG/PNG после каждой сессии, хранение файлов в общей папке (Nextcloud, git-репозиторий, обычный volume с бэкапом).
  2. Поднять community-бэкенд хранения, эмулирующий Firebase Storage API, которым пользуется официальный клиент для функции share. Такие проекты есть в экосистеме Excalidraw, но активно меняются и не входят в официальный docker-compose — проверяйте актуальность репозитория и совместимость с текущей версией фронтенда перед использованием.
  3. Держать сессию долгоживущей — если команда работает волнами, room-сервер можно не перезапускать между сессиями: пока жив хотя бы один сокет-коннект к комнате, сцена остаётся в памяти процесса. Это не замена бэкапу, но снимает часть боли с «забыли сохранить перед обедом».

Сам сервер и docker-volume с конфигами стоит бэкапить как обычно — том с сертификатами и nginx-конфигами восстанавливается штатным tar + rsync на внешнее хранилище. Общие принципы сборки продакшен-стека на Docker Compose разбирали в статье про docker-compose для продакшена, они применимы и здесь.

Если вам в принципе нужна не доска для схем, а совместное редактирование документов и заметок с постоянным хранением из коробки — присмотритесь к CryptPad: там персистентность встроена изначально, ценой другого набора компромиссов по производительности и функциональности рисования.

Отдельно про версии: latest тег официального образа может незаметно подтянуть breaking change в протоколе комнаты — участники на старой закешированной версии фронтенда не видят новых участников на свежей из-за рассинхрона протокола socket.io между версиями excalidraw-room. Фиксируйте конкретный тег образа вместо latest, обновляйте фронтенд и room-сервер синхронно и очищайте Service Worker в браузерах команды после релиза — Excalidraw кеширует статику агрессивно. По ресурсам сервер нужен скромный: и статика на nginx, и WebSocket-релей на Node.js легковесны, для команды до пары десятков одновременных участников хватает младшего VPS с 1 vCPU и 1-2 ГБ RAM.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Обязательно ли поднимать excalidraw-room, если совместное рисование не нужно?

Нет. Для личного инструмента без live-коллаборации хватит одного контейнера с фронтендом — кнопка Share просто не будет работать, остальной функционал не пострадает.

Можно ли поменять адрес room-сервера без пересборки образа?

Только если сами доработали Dockerfile так, чтобы конфиг читался в рантайме через entrypoint-скрипт. В официальной сборке VITE_APP_* переменные зашиваются на этапе vite build и после неизменяемы.

Почему участники видят курсоры друг друга, но правки не применяются?

Обычно это рассинхрон версий excalidraw-room между тем, что зашито в статике фронтенда, и тем, что реально запущено в контейнере — обновите оба до одной версии и очистите кеш браузера у участников.

Нужен ли Redis или база данных для excalidraw-room?

Нет, для одного инстанса room-сервер хранит состояние комнат в памяти процесса. Redis нужен только при горизонтальном масштабировании нескольких инстансов за балансировщиком.

Как перенести доски на новый сервер?

Экспортируйте каждую активную доску в .excalidraw через меню приложения, перенесите файлы и импортируйте через drag-and-drop на холст — способ работает независимо от того, как настроено хранилище.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →