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.
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.
- 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
The shortest path starts in Claude Code. Add the official marketplace and install the plugin:
/plugin marketplace add https://github.com/kdcube/agent-plugins
/plugin install kdcube@kdcubeThen run:
/kdcube:initThis 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
Let /kdcube:runtime-init complete this operation when you use the plugin. The manual equivalent keeps every action visible:
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:
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:
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"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
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
For this local proof, put the app below the runtime's default mounted app root:
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:
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:
<!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>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.
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
Register the local app and its main-view build in the staged descriptor:
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.
kdcube bundle status docs-site@1-0 \
--tenant "$TENANT" \
--project "$PROJECT" \
--live \
--jsonLive status succeeds and reports docs-site@1-0 from its translated local path.
OP 60OF 90Prove both aliases and the default root
The new site is immediately addressable by alias:
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:
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"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
Give the docs site one local host selector:
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"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:
site:
enabled: true
alias: docs
default: false
hosts:
- docs.localhost
- docs.example.testOnly one enabled site may be default: true. Aliases must be unique, and one host must not match several sites.
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
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.
Choose the site that should own the tunnel's clean root by adding the stable ngrok hostname to that site's hosts:
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:
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:
kdcube refresh --tenant "$TENANT" --project "$PROJECT"Start ngrok against the web-proxy port and preserve the public Host:
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.
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"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
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:
{
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:
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:
caddy reload --config ./Caddyfile --adapter caddyfileDo 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.
Validate the Caddyfile, then repeat the root, both site aliases, and control-plane checks through the public origin.
/platform/chatremains independent of app sites/sites/workspace/and/sites/docs/resolve different app artifacts- Only one site is the deployment default
Host: docs.localhostselects 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