KDCube
← Recipes
KDCube Recipes · The Dispatch Form

Run Multiple Websites on One Local KDCube

Run multiple websites locally on one KDCube, route each by alias or host, and expose the same routes through ngrok.

13 August 2026RecipesLocal application sitesThe Dispatch Form
application websites local runtime site routing app lifecycle run multiple websites locally KDCube app sites ngrok local website

WHAT LEAVES THE OFFICE

One local KDCube serves its reference website and a second app-owned website from the same web proxy. Each site has a stable /sites/{alias}/ address; one may own the default root, configured hosts may select another, and one ngrok HTTPS origin can expose the same route map.

Run multiple websites locally by building each frontend through its app's normal lifecycle, registering the built main view as a site, and letting one KDCube installation route all of them.

Current commands and descriptors still say bundle in names such as bundles.yaml, bundle reload, and @bundle_entrypoint. In this recipe, app = bundle: one deployable KDCube unit.

YOU WILL NEED
  • Docker running locally
  • Claude Code with the KDCube plugin, or Python 3.11+
  • A fresh tenant/project or initialized runtime
  • A directory below paths.host_bundles_path
  • ngrok only for public HTTPS
  • Caddy only for an extra local routing layer

OP 10OF 90Install the KDCube assistant

OWNERClaude Code plugin
OUTPUTA guided runtime bootstrap connected to current KDCube docs

The shortest path starts in Claude Code. Add the official marketplace and install the plugin:

Claude CodeCOMMANDS
/plugin marketplace add https://github.com/kdcube/agent-plugins
/plugin install kdcube@kdcube

Then run:

Claude CodeCOMMAND
/kdcube:init

This is the install path, not a step that assumes KDCube is already present. /kdcube:init connects the assistant to the current KDCube source and docs. If this machine has no runtime yet, it continues into /kdcube:runtime-init. That flow asks for the tenant/project, sign-in method, platform source, and whether the machine should have a stable public HTTPS address. It installs kdcube-cli when needed, runs the real kdcube init, and checks the runtime.

For the quickest local website proof, choose simple local sign-in. Choose application-hosted Google sign-in only with a real Google Web application OAuth client id. If you choose a public ngrok address, supply its stable hostname and select the app site that should own its clean root. The guided flow aligns CORS, trusted forwarded scheme, site host selection, and the tunnel.

OP 20OF 90Start one local KDCube

OWNERLocal runtime
OUTPUTOne running web proxy with a reference site and control plane

Let /kdcube:runtime-init complete this operation when you use the plugin. The manual equivalent keeps every action visible:

runtime bootstrapSHELL
python3 -m pip install kdcube-cli

export TENANT="local"
export PROJECT="sites"
export WORKDIR="$HOME/.kdcube/kdcube-runtime/${TENANT}__${PROJECT}"

kdcube init \
  --tenant "$TENANT" \
  --project "$PROJECT" \
  --auth-type simple \
  --build

kdcube start --tenant "$TENANT" --project "$PROJECT"
kdcube info --tenant "$TENANT" --project "$PROJECT"

Simple sign-in is for this local proof. For application-hosted Google sign-in, replace --auth-type simple with --auth-type bundle --client-id "<google-web-oauth-client-id>" and optionally add --bootstrap-admin-email "<verified-google-email>".

For a local KDCube checkout, add --path /absolute/path/to/kdcube to init. Do not run init again for an existing runtime. Use kdcube refresh --tenant "$TENANT" --project "$PROJECT" --build only when staged platform source or images must change. App-only source and descriptor changes use bundle reload.

Read the web-proxy port printed by start or info. The default is often 5173, but the running runtime is authoritative:

runtime originSHELL
export PROXY_PORT="5173" # replace from `kdcube info`
export ORIGIN="http://127.0.0.1:${PROXY_PORT}"

The canonical descriptor registers website@2026-07-12 as the default site under alias workspace. Check that site and the separate control plane:

initial route checkSHELL
curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' \
  "$ORIGIN/sites/workspace/"

curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' \
  "$ORIGIN/platform/chat"
CHECK · THE INSPECTOR'S STAMP

Both requests return 200. If your descriptor uses another proxy.route_prefix, replace /platform with that prefix.

OP 30OF 90Run multiple websites locally from one route map

