Your App Has a Frontend. Why Deploy It Twice?
The app already has HTML, JavaScript, APIs, authentication, and a release. KDCube publishes its existing main view as a complete website without creating a second frontend artifact.
Deploying the frontend again as a separate website creates a second build, another proxy contract, and another place for runtime configuration to drift. KDCube can publish the app's existing main view as a complete website without creating a second frontend artifact.
An app in KDCube can contain an agent, REST APIs, MCP services, named services, jobs, event handlers, widgets, and a complete browser experience. The website does not sit beside that app as another product. It is one of the app's surfaces, built and released with the rest of it.
The smallest useful change is one site declaration below the existing ui.main_view. That registration gives the same built file tree a stable /sites/{alias}/ address. It can also own a clean hostname root or become the one default root for the installation.
One app can contribute one site. One KDCube installation can load many apps, so it can serve many independently built websites while the control plane, APIs, MCP, streaming, and sockets keep their own routes.
00 Implementation highlights
| Concept | The takeaway |
|---|---|
| One build, two roles | ui.main_view builds the app frontend; ui.main_view.site registers that same artifact as a website. |
| Alias first | Every enabled site works at /sites/{alias}/ without DNS, TLS, or a custom domain. |
| Many sites | One app contributes at most one site, while one installation serves many sites by loading many apps. |
| Clean roots | hosts selects a site for a matching request host; one default: true site handles unmatched hosts. |
| Local to public | The same app declaration works through the local proxy, a tunnel, Caddy, or a cloud edge that preserves the request host. |
| Stable platform routes | The control plane and service routes remain reserved while root and other clean paths belong to the selected site. |
| Hot routing | App configuration is projected into a versioned catalog; requests resolve an immutable proc-local snapshot. |
01 The smallest honest website declaration
Assume an app already builds a frontend:
- id: docs@1-0
config:
ui:
main_view:
src_folder: ui/site
build_command: >-
npm run build -- --outDir <VI_BUILD_DEST_ABSOLUTE_PATH>
That is a main view. KDCube builds it, stores the active artifact, and serves it through app-scoped static routes. The control plane or another browser surface can open it, but it is not yet an installation-wide website.
Register the same artifact as a site by adding four routing fields:
- id: docs@1-0
config:
ui:
main_view:
src_folder: ui/site
build_command: >-
npm run build -- --outDir <VI_BUILD_DEST_ABSOLUTE_PATH>
site:
enabled: true
alias: docs
default: false
hosts:
- docs.example.com
- "*.docs-preview.example.com"
| Field | What it controls |
|---|---|
enabled | Whether the already-built main view participates in website routing. |
alias | Its unique installation-wide address under /sites/{alias}/. _root is reserved. |
default | Whether this site handles / and clean paths when no host declaration matches. At most one site may be default. |
hosts | Exact or wildcard request hosts that select this same site before the default. |
Other fields may live under site, but they are app-owned composition data. The platform routing contract interprets only these four.
Site registration belongs to the app's bundles.yaml entry. assembly.yaml owns installation concerns such as the control-plane route prefix; it does not select which app becomes a website.
ui.main_view defines and builds one app frontend artifact. ui.main_view.site registers that artifact in the installation-wide website catalog and assigns alias, host, and optional default-root behavior.
Disabling or omitting site removes website routing. It does not delete the main view or its app-scoped routes. Enabling site does not start another build and does not store another copy.
02 One artifact, four ways to reach it
The app-scoped addresses exist because the artifact belongs to an app:
authenticated app static route
/api/integrations/static/{tenant}/{project}/{app-id}/...
public app shell route
/api/integrations/bundles/{tenant}/{project}/{app-id}/public/static/...
Site registration adds three reader-facing roles.
A stable alias. Every enabled site receives an address independent of the request host:
/sites/docs/ /sites/docs/guide/ /sites/docs/assets/app.725ad1.js
This is the immediate local result. No domain setup is required.
A host-selected clean root. If Host: docs.example.com reaches KDCube and that host appears in the app's hosts, the site owns /, /guide/, and its other clean paths.
The default clean root. If no host-specific site matches, the one site with default: true owns those clean paths.
Selection is deterministic:
request for / or another clean path
|
v
does Host match an enabled site's hosts?
| yes | no
v v
matching site is one site default: true?
| yes | no
v v
default site root redirects to
the control plane;
other clean paths 404
An alias always selects one declared site directly. Host and default selection apply only to root and clean-path routing.
03 Many websites live beside many app capabilities
The current cardinality is simple:
one app +-- zero or one effective ui.main_view +-- optional zero or one @ui_main declaration `-- zero or one ui.main_view.site registration one KDCube installation +-- app A -> zero or one website +-- app B -> zero or one website `-- app N -> zero or one website
A configuration-backed main view does not require @ui_main. If an app uses that optional code surface, the loader permits at most one. Several hosts entries do not create several websites; they are several names for the same app, alias, configuration, and built file tree.
Use separate apps when sites need independent source, configuration, release, ownership, or lifecycle. Each app can still provide other surfaces around its site.
An enabled site requires a non-root control-plane mount such as /platform or /control/ui. The control plane and a host-selected website cannot both own the same root path, so proxy.route_prefix: / plus an enabled site is rejected as an invalid configuration.
04 The browser build must be address-portable
The same files may be served below an alias or at a clean root. The frontend therefore cannot assume that /assets/app.js always starts at the deployment root.
For Vite, use a relative base:
export default {
base: './'
}
Build the complete output tree into <VI_BUILD_DEST_ABSOLUTE_PATH>:
dist/
index.html
assets/
app.725ad1.js
logo.8f2a91.svg
guide/
index.html
pricing/
index.html
KDCube serves an existing file directly, resolves a directory to its index.html, and falls back to the root index.html for an unknown browser route when that shell exists. Traditional multipage output, a browser-routed SPA, and a hybrid site use the same surface.
For HTML responses, KDCube injects the route-appropriate <base> and a kdcube-site-context JSON block containing the app identity, site alias, public base, tenant/project scope, and catalog revision. The browser does not need to reverse-engineer those values from an internal URL.
The site should also use the platform's browser contracts:
/api/cp-frontend-config -> active route and provider-neutral auth config /profile -> authoritative browser-session state configured login/logout -> the deployment's active authentication flow app APIs and scenes -> the same-origin platform session
A public website shell does not make its data or actions public. Every app API, MCP call, file operation, event, and widget keeps its own authentication and authorization policy.
05 Local first, then choose the public topology
kdcube init gives the installation one local web origin. An enabled site is immediately testable by alias through that origin:
http://127.0.0.1:<proxy-port>/sites/docs/
If the site is the default, the same proxy can serve it at /. A local host entry can test host selection without public DNS:
curl -H 'Host: docs.local.test' \ http://127.0.0.1:<proxy-port>/
From there, three publication shapes cover the common cases.
Publish the complete KDCube origin
Point a tunnel, reverse proxy, or load balancer at the KDCube web-proxy port. The public origin retains the same route families:
https://runtime.example.net/platform/chat https://runtime.example.net/sites/docs/ https://runtime.example.net/sites/workspace/
One public hostname does not automatically create subdomains. To give a site a clean custom root, route that hostname to KDCube, preserve Host, and list the hostname in site.hosts.
Keep another website at /
An outer Caddy or equivalent adapter can keep a separately deployed website at the public root and forward KDCube's reserved paths:
public hostname +-- / -> separate website files +-- /platform/* -> KDCube control plane +-- /api/* and transports -> KDCube services `-- /sites/* -> KDCube application-site aliases
In this composition, the separate website deliberately owns /. KDCube sites remain available by alias. A KDCube site needs another hostname routed wholly to KDCube when it must own a clean root too.
Path forwarding must carry the public origin through every trusted proxy hop. For example, when ngrok terminates HTTPS before a local Caddy HTTP listener, Caddy trusts only the loopback ngrok peer and preserves its public scheme:
browser --https--> ngrok --http + scheme:https--> Caddy
--http + preserved scheme:https--> KDCube web proxy
KDCube separately validates that forwarded scheme under its descriptor-owned proxy policy. If either boundary is absent, the website may still render while request-derived callback, consent, download, or upload URLs are minted with the inward http scheme.
Use dedicated site domains
Several domains may reach one KDCube origin:
docs.example.com --------+ workspace.example.com ---+--> edge or tunnel --> KDCube web proxy status.example.com ------+ preserve Host and scheme
KDCube selects the app after the request arrives. site.hosts does not create DNS records, certificates, tunnel endpoints, or CDN behaviors. The outer deployment owns those resources and forwards the clean request path. The generated OpenResty route matrix performs the internal clean-path rewrite.
The app contract is the same locally and in production. Production adds the deployment's normal DNS, TLS, edge, and storage configuration; it does not add another app-specific frontend server.
06 Reload the app, not a hand-written proxy map
The app's source, build command, site declaration, and other surfaces move together through its normal lifecycle. When the app comes from Git, the website rides the same source and release path as the app APIs and agents:
source or build-affecting app props change -> app deploy/reload lifecycle -> main-view signature check -> build and atomically activate UI artifacts when needed -> reconcile the site catalog from current app configuration -> new alias/host/default routing becomes active
A site-only change does not require application-specific OpenResty edits. The proxy forwards stable route families; it never stores a list of site-owning apps.
The catalog rejects ambiguous states before publication:
- a missing, malformed, duplicate, or reserved alias;
- more than one default site;
- duplicate host declarations;
- overlapping exact and wildcard host patterns;
- overlapping wildcard patterns that could select more than one site.
During a live invalid update, the previous valid catalog continues serving. On a fresh start with no valid catalog, site routing stays unavailable until the declarations are corrected. KDCube does not select an arbitrary winner.
07 The request path does not scan app configuration
bundles.yaml remains the authority, but parsing every app declaration on every HTML or asset request would put configuration and distributed storage on the website's hot path.
KDCube separates the change path from the request path:
CHANGE PATH
effective app configuration
-> validate all enabled site declarations
-> build ApplicationSiteCatalog
revision: identity of this catalog content
generation: distributed update order
-> atomic Redis generation + complete snapshot + update event
-> each proc replaces its immutable local catalog
REQUEST PATH
browser
-> generated OpenResty route matrix
-> proc-local immutable catalog
-> alias or Host/default lookup
-> active app UI storage
-> HTML, asset, directory index, or SPA shell
No descriptor parse. No Redis read. No app scan.
Each proc subscribes before loading the current snapshot and rejects older generations, so a delayed update cannot roll one worker backward. The catalog also carries the resolved app target, keeping the route and the app version it points to in one coherent generation.
The OpenResty matrix is generated from one platform definition across the maintained local, ECS-reference, and Kubernetes proxy templates. It reserves the configured control-plane prefix and established API, authentication, streaming, socket, and monitoring paths before the site catch-all. Root, clean paths, and /sites/* then enter the site resolver with forwarded host and scheme context.
08 Know which publishing mechanism you need
An application-hosted website and KDCube public content can appear on the same domain, but they own different lifecycle contracts.
| Application-hosted website | @public_content publication |
|---|---|
| Complete main-view file tree | Independently published records and pages |
| HTML, JavaScript, CSS, images, browser routes | Articles, metadata, catalogs, and public entries |
| Alias, host, and optional default selection | Public slug, content alias, publish/retract state |
| Multipage and SPA fallback | Sitemap, JSON-LD, canonical-page lifecycle |
| App UI build and storage | Durable content registry and hot content tier |
Use a site for the browser shell and pages of a complete app experience. Use @public_content for independently indexed records. A site may link to those records without absorbing their publication model.
09 The end-to-end builder check
- Build one app
ui.main_viewwith a completeindex.htmlfile tree. - Use relative browser asset paths; for Vite, set
base: './'. - Add an enabled
ui.main_view.sitewith a unique alias. - Keep the control plane on a non-root
proxy.route_prefix. - Refresh the runtime when platform/proxy code changed; use normal app reloads for later app source or descriptor changes.
- Verify
/sites/{alias}/, one real asset, one directory index, and one SPA route through the real proxy. - Verify matching-host, default, unmatched-host, and disabled-site behavior.
- Verify the control plane, APIs, streaming, sockets,
/profile, and one protected app operation still retain their owners. - For a public hostname, route it to KDCube and preserve
Hostand scheme. - Inspect cache headers: entry HTML revalidates; content-hashed assets may be cached as immutable.
For several hosts, use a real YAML list in the descriptor. The current kdcube bundle --set-config value parser accepts scalar values; a JSON-looking list passed to that flag becomes one literal string rather than a YAML list.
The result is one app release with one frontend artifact and several deliberate ways to address it. The site can stay local by alias, share an origin with the control plane, sit beside another root website, or receive its own domain. None of those choices splits the website away from the APIs, agents, tools, and runtime configuration it was built to present.