Cruma
Dashboard

Put your homelab on the public Internet.

Cruma gives anything running on your machine a public HTTPS URL — from a quick CLI tunnel to a full desktop app with request inspection. Free for personal dev, with paid tiers for custom domains, end-to-end encryption, and built-in authentication. It also functions as a capable web and proxy-server with many built-in features for all your developer needs, entirely free.

One command
~/dev · zsh
$ cruma proxy http localhost:3000
https://random-id.tun.cruma.io
~/dev · zsh
$ cruma serve ~/example-www
https://random-id.tun.cruma.io
cruma.app Desktop
Cruma desktop app — request inspection and tunnel controls Cruma desktop app — request inspection and tunnel controls

§ Pricing

Start free. Upgrade when it earns its keep.

Free tunnels for quick testing, paid tunnels for custom domains, stronger privacy, and consistent performance. Free is for individual, non-commercial development use — not company-wide rollout.

Anonymous $0no signup

Disposable quick tunnels

Best for trying Cruma fast or sharing a localhost app for a short test.

  • 3-hour session window
  • Quick localhost sharing
  • No account required
Download agent →
Free $0registered

Persistent dev usage

Repeat use, moderate bandwidth, personal projects without the time cap. Fits 1–3 low-traffic sites.

  • No 3-hour session limit
  • Higher allowances than anonymous
  • Personal, non-commercial use
Create free account →
Pro $10/ month

Higher priority under load

Same capabilities as Basic with priority traffic, plus multiple agents on one tunnel for multi-location load balancing.

  • Everything in Basic
  • Higher priority under load
  • Multi-agent load balancing
Start with Pro →

§ Roadmap

What we're building next.

Planned work now that Cruma is live and the core platform is in production.

planned R1

Kubernetes ingress

Ship a Cruma Ingress/Gateway controller so cluster services get HTTPS endpoints exposed through cruma.io automatically.

planned R2

Raw UDP tunneling

Native UDP tunnel support for game servers, custom protocols, and other non-HTTP workloads.

§ Downloads

Pick your platform. Install the agent.

The latest tunnel agent is a single binary. Use our installer scripts or grab raw artifacts.

Windows agent

Download the agent

Download cruma.exe — the tunnel agent for Windows. Or install via PowerShell:

irm https://files.cruma.io/files/tunnel-agent/install/win-install.ps1 | iex

Winget and Chocolatey packages are on the way. Raw artifacts.

Linux agent

Download the AppImage

Download Cruma AppImage — the desktop GUI build for GNU/Linux. Or install the terminal agent via the helper script:

curl -fsSL https://files.cruma.io/files/tunnel-agent/install/linux-install.sh | bash

Uninstall with the same script:

curl -fsSL https://files.cruma.io/files/tunnel-agent/install/linux-install.sh | bash -s -- --uninstall

Debian, RPM, Arch, and Flatpak packaging flows are expanding. Raw artifacts.

macOS agent

Download the app

Download cruma.dmg — the notarized desktop app for Apple Silicon. Or install via Homebrew:

brew install cruma/tap/cruma

Raw artifacts.

§ Comparison

How Cruma stacks up.

Cruma is a focused tunnel for developers, small teams, and homelabs. We optimize for fast setup, local-first tooling, and privacy options rather than enterprise policy suites. High-level comparison with Cloudflare Tunnel and ngrok based on publicly documented features.

Legend Available ~ Limited / extra config $ Paid plans × Not available
FeatureCrumaCloudflare Tunnelsngrok
Account-free quick tunnelsYes — anonymous mode for quick tunnels without an account. Aggressively rate-limited for fair usage.Yes — supported via "Quick Tunnels" at trycloudflare.com. Aggressively rate-limited; relaxed with a free account.× No — requires an account and auth token.
Custom domains with E2E encryption$ Yes (subscribed) — create a CNAME to your tunnel's domain; the agent terminates TLS locally and forwards to your app. Requires a paid subscription.× No (for E2E) — custom domains supported, but HTTP(S) terminates at Cloudflare's edge, so plaintext is visible at that point.$ Paid — custom domains on paid plans.
Multi-endpointYes — serve any number of endpoints using different hostnames.Yes — serve any number of endpoints using different hostnames.$ Paid — multiple endpoints on paid plans only.
Protocol forwarding~ TCP — TCP forwarding today; UDP is on the roadmap.TCP & UDP — cloudflared supports UDP forwarding (Warp/QUIC proxies require Zero Trust setup).~ TCP — ngrok's public service currently supports TCP forwarding.
Agent uplink protocolsQUIC + HTTP/2 — agents keep TLS-encrypted QUIC and HTTP/2 sessions for lower latency and resilience.QUIC + HTTP/2 — cloudflared can use QUIC uplinks and HTTP/2 over TLS in supported configs.~ HTTP/2 — speaks HTTP/2 to its control plane; no public QUIC uplink.
Local UX & toolingTUI + desktop app — desktop app for config and request inspection, plus a terminal TUI. Headless mode available.~ CLI + hosted dashboard — configured via CLI and Zero Trust dashboards; no full local web UI.~ CLI + remote inspector — request inspector and many options live in the cloud dashboard.
HTTP request inspectionLocal UI — inspect in the TUI and desktop app; request logs never leave your machine.~ Zero Trust dashboards — routes through Cloudflare's edge, so logs live in their SaaS; no local inspector.× Remote-only — primary inspector UI runs in their cloud dashboard.
Serve local directoriesBuilt-in directory server — serve a local directory straight from the agent, no nginx needed.× No built-in static server — run your own web server or use Cloudflare Pages.× No built-in static server — run a local server yourself and point ngrok at it.
Built-in access controlsIP allow-lists, forms & API keys — allow-list source IPs per tunnel, or put form auth / header API-key enforcement in front of any HTTP backend.~ Via Zero Trust policies — HTTP access control via Cloudflare Access, managed in their SaaS control plane.$ Paid — access control and traffic-shaping on paid plans.
End-to-end encryptionSupported (subscribed) — TLS terminates on the agent as soon as a cert is generated, so Cruma only sees encrypted payloads. TLS passthrough also supported.× Not provider-blind — for standard HTTP(S) tunnels, TLS terminates at Cloudflare's edge, so plaintext is visible there.Supported — TLS passthrough and agent-side termination keep keys local.

Library

Docs

Welcome to our library! You can click the three dots in the navbar, or press ctrl+k if you wish to search for anything specific.

Error 404

Page not found

Back home

Hello this is a test

Cruma Pages

Cruma Sites turns a Git repository of Markdown into a fast, self-contained website that Cruma builds and hosts for you. You write content; Cruma bakes it into a single, themed, searchable page and serves it at a Cruma subdomain (or your own custom domain).

It also happens to be how this website (cruma.io) is created and hosted!

You can access it at pages.cruma.io.

How it works

  1. Point Cruma at a Git repo. From the dashboard, connect a public repo — or a private one via the GitHub App. The repo holds your content/ and a site.toml; nothing else is required.
  2. Cruma builds it. On every push, Cruma clones the repo, runs the generator, and publishes the result. There's no build step for you to run and no framework to configure.
  3. It's served live. Your site appears at <your-site>.pages.cruma.io, and you can attach a verified custom domain.

What a site looks like

A Cruma site is just a folder:

my-site/
  site.toml            # site-level settings (title, theme, …)
  content/
    home.md            # a page
    not-found.md       # the 404 page
    images/            # images, served from /images/<name>
    _components/       # reusable HTML components (optional)
    _themes/           # custom themes (optional)
    book/              # documentation books (optional)
    blog/              # dated posts (optional)

The only truly required pieces are site.toml and a content/ directory with at least a home.md. Everything else is opt-in.

The rest of this book covers each piece in turn:

Content & pages

Everything under content/ becomes part of your site. There are a few special kinds of content, distinguished by where the file lives.

Pages