OWNERInstallation-wide site catalog
OUTPUTStable aliases plus deterministic clean-path ownership
ONE LOCAL KDCUBE · EXPLICIT SITE ROUTES Each app builds its own frontend. The installation publishes one route map. APP LIFECYCLE SITE REGISTRATION INSTALLATION ROUTES website@2026-07-12 builds one main-view artifact ui.main_view docs-site@1-0 builds one main-view artifact ui.main_view SITE · WORKSPACE alias: workspace default: true SITE · DOCS alias: docs hosts: docs.localhost / Host match, then one default /sites/workspace/ stable alias route /sites/docs/ stable alias route KDCube OpenResty one web proxy · one route catalog many app-owned sites /platform/chat control plane stays separate ONE APP → ONE MAIN VIEW → ZERO OR ONE SITE · MANY APPS → MANY SITES
One box, many app-owned websites, one explicit route map.

ui.main_view defines and builds the app frontend. The nested ui.main_view.site registers that same built artifact in the installation-wide site catalog. One active artifact then serves both the app-scoped frontend and its website addresses.

Every enabled site gets /sites/{alias}/. hosts and default decide which site, if any, also owns / and other clean paths. One enabled app contributes at most one site; one installation can load many site-owning apps.

OP 40OF 90Make a second site-owning app

OWNERdocs-site@1-0
OUTPUTA thin app entrypoint and address-portable main view

For this local proof, put the app below the runtime's default mounted app root:

app sourceSHELL
export APP_ROOT="$WORKDIR/data/bundles/docs-site@1-0"
mkdir -p "$APP_ROOT/ui/site"

If staged assembly.yaml sets paths.host_bundles_path explicitly, use that directory instead.

Create a thin entrypoint:

docs-site@1-0/entrypoint.pyPYTHON
from kdcube_ai_app.apps.chat.sdk.solutions.chatbot.entrypoint import BaseEntrypoint
from kdcube_ai_app.infra.plugin.bundle_loader import bundle_entrypoint, bundle_id


@bundle_entrypoint(name="docs-site", version="1.0.0", priority=10)
@bundle_id(id="docs-site@1-0")
class DocsSiteEntrypoint(BaseEntrypoint):
    """Own the local documentation website."""

Create one address-portable page. Relative asset URLs let the same files work at /, /sites/docs/, and the app's scoped static route:

docs-site@1-0/ui/site/index.htmlHTML
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Local Docs Site</title>
  <link rel="stylesheet" href="./styles.css" />
</head>
<body>
  <main>
    <p>DOCS APP · LOCAL KDCUBE</p>
    <h1>Local Docs Site</h1>
    <p>This page is built, stored, and routed as one KDCube app surface.</p>
  </main>
