Host a Vibe Coded App
Put an app you built — with AI or otherwise — on a Leumas subdomain. Which target kind to pick, claiming a name, uploading a build, and what free vs paid actually gates.
Hosting an app you built
Someone has a working thing — a Vite SPA, a Next export, an Astro site, a folder of HTML, or a server they run themselves — and wants it at theirname.leumas.tech. This is the decision procedure.
Read shared/services/knowledge/build-knowledge/imperium-hosting.md for the architecture. This file is the choices around it. For selling a plugin in the app store instead, that is a different product with a different lifecycle — see build-leumas-plugin.
0 · The one decision that determines everything else
What did you build? hosting.target.kind decides whether and how a page is rendered at all, and picking wrong is the difference between a working site and a blank one.
| You have | target.kind | Who may set it |
|---|---|---|
A built folder — dist/, build/, out/, _site/ | bundle | member |
| Something you already run on a port | proxy | member (private IPs: self-host only) |
| A folder AND a port, and you want Leumas to run it | server | operator, self-host only |
| Nothing yet — you want Leumas to render the page | config (the default) | member |
| It moved | redirect | member |
[warning] mode and target.kind are different axes with the same words. mode: 'server' iframes somebody's app inside an Imperium page; target.kind: 'server' is the app, served as the whole domain. Both exist. Read imperium-hosting-targets before assuming which one you want.
bundle is the answer for a vibe-coded app. It needs no path, no port and no operator privilege — the bytes live under the site's own storage root and nothing else can reach them.
1 · The shortest path (Studio)
Hosting → New Website → "I already built it" → name it → Create & upload → drop the folder.
The wizard's upload lane skips Look and Content deliberately: those are an Imperium mode's theme and data payload, and a build you made elsewhere has neither. It creates the site as a draft and lands you on its Files tab.
It does not publish. Publishing an empty bundle puts a "No bundle uploaded yet" page on the internet; publish after the bytes land, from the editor, where you can see what you are publishing.
2 · The same thing over the API
Three calls, in this order. The order is not negotiable — an upload goes to builds/<id>/ under siteStorageRoot(site), so the site must exist before there is anywhere to put the bytes.
// 1 — claim a name. This is a VALIDITY CHECK, not a reservation: it answers "is this free right
// now", and the real allocation happens in step 2. Nothing is held between the two.
await api.hosting.claimSubdomain('my-app');
// 2 — create the site. `target` is NOT accepted here (it is an update-lane field), so the site is
// born as a `config` site rendering its mode, which is what a visitor sees until step 4.
const { site } = await api.hosting.create({
appName: 'My App', subdomain: 'my-app', mode: 'default', data: {}, seo: {},
});
// 3 — upload, one file per request, into a NEW build directory. Never overwrite the live one.
const dir = `builds/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
for (const f of files) await api.hosting.files.upload(site.id, `${dir}/${f.path}`, f.blob);
// 4 — ONE write flips the whole site over. This is also how you roll back: point at an older dir.
await api.hosting.update(site.id, { target: { kind: 'bundle', dir, spa: true } });
// 5 — go live. Refused with 402 if your plan has no published-site slot left; see §4.
await api.hosting.publish(site.id);
spa: true when your app owns its own routes (React Router, Vue Router, anything with client paths). It rewrites unknown paths to index.html. Leave it off for a static multi-page site, or every 404 becomes your home page.
3 · What to upload, and what to leave out
useFolderUpload (admin/hosting/model/useFolderUpload.js) already encodes this, and a hand-rolled uploader should copy it rather than re-derive it:
- It re-roots automatically. Drop the project folder; if it contains
dist/build/out/
public/_site/www, that becomes the root. Uploading the repo would otherwise serve your package.json at /.
- **It skips
node_modules,.git, and.env.* The last one is not tidiness — a bundle directory
is served to the public internet, so an uploaded .env is a published secret.
- Per file: 25 MB. Per site: the plan's
imperium.siteStorageBytes. .php/.jsp/.asp/.cgi/.pl/.htaccess/web.configare refused, on upload
and on rename. Nothing here executes; a bundle is static bytes.
Rollback is moving a pointer. Every previous build stays on disk, so target.dir is the only thing that changes. Never upload over a live directory: the deploy is two writes for exactly this reason, and the live site is untouched for the whole upload — which matters because an upload is N requests, not one.
4 · Free vs paid — what actually gates
Building is free. Publishing is the line.
| Free floor | imperium plan | |
|---|---|---|
imperium.hosting | [done] create, edit, preview | [done] |
imperium.maxSites | 1 | 3 |
imperium.publishedSites | 0 | 3 |
imperium.siteStorageBytes | 50 MB | 250 MB |
imperium.customCode (dynamic: / ssr: pages) | [no] | [done] |
Both are declared in shared/packages/entitlements/src/policy.js — BASE_CAPABILITIES is the floor, DEFAULT_PLAN_POLICIES is what a plan adds. A plan can only ever raise a floor (booleans OR, numbers MAX).
- Preview a draft without paying:
POST /api/hosting/sites/:id/preview-tokenreturns a signed
URL good for 30 minutes. Use the url it returns, not a locally rebuilt one — the server knows the deployment's port and base domain and your bundle does not.
- A publish refusal answers 402 with
limit/used/detailin the body. Read the body:
e.message is the status line ("POST /… → 402") and its own JSDoc says it is not for operators.
- "Pay to stay up" is automatic.
resolve.jsre-checks the owner's entitlement on *every visitor
request*; a lapsed membership swaps the site for the suspension page on the next hit. Nothing cleans up, nothing is deleted.
5 · After it is live
- A custom domain is
POST /sites/:id/domainsthen a DNS TXT record. Adding one never serves it —
only proving ownership does, and only one site can hold a verified host at a time.
- Per-page SEO works for
configsites via the Pages tab. Abundlesite owns its own<head>;
Leumas does not rewrite your HTML.
sitemap.xmlandrobots.txtare served per host.seo.robots: 'noindex'now genuinely
disallows crawling rather than only writing a meta tag.
- [warning] Studio cannot iframe a
bundlesite.applySecuritysetsX-Frame-Options: SAMEORIGINon
every process serving a hosted site, and Studio is a different origin by design. Preview opens a new window. It would have worked in dev and failed in production, because the Vite dev server sets no such header.
6 · Verify
pnpm smoke:site-upload # upload here, serve there — the real two-write deploy
pnpm smoke:hosting-tier # free builds, paid publishes; the five entitlement keys
pnpm smoke:hosting # all six target kinds off one routing table
pnpm smoke:site-pages # what a visitor actually receives, incl. per-page SEO
Then look at the real host. Every one of those runs against a rig. The failure this feature actually has is a bundle that serves its own index.html and 404s every deep link — which is spa: true not being set, and no test catches it because the home page is fine.