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. Если планируете рисовать вместе с подрядчиками, заранее решите, как выдаёте доступ к комнате: ключ лежит в ссылке, и она равносильна приглашению.