-
Notifications
You must be signed in to change notification settings - Fork 0
Self hosting
Draw Me A STIX is a folder of static files. There is no backend, no database, no session, no account. The server hands out HTML, JavaScript, CSS, fonts and a couple of JSON datasets, and every investigation lives in the IndexedDB of the analyst's own browser.
That shapes everything on this page. Hosting the tool is hosting a static site, with the operational consequences that follow: no migrations to run, no secrets on the server, no data to back up, and no way to recover an analyst's work for them.
The two-command quick start is in the README. This page is about the rest: what you actually deploy, what you have to change before it is your instance, and why the configuration files look the way they do.
npm run build in frontend/ runs four steps. Three of them are the build script itself (tsc -b && vite build && node prerender.mjs). The fourth comes first: npm fires a prebuild hook, node scripts/build-validators.mjs, which precompiles the vendored OASIS schemas into standalone ajv validators because the production CSP forbids unsafe-eval. Its output, frontend/src/stix/generated/validators.mjs, is deliberately not committed. So a pipeline that transcribes the three visible commands, or that disables npm's lifecycle scripts, builds nothing: the module the validation layer imports is not there.
Everything ends up in frontend/dist/:
In dist/
|
What it is |
|---|---|
index.html |
The application shell. Empty #root, hash router, hydrated by JavaScript. |
guide/index.html |
The STIX guide, content already in the HTML. |
about/index.html |
The "Your data" page, content already in the HTML, no script at all. |
assets/ |
Vite bundles, one per entry point, plus CSS and fonts. Every filename is content-hashed. |
logo.svg, og.png, robots.txt, sitemap.xml, examples/, attack-dataset.json
|
Copied verbatim from frontend/public/. |
Nothing else ships. In particular backend/ is not part of a deployment: it is the reference STIX library used to generate golden vectors in development and CI, and it never runs in production.
docker compose up --buildThe app is then on http://localhost:8000, mapped to port 80 in the container. docker-compose.yml declares no volume for the app service, on purpose: there is nothing on the server worth persisting. Recreating the container loses nothing.
The Dockerfile is two stages. node:22-alpine runs npm ci and npm run build; nginx:alpine copies dist/ into /usr/share/nginx/html and picks up the two configuration files. The final image contains no Node, no node_modules and no source. Its healthcheck is a wget --spider against http://127.0.0.1/ every 30 seconds, which tells you nginx is answering and nothing more, because there is nothing more to check.
The enrichment sidecar sits behind a Compose profile and does not start with the command above. See Enrichment-sidecar.
cd frontend
npm install
npm run build # Node 22, the version the CI usesThen serve frontend/dist/ from whatever you already run: nginx, Caddy, Apache, S3 plus a CDN, GitHub Pages, an internal file server. Three things your host has to get right, and none of them are automatic everywhere:
-
Directory indexes.
/guideand/aboutare directories containingindex.html. A host that does not serve a directory index will 404 on both. -
A fallback to
/index.htmlfor any path it does not recognise, so a stale bookmark lands on the app rather than on an error page. -
The security headers below. You are not using the bundled
nginx.conf, so nothing sets them for you.
This one bites people who deploy to http://10.0.0.12:8000 and wonder why nothing works.
The application calls crypto.randomUUID() to mint local identifiers (investigations, imported objects, enrichment endpoints) and crypto.subtle.digest() to compute the SHA-256 fingerprint that every export carries. Browsers expose both only in a secure context: HTTPS, or an origin on localhost / 127.0.0.1. Over plain HTTP on any other hostname they are simply absent, and the failure is not graceful: creating an investigation throws, and exporting cannot produce its fingerprint.
So: localhost for a try, real TLS for anything shared. The image itself listens on plain HTTP port 80 and ships no HSTS header, because TLS is expected to terminate in front of it, at your reverse proxy or load balancer, which is also where HSTS belongs.
Three HTML files hardcode app.drawmeastix.io. The og:image in particular must be an absolute URL, because several link-preview aggregators still refuse a relative one, so there is no way to write it once and have it follow the deployment.
| File | Lines to change |
|---|---|
frontend/index.html |
og:url, og:image, and rel="canonical"
|
frontend/guide.html |
og:url, og:image, and rel="canonical"
|
frontend/about.html |
og:url, og:image, and rel="canonical"
|
Leave them alone and your instance still works perfectly. What breaks is smaller and slower to notice: a link to your instance pasted in Slack or Mastodon renders a card announcing the public instance, with the public instance's URL under it. On an internal deployment that is a link people will click and end up somewhere else.
The canonical link is the one worth a decision rather than a find-and-replace. Pointing it at your own host says "this page is the original". Leaving it pointing at app.drawmeastix.io tells a crawler your pages are duplicates of the public site, which is roughly what you want for an internal instance that should not be indexed, although robots.txt is the blunter and more reliable way to say so.
Two more files carry the domain and are easy to forget: frontend/public/robots.txt (its Sitemap: line) and frontend/public/sitemap.xml (three absolute URLs). Both only matter on a public deployment.
frontend/src/export-image.ts and frontend/src/export-markdown.ts also embed https://app.drawmeastix.io as a source line at the foot of exported documents. That one is attribution rather than configuration, and there is a test asserting it is present, so changing it means changing the test too.
One last occurrence is prose, not configuration: frontend/src/components/DataPage.tsx names the domain in the "Who serves this page" section of /about, to say that the hosted instance sits behind Cloudflare. That sentence is about the public deployment, and the lines right after it tell the reader that serving the app yourself removes the question. Find-and-replace it with your own hostname and you have shipped a claim about your infrastructure that nobody wrote and nothing verifies.
nginx-security-headers.conf holds four headers:
| Header | Value | What it is there for |
|---|---|---|
Content-Security-Policy |
see below | The app parses untrusted JSON and holds an enrichment token in browser storage. A strict CSP is what turns a future XSS into a broken feature rather than an exfiltration. |
X-Content-Type-Options |
nosniff |
Stops a browser from re-guessing the type of a served file. |
Referrer-Policy |
no-referrer |
Nothing about a URL here is worth leaking outward. |
X-Frame-Options |
DENY |
Belt to the frame-ancestors 'none' braces, for older browsers. |
The file is included in the server block and again in each location block, and that repetition is deliberate rather than sloppy editing. nginx inherits add_header directives from an enclosing level only when the current level declares none of its own. The /assets/ block declares one Cache-Control, and that alone was enough to drop all four headers from every JavaScript, CSS and font response, which is precisely where nosniff earns its keep. Keeping them in one file that is included everywhere means the next location block someone adds cannot silently lose them by adding a header of its own.
If you add a location of your own, include the file in it. If you also add headers at your reverse proxy, check you are not emitting two Content-Security-Policy headers: browsers enforce the intersection of both, and the result is usually a blank page that nobody can explain.
The shipped policy allows all of https: in connect-src. The reason is stated in the config file: the enrichment sidecar is chosen by each analyst, its address is unknown at build time, and hardcoding one would force everybody to rebuild the image. SECURITY.md lists this as a documented trade-off rather than a vulnerability.
Hosting for a team is exactly the case where the trade-off no longer applies. You know your sidecar's address, so replace https: with that single origin, and drop http://localhost:* and http://127.0.0.1:* unless your analysts run sidecars on their own machines. Keep 'self'. Rebuild the image afterwards, since the file is baked in at build time.
Two pages of this project are prose rather than tool, and prose that only exists after JavaScript runs is prose nobody can index, preview or read in a locked-down browser. So they are built as real HTML.
vite.config.ts declares three entry points (index.html, guide.html, about.html), which also keeps the prose pages from dragging the canvas, the storage layer and the PDF readers along just to show text. After vite build, prerender.mjs loads the renderers through Vite's SSR API, fills the empty #root of each built page, writes the result to dist/guide/index.html and dist/about/index.html, then deletes the original dist/guide.html and dist/about.html so the same page is not left reachable at two addresses.
The pre-render refuses to produce a page that looks empty: it throws if a render comes back under 5000 bytes for the guide or 4000 for /about, and the build fails with it. A silently blank page is the failure mode that would otherwise ship unnoticed.
nginx then serves them with no special rule at all:
try_files $uri $uri/index.html /index.html;$uri/index.html is the middle term that matters. Without it, /guide fell through to the SPA shell, which has no non-hash route for it, and a crawler or a link preview got nothing.
/guide is reachable two ways, and both must show the same thing: as the pre-rendered page at /guide, and inside the app at #/guide. There is a test that enforces it. /about exists only as the pre-rendered page: the app has no hash route for it, and it carries no script whatsoever, which is the point for a page whose subject is that nothing leaves your browser. The public copies are at app.drawmeastix.io/guide and app.drawmeastix.io/about.
/assets/ is served with Cache-Control: public, max-age=31536000, immutable. Filenames there are content-hashed, so a year is safe, and immutable is written by hand rather than left to expires, which emits a Cache-Control of its own (the response used to go out with two different values) and cannot produce immutable at all.
index.html is served by the / block, which sets no Cache-Control, so it falls back to nginx's ETag and Last-Modified validation. Upgrading is therefore git pull followed by docker compose up --build, and a reload picks up the new shell while the hashed assets stay cached.
If you put a CDN in front, set an explicit no-cache on index.html there. Heuristic caching of an unversioned HTML document is the standard way to end up with a stale shell requesting asset filenames that no longer exist, and the symptom (a blank page for some users and not others) wastes an afternoon.
There is nothing to back up on the server. No volume, no database, no uploaded file. If the container dies, rebuild it and you have lost nothing.
The flip side is the part to communicate before anyone opens a real case: you cannot back up your analysts' work either. Investigations live in one browser profile on one machine, and an operator has no access to them. The exported STIX bundle is the save file, which the README states and SECURITY.md details under "Data lifecycle".
What is genuinely an operator's job:
-
Keep the origin stable. IndexedDB is scoped per origin. Moving the app from
http://stix.internal:8000tohttps://stix.internalmakes every existing investigation invisible, with no error and no way back except re-importing exports. Pick the final URL before people start working. -
Check your managed-browser policy. A policy that clears site data on exit, or an aggressive "clear cookies and site data" setting, deletes investigations. The app calls
navigator.storage.persist(), which protects the database from the browser's own low-disk clean-ups, not from a policy that was told to wipe it. - Say out loud that exporting is saving. It is the one habit that makes everything above harmless.
See Exporting for what a bundle carries and Troubleshooting when an instance misbehaves.
The sidecar is optional, opt-in and movable to another host. Two things about it are self-hosting concerns rather than sidecar concerns: ENRICHER_ORIGINS has to list the origin you actually serve the app from (its default is http://localhost:8000, and CORS is strict, so a mismatch fails every call), and an app served over HTTPS cannot call a sidecar over HTTP, because browsers block mixed content. Put the sidecar behind TLS too. Everything else, including the token, is on Enrichment-sidecar.
- How-it-is-built, for what runs where and why there is no server
- Enrichment-sidecar, for the only component that talks to the outside
- Troubleshooting, for symptoms and their causes
- Contributing, if a fix belongs upstream rather than in your fork
Using it
Running it
Changing it
When it breaks
In the application:
In the repository: