Cruma
DownloadsDashboard

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/cruma.psm1 | iex ; Install-CrumaTunnel

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. Or install via Homebrew:

brew install --cask umbra-yurei/brew/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 desktop app (GUI), a TUI (terminal UI), and a headless mode 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. Running cruma with no arguments creates a default configuration file (with anonymous credentials and no routes) and starts it — opening the desktop app on a graphical desktop, or the TUI when no display is available. If no default config location can be determined, a short numbered prompt in the terminal offers a few starting points (run without routes, serve a directory, or expose an HTTP/HTTPS service). 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 (alias --id) is the tunnel name; leave it out to use the default (sourced from your configuration file, otherwise ANON).
  • --secret-key (alias --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. It is a global option, so it goes before the subcommand: cruma --tower-server HOST:PORT proxy http 8080.
  • --protocol auto|quic|h2 selects the transport used for the tunnel channels (auto is the default). It sits between proxy and the kind: cruma proxy --protocol quic http 8080.

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

UI modes

Cruma is one program with three faces, all driving the same runtime and config file:

  • Desktop app (GUI) — the default when you run cruma, cruma start, or a config file on a machine with a graphical desktop.
  • TUI — the terminal UI. It is used automatically for proxy and serve, and whenever no graphical desktop is detected. Pass --tui to force it (for example cruma --tui start).
  • Headless — no interface at all; pass --headless (useful for scripts, services, and CI).

Related global options (place them before the subcommand):

  • --minimized — start the desktop app hidden, showing only the tray icon.
  • --theme light|dark|system — 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).
  • --protocol auto|quic|h2 — transport used for the tunnel channels (default: auto).
  • --tunnel-id / --secret-key — use your account credentials instead of an anonymous tunnel.

serve always runs with the TUI (or headless with --headless).

