Docs
/

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 havetarget.kindWho may set it
A built folder — dist/, build/, out/, _site/bundlemember
Something you already run on a portproxymember (private IPs: self-host only)
A folder AND a port, and you want Leumas to run itserveroperator, self-host only
Nothing yet — you want Leumas to render the pageconfig (the default)member
It movedredirectmember

[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.config are 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 floorimperium plan
imperium.hosting[done] create, edit, preview[done]
imperium.maxSites13
imperium.publishedSites03
imperium.siteStorageBytes50 MB250 MB
imperium.customCode (dynamic: / ssr: pages)[no][done]

Both are declared in shared/packages/entitlements/src/policy.jsBASE_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-token returns 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/detail in 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.js re-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/domains then 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 config sites via the Pages tab. A bundle site owns its own <head>;

Leumas does not rewrite your HTML.

  • sitemap.xml and robots.txt are served per host. seo.robots: 'noindex' now genuinely

disallows crawling rather than only writing a meta tag.

  • [warning] Studio cannot iframe a bundle site. applySecurity sets X-Frame-Options: SAMEORIGIN on

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.

Source .claude/skills/host-a-vibe-coded-app/SKILL.md (no-git)markdownjson
Generated from the Leumas repository. Every page cites the file it came from.leumas.techllms.txt