A content/*.md file is a page. Pages are hash-routed (#home, #pricing) and share the site's chrome. Optional front matter at the top of the file controls how the page appears:

---
slug: pricing      # the #hash / tab id      (default: file name)
title: Pricing     # nav + tab label         (omit to hide from the nav)
order: 2           # nav order               (default: 1000)
layout: splash     # page shape              (default: content)
---

# Pricing

Your Markdown here…

The Markdown is CommonMark plus tables and strikethrough, and raw HTML passes through — so you can drop in a <div> or a component wherever you need one.

Front matter reference

Every field is optional. The common ones:

KeyWhat it does
slugThe page's #hash id. Defaults to the file name without .md.
titleNav/tab label. Omit it to keep the page off the nav (still reachable by its slug).
orderPosition in the nav; lower comes first (default 1000).
layoutcontent (a carded article, the default) or splash (a full-bleed landing page with no card).
eyebrowA small kicker line shown above the first heading.
hidden: trueDrop the page from the nav and search (used by not-found.md).
chrome: offHide the top bar while this page is active — for pages that bring their own nav/footer.
banner: offOpt this page out of the site-wide header banner.
list-cardson renders every top-level list on the page as feature cards (default off).

Pages also accept the per-page banner keys (image, header-mode, header-fade, …) covered in Banners & headers, and blog metadata (date, authors, description) covered in Books & blog.

Linking between pages

Links are plain Markdown. What you put in the target decides where it goes:

[Pricing](#pricing)                        <!-- another page, by its slug -->
[Introduction](#cruma-sites/introduction)  <!-- a book chapter: #<book>/<chapter> -->
[Latest release](#blog/v2-launch)          <!-- a blog post: #blog/<slug> -->
[All posts](#blog)                         <!-- the blog index -->
[Cruma](https://cruma.io)                  <!-- an external link, opened as-is -->
  • To a page — use its slug: [text](#slug).
  • To a book chapter — use #<book-folder>/<chapter-slug> (the folder name, then the chapter's slug). Ordering prefixes like 01- are dropped from slugs, so content/book/guide/02-setup.md is #guide/setup.
  • To a blog post#blog/<post-slug>; the index itself is #blog.
  • To a heading on the current page — headings get an automatic id from their text, so [Front matter](#front-matter-reference) scrolls to that section.
  • External — any http(s)://, root-absolute /…, or mailto: link is left untouched and opens normally.

Inside a book, you can also link to a sibling chapter with a relative Markdown path and Cruma rewrites it to the right hash for you:

See [Themes](07-themes.md) for the full list.   <!-- → #cruma-sites/themes -->

Only relative *.md links are rewritten; everything else passes through as written.

Lists as cards

Some themes (like the Cruma and Tako themes) can render a bulleted or numbered list as a grid of feature cards. This is opt-in — by default every list renders as a normal list. To turn a single list into cards, put a <!-- cards --> comment on the line right before it:

<!-- cards -->
- **Fast** — builds on every push
- **Secure** — HTTPS out of the box
- **Yours** — bring your own domain

To make every list on a page render as cards, set list-cards: on in the page's front matter; a <!-- plain --> comment before a list opts that one back out. Themes that don't style cards just show a normal list.

The 404 page

content/not-found.md is served for unknown paths. If you don't ship one, Cruma provides a sensible default.

Images

Put images in content/images/ and reference them as images/<name>:

![Diagram](images/architecture.png)

Cruma serves them from your site's asset store, so they stay fast and cached. Keep the folder flat — one level, no subdirectories.

Every site ships a search palette that indexes all pages, chapters and posts. Readers open it from the button in the top bar or with a keyboard shortcut (Ctrl/⌘+K by default — change it with search-key).

Books and blog

Two folders get special treatment — content/book/ for documentation books and content/blog/ for dated posts. Both have their own chapter: Books & blog.

Navigation & the top bar

The bar across the top of every page is built for you — from your pages, plus a few site.toml keys when you want more control.

How the nav is built

Each top-level page with a title becomes a nav tab, ordered by its order front matter (lowest first). To keep a page out of the nav, omit its title (it stays reachable by its slug) or set hidden: true.

Books and the blog add their own entries — see Books & blog. When there are more tabs than fit, the rest collapse into the searchable menu.

Books in the nav

By default each book gets its own tab. If you have several books, fold them into a single Books menu:

books-nav = "folded"   # one "Books" tab; default is "tabs" (a tab per book)

Books that share a _book.toml category are grouped under one nav entry per category (this documentation sits under Docs). See Books & blog.

Brand

The top-left brand is the site title by default. Override it in site.toml:

title      = "Acme"              # browser tab + default brand text
brand      = "Acme Docs"         # brand text (overrides the title here)
brand-link = "#home"             # where the brand links (default: #home)
logo       = "images/logo.svg"   # a logo image instead of text

Use a #hash for brand-link to point at one of your own pages; an external URL opens in a new tab.

Add links to other sites (docs, a status page, social) and one prominent call-to-action button (Sign in, Get started, Book a demo…) to the top bar:

[[nav.links]]
label = "Docs"
url   = "https://docs.example.com"

[[nav.links]]
label   = "Changelog"
url     = "https://example.com/changelog"
new_tab = false            # optional; external links open in a new tab by default

[nav.cta]
label = "Sign in"
url   = "https://app.example.com/login"
style = "solid"            # solid | outline | plain
  • [[nav.links]] — repeat the block for each link. label and url are required; new_tab defaults to true for external URLs.
  • [nav.cta] — a single button. style picks how much it stands out: solid (filled with the theme's accent), outline, or plain (a plain link). The colours come from the active theme, so the button always looks native.

On a phone the call-to-action stays visible while the external links fold into the menu, so the bar never overflows.

Banners & headers

A banner is the wide image strip at the top of a page. You can set one for the whole site, override it per page, or let a page take the header over entirely.

A site-wide banner

Set a default image (and optional tuning) in site.toml. It appears as a band at the top of every page unless a page opts out:

banner          = "images/hero.jpg"   # the default header image
header-height   = "320px"             # band height (a CSS length)
header-position  = "center"           # focal point — any CSS background-position
header-blur     = "0"                 # blur radius (px); 0 = off
header-fade     = "0"                 # bottom fade height (px); 0 = off

header-position keeps the important part of a photo in frame on every screen size — e.g. top, or 50% 20% to favour faces near the top.

To hide the banner on a single page, add banner: off to that page's front matter.

A per-page banner

Give a page its own image with the image key, and tune it with the same header-* controls (without the header- prefix as banner-* also works):

---
title: Product
image: images/product-hero.jpg
header-mode: aligned    # aligned (default) | full
header-fade: 80px
---
  • aligned — the image sits within the content measure, lined up with the rest of the page.
  • full — the image runs edge-to-edge and the page's title sits over it.

A per-page image overrides the site-wide banner for that page; the two can also coexist (a site band plus a page cover) depending on the theme.

Splash pages

For a landing page with no card and the header taking the whole hero, use the splash layout together with a full header:

---
title: Home
slug: home
layout: splash
image: images/landing.jpg
header-mode: full
---

# Build docs your way

Spacing controls

Two more site.toml keys fine-tune the gap between the chrome and your content — handy if a fixed top bar or a tall banner crowds the first line:

header-pad     = "110px"   # top padding for content that sits under a banner
nav-clearance  = "72px"    # gap below the floating nav on pages with no banner

Per-page front matter of the same names overrides these for a single page.

Books & blog

Two folders get special layouts: content/book/ for multi-chapter documentation and content/blog/ for dated posts. This book you're reading is, itself, a book.

Books

A folder under content/book/ is a book. Each .md inside is a chapter, rendered with a table-of-contents sidebar, a sub-section list, and prev/next links.

content/book/guide/
  _book.toml          # book settings (optional but recommended)
  01-introduction.md
  02-setup.md
  03-configuration.md

Chapter front matter

A chapter is just a page in a book, so it takes the same front matter as any pagetitle, order, image (a per-chapter cover), eyebrow, banner: off, the header-* overrides, and so on.

---
title: Setup
order: 2
image: images/setup-cover.jpg   # optional per-chapter banner
---
  • Chapter order follows each chapter's order; without one, the 01-, 02- filename prefixes decide. Those prefixes are dropped from the slug, so 02-setup.md is reached at #guide/setup.
  • Titles come from title (or the file name).

_book.toml

The optional _book.toml names the book, places it in the nav, and sets defaults for its chapters:

title       = "The Guide"      # the book's nav label + TOC heading
order       = 10               # position among top-level nav entries
category    = "Docs"           # group several books under one nav entry
description = "Set up and go." # blurb for the library card
image       = "images/guide.jpg"  # default banner for chapters that set none
banner      = "off"            # opt the book's chapters out of the site banner
# header-mode / header-fade / header-height / header-blur / header-position —
# book-level defaults, each overridable per chapter (see Banners & headers).

Books that share a category are collected under a single nav entry (labelled with the category) with a small library index; a category with one book links straight to it. Uncategorised books each get their own tab. image and the header-* keys are inherited by every chapter that doesn't set its own — see Banners & headers.

The table of contents

Every book has a TOC sidebar. Move or hide it site-wide in site.toml:

book-toc = "left"    # left (default) | right | off

Blog

content/blog/ is an optional blog: one post per file, with an auto-generated index sorted newest-first. Each post routes at #blog/<slug>.

---
title: v2 is here
date: 2026-07-11        # ISO YYYY-MM-DD; sorts newest-first
authors: [Ada Lovelace]
description: What changed in the 2.0 release.
image: images/v2-cover.jpg   # optional post cover
---

# v2 is here

Post body…
  • date is shown on the post and drives the index order.
  • description feeds the index card and link previews.
  • image gives the post a cover banner (see Banners & headers).

The index page

The generated index lives at #blog. To give it a title, position, or intro text, add content/blog/_index.md:

---
title: Blog
order: 20
---

News and release notes from the team.

Custom components

Components are reusable HTML snippets you can drop into any page or chapter with a {{name}} tag. They're how the Cruma marketing site builds its hero, pricing cards, and comparison table without repeating markup.

Defining a component

Create content/_components/<name>.html. Inside it, use placeholders:

  • {{ attr }} — a named attribute, HTML-escaped.
  • {{ &attr }} — a named attribute inserted raw (already-safe HTML).
  • {{ body }} — the content between the opening and closing tags.

For example, content/_components/callout.html:

<aside class="callout callout-{{tone}}">
  <strong>{{title}}</strong>
  {{body}}
</aside>

Using a component

Invoke it from any Markdown file. Attributes are key="value" pairs; the content goes between the tags:

{{callout tone="warn" title="Heads up"}}
Custom domains need a verified DNS record first.
{{/callout}}

When a component takes no body, self-close it:

{{callout tone="info" title="Note" /}}

Styling and behaviour

A component can bring its own CSS and JS: put them beside the template as <name>.css and <name>.js in content/_components/. Cruma bundles them into the page automatically, only when the component is actually used.

content/_components/
  callout.html
  callout.css     # optional, auto-included
  callout.js      # optional, auto-included

That's the whole system — plain HTML in, {{placeholders}} filled, scoped CSS/JS attached. The cm-* components that build this very site are just a larger set of the same thing.

Themes

A theme controls the whole look of a site — colours, typography, the top bar, and the footer. Cruma ships several built-in themes, and you can add your own.

Choosing themes

List the themes you want in site.toml, active one first:

themes = ["cruma", "default", "kanagawa"]
  • The first theme is the default the site loads with.
  • Listing more than one adds a theme picker; listing a single theme drops the picker and strips the others' CSS from the output.

This site uses a single cruma theme for a consistent, branded look.

Custom themes

Add a stylesheet at content/_themes/<name>.css and it becomes selectable like a built-in one. A theme scopes its rules under html[data-theme="<name>"], so it only applies while active:

html[data-theme="mytheme"] {
  --bg: #0e0b14;
  --fg: #f4f1fb;
  --accent: #c81cc8;
}
html[data-theme="mytheme"] body { background: var(--bg); }

Owning the chrome

A theme can replace the shell's top bar and footer, not just recolour them. Add:

  • content/_themes/<name>.topbar.html — custom top-bar markup.
  • content/_themes/<name>.footer.html — a site footer.

The cruma theme uses exactly this to render the CRUMA.IO brand bar and the Umbra·Yurei footer.

Site branding

A couple of site.toml keys feed the chrome regardless of theme:

title = "Cruma"              # browser tab + default brand
logo  = "images/logo.jpg"    # navbar logo image (else the title as text)

site.toml reference

site.toml holds your site-level settings. Every key is optional — a bare title and a content/home.md are enough to publish. Keys are grouped below; several have a whole chapter of their own, linked inline.

Identity & SEO

title       = "Acme"                 # browser tab + default brand
brand       = "Acme Docs"            # brand text (overrides title in the bar)
brand-link  = "#home"                # where the brand links (default: #home)
logo        = "images/logo.svg"      # a logo image instead of brand text
favicon     = "images/favicon.png"   # tab icon
description = "Docs for Acme."        # <meta description> for SEO / link previews

See Navigation & the top bar for the brand.

Themes

themes = ["cruma", "default", "kanagawa"]   # active theme first; more adds a picker

Full details in Themes.

books-nav = "tabs"      # tabs (a tab per book, default) | folded (one "Books" menu)

[[nav.links]]           # external links in the top bar (repeatable)
label = "Docs"
url   = "https://docs.example.com"

[nav.cta]               # one call-to-action button
label = "Sign in"
url   = "https://app.example.com/login"
style = "solid"         # solid | outline | plain

Full details in Navigation & the top bar.

Books

book-toc = "left"       # table-of-contents side: left (default) | right | off

Layout

layout   = "wide"       # wide (default, up to 1728px) | cozy (narrower)
tako-bar = "floating"   # tako theme's bar: floating (default) | static

Banners & spacing

banner           = "images/hero.jpg"   # site-wide header image
header-height    = "320px"
header-position   = "center"           # focal point (any CSS background-position)
header-blur      = "0"
header-fade      = "0"
header-width     = "full"              # default aligns to the measure; full = edge-to-edge
header-pad       = "110px"             # content top padding under a banner
nav-clearance    = "72px"              # gap below the floating nav (no banner)

Full details in Banners & headers.

Backgrounds

post-background = "#0e0b14"             # background behind page/post cards
page-background = "#000"               # background behind the whole page section
search-key = "ctrl+k"                  # shortcut that opens the search palette

Analytics

google-analytics = "G-XXXXXXXXXX"      # GA4 measurement id
google-ads       = "AW-XXXXXXXXX"      # Google Ads id

When set, Cruma injects the gtag.js loader for you. Ids are validated before injection.

Custom code

Raw HTML injected verbatim — your own code, on your own origin, so it is not escaped:

head-code = "<script>/* verification / pixels */</script>"   # before </head>
body-code = "<script>/* chat widget */</script>"             # before </body>

A fuller example

title       = "Acme"
themes      = ["cruma", "default"]
brand-link  = "#home"
description = "The Acme documentation and blog."
books-nav   = "folded"
banner      = "images/hero.jpg"
search-key  = "ctrl+k"

[[nav.links]]
label = "Status"
url   = "https://status.acme.com"

[nav.cta]
label = "Get started"
url   = "https://app.acme.com/signup"
style = "solid"

Quick Start Guide

This guide should be simple enough to get your locally hosted web-site or service accessible on the public Internet within 1-3 minutes.

What is Cruma?

Cruma is a local-first tunneling agent that gives you a public URL for local or private services with a single command. It comes with a TUI (terminal UI) and a desktop app for inspection and control. It supports HTTP, HTTPS, TCP, raw TCP, serving local directories, and hosting processes.

If you bring your own domain via CNAME, TLS is terminated on your agent using a certificate automatically obtained from Let's Encrypt (ACME TLS-ALPN-01). This provides end-to-end encryption between clients and your agent — Cruma infrastructure only forwards the encrypted stream and sees control-plane metadata.

Key concepts

  • tunnel_id defines the public FQDN and routing pool; agents sharing a tunnel ID are load‑balanced together.
  • profile (config-file setting) scopes the cached identity (useful for multiple anonymous identities).
  • temp (config-file setting) creates a one‑off identity for a fresh FQDN each run.

Accessing the agent

Cruma is now in public beta. The service is free during this period while features, limits, and pricing evolve based on real‑world use.

Download the tunnel agent at https://cruma.io and pick your platform. On first run you'll see a TUI-based setup wizard; you can also run cruma --help to see all options.

Install & run (fast path)

  1. Download the agent from https://cruma.io and install for your OS.
  2. Start your local service (for example on port 3000).
  3. Run:
cruma proxy http 3000

That's it — you'll get a public URL printed in the terminal.

Anonymous quick test

You can run an anonymous tunnel without a tunnel ID or secret. A unique FQDN is assigned and persisted in the agent cache directory (see cruma show-cache):

cruma proxy http 3000

This is great for quick local tests; anonymous tunnels have tighter limits (see Rate Limits & Fair Use).

Anonymous mode is zero‑setup. When you're ready for custom domains or higher limits, add --tunnel-id and --secret-key from your account.

For a fresh anonymous FQDN each run, set temp: true in your config file. For multiple stable anonymous identities, set profile: <id> in your config file.

Basic usage with credentials

This assumes your service is running locally on port 8080. Replace TUNNEL_ID and SECRET_KEY with the values from your account.

cruma proxy http 127.0.0.1:8080 --tunnel-id TUNNEL_ID --secret-key SECRET_KEY
  • The target can be host:port (e.g. 127.0.0.1:8080), just a port number (e.g. 8080, defaults to 127.0.0.1), or a hostname (e.g. example.com).
  • --tunnel-id is the tunnel name; leave it out to use the default (sourced from your configuration file, otherwise ANON).
  • --secret-key is only used in combination with --tunnel-id; also sourced from your configuration file if not provided.
  • --tower-server defaults to tower.cruma.io:443; optional override to target a different region.

You'll receive the public URL after the tunnel is established.

UI modes

  • By default the agent starts an interactive TUI.
  • Use --headless to disable the interactive terminal UI (useful for scripts and CI).
  • Use --tui to force the interactive TUI (useful when the agent would otherwise default to headless).
  • Use --theme light|dark|system to control the UI theme (system is the default).

Serve a local directory

You can serve static files from a local directory without any other server:

cruma serve ./public

