Skip to content

Hosting

Muhkoo can host your app’s frontend too. Every app gets a DNS subdomain — https://<slug>.apps.muhkoo.dev (the <slug> is your app’s slug) — and you deploy your built static site there. No separate hosting account, no DNS to wire up. Deploys are content-addressed (only changed files upload), instant (an atomic release flip), and rollback-able. Each app also has a test site at https://<slug>.test.apps.muhkoo.dev; see Environments & deploy.

Deploys are authorized by your app’s secret key (mk_live_sk_… / mk_test_sk_…) as a bearer token (a developer session also works). The secret key is server-side only — never ship it in the browser bundle (that’s the publishable pk). In CI it’s a repository secret. Rotate or revoke it in the portal like any key.

The simplest path is the muhkoo CLI (npm i -g @muhkoo/cli). It walks dist/ (or the folder you give --dist), uploads only changed files, commits a release, and prints the URL:

Terminal window
npm run build
muhkoo deploy --app <appId> --key mk_live_sk_…
# or, from a provisioned app dir (.muhkoo-app.json present): muhkoo deploy
# the app-builder templates wire `npm run deploy` to run this for you

From a provisioned directory, a bare muhkoo deploy uses the test secret key and lands on the test site; pass a live secret key to publish production.

  • index.html must be in the release.
  • At most 2,000 files per release and 25 MiB per file.
  • Hosted bytes count against your storage quota: an upload over it is refused (402 on a blob, 413 on the release).

Deploy on every push to main with the bundled .github/workflows/deploy.yml. Set these repository secrets (Settings → Secrets and variables → Actions):

SecretWhat
MUHKOO_DEPLOY_KEYapp secret key (mk_live_sk_…) — authorizes the deploy
MUHKOO_APP_IDthe app id
VITE_MUHKOO_KEYapp publishable key (mk_live_pk_…) — baked into the bundle

If you’re rolling your own deploy, the API is under /api/apps/:appId/hosting, authorized by Authorization: Bearer <app sk | dev session>:

MethodPathBodyPurpose
PUT/hosting/blob/:sharaw file bytesUpload one file; the server verifies sha256(body) === sha and dedups.
POST/hosting/releases{ manifest: { "<path>": "<sha>" } }Commit a release (validates blobs, enforces quota, flips live). Returns { releaseId, url, bytes, files, env }.
GET/hosting—Site status: url, current release, bytes, history.
POST/hosting/rollback{ releaseId }Re-point to a prior release.
DELETE/hosting/releases/:releaseId—Delete a non-current release from history.
DELETE/hosting—Unpublish.

The manifest maps each file’s path (relative to dist/, forward slashes, index.html at the root) to its blob sha. The key you authorize with decides the environment: a test secret key commits to the test site, a live key to production.

  • An exact match in the manifest is served. A path ending in / serves that directory’s index.html (/docs/ serves docs/index.html).
  • Any other path without a file extension falls back to the root index.html, which is what single-page-app routing wants. Hosting does not resolve extensionless paths to files: /about serves the root index.html even if the release holds about/index.html or about.html. For a multi-page static site, link to directory-style URLs with a trailing slash (/about/).
  • A missing path with a file extension (a stale /assets/old.js) returns a 404 rather than serving HTML as JavaScript.

HTML, *.webmanifest, everything under /.well-known/, sw.js, service-worker.js and registerSW.js are served max-age=0, must-revalidate, so a deploy is live immediately. Every other file is served public, max-age=31536000, immutable (one year). That is safe for fingerprinted bundles (index-ABC123.js, which Vite emits), but a file with a stable name and changing content (logo.png, data.json) gets pinned in visitors’ browsers. Give such files a hashed or versioned name.

The platform keeps the last 10 releases per app (the live one is always kept); older releases auto-prune on deploy, and you can delete any non-current release.

Some browser features — SharedArrayBuffer, and therefore multi-threaded WebAssembly (e.g. ffmpeg.wasm, some game engines, OpenCV) — only work on a cross-origin-isolated page. That requires the document to be served with the Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy response headers, which you can’t set from static files.

Muhkoo makes this a per-app, opt-in hosting setting. Turn it on and the platform serves your app with:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: credentialless
Cross-Origin-Resource-Policy: same-origin

credentialless is used (rather than require-corp) so your cross-origin resources still load — CDN scripts, images, fonts, and CORS API calls — without every third party having to send Cross-Origin-Resource-Policy. In the browser, self.crossOriginIsolated becomes true and SharedArrayBuffer is available.

Enable it in any of three ways:

  • Provision spec — add "crossOriginIsolation": true to your app.json (top level, or under a "hosting" object) and run muhkoo provision.
  • Portal — App Detail → Hosting → toggle Cross-origin isolation.
  • API — PATCH /api/apps/:appId (see below).
MethodPathBodyPurpose
PATCH/api/apps/:appId{ "crossOriginIsolation": true }Turn isolation on/off. Authorized by Authorization: Bearer <dev session>.

The change takes effect immediately — no redeploy needed.

Serve your app on your own domain — app.yourcompany.com instead of <slug>.apps.muhkoo.dev. Muhkoo issues and auto-renews the TLS certificate; you keep your domain at whatever DNS provider you already use, and Muhkoo never touches your DNS.

  1. In the portal, open your app → Custom domains → enter your hostname (e.g. app.yourcompany.com) → Add domain.

  2. The portal shows two DNS records. Add them at your DNS provider:

    TypeNameValueWhy
    CNAMEapp.yourcompany.comcname.muhkoo.ioRoutes traffic to your app.
    CNAME_acme-challenge.app.yourcompany.com(shown in portal)One-time; lets Muhkoo issue + auto-renew the cert. Must be DNS-only — see below.

    The portal may also show a third, TXT record that verifies you own the domain. Add it too if it appears.

  3. Muhkoo verifies ownership from those records, then the domain flips to Active and serves your app over HTTPS (usually a few minutes). It only goes live after verification — an unverified domain is never served. The cert renews itself from then on.

The traffic record is a CNAME, and DNS doesn’t allow a CNAME on a root (apex) domain — yourcompany.com with nothing in front. Most registrars, GoDaddy included, reject it outright (“invalid record”). Use a subdomain and forward the root to it:

  1. Add www.yourcompany.com (not the bare yourcompany.com) as the custom domain, and add the two records above for the www host.

  2. At your registrar, turn on domain forwarding for the root: yourcompany.com → https://www.yourcompany.com, 301 / permanent. On GoDaddy this is Domain → Forwarding (not a DNS record) — GoDaddy manages the root’s A records for you.

Now www.yourcompany.com serves your app directly and the bare yourcompany.com redirects to it. Nothing leaves your registrar and no DNS transfer is needed.

From the terminal, muhkoo domains add <appId> app.yourcompany.com attaches a domain and prints the DNS records to add, muhkoo domains ls <appId> shows each domain’s status and certificate state, and muhkoo domains rm <appId> <hostname> detaches it. Over the API, under /api/apps/:appId/hosting/domains (Authorization: Bearer <app sk | dev session>): POST { hostname } to attach, GET to list with live status + the records to add, DELETE /:hostname to detach.

  • One site per app (the subdomain is the app’s slug, reserved to that app — two apps can never collide on a host).
  • The hosted page calls api.muhkoo.dev with your publishable key from <slug>.apps.muhkoo.dev; the default CORS allowedOrigins: "*" permits it. If you tighten CORS, add the hosting origin.
  • Rollback is a pointer flip — instant. The last 10 releases stay available to roll back to (or delete); the live release is never pruned.