Self-hosted diagram tools compared
draw.io, Excalidraw and Kroki on your own VPS: which parts never touch your server, and why that decides whether self-hosting buys you privacy.
Which self-hosted diagram tool should you run?
Self-hosted diagram tools come in two shapes, and the shape matters more than the feature list. draw.io and Excalidraw are browser applications: the container ships JavaScript, your browser does the drawing, and the server never sees the diagram. Kroki is the opposite. You send it diagram text over HTTP and it sends back an image, so every diagram passes through your own machine.
Run draw.io if you want a full editor beside a wiki. Run Excalidraw if you want a quick sketch pad and you accept that it saves nothing outside the browser you drew in. Run Kroki if your diagrams are text that lives in git next to the code they describe.
What self-hosting a diagram tool actually changes
Be exact about which parts touch your server, because that one fact decides whether self-hosting buys privacy or only availability.
- draw.io renders in the browser. Your container serves the application code. The file goes wherever you tell the editor to save it.
- Excalidraw renders in the browser and keeps the current scene in that browser's local storage. Nothing is written on the server side.
- Kroki renders on the server. The diagram source and the finished image both exist inside your container.
Only the third case moves data onto hardware you control. For the first two, self-hosting buys asset control and availability: the JavaScript comes from your host, so the editor keeps working when a third party has an outage, changes its terms, or becomes unreachable from your network. That is worth real money to some teams. It is a different claim from "the diagram never leaves the building".
draw.io: an official container that stores nothing
The project publishes its own image, and the quick start in its README is one line.
docker run -it --rm --name="draw" -p 8080:8080 -p 8443:8443 jgraph/drawioThat publishes the editor on every address the box has. On a VPS, bind the published port to loopback and reach it through a reverse proxy or an SSH tunnel.
docker run -d --name drawio --restart unless-stopped -p 127.0.0.1:8080:8080 jgraph/drawioOpen http://127.0.0.1:8080/?offline=1&https=0 through the tunnel. The README calls ?offline=1 "a security feature that disables support of cloud storage". Without it the editor offers Google Drive, OneDrive and GitHub as save targets, which are other people's servers.
Binding to 127.0.0.1 is what keeps that port off the public internet. A plain -p 8080:8080 is not filtered by ufw, because Docker inserts its own iptables rules ahead of the chains ufw manages, so the firewall reads as correct while the port answers the world. Docker publishing straight past ufw covers the mechanism and the fix.
Two environment variables matter as soon as the editor is not on localhost.
services:
drawio:
image: jgraph/drawio
container_name: drawio
restart: unless-stopped
ports:
- "127.0.0.1:8080:8080"
environment:
DRAWIO_SERVER_URL: "https://drawio.example.com/"
DRAWIO_BASE_URL: "https://drawio.example.com"The trailing slash is not a typo. The README defines DRAWIO_SERVER_URL as the "Public deployment URL with a trailing slash" and DRAWIO_BASE_URL as the "Same URL without a trailing slash", used by the viewer, lightbox and embed code paths. If you serve the editor under a subpath such as https://www.example.com/drawio/, both values must carry that subpath, because the app builds its viewer and embed URLs from them.
Persistence: there is none, and that is the design. No volume appears in that Compose file because the container holds no diagram data. A .drawio file is XML that the editor hands to your browser, and the save target you pick decides where it lands: a download on your own machine, or the application that embedded the editor. Back up that destination. If the answer is a folder on the VPS, then the thing worth protecting is that folder and the file manager you use to reach it, since draw.io keeps no copy of anything.
What still leaves your server. Export to PDF is the clearest case. The README describes DRAWIO_SELF_CONTAINED as "Set to 1 to route export requests through Tomcat's ExportProxyServlet (/service/0) instead of calling the export server directly". Read that backwards: by default, an export call does not stay inside your deployment. The project also publishes jgraph/export-server, a "standalone image-export-server of draw.io", for people who want that rendering on their own hardware. ENABLE_DRAWIO_PROXY is off by default and enables a /proxy endpoint that fetches external image URLs on the browser's behalf, so leave it off unless you need it.
Excalidraw: a static bundle with no server behind it
The official image page gives this command.
docker run --rm -dit --name excalidraw -p 5000:80 excalidraw/excalidraw:latestMove the published port onto loopback for the same reason as before.
docker run -d --name excalidraw --restart unless-stopped -p 127.0.0.1:5000:80 excalidraw/excalidraw:latestInside the container, nginx serves a compiled JavaScript bundle on port 80. The published image is around 41 MB compressed (Docker Hub, August 2026), which tells you how little is in there. No database, no session store, no upload directory, because there is nothing on the server to store.
The image page states the limit plainly: "At the moment, self-hosting your own instance doesn't support sharing or collaboration features." The buttons are still in the interface, so the reason is worth knowing. Live collaboration needs a websocket server, published separately as excalidraw/excalidraw-room. A share link needs a storage service to hold the encrypted scene. The addresses of both are compiled into the bundle at build time as Vite variables (VITE_APP_WS_SERVER_URL, VITE_APP_BACKEND_V2_GET_URL, VITE_APP_BACKEND_V2_POST_URL), and the production values in the repository point at Excalidraw's own hosted services. Vite substitutes those values during the build, so they end up as literal strings inside the JavaScript. Setting them as container environment variables changes nothing, because no code reads them at runtime. Pointing collaboration at your own room server means building the frontend from source with your own values. Check the state of that server before you plan around it: the excalidraw/excalidraw-room image on Docker Hub had not been rebuilt in over two years as of August 2026.
Where a drawing actually lives. The scene sits in the browser's local storage, on that device, for that origin. Open the same URL in a private window and the canvas is empty, which is the fastest way to prove it to yourself. Clearing site data deletes the drawing, and there is no server copy to restore from. So teach people to use "Save to..." and keep the .excalidraw file, which is JSON, somewhere that gets backed up. A shared instance gives every person their own private canvas. Treat it as a personal sketch pad that happens to be hosted.
Kroki: diagrams as code, rendered on your server
Kroki is one HTTP gateway in front of many renderers. You POST text and get SVG or PNG back. Graphviz, PlantUML, D2 and several others are built into the gateway image. Mermaid, BPMN and Excalidraw rendering live in companion containers, so Compose is the sensible way to run it. This is the example from the Kroki documentation.
services:
kroki:
image: yuzutech/kroki
depends_on:
- mermaid
- bpmn
- excalidraw
environment:
- KROKI_MERMAID_HOST=mermaid
- KROKI_BPMN_HOST=bpmn
- KROKI_EXCALIDRAW_HOST=excalidraw
ports:
- "8000:8000"
tmpfs:
- /tmp:exec
mermaid:
image: yuzutech/kroki-mermaid
expose:
- "8002"
bpmn:
image: yuzutech/kroki-bpmn
expose:
- "8003"
excalidraw:
image: yuzutech/kroki-excalidraw
expose:
- "8004"expose publishes nothing to the host, so the companions are reachable only from the gateway on the Compose network. That is what you want. Change the gateway line to "127.0.0.1:8000:8000" unless the wiki calling it runs on a different host. If you have not written a Compose file on a server before, running Docker Compose on a VPS covers the file layout and the docker compose up -d cycle.
Run two smoke tests, in this order, because they fail for different reasons.
curl -s -X POST http://127.0.0.1:8000/graphviz/svg \
-H 'Content-Type: text/plain' \
--data-binary 'digraph G {Hello->World}' | head -c 60Graphviz runs inside the gateway, so an SVG document here proves the gateway itself is healthy. Now test the path that crosses containers.
curl -s -X POST http://127.0.0.1:8000/mermaid/svg \
-H 'Content-Type: text/plain' \
--data-binary 'graph TD; A-->B;' | head -c 60SVG from the second command proves KROKI_MERMAID_HOST resolved and the companion answered. If the first works and the second does not, the fault is between the two containers, so read docker compose logs kroki before you touch the diagram syntax.
The GET form encodes the diagram into the URL, which is how a wiki embeds an image with no plugin at all. The documentation gives this encoder.
cat hello.dot | python -c "import sys; import base64; import zlib; print(base64.urlsafe_b64encode(zlib.compress(sys.stdin.read().encode('utf-8'), 9)).decode('ascii'))"On Ubuntu that prints python: command not found, because the system ships python3 and no unversioned python. Use python3. The output goes on the end of a URL shaped /{diagram-type}/{output-format}/{encoded-diagram}, and any <img> tag can point at it. There is a ceiling: KROKI_MAX_URI_LENGTH defaults to 4096 bytes, so a long diagram has to go by POST.
Kroki reads text you send it, so its security settings are the ones that matter. KROKI_SAFE_MODE defaults to SECURE, the most restrictive of the three levels, and KROKI_PLANTUML_ALLOW_INCLUDE defaults to false. Those defaults exist because PlantUML's !include directive reads files and URLs from the renderer's point of view. Relax them on an endpoint that anyone can reach and you have handed the internet a file reader running inside your container. Leave them alone unless you know which include path you need, then name it with KROKI_PLANTUML_INCLUDE_PATH.
Memory: which one hurts on a small VPS
The order is predictable once you know what each container runs.
- The Excalidraw image is nginx serving static files. It is the cheapest of the three by a wide margin.
- draw.io runs Tomcat, a Java application server, so it carries a JVM (Java virtual machine) whether anyone is drawing or not.
- The Kroki gateway is a Java service too, shipped as a jar for manual installs.
- The mermaid companion is the expensive one. Its Dockerfile installs Chromium and sets
PUPPETEER_EXECUTABLE_PATH=/usr/lib/chromium/chrome, because Mermaid renders in a real browser engine.
Idle numbers therefore tell you very little. The number that matters is the spike while a diagram renders, and KROKI_MERMAID_MAX_CONCURRENCY defaults to 6, so six browser renders can be in flight at once. Measure it on your own box instead of trusting a published figure.
docker stats --no-stream
docker system dfRun the first while everything is idle, then again while you render a large mermaid diagram in a loop. If the spike is uncomfortable on a small plan, cap it rather than guess: setting memory limits on a Compose service shows the syntax and what happens when a container reaches its ceiling. Dropping the mermaid companion is also a valid answer, since the gateway keeps serving every renderer built into it.
None of these ship a user model, so put one in front
draw.io has no accounts. Excalidraw has no accounts. Kroki answers whatever request reaches it. Any login has to come from the proxy.
sudo apt update && sudo apt install -y apache2-utils
sudo htpasswd -c /etc/nginx/.htpasswd alicehtpasswd -c creates the file and overwrites an existing one, so pass -c the first time and never again.
server {
listen 443 ssl;
server_name drawio.example.com;
location / {
auth_basic "diagrams";
auth_basic_user_file /etc/nginx/.htpasswd;
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
}
}Apply it with sudo nginx -t && sudo systemctl reload nginx. The nginx -t half is the important one: a reload on a broken config leaves the old config running, so the site still works and your change is not live. The reverse proxy config, explained line by line covers the header block and the certificate paths this snippet leaves out.
Basic authentication is the wrong tool for Kroki, and the reason is worth understanding. A wiki page embeds a Kroki image with an <img> tag. The reader's browser fetches that URL as a subresource, and it does not send your credentials to a different origin, so the request comes back 401 and every diagram on the page renders as a broken image. Keep Kroki off the public internet instead. Put it on the same Docker network as the wiki container and let the wiki reach it by service name, with nothing published to the host at all. How Compose networks resolve service names is the piece that makes this work.
Diagrams that live next to a self-hosted wiki
This is the usual reason people want any of this. A wiki page needs a picture, and nobody wants that picture to be a screenshot from somebody's laptop.
BookStack has a first-class hook for a self-hosted editor. Its default embed URL is https://embed.diagrams.net/?embed=1&proto=json&spin=1&configure=1, and one line in .env moves it to your container.
DRAWIO=https://drawio.example.com/?embed=1&proto=json&spin=1&configure=1Copy the query string exactly. The BookStack documentation says embed=1&proto=json&spin=1 "are required for the integration with BookStack to function", because they select the JSON message protocol the two pages use to talk to each other. The same page points at stealth=1 "if you don't want other external services to be used", which is the option you add when stopping outbound calls was the point of self-hosting. With this wired up, BookStack saves the drawing into its own image storage next to the page, so the wiki backup you already take is also the diagram backup.
If the wiki itself is undecided, settle that first. Choosing between BookStack, Wiki.js and Outline is the earlier decision, because the wiki determines how a diagram gets attached to a page and therefore which of these tools you bolt on.
Failure modes and the strings you will see
The drawing editor opens in BookStack and spins forever. The spinner is spin=1 waiting for a handshake that never arrives. Check that embed=1&proto=json&spin=1 is present in your DRAWIO value and that the host part has no typo.
The editor frame stays blank on an HTTPS wiki. The browser console reports mixed content, loading http:// inside https://. The browser blocks the frame, and draw.io never runs. Serve the editor over HTTPS.
Kroki returns 413 Request Entity Too Large. That string comes from nginx, not from Kroki. The nginx client_max_body_size default is 1 MB and Kroki's own KROKI_MAX_BODY_SIZE default is 1mb, so a large PlantUML source hits whichever limit is lower. Raise both.
Mermaid fails while graphviz works. The gateway is healthy and the companion is not being reached. Check the service is up with docker compose ps, then check KROKI_MERMAID_HOST matches the service name, because it defaults to 127.0.0.1, which inside the gateway container means the gateway itself.
Excalidraw collaboration never connects. If you built a frontend against your own room server and put it behind nginx, the proxy has to upgrade the connection with proxy_set_header Upgrade $http_upgrade; and proxy_set_header Connection "upgrade";. Without them the websocket handshake is answered as an ordinary HTTP request and the session never starts.
The canvas is empty after a browser cleanup. The scene was in local storage on that device and there is no server copy. The fix is a habit rather than a setting: export the .excalidraw file for anything worth keeping.
FAQ
Does self-hosting draw.io keep my diagrams private?
It keeps the application code on your server, which is a different thing from keeping the data private. draw.io renders in your browser, so the container never holds a diagram at all. Privacy then depends on where you save the file and which outbound calls you leave enabled. Use ?offline=1 to disable the cloud storage targets, and remember that export requests go to an export server unless you set DRAWIO_SELF_CONTAINED=1 and run jgraph/export-server yourself.
Why does collaboration not work on my self-hosted Excalidraw?
The official image page states that self-hosting "doesn't support sharing or collaboration features". Live collaboration needs the separate excalidraw/excalidraw-room websocket server, and share links need a storage service. The addresses of both are compiled into the JavaScript bundle at build time as Vite variables such as VITE_APP_WS_SERVER_URL, so setting an environment variable on the running container has no effect. Using your own room server means building the frontend from source with your values.
How do I render Mermaid diagrams on my own server?
Run Kroki with its mermaid companion container and set KROKI_MERMAID_HOST to that service name. Then POST the diagram text to /mermaid/svg and read the SVG from the response, or encode the diagram into a GET URL and point an <img> tag at it. The companion drives Chromium through Puppeteer because Mermaid needs a browser engine, so plan for the memory: KROKI_MERMAID_MAX_CONCURRENCY defaults to six renders at once.
Do I need a password in front of these tools?
Yes, because none of them have accounts. draw.io and Excalidraw hand a full editor to anyone who finds the URL, and Kroki renders whatever text is sent to it. Basic authentication at the reverse proxy is enough for the two editors. For Kroki, keep it unpublished on a Docker network shared with the wiki, because an <img> request from a reader's browser will not carry credentials to another origin and every embedded diagram would break.