What Cruma is (and isn't)

  • Local-first tooling: desktop app, TUI, and headless mode; 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 before the subcommand:

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

In the desktop app, the same choice lives under Settings → Tower Server.

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"

listeners:
  - kind: cruma

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

frontends:
  - hostname: "app"
    backend_id: web

The kind: cruma listener is what connects the agent to the Cruma cloud. Config files without one run local-only; the one-off proxy and serve commands add it for you.

Run it with:

cruma start ./cruma.yaml

Shortcuts: a bare .yaml/.yml/.json path (cruma ./cruma.yaml) or --config/-c (cruma -c ./cruma.yaml) mean the same thing. cruma start with no path uses the platform default config file, creating it if missing (cruma config locate shows where it lives). On a desktop this opens the desktop app; add --tui or --headless to choose otherwise.

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

cruma config set-credentials TUNNEL_ID SECRET_KEY
cruma config add http 127.0.0.1:3000 --hostname app
cruma config add dir ./public --hostname docs
cruma config add-listener 8443
cruma config show

config add supports http, https, tcp, raw, dir, and process targets; config show lists everything with indices you can pass to config update, config remove, config remove-process, and config remove-listener. Add -c PATH right after config to operate on a file other than the default.

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

Use --port to pick the ingress TCP port to expose; it defaults to the destination port.

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; falls back to HTTP/1.1 on cleartext backends
  • h2pk — HTTP/2 Prior Knowledge (speak HTTP/2 directly without negotiation; use this for cleartext HTTP/2)
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 (default config when omitted)
cruma -c <PATH>Shorthand for cruma start <PATH>
cruma show-cacheShow the cache directory location
cruma clear-cacheClear cached identity and credentials
cruma config locateShow the default config file path
cruma config init [PATH]Create a new config file with anonymous credentials and no targets
cruma config set-credentials <ID> <KEY>Store tunnel credentials in the config file
cruma config profilesList known profile IDs
cruma config showDisplay the current configuration
cruma config add <KIND> …Add an http, https, tcp, raw, dir, or process target
cruma config add-listener <PORT>Add a local listener
cruma config resetDelete and recreate the default config file
cruma grant-port-accessAllow binding ports below 1024 without root (Linux)
cruma schemaPrint the JSON schema for the config file
cruma --generate-completions <SHELL>Generate shell completions (bash, zsh, fish, powershell, elvish, nushell)

Global options (--headless, --tui, --minimized, --theme, --tower-server) go before the subcommand.

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 when you run cruma, cruma start, or a config file; add --tui for the terminal UI, --headless for none, or --minimized to start with only the tray icon. The one-shot proxy and serve commands always use the TUI. Without a graphical desktop (SSH, servers), Cruma falls back to the TUI.

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.Web Frontends
Backend / ProcessThe destination — a service you point at (localhost:3000), a folder of files, or a program Cruma runs for you.Web Backends / Processes

(Plain TCP services that aren't HTTP get their own TCP Ports page.)

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

First launch: guided setup

Opening the app for the first time (or running cruma with no arguments) creates a default config file with an anonymous tunnel and no routes yet. The Dashboard then offers Guided Setup cards that launch a short Setup Wizard — pick a template (share a directory, expose a web service, host a process, or share a TCP port), fill in the details, optionally add authentication, review, and save. No config file to write by hand. The Dashboard also lists Detected Local Services — programs already listening on your machine that aren't exposed yet — so you can route to them quickly. The wizard is available from the Dashboard whenever you want to add another site.

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 and your assigned public domain (e.g. abc123.tun.cruma.io). Clicking the signal indicator in the sidebar opens a detailed Connections view.
  • 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 (Dashboard Mode):

  • Modern — guided-setup cards plus a card per site, each with a QR code, copy-address, and open buttons. Great for grabbing a URL onto your phone.
  • Classic — a denser, compact route list with the detected-services list alongside.

📝 The domain follows your agent, not your config

You don't pick your *.tun.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.

Web 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.tun.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).

Web Backends & Processes — the destinations

  • A Backend is where a frontend sends traffic:
    • an HTTP, HTTPS, or TCP address (localhost:3000, or a remote server) — with optional load balancing across several destinations, health checks, and a maintenance-mode switch;
    • a Directory — 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, arguments, working directory, and environment; Cruma hands it a PORT environment variable and proxies to that port, so a frontend can point straight at the process. Processes can also start lazily on the first request and stop again when idle. 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 (bodies are truncated at a capture 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

  • Auth Providers and Auth Server — set up who can reach a site: HTTP Basic, a hosted login form, or sign-in through an external OAuth2 provider (with ready-made templates such as GitHub). The Auth Server page lets Cruma 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 configuration file in use, dashboard mode and sections, the tower server (ingress region), and auto-start on login.
  • 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 (some builds also offer a small local model). 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 Web Backends → Add Backend Service, choose HTTP, and enter your local server (e.g. localhost:3000). Save.
  3. Go to Web Frontends → Add Frontend Route, 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 the --tui interface has pages for listeners, processes, requests, certificates, events, logs, and a QR code for your public URL; they all read 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.

Config files need a cruma listener

The agent only connects to Cruma cloud when the config contains a listener with kind: cruma. The one-off proxy and serve commands add it for you, but in a config file you declare it yourself — every cloud example below includes it. Leave it out to run as a purely local reverse proxy.

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.
  • The target can be <address>:<port>, just <address> (e.g. example.com), or just a port (3000 means 127.0.0.1:3000).
  • Without --hostname, the tunnel's own address (<tunnel-id>.tun.cruma.io) routes to the target. Pass --hostname app to expose it as app.<tunnel-id>.tun.cruma.io instead, or a full domain for a custom hostname.

API server on port 8080 with custom domain

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

listeners:
  - kind: cruma

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. A hostname containing a dot is used as-is; @ (or an empty hostname) means the tunnel's own address.

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"

listeners:
  - kind: cruma

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

Backend kind is one of http, https, tcp, or local-directory.

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.

In a config file the equivalent backend is kind: local-directory with destination set to the directory path, plus the optional allow_directory_indexing, render_markdown, and spa_fallback booleans. With cruma config add dir ./public, note that --user and --api-key take a single username:password / header:value argument instead of two words.

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"

listeners:
  - kind: cruma

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 (never, on-failure (default), or always), and route traffic to it. If you omit PORT from env, the agent allocates a free port and passes it to the process as PORT, so your app should listen on process.env.PORT (or the equivalent). $VAR/${VAR} references in args are expanded against the process environment, including that PORT.

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 (the wait is bounded by backend_timeout_seconds, or 30 seconds by default). With idle_timeout_seconds, the process is automatically stopped after being idle for the specified duration. Add auto_start: false (CLI: --no-auto-start) if the process should only ever be started on request rather than at agent boot.

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: "localhost"
    backend_id: web

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

Access your service at https://localhost:8443. If you use a name like web instead of localhost, the loopback listener answers for both web and web.localhost. Listener kind is http, https, or cruma; addr is localhost (default) or all, or set bind_ip for a specific address. cert_mode is self_signed (default) or acme_alpn.

The same is possible for one-off commands with --listen, which replaces the default cruma listener:

cruma proxy http 3000 --listen http:8080

--listen accepts cruma, http:PORT, http:ADDR:PORT, https:PORT, https:ADDR:PORT, and https:[ADDR:]PORT:CERTMODE (where CERTMODE is self-signed or acme-alpn), and can be repeated. HTTPS listeners require an explicit --hostname.

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:
  - kind: cruma
  - kind: http
    port: 8080

Now your service is available both at app.<tunnel-id>.tun.cruma.io and http://app.localhost:8080. On local listeners a single-label hostname stays local (app, plus app.localhost on loopback) rather than expanding to the tunnel domain. Frontends attach to all listeners by default; set listener_kinds on a frontend (e.g. ["cruma"] or ["http:8080"]) to restrict it.

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

origins.mode can also be list (origins: [...]), wildcard (patterns: [...]), regex (patterns: [...]), or mirror (allowlist: [...]). Middlewares can be set on a backend too; backend middlewares run before frontend middlewares. They apply to http, https, and local-directory backends, not tcp.

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.

To send different path prefixes on one hostname to different backends, use path_routes instead of backend_id:

frontends:
  - hostname: "app"
    path_routes:
      - path_prefix: "/api/"
        backend_id: api
      - path_prefix: "/"
        backend_id: web

The longest matching prefix wins regardless of declaration order. Each route can point at a backend_id or a process_id.

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 h2pk

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

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

Available values: H1 (default), H2, H2PK (HTTP/2 Prior Knowledge). H2 is negotiated via ALPN and therefore only takes effect for https backends — on a cleartext http backend it falls back to HTTP/1.1. For cleartext HTTP/2 (gRPC servers, for example) use H2PK.

⚠️ 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").

Forwarding the Host header

By default the original Host header is forwarded to your backend (--forward-host defaults to true; forward_host_header: true on a frontend). If your backend expects to be addressed by its own address instead, pass --forward-host=false or set forward_host_header: false on the frontend.

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 shown in the terminal UI 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 (plus a kind: cruma listener for cloud access) to run multiple services from one agent, and start it with cruma start ./cruma.yaml (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 (auth, CORS, header manipulation, path rewrites, redirects, rate limiting) to frontend routes, or fan one hostname out to several backends with path_routes (see Configuration and Common Scenarios).
  • Bind local http/https listeners next to the cloud tunnel and use --listen on one-off commands to expose a service locally (see Common Scenarios).
  • 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.

A single backend can also spread load across several local upstreams: replace destination with a destinations list of host:port strings (for http, https, and tcp backends), optionally with a health_check to take failing endpoints out of rotation. And one hostname can fan out to several backends by path prefix with path_routes (see Common Scenarios).

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: the profile config key only scopes the agent's cache directory and 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"

listeners:
  - kind: cruma

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. Each frontend points at exactly one target: a backend_id, a process_id (hosted process), or a path_routes table. The kind: cruma listener is what connects the agent to Cruma cloud; without it the config runs as a local-only reverse proxy.

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, JSON, or TOML (picked by file extension: .json → JSON, .toml → TOML, anything else → YAML) 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.

Note

The agent only connects to the Cruma cloud when the config contains a listener with kind: cruma. A freshly created config (cruma config init, or the file auto-created on first cruma start) includes one; if you write a config by hand, add it yourself or your routes will only be reachable through local listeners (see Local-only mode).

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

listeners:
  - kind: cruma

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:
  - kind: cruma                   # cloud ingress (public *.tun.cruma.io / custom domains)

  - 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
destinationyes*host:port for network backends, or a directory path for local-directory. *Optional when destinations is set.
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, the backend is excluded from traffic and clients get a maintenance response instead (default: false)
backend_timeout_secondsnoUpstream response timeout in seconds before a 504 is returned. 0 or omitted means the built-in default of 10
upstream_protocolnoProtocol to the upstream: H1 (default), H2, H2PK. Uppercase in the config file. (http/https only)
middlewaresnoBackend-level middleware list, run before the frontend's middlewares for every frontend that uses this backend

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

destination (and destinations) may reference $cfg_dir (the config file's directory), $root_dir (see root_dir in the top-level reference), or a leading ~ — handy for local-directory backends that should move with the config file.

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 } (plain connect check)
        path: "/healthz"     # default "/"
        expected_status: 200 # default 200
      interval_secs: 5       # default 10
      timeout_secs: 2        # default 3
      healthy_threshold: 2   # consecutive OK probes to mark healthy (default 2)
      degraded_threshold: 2  # consecutive failures to mark degraded (default 2)
      down_threshold: 3      # consecutive failures to mark fully down (default 3)

Only kind is required inside health_check; every other field falls back to the default shown. HTTP probes always use plain HTTP, even for https backends.

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 a backend, a hosted process, a Kubernetes target, or a set of path routes. Each hostname gets its own frontend entry.

FieldRequiredDescription
kindnoweb (default) or tcp. tcp frontends expose a port instead of a hostname — see TCP frontends
hostnameyes (web)Hostname pattern (see below)
backend_idone ofReferences a backend id
process_idone ofReferences a process id
kubernetes_target_idone ofReferences a kubernetes_targets entry — see Kubernetes targets
path_routesone ofRoute 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
listener_kindsnoRestrict this frontend to specific listeners. Each entry is "cruma", "http:PORT", "https:PORT", or "all". Omitted means all listeners; an empty list detaches the frontend from every listener
request_limitsnoPer-frontend request limits: max_request_body_bytes, max_header_count, max_header_bytes, max_header_name_bytes, max_header_value_bytes, header_read_timeout_secs (default 30), body_read_timeout_secs (off by default). Same block is accepted on path routes and listeners; a route can only tighten a listener's value
forward_host_headernoSend the client's original Host header to the backend instead of rewriting it to the backend address (default: true)
forwarded_headers_modenoWhat to do with incoming X-Forwarded-*/Forwarded headers: preserve (default — keep and append), strip_incoming (drop what arrived, add fresh ones), or none (backend receives none)
tcp_terminate_tlsnotcp backends only — see TCP pass-through below (default: true)
alpnnoALPN protocol list offered on the TLS handshake for this route (tcp backends only; must be non-empty when set)
cert_mode_overridesnoPer-listener certificate mode for custom (non-Cruma-assigned) domains: { https: <mode>, cruma: <mode> } — see Certificate overrides

Exactly one of backend_id, process_id, kubernetes_target_id, or path_routes must be set on a web frontend; combining them is a validation error.

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 (routed by SNI); 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. Note that those commands create a port-based TCP frontend rather than a hostname-based one.

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.

TCP frontends

A frontend with kind: tcp exposes a port on the Cruma TCP ingress instead of a hostname. It must reference a tcp backend via backend_id, must set port, and must not set hostname or path_routes. Ports must be unique across tcp frontends.

backends:
  - id: postgres
    kind: tcp
    destination: "127.0.0.1:5432"

frontends:
  - kind: tcp
    port: 5432
    backend_id: postgres
    tcp_terminate_tls: true
    # accept_direct_tcp_connections: true
    listener_kinds: ["cruma"]
FieldRequiredDescription
portyesIngress TCP port to expose
backend_idyesA tcp backend
accept_direct_tcp_connectionsnoLet the Cruma edge accept raw TCP connections arriving on the assigned TCP DNS name/port and forward them straight to the backend. When false (default) only connections that arrived through a normal Cruma tunnel are forwarded
tcp_terminate_tls, alpn, listener_kindsnoAs for web frontends

This is what cruma config add tcp <dest> [--port N] and cruma config add raw <dest> [--port N] produce (--port defaults to the destination's port).

Hostname patterns

  • app — expands to app.<assigned-fqdn> (e.g. app.abc123.tun.cruma.io)
  • @ (or an empty hostname) — the bare assigned FQDN itself
  • *@ — the assigned FQDN and all of its subdomains
  • app.yourdomain.com — exact match on a custom domain (requires CNAME)
  • *.yourdomain.com — wildcard match for any subdomain
  • *.api — any subdomain of api.<assigned-fqdn>
  • app-* — glob match on a single label
  • * — matches any hostname (not recommended)

On local http/https listeners, single-label names are not expanded with the assigned FQDN: app matches app (and app.localhost on loopback listeners), so local routing stays intuitive.

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 exactly one target (backend_id, process_id, or kubernetes_target_id), and may carry its own request_limits. path_routes replaces the frontend's own backend_id/process_id (setting both is a validation error), so add a / route as the catch-all:

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

Longer prefixes win over shorter ones regardless of declaration order, so /api/v2 can override /api. Path routes can target http, https, and local-directory backends (not tcp).

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
    # upstream_protocol: H2      # H1 (default) | H2 | H2PK
    # backend_timeout_seconds: 30
    # middlewares: []            # run before the frontend's middlewares

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

Certificate overrides

For custom (CNAME'd) domains the agent obtains certificates via ACME TLS-ALPN-01 by default; Cruma-assigned hostnames always use Cruma's built-in provider and ignore these settings. To change how a specific frontend gets its certificate, set cert_mode_overrides with a mode per listener kind (https for local HTTPS listeners, cruma for the cloud ingress):

frontends:
  - hostname: "app.yourdomain.com"
    backend_id: web
    cert_mode_overrides:
      cruma:
        type: acme_dns01
        provider: cloudflare
        api_token: "cf-token"
        # zone_id: "..."
      https:
        type: static_pem
        pem_path: "./certs/app.pem"   # key + chain in one bundle

Available modes (type): self_signed, static_pem (pem_path), acme_alpn, and acme_dns01 with a provider of cloudflare (api_token, optional zone_id), hetzner (api_token), digital_ocean (api_token), cruma_dns (api_key), or custom_webhook (url, optional headers). The older single-value cert_mode_override field is still read for backwards compatibility but no longer written.

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. $VAR/${VAR} references are expanded against the process's environment (including the auto-allocated PORT) before launch
working_directorynoWorking directory for the process
envnoMap of environment variables (merged over the top-level global_env; per-process values win)
auto_startnoStart the process automatically (default: true)
start_on_requestnoLazily start the process on first incoming request (default: false). The request waits for readiness, bounded by backend_timeout_seconds or 30 s
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_watchnoReact when the executable (or another path) changes on disk: { watch_path?, recursive?, on_change: warn | restart }. watch_path defaults to the resolved binary, or the working directory when recursive: true; on_change defaults to warn
shadow_copynoRun from a copied binary so the original can be replaced while running: { scope: BinaryOnly | WorkingDirectory, deterministic_paths?: bool }. scope defaults to BinaryOnly
cpu_affinitynoall (default — every online CPU, even if cruma itself is pinned), inherit (keep cruma's affinity), or a list of core indices such as [8, 9, 10, 11]

command, args, working_directory, and env values may use $cfg_dir, $root_dir, and a leading ~, just like backend destinations.

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. The cloud ingress itself is a listener too — the virtual kind: cruma entry — and the agent only connects to the Cruma cloud when one is present (at most one is allowed).

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 (all interfaces)
bind_ipnoBind to a specific IP instead of the addr preset
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)
request_limitsnoListener-wide request limits (same fields as on frontends)
max_connectionsnoCap total concurrent connections on this listener (unset = unlimited)
max_connections_per_ipnoCap concurrent connections from a single client IP (unset = unlimited)
listeners:
  - kind: cruma

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

  - kind: http
    port: 8080
    addr: localhost

Add a listener via CLI (--addr accepts localhost, all, or a concrete IP, which sets bind_ip):

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
cruma config add-listener 0 --kind cruma

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"
        # session_ttl_secs: 3600        # default
        # login_path: "/_auth/login"    # default
        # logout_path: "/_auth/logout"  # default
        # cookie_name: "cruma_session"  # default

Optional fields: session_ttl_secs (default 3600), login_path, logout_path, cookie_name, cookie_secure (default true), cookie_same_site (lax default, strict, none), and cookie_domain.

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"

The authentication middleware also accepts variant: basic (with a users list of [username, password] pairs) and variant: jwt for Bearer-token validation:

      - type: authentication
        auth_type:
          variant: jwt
          # Either a shared HMAC secret …
          secret: "hs256-shared-secret"
          # … or a JWKS endpoint for RSA/EC keys (cached for 5 minutes)
          # jwks_url: "https://issuer.example.com/.well-known/jwks.json"
          issuer: "https://issuer.example.com"     # optional `iss` check
          audience: ["my-api"]                      # optional `aud` check
          algorithms: ["HS256"]                     # default ["HS256"]

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"]                 # default ["openid", "profile", "email"]
    # For OIDC providers you can instead set `discovery_url` and let Cruma
    # fill in auth/token/userinfo automatically. Other optional knobs:
    # authorize_params: { prompt: select_account }
    # skip_userinfo: true                # id_token already has the claims
    # verify_id_token_signature: true    # check id_token against the JWKS

frontends:
  - hostname: "app"
    backend_id: web
    middlewares:
      - type: oauth2
        provider_ids: ["github"]     # which providers to offer
        session_secret: "replace-with-a-random-secret"
        # callback_path: "/_cruma_auth/callback"   # default
        # logout_path: "/_auth/logout"             # default
        # session_ttl_secs: 3600                   # default

With a single provider the user is redirected straight to it; with several, a picker page is shown. The oauth2 middleware shares the cookie options of form_auth (cookie_name defaults to cruma_oauth2_session).

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 desktop app's Auth Server page. When enabled: true, it is mounted at /_cruma_auth on every TLS listener, and the reserved provider id local-oauth2 can be listed in an oauth2 middleware's provider_ids to offer its local login:

local_oauth2_server:
  enabled: true
  realm: "my-realm"
  default_scopes: ["read"]
  users:
    - username: alice
      password: "alice-pass"
      # scopes: ["read", "write"]      # empty = inherits default_scopes
  clients:                              # pre-registered clients (client_credentials + code flow)
    - client_id: my-app
      client_secret: "app-secret"
      redirect_uris: ["https://app.example.com/callback"]
      # scopes: [], allowed_origins: []
  upstream_provider_ids: ["github"]     # show these oauth2_providers on the local login page
  token_signing_secret: "replace-me"    # generated automatically by the GUI when omitted
  # token_ttl_secs: 3600, code_ttl_secs: 300, refresh_token_ttl_secs: 2592000

Middlewares

Frontends support an ordered list of HTTP middlewares. Backend middlewares run first, then the frontend's, each in the order listed. tcp backends have no HTTP phase and therefore accept no middlewares.

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: ip_filter
        allow: ["10.0.0.0/8"]     # `deny` is evaluated after `allow`
        trust_proxies: 0

      - type: rate_limit
        key: ByIp                 # or ByHeader: X-Key | ByCookie: sid | Composite: [...]
        limit_per_minute: 600
        burst: 50
        reject_status: 429

      - type: redirect
        location: "https://example.com/new-path"
        code: M301                # M301 | M302 | M307 | M308

Available middleware types

TypeDescription
redirect_http_to_httpsRedirect HTTP requests to HTTPS
basic_authHTTP Basic Auth (RFC 7617) — users list of [username, password] pairs
form_authHTML form-based login with signed-cookie sessions
oauth2Sign-in via one or more OAuth2/OIDC providers (GitHub, Google, …); references oauth2_providers
authenticationGeneral authentication with pluggable schemes (basic, api_key, jwt)
allow_corsCORS headers and preflight handling. origins.mode: any, list (origins), wildcard (patterns), regex (patterns), or mirror (allowlist)
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: [pattern, replacement])
rewrite_hostReplace the Host header sent to origin
forward_original_hostKeep the client's original Host header when proxying (the per-frontend forward_host_header flag does the same)
redirectIssue an HTTP redirect (short-circuit); code is M301, M302, M307, or M308
cookie_opsAdd (add: [name, value, attributes] triples) or remove (remove: names) cookies
ip_filterIP allow/deny filtering; trust_proxies picks the client IP from X-Forwarded-For (0 = raw peer)
cache_control_overrideOverride Cache-Control/Surrogate-Control, optionally strip Set-Cookie
rate_limitFixed-window rate limiting by IP, header, cookie, or composite key
cacheIn-memory response cache for GET requests (max_size_bytes, default_ttl_secs, max_entry_size_bytes, max_ttl_secs, distributed)
compressionCompress responses to the client (to_client) and/or strip Accept-Encoding towards the origin (decompress_to_origin); optional min_bytes
inject_scriptInject <script>/<link>/<style> tags into every HTML response (entries, optional local_only)
cruma_assistantEnable Cruma's site-local assistant widget and API for this site

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 (see forwarded_headers_mode on the frontend to change how incoming ones are handled).

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 (exposes a TCP frontend on the destination port, or --port)
cruma config add tcp 127.0.0.1:5432
cruma config add tcp 127.0.0.1:5432 --port 15432

# Add a raw TCP target (no TLS termination)
cruma config add raw 127.0.0.1:4943

# 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

http, https, and dir targets also accept --api-key "Header:value" to require an API key. If the destination/path is omitted the CLI prompts for it.

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 (created with a cruma listener if it doesn't exist yet)
cruma start

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

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

--tower-server <HOST:PORT> on the top-level command overrides the file's tower_server.

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. Read from the file but not written back when the CLI/GUI saves it — prefer the --tower-server flag if you also edit the config with those tools
profilenoNamed profile to scope cached identity
tempnofalseUse a temporary identity (fresh FQDN each run)
processesno[]Hosted processes supervised by the agent
listenersno[]Listeners: local http/https sockets and the virtual cruma cloud ingress
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 (per-process env wins on conflicts)
acme_directorynoLet's Encrypt productionWhich ACME CA to use: { authority: lets_encrypt, staging: false }, { authority: zero_ssl }, { authority: google_trust_services }, or { authority: custom, url: "…" }. ZeroSSL and Google require acme_eab and do not support TLS-ALPN-01
acme_eabnoExternal Account Binding for CAs that need it: { key_id: "…", hmac_key: "…" }
root_dirnocurrent working directoryWhat $root_dir / ${root_dir} expands to in config values. A relative value is resolved against the config file's directory; ~ and $cfg_dir are allowed
max_tunnel_connectionsnounlimitedCap on concurrent tunnel ingress streams; extra streams are dropped
performancenoData-plane tuning, applied on restart: thread_per_core (auto default, on, off), tpc_cores, tpc_pin (Linux only), pool_max_idle_per_host (default 512), pool_idle_timeout_ms (default 30 s), upstream_retry (default true), io_uring (default true; native completion-based plane where supported, silently falls back otherwise). Environment variables such as CRUMA_IO_URING=0 override the file

Notes:

  • profile and temp are mutually exclusive.
  • Changes to tower_server, profile, or temp require a restart to take effect.
  • Credentials, backends, frontends, listeners, oauth2_providers, and kubernetes_targets are hot-reloaded when the config file is saved.
  • Anywhere a path or destination is expected you can write $cfg_dir (directory of the config file), $root_dir, or a leading ~; the raw form is preserved when the CLI/GUI writes the file back.
  • 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. The kind: cruma listener is what connects the agent to Cruma cloud — without it the config only serves local listeners:

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

listeners:
  - kind: cruma

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. A hostname containing a dot is treated as a fully-qualified custom domain; a single label is expanded under your assigned FQDN.

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.

Note that the default TLS-ALPN-01 issuance (below) cannot produce wildcard certificates. With a wildcard frontend the agent instead obtains a certificate per concrete hostname, on demand, the first time a client connects to it — so the first request to a new subdomain is slower while the certificate is issued. If you want a single real wildcard certificate, use a DNS-01 override with your DNS provider (see below).

Wildcards under your assigned hostname are limited: the Cruma ingress only routes a single label in front of the assigned FQDN, so a pattern like *.sub (meaning *.sub.<tunnel-id>.tun.cruma.io) is not reachable through the cloud tunnel. Use plain shortnames (api, app-*) or * instead.

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

A few details worth knowing:

  • Certificate authority. Let's Encrypt production is used by default. The top-level acme_directory setting switches to Let's Encrypt staging, ZeroSSL, Google Trust Services, or any custom ACME directory URL; CAs that require External Account Binding take their credentials from acme_eab.
  • Where certificates live. Issued certificates, their private keys, and the ACME account key are stored in the agent's cache directory (cruma show-cache prints the path). They stay on your machine — they are not uploaded to Cruma. cruma clear-cache deletes them, and the agent will simply re-issue on the next run.
  • Renewal. The agent checks its cached certificates every minute and logs a warning when one has less than 7 days left (and an error under 24 hours). TLS-ALPN-01 certificates for custom domains are replaced on demand: when a client connects and the cached certificate is no longer valid, the agent obtains a new one during that connection. The assigned-hostname certificate (DNS-01, see below) is renewed proactively in the background once fewer than 7 days remain. Certificates for hostnames no longer in your configuration are left alone and eventually expire.
  • Bringing your own certificate. Per-frontend cert_mode_overrides let a custom domain use a static PEM bundle (static_pem with pem_path), a self-signed certificate, TLS-ALPN-01 explicitly, or ACME DNS-01 with a Cloudflare, Hetzner, DigitalOcean, or generic webhook DNS provider. Overrides are only honoured for custom domains — assigned Cruma hostnames always use the built-in flow described next.

TLS for assigned hostnames

Assigned Cruma hostnames (<tunnel-id>.tun.cruma.io and *.<tunnel-id>.tun.cruma.io) have two modes:

  • On paid plans with an active subscription, the agent obtains a certificate covering both the assigned FQDN and its *. wildcard via ACME DNS-01. The DNS challenge records are published for you through the tunnel's control connection, so nothing needs configuring. Once that certificate is cached and valid, the agent advertises to Cruma that it can terminate TLS for those hostnames itself, and the ingress forwards the encrypted stream to it.
  • Without an active plan, or while issuance is still in progress or has failed, TLS for assigned hostnames is terminated at the Cruma ingress using Cruma-managed certificates. Traffic keeps working either way.

You can see the current state of every hostname — which certificate covers it and whether it is agent- or ingress-terminated — on the TLS Coverage tab of the Certificates page in the desktop app.

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.

How the agent identifies itself

Which tier applies to you is decided by how the agent registers:

  • Anonymous: when tunnel_id and tunnel_secret are left at their default (ANON), or you run a one-off cruma proxy/cruma serve without --tunnel-id/--secret-key. On first run the agent generates an Ed25519 keypair in its cache directory and signs every registration with it; your anonymous FQDN is bound to that key, so it stays stable across restarts as long as the cache is kept. cruma clear-cache (or temp: true in the config) gives you a fresh anonymous identity and a new FQDN; profile: <id> keeps several stable anonymous identities side by side.
  • Registered/Subscribed: set tunnel_id and tunnel_secret (or cruma config set-credentials <id> <secret>). The agent still signs with a per-tunnel-ID keypair from the cache, so the same identity is reused across restarts.

When the connection is established the server tells the agent which plan (if any) is active. The agent uses that to size its transport to Cruma: anonymous tunnels open 1 HTTP/2 + 1 QUIC channel, registered accounts without a plan 1 HTTP/2 + 2 QUIC, and subscribed accounts 2 HTTP/2 + 2 QUIC. The server also pushes a tier snapshot the agent records in its log: the per-stream rate limit, the maximum number of agents per tunnel, channels per agent, concurrent streams per channel, site-list entries, and whether TLS passthrough and control features such as DNS-01 issuance are allowed for the tier.

When you are throttled or disconnected

  • Per-stream bandwidth shaping happens on the Cruma side; the agent does not need to do anything and the connection stays up.
  • If the tunnel connection drops, the agent reconnects automatically with an increasing backoff. Authentication failures (wrong tunnel_id/tunnel_secret) use a longer backoff and clear the assigned hostname in the UI until credentials are accepted again.
  • You can put your own ceiling on inbound load with the top-level max_tunnel_connections config key: streams arriving through the tunnel beyond that number are dropped and the client sees a connection error. Leave it unset for no local cap.

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

The server enforces per-tier limits on how many agent processes may connect to a single tunnel at once and how many channels each agent may hold; the agent receives these values at connect time (see above). The exact numbers are being refined and will be better defined after the beta period.

Security and TLS

Where TLS terminates

  • Assigned Cruma hostnames (<tunnel-id>.tun.cruma.io and *.<tunnel-id>.tun.cruma.io): On paid plans with an active subscription, the agent obtains a certificate covering both names via ACME DNS-01 and takes over TLS termination itself. Once the certificate is cached and valid, the agent tells the cloud service which hostnames it can terminate, and TLS for those hostnames ends on the agent. If certificate issuance is unavailable (no active plan), 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 by default). Cruma infrastructure never sees the plaintext payload for these hostnames — it only forwards the encrypted TLS stream to your agent, which terminates it locally. You can replace the automatic certificate with your own PEM bundle or a DNS-01-issued one via per-frontend cert_mode_overrides (see Custom domains).
  • Local TLS listeners: https listeners use self-signed certificates by default (cert_mode: self_signed, good for local development) or ACME TLS-ALPN-01 (cert_mode: acme_alpn, requires port 443 reachable from the internet). The same per-frontend cert_mode_overrides apply here. 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 (routed by SNI) → 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.

Because TCP frontends have no HTTP phase, HTTP middlewares (including the authentication middlewares below) do not apply to them — the agent logs a warning and ignores them. Use the firewall to restrict TCP routes by source IP.

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.

What the agent sends to Cruma

Over the control connection the agent sends: its version and operating system name, your tunnel ID and secret, the agent's public key and a signature proving it holds the matching private key, the list of hostnames it serves (with their type — HTTP or TLS — and whether it can terminate TLS for each), and, for assigned-hostname certificates, the DNS-01 challenge values to publish. The connection to the control tower is itself TLS, verified against your operating system's trust store.

Certificates and their private keys are not uploaded: they are stored only in the agent's local cache directory.

Assigned-hostname certificate flow

For eligible paid tunnels, the agent obtains a certificate for its assigned <tunnel-id>.tun.cruma.io hostname and the *.<tunnel-id>.tun.cruma.io wildcard using ACME DNS-01. The challenge records are published through the tunnel's control connection. After the certificate is ready, the agent advertises the hostnames as agent-terminated and Cruma forwards the encrypted stream instead of terminating it.

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

The Certificates page in the desktop app (TLS Coverage tab) shows the current state of this handoff 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 certificate using the ACME TLS-ALPN-01 challenge (Let's Encrypt by default; acme_directory selects another CA). 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. Access control is configured as per-frontend (or per-backend) 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 (local_oauth2_server).
  • authentication — Basic, API key (a required header/value pair), or JWT bearer tokens verified with a shared HMAC secret or a jwks_url, for machine-to-machine access.
  • ip_filter — allow/deny by client IP. When you run the agent behind your own reverse proxy, set its trust_proxies hop count so the client IP is taken from X-Forwarded-For instead of the proxy's address; leave it at 0 otherwise, or a direct client could spoof the header.

The one-off commands have shortcuts for the two most common cases: cruma proxy http 3000 --user alice s3cret adds form_auth, and --api-key X-API-Key <value> adds an API-key authentication middleware (the config add form takes --user alice:s3cret and --api-key X-API-Key:<value>).

Put an auth middleware at the top of a frontend's middleware list so it runs before anything reaches your service. Backend middlewares run before frontend middlewares, so a backend-level auth middleware protects every frontend that uses that backend.

Firewall and WAF

Independently of middlewares, a firewall runs on every connection before routing — for HTTP requests, TLS connections, and raw TCP alike. Rules are evaluated in order and match on source IP/CIDR, protocol, listener port, and hostname (SNI or Host); the first match decides, and default_action applies otherwise. A deny rule can either respond with an error or silently drop the connection, and drop rules are enforced as early as the TCP accept or the TLS ClientHello, before any handshake work is done. WAF rules can additionally match the request path, method, user agent, or query string and optionally auto-ban the offending client IP for a period.

Request hygiene

The proxy is designed to hold up against hostile clients, and these protections are always on:

  • Hop-by-hop headers are stripped in both directions. Requests with conflicting Content-Length values are rejected, and Transfer-Encoding/Content-Length combinations are neutralised by re-framing the request before it reaches your backend, so a hostile client cannot desynchronise it (request smuggling).
  • Routing is by the Host header (or :authority); the port, trailing dots, and letter case are normalised, and a client cannot steer a request to a different route through an absolute-form URL.
  • X-Forwarded-Proto and X-Forwarded-Host are always overwritten with the real values, so a backend's "require HTTPS" check cannot be forged. X-Forwarded-For handling is per frontend via forwarded_headers_mode: preserve (default) appends the real client, strip_incoming discards whatever the client sent so your backend only sees the real peer, and none forwards nothing.
  • Slow clients are bounded: request headers must arrive within 30 seconds by default, and an optional per-frontend body idle timeout aborts stalled uploads. Header count and body size limits are also configurable per frontend under request_limits.

Local data and the cache directory

The agent keeps its state in a per-user cache directory (cruma show-cache prints the path; profile scopes it per profile, temp: true uses a throwaway directory). It contains the agent's Ed25519 identity keypair for each tunnel ID (written with owner-only permissions), the ACME account key, issued ACME certificates and private keys, self-signed certificates, and the local development CA. Treat it like any other secret material — anyone who can read it can impersonate your agent's identity and serve your certificates.

cruma clear-cache deletes the whole directory. The agent will generate a fresh identity (and therefore a new anonymous FQDN) and re-issue certificates on the next run.

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

Testing your routing

Once you have more than a couple of frontends, one question comes up again and again: if a request for this host and that path arrived right now, where would it actually go? Cruma gives you two ways to answer that without touching real traffic — one that predicts, and one that asks the live proxy directly.

Today both are reached through the MCP server (so any connected AI assistant can run them for you), and the live probe can also be driven by hand with curl. The desktop app carries a visual version of both tools on the Connections page, but only in developer (debug) builds — the release app you install does not show it yet.

The Route Simulator — a prediction

The Route Simulator answers "where would this go?" without sending anything anywhere. Give it an SNI (the TLS server name), a Host header, and a path, and Cruma walks the request through your routing rules — the same firewall, TLS, and route-matching logic the proxy uses — and reports, for every listener, which TLS route matches, which frontend rule wins, and which backend would serve it.

It's instant and safe because it never opens a connection. Set a source IP to see how source-based firewall rules would treat the request, or mark the request as HTTP/3 (UDP) so protocol-aware firewall rules are evaluated as they would be for QUIC.

💡 Ask your assistant

The simulator is the simulate_route MCP tool. If you've connected an AI assistant, you can ask "simulate a request for app.example.com/api" and it will run this for you, read back the outcome, and render the result as a flow diagram. (The assistant built into your proxied sites can run simulate_route too, for the site it is embedded in.)

Because the simulator re-derives the decision from your configuration, it's the right tool for "what if" questions. Where the answer depends on something only the running proxy knows — the current round-robin position, whether a rate limit is about to trip, whether a login would succeed — it says so rather than guessing.

The Live probe — the ground truth

Sometimes you don't want a prediction; you want to know what the running proxy would do right now. The Live probe does exactly that: it sends a real request to one of your listeners on this machine and reports the decision the live data plane actually made.

You tell it the port of the listener to test and whether that listener is TLS; the SNI, Host, and path are the same inputs as the simulator, and you can optionally force HTTP/2. The probe never contacts your backend and never disturbs your traffic counters, rate limits, or firewall state — it stops at the decision and hands it back.

What it tells you:

  • Plane — whether the request was handled by the hyper engine or the io_uring engine (see the performance section in Configuration; hyper is the default).
  • Fast path — on the io_uring engine, whether the matched route qualifies for the zero-copy fast path, and if not, which middleware disqualifies it. Not applicable on hyper.
  • Route — the frontend rule that matched, how it matched (host, path, or both), and what kind of target it points at.
  • Middleware — the request and response middleware that would run, in declared order, and where the request would stop. A rule that always ends the request (a redirect, for example) is marked as terminating; a rule that might end it depending on something at runtime — a login wall, an IP filter, a rate limit, a CORS preflight — is marked as may terminate. The probe deliberately never says whether a given credential would have passed.
  • Origin — the exact backend endpoint (address:port) that would be picked, the protocol it would be spoken to with, and why it was picked (single endpoint, round-robin, cookie affinity, client-IP hash, or an explicit override). The round-robin position is only previewed, never advanced.
  • Notes — anything the probe could not determine, in plain words.
  • The full raw decision as JSON, for copying into a bug report.

📝 When the two disagree, trust the probe

The simulator predicts; the live probe reports. If they ever differ, the live probe is the ground truth — it's the real engine answering. A mismatch is worth reporting.

For your assistant, this is the probe_route MCP tool — "probe port 443 for shop.example.com/checkout". It takes port, and optionally tls (inferred from your configuration if omitted), sni, host, path, and http2, and returns both a readable summary and the raw decision.

⚠️ What the live probe can reach

The probe only answers on this machine (loopback), and only on listeners that terminate HTTP or HTTPS. A TLS-passthrough or raw-TCP listener has no routing decision to report, so it will handle the request normally instead of answering the probe — the tool tells you when that happened.

Advanced: the probe header

Under the hood, the live probe is triggered by a single request header, x-cruma-probe, sent from a loopback client. Its value doesn't matter — its presence is the trigger. You can use it directly with curl for scripting or CI:

curl -H "Host: app.example.com" -H "x-cruma-probe: 1" http://127.0.0.1:8080/api
curl -k --resolve app.example.com:8443:127.0.0.1 \
  -H "x-cruma-probe: 1" https://app.example.com:8443/api

Instead of proxying the request, the proxy replies 200 with the decision as JSON and marks the reply with x-cruma-probe: echo, so you can tell a real probe answer from an ordinary response. The second form uses --resolve so the request presents app.example.com as its SNI and Host while still connecting to your local listener — the same trick the MCP tool uses.

A response looks like this:

{
  "listener_port": 8080,
  "sni": null,
  "host": "app.example.com",
  "path": "/api",
  "is_tls": false,
  "alpn": null,
  "plane": "Hyper",
  "fast_path": "NotApplicable",
  "tls_decision": null,
  "route": {
    "name": "api",
    "matched_route_host": "app.example.com",
    "match_summary": "HostAndPath[1h,1p]",
    "target_kind": "Backend"
  },
  "request_chain": [
    { "label": "BasicAuth", "outcome": { "MayTerminateHere": { "rule": "BasicAuth (authentication)" } } },
    { "label": "AddRespHeader", "outcome": "Ran" }
  ],
  "response_chain": [
    { "label": "AddRespHeader", "outcome": "Ran" }
  ],
  "origin": {
    "backend_id": "api-backend",
    "endpoint": "127.0.0.1:9101",
    "why": "RoundRobin",
    "rr_cursor_previewed": true,
    "upstream_proto": "h1.1",
    "origin_tls": false
  },
  "outbound_framing": null,
  "notes": []
}

Field by field:

  • plane is "Hyper" or "IoUring".
  • fast_path is "Eligible", "NotApplicable", or { "Ineligible": { "reason": "..." } } naming the middleware that disqualifies the route.
  • route is null when nothing matched. target_kind is one of Backend, ServeDir, Respond, Redirect, DynamicBackend, or HyperService.
  • Each middleware outcome is "Ran", "Skipped" (an earlier step ended the request), { "TerminatedHere": { "detail": "..." } }, or { "MayTerminateHere": { "rule": "..." } }.
  • origin is null when no route matched, the target isn't a proxied backend, or the backend has no available endpoint (the reason then appears in notes). why is one of SingleEndpoint, Override, CookieAffinity, ClientIpHash, or RoundRobin; upstream_proto is h1.0, h1.1, h2, h2c, or h2c-pk.
  • A few fields are reserved and not filled in yet: tls_decision and outbound_framing are always null today, sni is only reported by the hyper engine, and alpn only by the io_uring engine.

⚠️ Don't send X-Forwarded-For

The probe is deliberately restricted to loopback callers. If you add your own X-Forwarded-For header, Cruma can no longer be sure the request is really local, so it fails safe and serves the request normally instead of answering the probe. Leave that header off.

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.