Options:

  • --allow-dir-index — show directory listing when no index file is present.
  • --render-markdown — render .md files as HTML.
  • --spa — SPA fallback: serve the nearest index.html instead of 404 for missing paths.
  • --user USERNAME PASSWORD — add form-based authentication.
  • --api-key HEADER VALUE — require an API key header.
  • --listen SPEC — add a local listener (e.g. --listen https:8443). Defaults to cruma when omitted.
  • --hostname PATTERN — hostname pattern for routing.
  • --forward-host true|false — forward the original Host header to the backend (default: true).

What Cruma is (and isn't)

  • Local-first tooling: TUI and desktop app; logs stay on your machine.
  • Optional end-to-end TLS when using your own domain.
  • No bundled SSO suite, no complex enterprise policy engine, and no formal SLA.

Choosing a region (optional)

By default the agent connects to the closest region via tower.cruma.io:443. To pin a region explicitly, pass --tower-server:

cruma proxy http 127.0.0.1:8080 --tower-server tower.us-east.cruma.io:443
cruma proxy http 127.0.0.1:8080 --tower-server tower.eu-fi-hel.cruma.io:443

Using a config file

For multi-target setups, use a config file (YAML or JSON). The config uses backends (where traffic goes) and frontends (hostname routes that point to backends):

tunnel_id: "demo-tunnel"
tunnel_secret: "beta-secret-123"

backends:
  - id: web
    kind: http
    destination: "127.0.0.1:3000"

frontends:
  - hostname: "app"
    backend_id: web

Run it with:

cruma start ./cruma.yaml

You can also manage configs from the CLI without editing YAML by hand:

cruma config add http 127.0.0.1:3000 --hostname app
cruma config show

See the configuration guide for the full reference including processes, listeners, middlewares, and local-only mode.

Forwarding TCP services

Use proxy tcp to forward plain TCP to your backend after TLS termination:

cruma proxy tcp 127.0.0.1:5432 --tunnel-id TUNNEL_ID --secret-key SECRET_KEY

For assigned FQDNs (*.tun.cruma.io), TLS is terminated at the Cruma ingress. For CNAME'd custom domains, TLS is terminated on your agent. Either way, your backend receives a plain TCP stream.

Use proxy raw for TLS pass-through to your backend (the backend handles TLS itself):

cruma proxy raw 127.0.0.1:4943 --tunnel-id TUNNEL_ID --secret-key SECRET_KEY

Note: raw only provides true TLS pass-through when using a CNAME'd custom domain. With assigned FQDNs, TLS is always terminated at the Cruma ingress, so raw behaves the same as tcp.

The platform will provide the reachable endpoint once the tunnel is up.

Upstream protocol

When proxying HTTP/HTTPS traffic, you can select the protocol used to talk to your backend with --upstream-protocol:

  • h1 — HTTP/1.1 (default, most compatible)
  • h2 — HTTP/2 (ALPN-negotiated for HTTPS backends, h2c upgrade for HTTP)
  • h2pk — HTTP/2 Prior Knowledge (speak HTTP/2 directly without negotiation)
cruma proxy http 127.0.0.1:3000 --upstream-protocol h2

Useful commands

CommandDescription
cruma --helpShow all commands and global options
cruma proxy http <TARGET>Proxy HTTP traffic to a backend
cruma proxy https <TARGET>Proxy HTTPS traffic to a backend
cruma proxy tcp <TARGET>Proxy TCP traffic (after TLS termination)
cruma proxy raw <TARGET>Proxy raw TCP (TLS pass-through)
cruma serve <PATH>Serve a local directory
cruma start [PATH]Start from a config file
cruma show-cacheShow the cache directory location
cruma clear-cacheClear cached identity and credentials
cruma config locateShow the default config file path
cruma config profilesList known profile IDs
cruma config showDisplay the current configuration
cruma schemaPrint the JSON schema for the config file
cruma --generate-completions <SHELL>Generate shell completions (bash, zsh, fish, powershell, elvish, nushell)

The Desktop App

The Quick Start gets you online from the terminal with a single command. But Cruma is also a full desktop application — a visual control panel for the exact same agent. Everything the CLI does, you can do here by clicking, and you get live dashboards, request inspection, and a built-in assistant on top.

This chapter is a guided tour of that app. It assumes no prior experience with proxies or web servers — if a term is new, we explain it as we go.

💡 Three ways to run the same agent

Cruma is one program with three faces: the desktop app (a graphical window), the TUI (a text interface that runs inside your terminal, great over SSH), and headless (no interface at all, for servers and CI). They all drive the same runtime and the same config file — pick whichever fits where you are. On macOS, Windows, and Linux desktops the graphical window opens by default; add --tui for the terminal UI or --headless for none.

New to proxies? The 30-second mental model

Cruma sits in front of your own programs and decides where each incoming web request should go. Picture a receptionist at a front desk:

  • Someone walks in the door → that's a request arriving.
  • The receptionist reads who they're here to see (the website address, or hostname) → that's routing.
  • They send the visitor to the right office → that's your service (a website, an API, a folder of files).
  • Along the way they can check ID, turn away banned visitors, or stamp a form → those are middlewares (auth, rate limits, header tweaks).

Cruma is that receptionist. Three nouns describe the whole job, and the app has one page for each:

NounPlain meaningPage
ListenerThe door traffic comes in through — a local port, or the Cruma tunnel from the internet.Listeners
FrontendA rule: "requests for this address go to that service," plus any checks to run first.Frontends
Backend / ProcessThe destination — a service you point at (localhost:3000), a folder of files, or a program Cruma runs for you.Backends / Processes

Once those three click, the rest of the app is just windows onto the running system.

First launch: the setup wizard

The first time you open the app, a short wizard walks you through connecting the tunnel and exposing your first service — no config file to write by hand. It can detect programs already listening on your machine and offer to route to them in one click. You can re-open it any time from the Dashboard.

The Dashboard — your home base

The Dashboard is where you land. At a glance it shows:

  • Tunnel status — whether you're connected to Cruma's cloud ingress, your assigned public domain (e.g. abc123.cruma.io), and your plan.
  • Your routes — every frontend you've configured and where it points.
  • Live activity — recent connections, errors, and events as they happen.

It has two looks, switchable in Settings:

  • Modern — a card per site, each with a QR code, copy-link, and quick-open buttons. Great for grabbing a URL onto your phone.
  • Classic — a denser split view: route list on one side, a live event stream on the other.

📝 The domain follows your agent, not your config

You don't pick your *.cruma.io hostname — it's tied to your agent's identity on disk. Reinstall with the same identity and you get the same domain back. See Custom Domains to put your own domain in front of it.

The three pages you'll use most

These map directly onto the mental model above.

Listeners — where traffic enters

A listener is a door. Cruma has three kinds:

  • http / https — bind a local port (like 80 or 8443) so browsers on your machine or network can reach you. https also handles the encryption (TLS) for you.
  • cruma — the tunnel door. It dials out to Cruma's cloud and receives traffic from your public domain. This is what makes a local service reachable from the internet without opening any ports on your router.

The page shows each listener with its port and whether it's currently bound (a listener can be configured but fail to bind if another program already holds that port — the page tells you when that happens). HTTPS listeners also serve HTTP/3 (QUIC) by default for faster connections.

Frontends — the routing rules

A frontend answers "when a request comes in for this address, what do I do with it?" Each one has:

  • A match — a hostname and (optionally) a path, e.g. app.example.com or abc123.cruma.io/api.
  • A target — the backend, process, folder, or Kubernetes service to send it to.
  • An optional list of middlewares — checks and transforms applied before the target sees the request: login walls, IP allow/deny, rate limits, CORS, header rewrites, and more.

The editor lets you build all of this visually. One hostname can even fan out to different targets by path (an /api route to one service, everything else to another).

Backends & Processes — the destinations

  • A Backend is where a frontend sends traffic:
    • a host:port address (localhost:3000, or a remote server) — with optional load balancing across several addresses, health checks, and a maintenance-mode switch;
    • a dir_server — Cruma's own built-in static file server for a folder on disk (directory listings, Markdown-to-HTML, single-page-app fallback, image thumbnails).
  • A Process is a program Cruma runs and supervises for you — it starts, stops, restarts, and can relaunch on crash or when the binary changes. Set its command, working directory, and environment; if it opens a web port, Cruma wires a route to it automatically. Advanced options include pinning it to specific CPU cores.

Watching what's happening

The app isn't just for setup — it's a live lens on your traffic:

  • Requests — turn on recording and every request through Cruma is captured with its headers, timing, status, and body (up to a size limit). Click one to inspect it, copy it as a curl command, or hand it to the assistant with Ask AI.
  • Statistics — traffic volume, error rates, and a per-backend breakdown.
  • Observations — a combined stream of agent events and the logs of the processes Cruma is hosting.
  • Service Map — a live diagram of what has actually talked to what, drawn from real traffic rather than your config.

TLS & certificates, handled for you

The Certificates page shows the encryption certificate for each of your HTTPS names and its status. Cruma can obtain and renew free certificates automatically (Let's Encrypt via ACME), use a self-signed one for local testing, or use PEM files you provide — no manual certificate wrangling required. See Security & TLS for the full picture.

Access control & integrations

  • Custom Auth and Local OAuth2 — set up who can reach a site: HTTP Basic, a hosted login form, or sign-in with GitHub/Google (OAuth2). Cruma can even act as its own OAuth2 provider.
  • MCP Server — expose the running agent to AI coding tools (Claude Code, Cursor, Copilot) as a controllable API, with its own auth. Handy if you want an assistant to inspect or reconfigure Cruma for you.
  • Kubernetes — point frontends at services in a Kubernetes cluster.

Settings & Profiles

  • Settings — the config file in use, dashboard look, ingress region, launch-at-login, and MCP options.
  • Profiles — keep several independent config files (say, one per project) and switch between them. Useful when you want separate public domains or isolated setups.

The built-in assistant

Desktop builds include an AI assistant page: a chat that understands Cruma and can see your live setup (when you allow it). Ask it things like "why isn't my site loading?" or "add a login wall to my API route" in plain English. It can be backed by Anthropic Claude, GitHub Copilot, an OpenAI-compatible endpoint, or the Claude Code CLI, depending on what you connect. The Ask AI button on a captured request drops that request straight into the chat for analysis.

Your first site, in the GUI

Putting it together — the visual version of the Quick Start:

  1. Open the app and confirm the tunnel shows connected (green) on the Dashboard.
  2. Go to Backends → Add, choose host:port, and enter your local server (e.g. localhost:3000). Save.
  3. Go to Frontends → Add, set the match to your assigned domain, and pick the backend you just made. Save.
  4. Open https://<your-domain> — you're live. No DNS setup, no certificate step.
  5. Flip on recording in Requests and reload the page to watch the request flow through.

That's the whole loop: a door (listener), a rule (frontend), a destination (backend). Everything else in the app builds on those three ideas.

💡 Prefer the terminal?

None of this is GUI-only. The same actions exist as cruma config add … commands and as pages in the --tui interface, and they all read and write the same config file. Mix and match freely — see Configuration for the file format behind the buttons.

Common Scenarios

Quick recipes for typical setups. Replace SECRET_KEY/TUNNEL_ID with your account credentials, or omit --tunnel-id and --secret-key entirely for anonymous mode.

React dev server on port 3000

cruma proxy http 127.0.0.1:3000 --tunnel-id react-dev --secret-key SECRET_KEY
  • Start your dev server (npm start/yarn start), then run the tunnel.
  • If your dev server uses HTTPS, use proxy https instead of proxy http.

API server on port 8080 with custom domain

tunnel_id: "api-demo"
tunnel_secret: "SECRET_KEY"

backends:
  - id: api
    kind: http
    destination: "127.0.0.1:8080"

frontends:
  - hostname: "api"
    backend_id: api
  - hostname: "api.dev.yourdomain.com"
    backend_id: api

Run it:

cruma start ./cruma.yaml
  • CNAME api.dev.yourdomain.com to <tunnel-id>.tun.cruma.io, then add it to the hostname list.
  • api expands to api.<tunnel-id>.tun.cruma.io.

Or add it to an existing config via CLI:

cruma config add http 127.0.0.1:8080 --hostname api --hostname api.dev.yourdomain.com

Multi-target config file

Use a config file to run multiple targets with one command:

tunnel_id: "demo-tunnel"
tunnel_secret: "beta-secret-123"

backends:
  - id: api
    kind: http
    destination: "127.0.0.1:8080"
  - id: web
    kind: http
    destination: "127.0.0.1:3000"

frontends:
  - hostname: "api"
    backend_id: api
  - hostname: "api.dev.yourdomain.com"
    backend_id: api
  - hostname: "react-dev"
    backend_id: web
  - hostname: "react.dev.yourdomain.com"
    backend_id: web

Run it:

cruma start ./cruma.yaml

Serve a local directory

cruma serve ./public --allow-dir-index --render-markdown --tunnel-id YOUR_TUN_ID --secret-key SECRET_KEY
  • Add --allow-dir-index to enable directory listing when no index file is present.
  • Add --render-markdown to render index.md (and other .md files) as HTML.
  • Add --spa to enable SPA fallback: serve the nearest index.html instead of 404 for missing paths.
  • You can add simple auth with --user USERNAME PASSWORD or --api-key X-API-Key secret123.

SPA (single-page application)

If you're building a React, Vue, or similar SPA and want client-side routing to work:

cruma serve ./dist --spa

With --spa, any request that would 404 instead serves the nearest index.html, so your client-side router handles the path.

In a config file, set spa_fallback: true on the backend:

backends:
  - id: spa
    kind: local-directory
    destination: "./dist"
    spa_fallback: true

frontends:
  - hostname: "app"
    backend_id: spa

Hosted process (e.g. Node.js app)

Let the tunnel agent start and supervise your app server:

tunnel_id: "demo"
tunnel_secret: "SECRET_KEY"

processes:
  - id: my-app
    command: node
    args: ["server.js"]
    working_directory: "./app"
    env:
      PORT: "3000"
      NODE_ENV: "production"
    restart_policy: on-failure

frontends:
  - hostname: "app"
    process_id: my-app

Or add it via CLI:

cruma config add process node --arg server.js --working-dir ./app --env "PORT=3000" --hostname app

The agent will start the process, restart it according to the restart policy, and route traffic to it.

Lazy process start (start on request)

You can configure a process to only start when the first request arrives, and optionally stop it after a period of inactivity:

processes:
  - id: my-app
    command: node
    args: ["server.js"]
    working_directory: "./app"
    env:
      PORT: "3000"
    start_on_request: true
    idle_timeout_seconds: 300
    restart_policy: on-failure

Or via CLI:

cruma config add process node --arg server.js --start-on-request --hostname app

When a request arrives for a frontend backed by this process and the process is not running, the agent will start it and wait for it to become ready before proxying the request. With idle_timeout_seconds, the process is automatically stopped after being idle for the specified duration.

Local-only reverse proxy (no cloud tunnel)

The agent only connects to Cruma cloud when a cruma-kind listener is present. Simply don't define one to run the agent as a purely local reverse proxy — no traffic goes through Cruma cloud:

backends:
  - id: web
    kind: http
    destination: "127.0.0.1:3000"

frontends:
  - hostname: "web"
    backend_id: web

listeners:
  - port: 8443
    addr: localhost
    tls: true
    cert_mode: self_signed

Access your service at https://localhost:8443.

Local listener alongside cloud tunnel

You can have both cloud access and a local listener at the same time:

tunnel_id: "demo"
tunnel_secret: "SECRET_KEY"

backends:
  - id: web
    kind: http
    destination: "127.0.0.1:3000"

frontends:
  - hostname: "app"
    backend_id: web

listeners:
  - port: 8080
    tls: false

Now your service is available both at app.<tunnel-id>.tun.cruma.io and http://localhost:8080.

High-assurance (pinning/mTLS)

  • Use a custom hostname (CNAME to <tunnel-id>.tun.cruma.io). The agent automatically obtains a Let's Encrypt certificate via ACME TLS-ALPN-01 and terminates TLS locally — payloads are end-to-end encrypted between clients and your agent by default.
  • On paid plans with an active subscription, the agent can also terminate TLS for its assigned *.tun.cruma.io hostname after obtaining a certificate via ACME DNS-01. If that process is not ready or fails, Cruma falls back to ingress termination automatically.
  • For additional hardening, pin your agent's certificate in clients (or use mTLS) so only your cert is accepted.
  • Set CAA records on your domain to restrict certificate issuance to your chosen CA (and optionally to your specific ACME account).

CORS headers for an API

Use the allow_cors middleware on a frontend:

backends:
  - id: api
    kind: http
    destination: "127.0.0.1:8080"

frontends:
  - hostname: "api"
    backend_id: api
    middlewares:
      - type: allow_cors
        origins: { mode: any }
        allow_credentials: false
        handle_preflight: true
        allow_private_network: false

Path-based routing with rewrites

Strip a path prefix before forwarding to the backend:

frontends:
  - hostname: "app"
    backend_id: api
    middlewares:
      - type: rewrite_path
        strip_prefix: "/api"

Requests to app.<fqdn>/api/users will be forwarded to the backend as /users.

Upstream protocol selection

When your backend requires HTTP/2, use --upstream-protocol on the CLI:

cruma proxy http 127.0.0.1:3000 --upstream-protocol h2

Or set upstream_protocol on a process (or backend) definition in the config file:

processes:
  - id: grpc-server
    command: ./grpc-server
    upstream_protocol: H2

Available values: H1 (default), H2, H2PK (HTTP/2 Prior Knowledge).

⚠️ Casing differs between the CLI and the config file

The CLI flag takes lowercase (--upstream-protocol h2). The config-file value is uppercase — H1 / H2 / H2PK. A lowercase upstream_protocol: h2 in YAML/JSON/TOML will fail to parse ("unknown variant h2").

Choose Your Path

Pick the path that matches your experience. Each path gives you a minimal set of steps to get your site reachable.

Beginner: get a site online fast

  1. Start a simple server locally (pick one):

    • Static files (built-in): cruma serve ./public (optionally passing --allow-dir-index)
    • SPA (React/Vue/etc.): cruma serve ./dist --spa
    • Static files (your own server): python -m http.server 8080
    • React/Vite dev server: npm create vite@latest && npm install && npm run dev -- --host --port 3000
  2. Run the tunnel (anonymous mode, no signup):

    • React/Vite on 3000:
      cruma proxy http 3000
      
    • If you ran cruma serve, that command already started the tunnel; you can skip this step.
  3. Copy the public URL printed by the CLI and open it in your browser.

If it doesn't load, check: is your local server running? Is your firewall blocking the port? Try curl http://127.0.0.1:8080 (or :3000) locally first.

Intermediate: custom domains and multiple services

  • Use a config file with backends and frontends to run multiple services from one agent (see Configuration).
  • Add a CNAME to use your own domain and add that hostname as a frontend route (see Custom Domains).
  • Let the agent host your app process (cruma config add process ...) so you manage everything in one place (see Common Scenarios).
  • Review Security & TLS to decide on pinning/CAA for custom domains.

Advanced: isolation, regions, and hardening

  • Run multiple agents on the same tunnel ID for regional load spreading (see Multiple Targets and Tunnels).
  • Use separate tunnels for isolation between teams/apps.
  • Run local-only by omitting a cruma-kind listener for a purely local reverse proxy — no cloud tunnel (see Configuration).
  • Add middlewares (CORS, header manipulation, path rewrites, redirects) to frontend routes (see Configuration).
  • Enforce issuance and trust: set CAA on your domain, pin your agent's cert (or use mTLS) for custom hostnames (see Security & TLS).

Multiple Targets and Tunnels

One agent, many targets

You can expose multiple services from a single agent and tunnel. Use the config file to declare multiple backends and frontend routes; you usually do not need separate tunnels for each service on the same machine.

Shared tunnel IDs (important)

A tunnel ID defines the public FQDN and the routing pool at cruma.io. If you run multiple agents with the same tunnel_id, they all share that same public address and traffic is load‑balanced between them.

This is great for multi‑region or multi‑host deployments as long as the configs are the same across those agents. If configs differ (different hostnames, targets, or auth), routing becomes unpredictable because requests may land on a different agent than you expect.

Rule of thumb: if you need different hostnames/routes/configs, use different tunnel IDs. If you want the same public address served from multiple places, keep the same tunnel ID and keep configs identical.

Profiles are separate from tunnel IDs: profiles only scope the cached identity (useful for anonymous tunnels), while the tunnel ID controls the public FQDN and routing pool.

Example:

tunnel_id: "demo-tunnel"
tunnel_secret: "beta-secret-123"

backends:
  - id: api
    kind: http
    destination: "127.0.0.1:8080"
  - id: web
    kind: http
    destination: "127.0.0.1:3000"

frontends:
  - hostname: "api"
    backend_id: api
  - hostname: "api.dev.yourdomain.com"
    backend_id: api
  - hostname: "react-dev"
    backend_id: web
  - hostname: "react.dev.yourdomain.com"
    backend_id: web

Run with:

cruma start ./cruma.yaml

Multiple frontends can reference the same backend — for example, a shortname (api) and a custom domain (api.dev.yourdomain.com) both routing to the same service.

When one tunnel is enough

  • A few services owned by the same team/environment.
  • Shared credentials are acceptable (same tunnel secret).
  • Simple DNS: one tunnel ID with shortnames or custom hostnames.

When to use separate tunnels

  • You need different hostnames/routes/configs that should not be load‑balanced together.
  • Isolation per app/team/environment (different credentials and blast radius).
  • Different domains/hostnames that you want to keep apart.
  • Different usage profiles or rate-limit buckets.

Multiple agents for the same tunnel

You can run multiple agents with the same tunnel ID for load spreading and regional placement. Each agent connects to its nearest Cruma datacenter; callers are routed to the nearest datacenter where that tunnel is connected. Common patterns:

  • Regional presence: one tunnel, agents in EU and US to serve users closest to each region.
  • Simple load sharing: multiple agents behind the same tunnel ID on different servers.

Coordinate credentials and targets carefully when sharing a tunnel across agents. Use distinct tunnels if you need stricter isolation or different routing behavior.

Configuration via file

You can run cruma from a config file instead of passing flags. The CLI accepts YAML or JSON and will validate the structure before starting the tunnel. The agent watches the file and applies changes to credentials and targets automatically.

Quick reference

CommandDescription
cruma config locatePrint the default config file path
cruma config init [PATH]Create a new config with ANON credentials and no targets
cruma config resetDelete and recreate the default config
cruma config profilesList known profile IDs
cruma config showDisplay the current configuration
cruma config set-credentials <TID> <SK>Set tunnel credentials
cruma config clearRemove all frontends, backends, and processes
cruma config add <type> ...Add a backend + frontend route
cruma config remove <INDEX>Remove a frontend/backend by index
cruma config remove-process <INDEX>Remove a hosted process by index
cruma config update <INDEX> ...Update a target by index
cruma config add-listener <PORT>Add a local listener
cruma config remove-listener <INDEX>Remove a local listener by index
cruma config update-listener <INDEX> ...Update a local listener by index
cruma schemaPrint the full JSON schema for the config file

All config subcommands accept -c <PATH> / --config <PATH> to target a specific file instead of the default.

Config file structure

The config file has two required top-level arrays — backends and frontends — plus optional sections for credentials, processes, listeners, and agent behavior.

⚠️ Warning

Older configs that used a flat targets array are no longer supported. The agent will reject them with an error and suggest running cruma config reset --config <path> to migrate.

Minimal example

tunnel_id: "ANON"
tunnel_secret: "ANON"

backends:
  - id: backend-1
    kind: http
    destination: "127.0.0.1:3000"

frontends:
  - hostname: "react-dev"
    backend_id: backend-1

Full example

tunnel_id: "demo-tunnel"
tunnel_secret: "beta-secret-123"
tower_server: "tower.cruma.io:443"   # optional, default shown
profile: "my-profile"                 # optional, scopes cached identity
# temp: true                         # optional, fresh FQDN each run

backends:
  - id: web
    kind: http
    destination: "127.0.0.1:3000"

  - id: api
    kind: https
    destination: "example.com:443"

  - id: docs
    kind: local-directory
    destination: "./public"
    allow_directory_indexing: true
    render_markdown: true
    spa_fallback: false

frontends:
  - hostname: "react-dev"
    backend_id: web

  - hostname: "api"
    backend_id: api
  - hostname: "api.dev.yourdomain.com"
    backend_id: api

  - hostname: "docs"
    backend_id: docs

processes:
  - id: my-app
    command: node
    args: ["server.js"]
    working_directory: "./app"
    env:
      PORT: "3000"
      NODE_ENV: "development"
    restart_policy: on-failure    # never | on-failure | always
    auto_start: true

listeners:
  - port: 8443
    kind: https
    addr: localhost
    cert_mode: self_signed

  - port: 8080
    kind: http
    addr: localhost

Backends

A backend describes where traffic goes. Each backend has a stable id that frontends reference.

FieldRequiredDescription
idyesStable identifier (e.g. backend-1, web, api)
kindyeshttp, https, tcp, or local-directory
destinationyeshost:port for network backends, or a directory path for local-directory
allow_directory_indexingnoShow directory listing when no index file is present (local-directory only)
render_markdownnoRender .md files as HTML (local-directory only)
spa_fallbacknoServe the nearest index.html for 404 paths — standard SPA behavior (local-directory only)
destinationsnoList of host:port strings for load balancing across several upstreams. When present, takes precedence over destination. (http/https/tcp only)
health_checknoPeriodically probe upstreams and route around unhealthy ones — see Load balancing and health checks
maintenance_modenoWhen true, short-circuit the backend with a maintenance response instead of proxying
backend_timeout_secondsnoUpstream response timeout in seconds (default: 10)
upstream_protocolnoProtocol to the upstream: H1 (default), H2, H2PK. Uppercase in the config file.
middlewaresnoBackend-level middleware list (applied in addition to the frontend's)

Form-based and API key auth are configured as middlewares on a frontend, not as backend fields — see Access controls below.

Load balancing and health checks

Give a backend several upstreams with destinations and Cruma spreads traffic across them. Add a health_check and it will probe each one and route around the ones that fail.

backends:
  - id: web
    kind: http
    destinations:
      - "127.0.0.1:3000"
      - "127.0.0.1:3001"
      - "127.0.0.1:3002"
    health_check:
      kind:
        type: http          # or: { type: tcp }
        path: "/healthz"     # default "/"
        expected_status: 200 # default 200
      interval_secs: 5
      timeout_secs: 2
      healthy_threshold: 2   # consecutive OK probes to mark healthy
      degraded_threshold: 2  # failures to mark degraded
      down_threshold: 3      # failures to mark fully down

Set maintenance_mode: true on a backend to take it offline gracefully (clients get a maintenance response) without deleting its config.

Backend kinds

  • http — connect to the backend over plain HTTP.
  • https — connect to the backend over HTTPS (TLS to origin).
  • tcp — forward TCP to the backend. Whether the backend receives plain TCP or the raw (still-encrypted) TLS stream is controlled per-frontend by tcp_terminate_tls — see TCP pass-through below.
  • local-directory — serve static files from a local directory path.

There is no separate raw backend kind — raw is a CLI convenience (cruma proxy raw / cruma config add raw) for a tcp backend with tcp_terminate_tls: false on its frontend.

Frontends

A frontend maps a hostname pattern to either a backend or a hosted process. Each hostname gets its own frontend entry.

FieldRequiredDescription
hostnameyesHostname pattern (see below)
backend_idone ofReferences a backend id. Mutually exclusive with process_id / kubernetes_target_id.
process_idone ofReferences a process id.
kubernetes_target_idone ofReferences a kubernetes_targets entry — see Kubernetes targets
path_routesnoRoute different paths on this hostname to different targets — see Path-based routing
middlewaresnoOrdered list of HTTP middlewares (see Middlewares) — this is also where form-based and API key auth are configured
tcp_terminate_tlsnotcp backends only — see TCP pass-through below (default: true)

TCP pass-through

For a tcp backend, the frontend's tcp_terminate_tls controls what the backend receives:

  • true (default) — TLS is terminated before the backend; it receives a plain TCP stream.
  • false — the raw, still-encrypted TLS stream is forwarded untouched; the backend must terminate TLS itself (true end-to-end encryption between client and backend).
frontends:
  - hostname: "db"
    backend_id: postgres
    tcp_terminate_tls: false

cruma proxy raw and cruma config add raw are shortcuts that set this to false on a tcp backend automatically; proxy tcp/config add tcp leave it at the true default.

This only gives true pass-through for CNAME'd custom domains, where TLS always terminates on your agent. For assigned Cruma hostnames (*.tun.cruma.io), where TLS may instead terminate at the Cruma ingress (see Security), tcp_terminate_tls: false still avoids local termination on the agent, but can't control a decision made upstream of it.

Hostname patterns

  • app — expands to app.<assigned-fqdn> (e.g. app.abc123.tun.cruma.io)
  • app.yourdomain.com — exact match on a custom domain (requires CNAME)
  • *.yourdomain.com — wildcard match for any subdomain
  • * — matches any hostname (not recommended)

Multiple frontends can point to the same backend (e.g. a shortname and a custom domain both routing to the same service).

Path-based routing

One hostname can fan out to different targets by URL path prefix using path_routes. Each entry needs a path_prefix and one target (backend_id, process_id, or kubernetes_target_id); requests that don't match any prefix fall through to the frontend's own backend_id/process_id.

frontends:
  - hostname: "app"
    backend_id: web          # default: everything not matched below
    path_routes:
      - path_prefix: "/api"
        backend_id: api
      - path_prefix: "/admin"
        process_id: admin-app

Longer prefixes win over shorter ones, so /api/v2 can override /api.

Kubernetes targets

Frontends (and path routes) can point straight at a service in a Kubernetes cluster. Define the target once under the top-level kubernetes_targets, then reference it by id:

kubernetes_targets:
  - id: orders-svc
    context: "prod-cluster"     # kubeconfig context
    namespace: "orders"
    service: "orders-api"
    port: 8080

frontends:
  - hostname: "orders"
    kubernetes_target_id: orders-svc

Processes

The agent can supervise local processes and optionally create frontend routes backed by them. This is useful for running your app server alongside the tunnel in a single command.

FieldRequiredDescription
idyesStable process identifier
commandyesBinary or command to run
argsnoList of arguments
working_directorynoWorking directory for the process
envnoMap of environment variables
auto_startnoStart the process automatically (default: true)
start_on_requestnoLazily start the process on first incoming request (default: false)
restart_policynonever, on-failure, or always (default: on-failure)
upstream_protocolnoHTTP protocol to the process: H1 (default), H2, or H2PK. Uppercase in the config file (the CLI flag uses lowercase).
upstream_tlsnoUse HTTPS to connect to the process (default: false)
backend_timeout_secondsnoTimeout for upstream response in seconds (default: 10)
idle_timeout_secondsnoAuto-stop the process after being idle for this many seconds. Combined with start_on_request for full spin-up/spin-down lifecycle. 0 or omitted means never auto-stop.
binary_watchnoRestart the process when its executable changes on disk (see cruma schema for fields)
shadow_copynoRun from a copied binary so the original can be replaced while running (see cruma schema)

To route traffic to a process, add a frontend with process_id instead of backend_id:

processes:
  - id: my-app
    command: node
    args: ["server.js"]
    working_directory: "./app"
    env:
      PORT: "3000"
    restart_policy: on-failure

frontends:
  - hostname: "myapp"
    process_id: my-app

Add a process via CLI:

cruma config add process node --arg server.js --working-dir ./app --env "PORT=3000" --hostname myapp

Additional CLI options for config add process:

  • --id <ID> — set a stable process identifier (auto-generated if omitted)
  • --no-auto-start — disable auto-start
  • --start-on-request — lazily start on first request
  • --restart <POLICY> — restart policy: never, on-failure, always (default: on-failure)

Listeners

Listeners bind to local addresses so you can access your routes directly on localhost (or on a LAN) without going through the Cruma cloud ingress. They share the same routing table as the cloud ingress.

FieldRequiredDescription
kindnohttp, https, or cruma. When omitted, defaults to https if TLS is enabled, http otherwise.
portyesPort number to listen on (ignored for kind: cruma)
addrnolocalhost (default, loopback only) or all (0.0.0.0)
tlsnoLegacy field. Prefer using kind instead.
cert_modenoself_signed (default) or acme_alpn (Let's Encrypt via TLS-ALPN-01; requires port 443 reachable from the internet). from_frontend is a legacy alias for acme_alpn — per-frontend cert_mode_overrides always take precedence regardless of the listener's mode.
http3noServe HTTP/3 (QUIC) alongside HTTP/1.1 + HTTP/2 on an HTTPS listener (default: true)
bind_ipnoBind to a specific IP instead of the addr preset
max_connectionsnoCap total concurrent connections on this listener
max_connections_per_ipnoCap concurrent connections from a single client IP
listeners:
  - kind: https
    port: 8443
    addr: localhost
    cert_mode: self_signed

  - kind: http
    port: 8080
    addr: localhost

Add a listener via CLI:

cruma config add-listener 8443
cruma config add-listener 8080 --no-tls
cruma config add-listener 443 --addr all --cert-mode acme-alpn
cruma config add-listener 8443 --kind https --cert-mode from-frontend

Update a listener:

cruma config update-listener 0 --port 9443
cruma config update-listener 0 --addr all
cruma config update-listener 0 --cert-mode acme-alpn
cruma config update-listener 0 --kind http --tls false

Local-only mode

There's no dedicated local_only setting. The agent only connects to the Cruma cloud when a cruma-kind listener is present — so to run purely as a local reverse proxy, simply don't define one. Only your local http/https listeners will be active.

backends:
  - id: web
    kind: http
    destination: "127.0.0.1:3000"

frontends:
  - hostname: "web"
    backend_id: web

listeners:
  - kind: https
    port: 8443

Access controls

Form-based and API key auth are middlewares on a frontend (see Middlewares), not backend fields.

Form-based authentication

Form auth provides a built-in login page with signed-cookie sessions:

frontends:
  - hostname: "web"
    backend_id: web
    middlewares:
      - type: form_auth
        users:
          - ["admin", "secret-password"]
          - ["viewer", "viewer-pass"]
        secret: "replace-with-a-random-secret"

API key authentication

Require a specific header and value on every request:

frontends:
  - hostname: "web"
    backend_id: web
    middlewares:
      - type: authentication
        auth_type:
          variant: api_key
          header: "X-API-Key"
          value: "secret123"

OAuth2 / social sign-in

Put a "Sign in with GitHub/Google/…" wall in front of a route. Define the provider(s) once at the top level under oauth2_providers, then reference them from an oauth2 middleware. The middleware needs a session_secret to sign session cookies.

oauth2_providers:
  - id: github
    label: "GitHub"
    client_id: "your-client-id"
    client_secret: "your-client-secret"
    auth_url: "https://github.com/login/oauth/authorize"
    token_url: "https://github.com/login/oauth/access_token"
    userinfo_url: "https://api.github.com/user"
    scopes: ["read:user"]
    # For OIDC providers you can instead set `discovery_url` and let Cruma
    # fill in auth/token/userinfo automatically.

frontends:
  - hostname: "app"
    backend_id: web
    middlewares:
      - type: oauth2
        provider_ids: ["github"]     # which providers to offer
        session_secret: "replace-with-a-random-secret"

Cruma can also act as its own OAuth2 authorization server (issue tokens to your apps) via the top-level local_oauth2_server block and the GUI's Local OAuth2 page — see cruma schema for its fields.

Middlewares

Frontends support an ordered list of HTTP middlewares. These are applied in order for each request.

frontends:
  - hostname: "app"
    backend_id: web
    middlewares:
      - type: redirect_http_to_https

      - type: allow_cors
        origins: { mode: any }
        allow_credentials: false
        handle_preflight: true
        allow_private_network: false

      - type: add_req_header
        name: "X-Custom-Header"
        value: "my-value"

      - type: remove_req_header
        name: "X-Unwanted"

      - type: add_resp_header
        name: "X-Frame-Options"
        value: "DENY"

      - type: remove_resp_header
        name: "Server"

      - type: rewrite_path
        strip_prefix: "/api"

      - type: rewrite_host
        to: "internal.example.com"

      - type: redirect
        location: "https://example.com/new-path"
        code: 301

Available middleware types

TypeDescription
redirect_http_to_httpsRedirect HTTP requests to HTTPS
basic_authHTTP Basic Auth (RFC 7617)
form_authHTML form-based login with signed-cookie sessions
oauth2Sign-in via an OAuth2/OIDC provider (GitHub, Google, …); references oauth2_providers
authenticationGeneral authentication with pluggable schemes (Basic, API key, JWT)
allow_corsCORS headers and preflight handling
add_req_headerAdd a header to the inbound request
remove_req_headerRemove a header from the inbound request
add_resp_headerAdd a header to the outbound response
remove_resp_headerRemove a header from the outbound response
rewrite_pathRewrite the request path (strip_prefix or replace_regex)
rewrite_hostReplace the Host header sent to origin
redirectIssue an HTTP redirect (short-circuit)
cookie_opsAdd or remove cookies
ip_filterIP allow/deny filtering
cache_control_overrideOverride Cache-Control headers
rate_limitRate limiting by IP, header, cookie, or composite key
cacheIn-memory response cache for GET requests (not yet implemented in runtime — returns 501)
compressionContent compression negotiation (not yet implemented in runtime — returns 501)

Note: X-Forwarded-Proto, X-Forwarded-For, and X-Forwarded-Host headers are automatically added by the proxy runtime — you don't need to configure them.

Use cruma schema to see the full JSON schema with all middleware fields and validation rules.

Managing configs with the CLI

Adding targets

The cruma config add command creates a backend and a frontend route in one step:

# Add an HTTP target
cruma config add http 127.0.0.1:3000 --hostname react-dev

# Add an HTTPS target with multiple hostnames
cruma config add https example.com:443 --hostname api --hostname api.dev.yourdomain.com

# Add a TCP target
cruma config add tcp 127.0.0.1:5432 --hostname db

# Add a raw TCP target
cruma config add raw 127.0.0.1:4943 --hostname raw-service

# Add a local directory with auth
cruma config add dir ./public --hostname docs --enable-index true --render-markdown --user admin:pass123

# Add a hosted process with a frontend route
cruma config add process node --arg server.js --hostname myapp --env "PORT=3000" --restart on-failure

# Add a process that starts lazily on first request
cruma config add process node --arg server.js --hostname myapp --start-on-request

# Add a process without auto-starting it
cruma config add process node --arg server.js --no-auto-start

Inspecting configuration

cruma config show

This prints an indexed view of all backends, frontends, processes, and listeners, which you need for remove and update commands.

Updating targets

# Change destination
cruma config update 0 --dest 127.0.0.1:4000

# Add/remove hostnames
cruma config update 0 --add-hostname new-host --remove-hostname old-host

# Clear all hostnames at once
cruma config update 0 --clear-hostnames

# Add form auth user
cruma config update 0 --add-user admin:password

# Clear all form auth users
cruma config update 0 --clear-users

# Set API key auth
cruma config update 0 --api-key "X-API-Key:secret123"

# Clear API key auth
cruma config update 0 --clear-api-key

# Enable/disable directory indexing (for directory targets)
cruma config update 0 --enable-index true

# Enable/disable markdown rendering (for directory targets)
cruma config update 0 --render-markdown false

Removing targets

# Remove by index (see 'config show' for indices)
cruma config remove 0

# Remove a process
cruma config remove-process 0

# Remove a listener
cruma config remove-listener 0

Setting credentials

cruma config set-credentials MY_TUNNEL_ID MY_SECRET_KEY

Running from a config file

# Use the default config file
cruma start

# Use a specific file
cruma start ./cruma.yaml

# Shorthand: use -c / --config on the top-level command
cruma -c ./cruma.yaml

Default config vs. multiple configs

The default config file is just a convenience. You can run multiple agents by pointing each one at a different config file (with a different profile set in each config):

cruma start ./configs/app-a.yaml
cruma start ./configs/app-b.yaml

Where each config file specifies its own profile:

# configs/app-a.yaml
profile: "app-a"
# ...

Use multiple configs when you want separate tunnel credentials or distinct target sets per agent. If you only need one agent with multiple services, keep a single config file and add multiple targets.

Top-level settings reference

FieldRequiredDefaultDescription
backendsyes[]Backend services
frontendsyes[]Frontend routes
tunnel_idnoANONTunnel ID for the public FQDN
tunnel_secretnoANONTunnel secret key
tower_servernotower.cruma.io:443Control plane endpoint
profilenoNamed profile to scope cached identity
tempnofalseUse a temporary identity (fresh FQDN each run)
processesno[]Hosted processes supervised by the agent
listenersno[]Local listeners
kubernetes_targetsno[]Named Kubernetes services frontends can target
oauth2_providersno[]OAuth2/OIDC sign-in providers referenced by oauth2 middlewares
local_oauth2_servernoConfiguration for Cruma acting as its own OAuth2 authorization server
global_envno{}Environment variables shared by all hosted processes
acme_directorynoACME directory/account settings for automatic certificate issuance
root_dirnoBase directory that relative paths in the config resolve against
performancenoData-plane/engine tuning (thread-per-core, native io_uring plane). Applied on restart.

Notes:

  • profile and temp are mutually exclusive.
  • Changes to tower_server, profile, or temp require a restart to take effect.
  • Other changes (credentials, backends, frontends) are hot-reloaded when the config file is saved.
  • There's no local_only field — omit a cruma-kind listener to run without the cloud tunnel (see Local-only mode above).

Bring your own domain

📝 Subscription required

Custom domains are available on subscribed (Basic/Pro) plans only. Anonymous tunnels and free/unsubscribed accounts cannot use custom domains or CNAMEs.

Each tunnel gets an assigned FQDN based on your tunnel ID (e.g., <tunnel-id>.tun.cruma.io). To use your own domain, add a CNAME and include that hostname in a frontend route.

Add the CNAME

Create a CNAME in your DNS provider pointing to the tunnel FQDN:

app.yourdomain.com CNAME <tunnel-id>.tun.cruma.io.

Use it in a config file

Add the custom hostname as a frontend that references your backend:

tunnel_id: "demo-tunnel"
tunnel_secret: "beta-secret-123"

backends:
  - id: web
    kind: http
    destination: "127.0.0.1:3000"

frontends:
  - hostname: "react-dev"
    backend_id: web
  - hostname: "app.yourdomain.com"
    backend_id: web

Run it:

cruma start ./cruma.yaml

Or add it to an existing config via CLI:

cruma config add http 127.0.0.1:3000 --hostname app.yourdomain.com

Multiple frontends can point to the same backend — here both the shortname (react-dev, which expands to react-dev.<tunnel-id>.tun.cruma.io) and the custom domain (app.yourdomain.com) route to the same service.

Wildcard hostnames

You can also use wildcard patterns for custom domains:

frontends:
  - hostname: "*.yourdomain.com"
    backend_id: web

This requires a wildcard CNAME (or individual CNAMEs for each subdomain) pointing to your tunnel FQDN.

TLS for custom hostnames

Once DNS propagates, your custom hostname will resolve through the tunnel just like the assigned address.

TLS for custom hostnames is terminated on your agent, not on Cruma's servers. The agent automatically obtains a trusted certificate from Let's Encrypt via ACME TLS-ALPN-01 — no manual certificate setup is needed. This means payloads are encrypted end-to-end between the client and your agent; Cruma infrastructure only forwards the encrypted TLS stream.

Assigned Cruma hostnames (*.tun.cruma.io and *.*.tun.cruma.io) now have two modes:

  • On paid plans with an active subscription, the agent attempts to obtain a certificate for its assigned hostname via ACME DNS-01. Once that certificate is ready, the agent can terminate TLS for the assigned hostname too.
  • If the agent cannot get the certificate yet, TLS falls back to the Cruma ingress using Cruma-managed certificates.

You can see the current assigned-hostname certificate status in the GUI on the Certificates page.

For additional hardening, you can set CAA records on your domain to restrict certificate issuance and consider pinning or mTLS. See Security & TLS for details.

Rate Limits & Fair Use

Cruma.io is built by a small team with limited resources. There is no formal SLA; we will do our best to keep tunnels responsive and reliable. Fair-use limits protect shared capacity.

What to expect

  • Throughput/latency: Best-effort. Typical dev workloads (web apps/APIs) should feel snappy; heavy load tests or bulk transfers may be throttled.
  • Concurrent tunnels/targets: Keep to a small number per beta account.
  • Request/byte volume: We monitor request counts and bandwidth. Sustained high volume may be rate-limited.
  • Burst control: Sudden spikes can be shaped to keep the service healthy for everyone.

Custom domains

Custom hostnames routed via CNAME are only available on subscribed (Basic/Pro) plans. Anonymous and free/unsubscribed (registered) tunnels cannot use custom domains.

Tiers and typical limits

  • Anonymous: Intended for quick, disposable tests. Hard limits apply:
    • No custom domains (CNAMEs)
    • Max 3 hours of uptime per session
    • Max 1 GB of traffic per session
    • Max 1 million requests per session
  • Free/Unsubscribed (Registered): Higher allowances than anonymous; suited for sustained dev use, moderate bandwidth, and a handful of tunnels/targets.
    • No custom domains (CNAMEs)
  • Subscribed (Basic/Pro): Highest allowances and more headroom on concurrency and throughput. Custom domains supported. Exact limits may evolve; the goal is to support heavier workloads reliably.

Bandwidth per TCP stream (caller ↔ target):

  • Anonymous: ~1 MB/s per stream
  • Registered: ~5 MB/s per stream
  • Subscribed: ~10 MB/s per stream

Agent and tunnel connection limits

We plan to enforce limits on how many agents can connect to a single tunnel, and on the number of concurrent agents per user. These limits are being refined and will be better defined after the beta period.

Security and TLS

Where TLS terminates

  • Assigned Cruma hostnames (*.tun.cruma.io and *.*.tun.cruma.io): On paid plans with an active subscription, the agent attempts to obtain a certificate for its assigned hostname via ACME DNS-01 and take over TLS termination itself. Once the certificate is ready and the agent has notified the cloud service, TLS is terminated on the agent. If certificate issuance is unavailable, still in progress, or fails for any reason, TLS falls back to the Cruma ingress using Cruma-managed certificates.
  • Custom CNAME hostnames: TLS is terminated on your agent using a certificate automatically obtained via ACME TLS-ALPN-01 (Let's Encrypt). Cruma infrastructure never sees the plaintext payload for these hostnames — it only forwards the encrypted TLS stream to your agent, which terminates it locally.
  • Local TLS listeners: You can choose between self-signed certificates (good for local development) or ACME TLS-ALPN-01 (requires port 443 reachable from the internet). See Configuration for details.

TCP backends: terminate or pass through

A tcp backend's frontend has a tcp_terminate_tls setting (see Configuration) that affects what happens after TLS termination. cruma proxy tcp/config add tcp leave it at its default (true); cruma proxy raw/config add raw set it to false:

Assigned Cruma hostnameCNAME'd custom domain
tcp_terminate_tls: true (tcp)Agent terminates TLS when the assigned-hostname certificate is ready; otherwise Cruma ingress terminates TLS. Backend always receives plain TCP.Agent terminates TLS (ALPN-01) → plain TCP to backend
tcp_terminate_tls: false (raw)If the assigned hostname is agent-terminated, the agent terminates TLS and the backend receives plain TCP. If not, Cruma ingress terminates TLS and the backend still receives plain TCP.Cruma forwards the encrypted TLS stream → agent passes it through untouched → backend handles TLS

Key takeaway: raw (tcp_terminate_tls: false) only provides true end-to-end TLS pass-through (where the backend terminates TLS itself) when using a CNAME'd custom domain. With assigned Cruma hostnames, TLS is still terminated before the backend, either on the agent or at the Cruma ingress, so raw and tcp both result in plain TCP at the backend.

What we can see

  • Assigned Cruma hostnames: Visibility depends on where TLS terminates for that hostname. If the agent has successfully provisioned its assigned-hostname certificate and is actively terminating TLS, Cruma only forwards the encrypted stream. If the hostname is still using fallback ingress termination, payloads are technically accessible to Cruma infrastructure. Today we only handle what's needed for routing and telemetry (e.g., request counts, health checks) and do not run MITM or payload-inspection features. If we ever add a feature that needs payload inspection, it would be explicitly opt-in.
  • Custom CNAME hostnames: Cruma sees only control-plane metadata (tunnel ID, target types, health/connection status) plus request/byte counts for abuse prevention. Because TLS terminates on your agent via ACME TLS-ALPN-01, payloads remain end-to-end encrypted between the client and your agent.

Assigned-hostname certificate flow

For eligible paid tunnels, the agent attempts to obtain a certificate for its assigned *.tun.cruma.io or *.*.tun.cruma.io hostname using ACME DNS-01. After the certificate is ready, the agent tells the cloud service that it is ready to terminate TLS locally.

If that process cannot complete, traffic continues to work with the normal fallback: the Cruma ingress terminates TLS using Cruma-managed certificates.

The GUI shows the current state of this handoff on the Certificates page so you can see whether the assigned hostname is agent-terminated or still using cloud termination.

How custom-domain TLS works

When you CNAME a custom hostname to your tunnel FQDN, the agent automatically provisions a Let's Encrypt certificate using the ACME TLS-ALPN-01 challenge. This happens transparently — you don't need to configure certificates manually. The Cruma ingress routes the raw TLS connection to your agent, which presents the certificate and terminates TLS locally.

This means:

  • Clients connect with a valid, publicly trusted certificate.
  • The payload is encrypted end-to-end between the client and your agent.
  • Cruma infrastructure forwards the encrypted stream but cannot decrypt it.

Inspection and opt-in

  • Ingress-terminated traffic can be inspected in principle because TLS ends on Cruma. No payload inspection is performed today beyond what's required to operate the service; any future feature needing payload visibility would be opt-in.
  • For custom CNAME hostnames, your agent terminates TLS and Cruma cannot inspect payloads. For additional hardening, see the CAA and pinning options below.

Hardening options (custom domains)

The automatic ACME TLS-ALPN-01 issuance on your agent already provides strong end-to-end encryption for custom domains. The options below are for users who want additional guarantees.

CAA records (restrict certificate issuance)

Set CAA records on your custom domain to restrict which Certificate Authority can issue certificates for it. This prevents anyone (including Cruma infrastructure) from obtaining a certificate for your hostname through a different CA or ACME account.

Example — permit only Let's Encrypt:

app.yourdomain.com.  CAA 0 issue "letsencrypt.org"

For tighter control, use accounturi to limit issuance to your agent's specific ACME account:

app.yourdomain.com.  CAA 0 issue "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/<your-acct-id>"

See Let's Encrypt CAA docs and Cloudflare's CAA overview for more details.

Certificate pinning / mTLS

If your security model requires it, you can pin the certificate your agent presents for custom hostnames. Clients will reject any different certificate, preventing a silent MITM even if someone were to obtain a valid cert for your domain through another path. Pinning is advanced and makes certificate rotation more complex — use it only when your threat model demands it.

Combine CAA with pinning (or mTLS) for the strongest guarantees: CAA restricts who can issue a certificate, and pinning ensures clients only accept the specific certificate your agent presents.

Restricting who can access a route

TLS secures the channel; access control decides who is allowed through. Both are configured as per-frontend middlewares (see Configuration → Access controls):

  • basic_auth — HTTP Basic, a static user list. Simplest, good for quick internal tools.
  • form_auth — a hosted login page with signed-cookie sessions.
  • oauth2 — "Sign in with GitHub/Google/…" via an OAuth2/OIDC provider; Cruma can also run as its own OAuth2 server.
  • authentication — API key or JWT (optionally verified against a jwks_url), for machine-to-machine access.
  • ip_filter — allow/deny by client IP (X-Forwarded-For aware), plus a firewall/WAF layer that runs before routing.

Put an auth middleware at the top of a frontend's middleware list so it runs before anything reaches your service.

How this compares

  • Assigned hostnames: Cruma can fall back to provider-edge termination for assigned *.tun.cruma.io hostnames, similar to Cloudflare, ngrok, and similar services for their default domains. On eligible paid tunnels, the preferred path is now agent-side termination once the assigned-hostname certificate is ready.
  • Custom hostnames: Terminating TLS on the agent via ACME TLS-ALPN-01 provides true end-to-end encryption by default — no manual certificate setup needed. This is stronger than providers that terminate custom-domain TLS at their edge. For additional hardening, layer on CAA and/or pinning.

What is Promenade?

Promenade is a drop-in widget that turns any web page into a small shared place. Add one <script> tag to a site and the page itself becomes a playable landscape: every letter of every paragraph is solid ground, so visitors control a little stick figure that walks across headings, jumps between list items, fires a jetpack to reach the skyline, and falls through the gaps between words.

Anyone else reading the same page appears as their own figure walking the same text, in real time — and you can talk to them, by text chat or by voice and video. It needs no cooperation from the host page's own code, and it renders entirely inside a shadow root so it can't touch (or be touched by) the host site's styles.

The fastest way to see it: There is a decicated site over at promenade.cruma.io which has the script running in server mode

What it's for

  • Presence on a quiet page. A blog or docs page stops being read-once-and-forgotten — you can see who else is here right now and wander over to them.
  • The page is the level. There's no authored level geometry. The article is the terrain, so every page is a different place, for free. Change the page, and you've changed the map.
  • Chat tied to a location. General chat, named channels, direct messages, and a voice/video room — attached to a figure standing somewhere on the page, not a faceless sidebar.
  • A serverless option. A single attribute (data-p2p) drops the server entirely and runs the whole thing over a browser-to-browser mesh. Fewer features, but no infrastructure at all.

Four ideas carry everything

The page is the level. Every non-whitespace glyph of every matched element becomes its own small platform, measured from the page's real layout. Text-less elements (images, inputs) become one block platform instead.

It is a guest on someone else's page. Everything renders inside a shadow root, the scene is anchored to the document rather than pinned over your content, and the host's own CSS is never touched. A widget that breaks its host has failed.

One origin, one world. Visitors are partitioned into universes keyed by the website they're on. Each embedding site gets its own private area — people on foo.com never see people on bar.com.

Two transports, one client. Server mode (a relay with persistence and moderation) and P2P mode (a serverless browser mesh) run the same simulation and the same UI. The only difference is where packets go and which features exist.

Server mode vs P2P mode at a glance

Server modeP2P mode (data-p2p)
InfrastructureTalks to a Promenade serverNone — browsers connect directly
Room identityThe site's origin → a universeA room key you choose (default: the site origin)
Chat historyPersisted per site (owner-toggleable)Ephemeral only
ModerationMute, ban, purge, reports, audit trailNone — local hide only
Voice / videoYes, owner can gate itYes, open to everyone in the room
IdentityStable per-browser id, hashed server-sideRandom per page load (today)

Most sites use server mode by pointing the script at the managed server at promenade.cruma.io — you get moderation, persisted chat, and the owner dashboard with nothing to run yourself. Reach for P2P mode when you want zero infrastructure and don't need moderation or history.

The next chapter shows how to add the widget to a page. If you just want to know what the keys do, jump to Controls, chat & video. If you're curious how the page-as-terrain trick actually works, see How it works.

Embedding Promenade

The widget is a single script tag. It mounts all of its own DOM and styles inside a shadow root, draws a non-interactive overlay pinned to your page, and connects back to a Promenade server for presence and chat.

The one-line version

Point the script at the managed server and you're done:

<script src="https://promenade.cruma.io/promenade.js" async></script>

That's the whole install. The async attribute keeps the widget from holding up your page — it never blocks the parser and doesn't delay your DOMContentLoaded. Promenade finds its own <script> tag to read the options below, so load order never matters.

Because the script is loaded from promenade.cruma.io, it also talks to that server by default — everyone visiting your site lands in your site's own private universe (keyed by your origin), and you never see visitors from other sites.

Hosting the script elsewhere

If you'd rather serve promenade.js from your own CDN but still use the managed server, tell it where the server is with data-server:

<script src="https://cdn.example.com/promenade.js" async
        data-server="wss://promenade.cruma.io"></script>

data-server accepts ws://, wss://, http://, https://, or a bare host; the /ws path is added automatically.

Serverless (P2P) embed

To run without any server at all, add data-p2p. Browsers discover each other and connect directly:

<script src="https://promenade.cruma.io/promenade.js" async data-p2p></script>

See P2P mode for what changes, room keys, and the trade-offs.

Where the widget appears

By default the widget fits itself into your page without growing it whenever it can: it measures where your real content ends and, if the whole promenade band fits in the empty space at the foot of the viewport, it tucks in there — no extra scrollbar, the line stays visible above the fold. Only when your content is tall enough that the band genuinely doesn't fit does it add a little padding at the page foot and put the line below your content (scroll down to reach it).

You can override this with data-clearance (see the reference below).

Boxed embed

Instead of overlaying the whole page, you can mount the promenade inside a specific element — a framed, clipped box the figure can't leave, perfect for a "try it here" section:

<div id="playground" style="height:360px"></div>
<script src="https://promenade.cruma.io/promenade.js" async
        data-mount="#playground"></script>

Size the box with your own CSS on that element. A boxed embed is floor-only (there's no host text inside it to stand on). The chat panel and the fullscreen toggle still work — they float over the whole viewport rather than being trapped in the box.

Caveat: a transform, filter, or contain on the mount element (or any ancestor) makes it the containing block for otherwise viewport-fixed layers, which would trap the chat panel and fullscreen stage inside the box. Keep those off the container.

Attribute reference

All attributes go on the <script> tag.

AttributeEffect
data-serverWhich server to talk to. Defaults to the script's own origin.
data-p2pRun serverless over a WebRTC mesh (see P2P mode).
data-p2p-roomP2P rendezvous key. Default: location.origin.
data-p2p-relaysOverride the Nostr relays used for P2P discovery/signalling.
data-p2p-strict-powHide P2P peers with missing/invalid proof-of-work instead of just flagging them. Off by default.
data-solids="h1,h2,img,…"CSS selector for which elements become terrain. Defaults to headings, p, a, li, media, table cells, and form controls.
data-mount="#selector"Bounded/"boxed" embed inside a container (implies floor-only).
data-max-width / data-max-heightSize caps (CSS px) for a boxed embed.
data-clearance="<px>"Manual control of the headroom reserved above the promenade line. 0 keeps only the UI band; a negative value eats into the band so the page grows less (≈-64 zeroes the reserve). Leave it off to keep the automatic fit.
data-delay="<ms>"Defer the first page measurement (and server connection) by N ms. Useful when your content slides/fades in with an entrance animation — measuring mid-animation bakes terrain a few pixels low. Set it to roughly the animation's duration.
data-chart-nodes="<selector>"Make diagram nodes climbable platforms. Defaults to Mermaid flowchart nodes; point it at another library's node elements, or set empty to disable.
data-chart-edges="<selector>"Make diagram edges walkable ramps (sampled into little staircases). Defaults to Mermaid edge paths; override or set empty to disable.
data-lamp-switchTurn the lamppost into a light switch: walk up to it and press Enter (or click) to flip the page between light and dark. Writes data-theme on <html>; the choice is local to the browser and remembered across reloads.
data-debugOutline every solid platform, for debugging your terrain.

Cooperating with your page's own theme toggle

If you use data-lamp-switch and your page has its own dark-mode control, they should agree. Promenade drives the page the same way a normal theme button would — by setting data-theme (and color-scheme) on <html>, and persisting light/dark under the localStorage key pm-theme.

Use the same source of truth so you don't get two competing preferences. Two ways to stay in sync — pick either:

Listen for the event. Each flip dispatches a promenade:theme event on document:

document.addEventListener("promenade:theme", function (e) {
  myThemeSwitch.checked = e.detail.light;   // e.detail.theme is "light" | "dark"
});

Observe the attribute (catches any writer, not just the lamp):

new MutationObserver(function () {
  var light = document.documentElement.getAttribute("data-theme") === "light";
  myThemeSwitch.checked = light;
}).observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });

A note on performance

The widget is around 330 KB and loads lazily with async, so it never blocks your page render. Terrain is measured once and only recomputed on resize or when the DOM changes — scrolling costs nothing. The P2P transport lives in a separate chunk that's only downloaded when you actually use data-p2p.

Controls, chat & video

This chapter is for visitors — the people walking around on a page that has Promenade embedded. Nothing here needs setup; it's how you actually play and talk.

Moving around

KeyDoes
A / DWalk left / right
WJump
J or ShiftJetpack — hold to fly (burns limited fuel)
SClimb down through a bordered edge of a div or pre
TOpen the Travel menu — jump to any heading on the page, or to another page. Start typing to filter; Enter jumps to the first match.
EOpen your camp / settings dialog (name + colour) from anywhere
. (dot)Open a slim chat bar — Enter sends, Esc cancels
CToggle the chat history panel
;Toggle the fullscreen stage

Some abilities depend on the mode the site runs — on a floor-only "promenade" site there's no glyph terrain, jetpack, or climbing (see Modes & universes).

Carrying your figure (desktop)

On desktop you can pick up and carry your own figure with the mouse: press and drag it anywhere on the page, then release to drop it — it falls and lands on whatever's beneath. Everyone else sees a little UFO swoop in, beam your figure up and away, then set it down at its new spot. A quick click (no drag) just opens your settings instead.

Pickups are limited to one every 5 seconds; while it's cooling down the figure shows a red "blocked" cursor. (Dragging is mouse-only — on touch devices a press-drag scrolls the page as usual.)

Idling

If you stop for a while, your figure shows Zz after 10 seconds and settles into a bed after 30. Everyone sees the right pose — even people who just arrived.

Setting your name and colour

Press E anywhere, or walk up to the camp tent prop (beside the fire and tree) and press Enter when it lights up. Set your name, a colour, and an optional short description. Your choices are saved in your browser and shared with everyone else in the universe in real time. (Esc closes the dialog.)

Chatting

Everyone on the same site shares a chat. Press . (dot) for a slim input bar pinned low on the screen — Enter sends, Esc cancels. Each message pops a speech bubble over your figure for a few seconds.

Press C to open the full history panel: a floating, draggable, resizable window that does not block or blur the page — you can keep walking and reading while it's open. Drag it by its header to move it, drag a corner to resize, and use the header button to switch between a glass (translucent) and a solid background. Its size, position, and look are remembered in your browser.

The panel lists every message with the sender's name, a timestamp, and the text, and has a composer at the bottom so you can type and send right there. On a server-mode site the server keeps a rolling history and replays whatever's still in the window when you arrive, so you land mid-conversation. Chat is scoped to the site's universe — foo.com and bar.com never see each other's messages.

Beyond the general chat there are also named channels and 1:1 direct messages in the same panel. Direct messages between two people negotiate a direct, encrypted browser-to-browser connection where possible — the panel shows whether a conversation is direct or relayed via the server — and fall back to the server relay if a direct link can't form.

Voice & video

When the site owner enables it, the chat panel gains a 🎥 Live room: a WebRTC mesh where participants share audio and optional video.

It is strictly opt-in — nothing touches your mic or camera until you click Join with mic or Join with camera, so you never unexpectedly broadcast. Participants appear in a video grid (camera-off shows an avatar tile), and a 🔊 indicator lights over someone's head while they're talking. Media flows directly between browsers; the server only relays who's in the room.

A few things worth knowing:

  • The video grid sits beside the chat and stacks on a narrow panel. You can hide the chat column for a video-only view, and split the layout side-by-side or stacked.
  • On a phone in landscape, opening chat during a call switches to a clean theater (fullscreen) layout built for small screens.
  • You can opt into camera thumbnails over players' heads (Settings → Live room) to see participants' video in the scene itself, not just in the grid.
  • If your mic or camera can't be opened, the room tells you why (for example it needs a secure HTTPS or localhost page, or permission was blocked) instead of silently doing nothing.

Connection trouble? Direct media needs the two browsers to reach each other. On open or LAN networks this just works, but some restrictive networks need a relay (TURN) that the site owner has to provide. Without one, a connection that can't get through shows a clear "your networks may need a TURN server" notice rather than stalling silently.

The props

Along the foot of the page runs the promenade line with a few interactive props, placed at fractions of the page width:

  • A bus stop — the Travel menu.
  • A lamppost — optionally a light switch (if the site enabled data-lamp-switch).
  • Benches you can sit on.
  • A telephone box — toggles the fullscreen stage.
  • A camp tent — your name/colour dialog.
  • A campfire that can set you alight — and, in floor mode, doubles as a connection tell: it burns out when your connection drops and relights when it comes back.
  • A tree.

Props scale and shift on narrow screens so they don't pile up. Which props appear is configurable by the site owner.

Modes & universes

Promenade runs the same client everywhere, but a site can be configured to feel quite different. This chapter explains the two concepts that shape that: universes (who shares a world) and modes (what that world lets you do).

Universes: one world per site

Visitors are partitioned into universes, keyed by the browser's origin — the site the widget is embedded on. Every site that embeds the widget gets its own private street: visitors on https://foo.com never see visitors on https://bar.com. A universe is created on demand when its first visitor arrives and dropped when its last visitor leaves.

Everything is scoped to the universe the same way: movement, chat history, channels, and the voice room. This is why a busy site and a quiet one embedded from the same server never bleed into each other.

On the roadmap: "stargates" that let a figure hop from one site's universe into another. Today, a universe is a firm boundary.

Modes

The mode decides which abilities exist in a universe. In server mode the owner picks the mode (globally or per-site); the server advertises it to every visitor so everyone in a universe agrees on the rules, and it enforces the mode — a modified client can't, say, fly in a mode where flight is off.

ModeTerrainWhat it feels like
explore (default)Full glyph terrain, jetpack, warp links, figure-draggingThe full experience: the page's text is terrain and figures climb, fly, and warp around it.
promenade (floor)Floor only — walk and jump on one lineA calm "town square": visitors can only walk and jump along a single promenade line at the foot of the page, never interacting with the page content itself.
fullscreen stageFloor onlyAny visitor can toggle a fullscreen stage with ; (or the telephone box). Occupants share a world with each other and appear as "ghosts" to people still on the page.
boxedFloor onlySet by the site's data-mount embed — the promenade lives inside a bordered box instead of overlaying the whole page.

explore mode

The default. The host page's text is the terrain: every glyph is a platform, figures climb and jump between headings and list items, fire the jetpack to reach the top, warp through links, and can be picked up and carried. The Travel menu jumps to any heading. This is the mode most sites want.

promenade (floor) mode

A deliberately minimal, TownSquare-style mode. Visitors can only walk and jump on the promenade line, which sits at the foot of the page — you scroll down to reach it. The view never camera-follows as you walk; your figure slides across a fixed-width stage instead of dragging the page around.

The widget never scans the host page in this mode — no glyph terrain, no link-walking, no watching the DOM — because there's nothing to collide with. Glyph terrain, climb-down, the jetpack, link-warp signs, and figure-dragging are all off. The Travel menu remains but only lists "leave this site" links. Jumps are a purely cosmetic hop that never affects a figure's networked position, so they can never jitter on other people's screens. The result is smooth, even motion that suits a busy or a very simple page.

Ghosts: when layouts don't match

A figure's position is expressed as which glyph it's standing on, not as pixels (see How it works). That only lines up perfectly when two visitors' pages wrap text the same way. When they don't — a different window size or zoom, a fullscreen stage, a boxed embed, or genuinely different content — the other person is shown as a faded ghost: an approximate figure placed onto your terrain rather than positioned precisely-but-wrongly. Ghosts are how the widget stays sensible across a phone and a widescreen monitor looking at the same page at the same time.

Serverless (P2P) mode

Add one attribute and Promenade runs with no server at all:

<script src="https://promenade.cruma.io/promenade.js" async data-p2p></script>

In P2P mode, visitors' browsers connect directly to each other over a WebRTC mesh. Public relays are used only for discovery and signalling — the handshake that lets two browsers find each other — never for your movement or your messages. Once connected, everything flows browser-to-browser.

This is the zero-infrastructure option: you don't run anything, you don't need an account, and there's nothing to administer. The trade-off is that features which fundamentally require a trusted server aren't available.

What you get, and what you give up

P2P modeServer mode
InfrastructureNoneA Promenade server
Chat historyEphemeral (in-session only)Persisted per site
ModerationLocal hide onlyMute, ban, purge, reports
IdentityRandom per page load (today)Stable, hashed per browser
Voice / videoYes — open to everyone in the roomYes — owner can gate it
Anti-abuseReceiver-side guard + proof-of-workServer-enforced

The single biggest difference is moderation. With no server there's no authority to ban anyone, persist history, or resolve identity disputes — each browser can only locally hide peers it doesn't want to see. Sites that need real moderation should use server mode.

Rooms

In server mode, the "room" you land in is fixed by the site's origin. In P2P mode you choose it with a room key — a rendezvous string that decides which browsers try to find each other:

<script src="https://promenade.cruma.io/promenade.js" async
        data-p2p data-p2p-room="my-cool-room"></script>

The default room key is location.origin, so by default P2P behaves like server mode's per-site partitioning. Note a key difference, though: a room key is a rendezvous point, not a boundary. Anyone who knows the key can join. It decides who finds each other, not who's allowed in.

Relays

Discovery uses public Nostr relays by default. You can point at your own with data-p2p-relays if you'd rather not depend on the public ones:

<script src="https://promenade.cruma.io/promenade.js" async
        data-p2p data-p2p-relays="wss://relay.example.com,wss://relay2.example.com"></script>

Abuse handling without a server

There's no server to enforce rules, so P2P does its policing on the receiving side: each browser vets what it receives and quarantines misbehaviour locally. That needs no cooperation from the sender, which is why it works at all.

  • Per-peer movement and flood limits catch an individual bad actor.
  • Aggregate budgets catch a swarm — twenty peers each individually compliant are still a swarm — and shed the least-trusted first, where "trust" is simply time-in-room (unforgeable, and paid in wall-clock).
  • New peers solve a small proof-of-work before they're shown, so a flood of fresh identities costs real effort.

By default, peers with missing or invalid proof-of-work are flagged but still shown. Add data-p2p-strict-pow to hide them entirely:

<script src="https://promenade.cruma.io/promenade.js" async
        data-p2p data-p2p-strict-pow></script>

When to choose P2P

Reach for P2P mode when:

  • You want presence and chat with zero infrastructure.
  • You don't need persisted history or the ability to ban people.
  • Your audience is small-to-medium and mostly on open networks (very restrictive NATs may struggle to form direct connections without a relay).

Choose server mode when you need moderation, persisted chat, a stable identity per visitor, or a gated voice room. See Owner & admin controls for what that gives you.

Owner & admin controls

When you embed Promenade in server mode (pointing at promenade.cruma.io), you can claim your site and take control of how the widget behaves there — the mode, whether the voice room is on, whether chat history is kept, and who gets moderated. This chapter covers that owner experience.

This applies to server mode only. P2P mode has no server and therefore no owner controls, no persisted history, and no moderation beyond a local per-viewer hide.

Claiming your site

Everything an owner can do is scoped to origins you've proven you control. Promenade deliberately never trusts the widget's own Origin header for this — a browser sets it honestly, but a non-browser client can forge it — so ownership is verified out-of-band, the same way other services verify a domain.

From your Cruma dashboard, add your site's origin and pick one of two proofs:

  • DNS — add a TXT record at _promenade-challenge.<your-host> containing the challenge token you're shown.
  • File — serve the token at https://<your-host>/.well-known/promenade-verify.txt (the file's contents should be exactly the token).

Then click Verify. Each origin has exactly one verified owner — first to verify wins. Once verified, you get a copy-paste embed snippet and access to the per-site settings below.

Per-site settings

These are all scoped to your verified origin (your universe). Each one is an override of the server's global default — where you don't set anything, the site inherits the default.

SettingWhat it controls
ModeFloor-only promenade mode vs full explore mode for your site (see Modes & universes).
Voice / video roomTurn the live 🎥 room on or off for your site.
Registered-only voiceRequire visitors to be signed in (to a linked account) before they can join voice or place 1:1 calls. When off, guests can join.
Chat historyWhether chat is persisted and replayed to new arrivals, or live-only.
FederationOpt in to listing your site's open, password-free channels in other sites' channel trees so people can discover them. Off by default.
Multiple channelsAllow named channels beyond the general chat.
Fullscreen stageAllow the ; fullscreen stage.
Chat widgetWhether the draggable chat panel is available.
Travel menuWhether the T travel menu is available.
Chat themeThe chat panel's colour theme.

You manage these from the site's page in your dashboard, which also shows a live list of who's currently on your site and lets you drop into it yourself.

Moderation

Moderation exists only in server mode. There are two levels: what a site owner can do inside the running widget, and the platform-level controls the server operator holds.

As a site owner (in the widget)

Once you're recognised as the owner of your origin, you get a small set of moderation actions right inside the running widget — from a player's info panel or the in-call UI. Every action is re-checked server-side against your ownership of that origin, so it only ever affects your site:

  • Mute — silence a visitor's chat (general and channels). They're told they've been muted; you can unmute them again.
  • Voice-block — revoke a visitor's access to the voice room. If they're in a call, they're removed from it.
  • Kick / ban — remove a visitor and block their network address from your site. Their recent messages are purged and their connection is dropped.

Reports

Any visitor can report another (for spam or abuse, with an optional note), rate-limited so it can't itself be spammed. Reports are retained even after the offender disconnects, and are backed by a message audit trail, so a late report is still actionable — a moderator can trace the reported visitor's network addresses and recent messages and act on them after the fact.

Automatic anti-abuse

On top of manual moderation, the server runs the layered anti-abuse defences described in How it works — connection-rate limits, chat-flood gates, identity-churn detection, movement-plausibility scoring, and an optional proof-of-work at connect. These run automatically; you don't have to configure anything to benefit from them.

Platform-level controls

The server operator (the Cruma team, for the managed service) additionally has a super-admin dashboard covering every universe on the server: global defaults that all sites inherit, server-wide bans, the report queue and audit trail across all sites, an embed/connect allowlist, and the sign-in providers offered to visitors. As a site owner you don't need this — your per-site settings and in-widget moderation cover your own universe — but it's what backstops the whole service.

Running your own server

The managed server at promenade.cruma.io is not currently open source, so self-hosting isn't covered here. If you want moderation and persisted chat, the managed server is the path; if you want zero infrastructure and don't need those, P2P mode needs no server at all.

How it works

You don't need any of this to use Promenade — but if you're curious how a web page becomes walkable ground and how everyone stays in sync, here's the mental model, top down.

The page is the level

When the widget loads, it measures the host page's real, laid-out text. It walks every matched element (headings, paragraphs, links, list items, table cells, and so on) and, for each non-whitespace character, measures the glyph's actual on-screen rectangle. Each glyph becomes its own tiny platform, sized to the visible ink of the letter — so a figure stands right on the letters, walks across them, slips off their edges, and falls through the gaps between words and lines. Text-less elements like images and inputs become a single block platform.

Because playability comes straight from typography, the whitespace is the gameplay: the gaps between words and lines are deliberately holes to fall through. Change the page's text and you've changed the map, for free — there's no authored level anywhere.

Terrain is stored in document coordinates (scroll-independent), so scrolling costs nothing; it's only recomputed on a resize or when the page's DOM changes.

Positions are glyphs, not pixels

Here's the idea the whole project rests on. A position cannot be sent as pixels, because your window is a different width than mine — my pixel 400 is the middle of a different word than yours. So a figure's position is expressed as which glyph it's standing on:

  • el — the element's index in the match list,
  • seg — the character's offset within that element's text,
  • plus a fraction across that glyph.

Both el and seg are derived from the shared DOM that every visitor received identically. So the same glyph is identifiable on every visitor's screen, no matter how differently their browser lays the page out. When your figure walks across a heading, the message that goes out says "I'm on the W of element 3" — and the receiving browser resolves that against its own layout and draws the figure there. Which is why it looks right on a phone and a widescreen monitor at the same time.

When two visitors' pages don't lay out the same way — different window size, zoom, a fullscreen stage, or genuinely different content — the glyph anchor would resolve to a weird spot, so that peer is drawn as a faded ghost placed approximately onto your terrain instead (see Modes & universes).

What runs where

There are two pieces: one JavaScript bundle in every visitor's page, and — in server mode — one server.

  • The client bundle contains the whole simulation, the UI, and both transports. Local physics actually runs in a small Rust core compiled to WebAssembly, with a pure-JavaScript fallback so the widget always works even if the WASM can't load.
  • The server (server mode only) is a relay and a referee, not a simulator. It never runs physics. It sanitizes and forwards each visitor's state, enforces the mode (clearing flags for abilities a mode disables, so a modified client can't express them), scores movement for plausibility, and relays chat. Physics runs only in browsers.

In server mode the server forwards each visitor's state the moment it arrives (rate-capped per player, and always flushing a player the instant it comes to rest so its settled position is exact). A figure standing still generates no traffic at all. In P2P mode there's no referee, so each browser instead vets what it receives and quarantines misbehaviour locally.

Identity

In server mode, each browser mints a random secret on its first visit and keeps it locally. It presents that secret to the server on connect, and the server hashes it into the public id other people see. The secret itself never leaves the browser, so a public id can't be reverse-engineered into someone's secret or used to impersonate them — and because the hash is deterministic, a returning visitor keeps the same identity. Multiple tabs from the same browser share one player.

In P2P mode, identity is random per page load today (a stable-identity scheme is in progress).

Anti-abuse, briefly

The widget is embeddable by anyone, so the abuse surface is "a bot connects to every site using the script." The defences are layered, and mostly about anchoring limits to something that costs real money:

  • Limits key on IP, not identity, because a browser identity is free to mint — per-identity penalties would reset for nothing.
  • Identity churn is the signal that separates a real office behind one address (24 identities that persist) from a spammer (a fresh identity every few messages). Message rate alone can't tell them apart; churn can.
  • Proof-of-work at connect is a speed bump that makes swarms cost effort, solved off the main thread so the host page never janks.
  • In P2P, the same ideas are judged locally by each receiver, plus aggregate budgets that catch a swarm of individually-compliant peers.

If you self-host and every visitor appears to arrive from one IP (a common misconfiguration behind naive Docker port-forwarding), the per-IP defences detect the collapse and fail open with a loud warning, rather than throttling your whole audience into one shared bucket.

Want the deep version?

This is the tour. The widget also has an interactive, six-altitude architecture walkthrough (arch-and-mental-model.html in the source tree) and a set of design docs covering anti-abuse, anti-cheat, deterministic replay, the simulation-core interface, and the in-progress P2P identity spec. Those are the source of truth for anyone working on Promenade itself.