Skip to content

Self hosting

pak edited this page Aug 13, 2026 · 2 revisions

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.

What you are actually deploying

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.

Running it with Compose

docker compose up --build

The 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.

Running it on any static host

cd frontend
npm install
npm run build      # Node 22, the version the CI uses

Then 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:

  1. Directory indexes. /guide and /about are directories containing index.html. A host that does not serve a directory index will 404 on both.
  2. A fallback to /index.html for any path it does not recognise, so a stale bookmark lands on the app rather than on an error page.
  3. The security headers below. You are not using the bundled nginx.conf, so nothing sets them for you.

Serve it over HTTPS, or from localhost

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, so nothing can be recorded and nothing can be exported.

The application now says so on its front page instead of starting and throwing at the first click. It checks for the two functions before mounting, and when they are missing it renders an explanation and the two ways out rather than the canvas. That check exists because this page did not save anyone: the failure used to surface as crypto.randomUUID is not a function halfway through a first try, and nobody reads a self-hosting page before the thing breaks.

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. That also means no server-side setting can fix this for you: your proxy hands the container plain HTTP even when the browser sees HTTPS, so the container cannot tell the two situations apart. Only the browser knows, which is why the check lives in the page.

The lines that carry the reference domain

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.

The security headers, and why the file is included everywhere

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.

Tightening connect-src

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.

/guide and /about are pre-rendered

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.

Caching and upgrades

/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.

Backups are yours, and mostly they are not yours at all

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:8000 to https://stix.internal makes 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.

Enrichment, in one paragraph

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.

Related

Clone this wiki locally