</body>
</html>
docs-site@1-0/ui/site/styles.cssCSS
body {
  margin: 0;
  min-height: 100vh;
  display: grid;
  place-items: center;
  color: #173047;
  background: #f5faf9;
  font: 16px/1.5 system-ui, sans-serif;
}
main { max-width: 42rem; padding: 3rem; }
p:first-child { color: #087f78; font-weight: 700; }

This is the smallest local routing proof. Before releasing the app, add its normal README, interface declaration, tests, and release metadata.

CHECK · THE INSPECTOR'S STAMP

python3 -m py_compile "$APP_ROOT/entrypoint.py" exits successfully, and index.html references ./styles.css, not /styles.css.

OP 50OF 90Register and reload the second site

OWNERbundles.yaml plus app lifecycle
OUTPUTAn active docs site registration

Register the local app and its main-view build in the staged descriptor:

register and reloadSHELL
kdcube bundle docs-site@1-0 \
  --tenant "$TENANT" \
  --project "$PROJECT" \
  --local-path "$APP_ROOT" \
  --name "Docs Site" \
  --module entrypoint \
  --no-singleton \
  --set-config ui.main_view.src_folder ui/site \
  --set-config ui.main_view.build_command \
    'cp index.html styles.css <VI_BUILD_DEST_ABSOLUTE_PATH>/' \
  --set-config ui.main_view.site.enabled true \
  --set-config ui.main_view.site.alias docs \
  --set-config ui.main_view.site.default false

kdcube bundle reload docs-site@1-0 \
  --tenant "$TENANT" \
  --project "$PROJECT"

The first command updates the source-of-truth bundles.yaml. Reload imports the app, builds the main view when its signature requires it, activates the artifact, and reconciles the site catalog.

live app statusSHELL
kdcube bundle status docs-site@1-0 \
  --tenant "$TENANT" \
  --project "$PROJECT" \
  --live \
  --json
CHECK · THE INSPECTOR'S STAMP

Live status succeeds and reports docs-site@1-0 from its translated local path.

OP 60OF 90Prove both aliases and the default root

OWNERWeb proxy route contract
OUTPUTTwo site aliases, one root owner, one reserved control-plane route

The new site is immediately addressable by alias:

alias checksSHELL
curl -sS "$ORIGIN/sites/docs/" | grep -F "Local Docs Site"
curl -sS "$ORIGIN/sites/docs/styles.css" | grep -F "place-items"
curl -sS "$ORIGIN/sites/workspace/" | grep -F "KDCube"

The reference workspace site remains the default because docs declares default: false:

root and control planeSHELL
curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' "$ORIGIN/"
curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' \
  "$ORIGIN/platform/chat"
CHECK · THE INSPECTOR'S STAMP

Both aliases, the root, and the control plane return 200. An unknown alias such as /sites/not-registered/ returns a controlled 404.

OP 70OF 90Select a clean root by host

OWNERSite selector
OUTPUTAn exact host that selects the docs site without changing DNS

Give the docs site one local host selector:

host selectorSHELL
kdcube bundle docs-site@1-0 \
  --tenant "$TENANT" \
  --project "$PROJECT" \
  --set-config ui.main_view.site.hosts docs.localhost

kdcube bundle reload docs-site@1-0 \
  --tenant "$TENANT" \
  --project "$PROJECT"

curl -sS -H 'Host: docs.localhost' "$ORIGIN/" \
  | grep -F "Local Docs Site"
CLEAN-PATH SITE SELECTION Host wins. One declared default follows. Every other outcome is explicit. request reaches KDCube HOST MATCHES ONE SITE? exact or configured wildcard YES matched site owns / and clean paths NO ONE SITE IS DEFAULT? default: true YES default site owns / and clean paths NO REQUEST PATH IS / ? YES 307 → /platform/chat controlled root fallback NO 404 unknown clean path ALIASES REMAIN STABLE · CLEAN-PATH OWNERSHIP IS DETERMINISTIC
Host first, one default second, then a controlled fallback.

hosts is a YAML list because several names may select the same site. The current --set-config parser accepts one scalar host. For several hosts, apply a descriptor with a real list; do not pass a JSON-looking string:

bundles.yamlYAML
site:
  enabled: true
  alias: docs
  default: false
  hosts:
    - docs.localhost
    - docs.example.test

Only one enabled site may be default: true. Aliases must be unique, and one host must not match several sites.

CHECK · THE INSPECTOR'S STAMP

The docs.localhost request renders the docs site while an unmatched host still resolves the reference default site.

OP 80OF 90Publish the same box through ngrok

OWNERTrusted public-origin boundary
OUTPUTOne HTTPS origin preserving Host and the browser-visible scheme

One tunnel can expose the complete KDCube origin: the control plane, APIs, streaming routes, and every site alias. Site aliases and host selection distinguish the websites behind it.

ONE PUBLIC ORIGIN · THE SAME LOCAL ROUTE MAP Preserve the browser's Host and public scheme across every hop. public browser https://public-host one visible origin ngrok HTTPS terminator Host preserved KDCube OpenResty local web-proxy port trusted forwarded scheme one site catalog SAME ORIGIN / host/default site /sites/workspace/ /sites/docs/ /platform/chat HTTPS Host XFP=https proxy.forwarded_proto.source trusted_x_forwarded_proto only behind the trusted terminator A rendered page proves routing. A real callback or signed file URL proves the public scheme. PRESERVE HOST · TRUST THE IMMEDIATE PROXY · VERIFY AN ABSOLUTE URL
One public origin exposes the whole local KDCube route map.

Choose the site that should own the tunnel's clean root by adding the stable ngrok hostname to that site's hosts:

public host selectorSHELL
export PUBLIC_HOST="your-stable-domain.ngrok-free.app"

kdcube bundle docs-site@1-0 \
  --tenant "$TENANT" \
  --project "$PROJECT" \
  --set-config ui.main_view.site.hosts "$PUBLIC_HOST"

kdcube bundle reload docs-site@1-0 \
  --tenant "$TENANT" \
  --project "$PROJECT"

Direct local HTTP uses the safe descriptor default source: request. Before placing ngrok or Caddy in front, edit $WORKDIR/config/assembly.yaml so the public browser origin is allowed and OpenResty accepts the public scheme from that trusted local terminator:

assembly.yamlYAML
cors:
  allow_origins:
    - "https://your-stable-domain.ngrok-free.app"

proxy:
  route_prefix: "/platform"
  forwarded_proto:
    source: "trusted_x_forwarded_proto"

This mode is appropriate only when untrusted callers cannot bypass the terminator and reach OpenResty with caller-authored forwarding headers. When the public hostname is known during setup, the plugin passes it to kdcube init --cors-origin; the manual edit above is the existing-runtime equivalent.

Activate the assembly change. Refresh preserves staged descriptors and restarts the local stack; --build is not needed for this value-only change:

activate assemblySHELL
kdcube refresh --tenant "$TENANT" --project "$PROJECT"

Start ngrok against the web-proxy port and preserve the public Host:

public tunnelSHELL
ngrok http "$PROXY_PORT" --url "https://$PUBLIC_HOST"

Do not add --host-header=rewrite. KDCube needs the browser's Host for site selection and the public scheme for request-derived callback, consent, upload, and download URLs.

public checksSHELL
curl -sS "https://$PUBLIC_HOST/" | grep -F "Local Docs Site"

curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' \
  "https://$PUBLIC_HOST/sites/workspace/"

curl -sS -o /dev/null -w '%{http_code} %{content_type}\n' \
  "https://$PUBLIC_HOST/platform/chat"
CHECK · THE INSPECTOR'S STAMP

The clean root renders the host-selected docs site, the workspace alias remains reachable, and the control plane returns 200. Inspect one real absolute callback or signed file URL and confirm it starts with https://$PUBLIC_HOST.

OP 90OF 90Add Caddy only when another routing layer is required

OWNEROptional local edge
OUTPUTLocal hostnames, local TLS, or a composed non-KDCube root

Direct ngrok-to-KDCube is the shortest topology. Add Caddy when you need local hostnames, local TLS, or a separately served website at the same public origin.

When ngrok terminates HTTPS and reaches Caddy over loopback HTTP, trust only that immediate local peer so Caddy preserves ngrok's public scheme:

CaddyfileCADDY
{
  servers {
    trusted_proxies static 127.0.0.1/32 ::1/128
    trusted_proxies_strict
  }
}

:18080 {
  reverse_proxy 127.0.0.1:{$PROXY_PORT}
}

Point ngrok at Caddy:

validate and startSHELL
caddy validate --config ./Caddyfile --adapter caddyfile
caddy start --config ./Caddyfile --adapter caddyfile
ngrok http 18080 --url "https://$PUBLIC_HOST"

After later edits, reload the existing process:

reload CaddySHELL
caddy reload --config ./Caddyfile --adapter caddyfile

Do not replace X-Forwarded-Proto with Caddy's {scheme} here. That value describes the inward ngrok-to-Caddy HTTP hop, not the browser's public HTTPS origin.

If a separate non-KDCube website owns /, its Caddy matcher must forward KDCube's reserved paths, including both /sites and /sites/*, before the file-server fallback. Use another hostname routed wholly to KDCube when an app site must own its own clean root.

CHECK · THE INSPECTOR'S STAMP

Validate the Caddyfile, then repeat the root, both site aliases, and control-plane checks through the public origin.

FINAL INSPECTION · DONE MEANS
  • /platform/chat remains independent of app sites
  • /sites/workspace/ and /sites/docs/ resolve different app artifacts
  • Only one site is the deployment default
  • Host: docs.localhost selects the docs site at /
  • Relative frontend assets work below the alias
  • An unknown alias returns a controlled 404
  • App changes become live through bundle reload
  • The ngrok origin preserves Host and HTTPS request-derived URLs
  • Caddy is absent unless the topology requires it

Related articles and documentation

KDCube Recipe13.08.2026