---
title: "Hosting & deploy"
description: "`kumodeck deploy <dir>` publishes a folder of static files (your built web app or game) as a new version of an environment."
url: "/docs/guides/hosting/"
lang: en
index: "/llms.txt"
---
# Hosting & deploy

`kumodeck deploy <dir>` publishes a folder of static files (your built web app or game) as a new **version** of an environment.
Every version is kept; switching between them is instant. Apps that render on the server (Next.js, Astro, SvelteKit, Nuxt, React
Router, TanStack Start, Hono…) deploy too: see [Server-rendered apps](#server-rendered-apps).

Hosting is optional. Your app can live anywhere — your own server, itch.io, a CDN — and still use every other part of
KUMODeck (list its origin in `web.allowedOrigins` if you want to stop other sites from using your publishable key).
Hosted apps are served under your app's own subdomain — or [your own domain](#custom-domains) — with no KUMODeck branding.

## Deploy

```sh
kumodeck deploy dist --env production     # production (deploy's default is development)
kumodeck deploy dist --env development -m "new boss fight"
kumodeck deployments                      # list versions (* = live)
kumodeck rollback 12                      # make version 12 live again, instantly
```

| Environment | URL |
|---|---|
| production | `https://<slug>.kumodeck.app/` |
| development | `https://<slug>--dev.kumodeck.app/` |
| local server | `https://api.kumodeck.com/play/<slug>/` and `https://api.kumodeck.com/play/<slug>--dev/` |

The folder must contain an `index.html`. Dotfiles and `node_modules` are skipped (`.well-known/` is included);
symlinks are not followed.

## Server-rendered apps

Apps that make their pages on the server — Next.js (with vinext or OpenNext, [below](#nextjs)), Astro (with its Cloudflare
adapter), SvelteKit, Nuxt, React Router in framework mode, TanStack Start, Hono, SolidStart — deploy the same way you
would to Cloudflare Workers: a Worker plus its static assets.
Off by default: `kumodeck deploy` turns on `serverRendering` (and `hosting`, which it needs) for that environment and says
so in one line, the same way a static deploy turns on `hosting`. To turn it on yourself:
`kumodeck features on serverRendering && kumodeck config push --env development` (this also turns on `hosting`).

```sh
kumodeck deploy --env development --dry-run   # build, then check with KUMODeck without deploying
kumodeck deploy --env development             # build, upload what is new, make it live
```

Run it in the project folder **without a folder argument**. The CLI then:

1. **Detects** the project: a `wrangler.jsonc` with `main`, or a server framework in `package.json`. Anything else — or
   `kumodeck deploy dist`, or a `deployDir` in `kumo.json` — is the static deploy above. `--app` / `--static` override it.
2. Runs `wrangler setup --yes` if the project has no `wrangler.jsonc` yet (Cloudflare's own setup adds the adapter).
3. Builds on your computer (`npm run build`, or pnpm / yarn / bun from the lockfile), then bundles with
   `wrangler deploy --dry-run --outdir .kumo/app-build`. Nothing is sent to Cloudflare by wrangler, and KUMODeck
   never builds your code.
4. Asks KUMODeck for a dry run (sizes, files to upload, databases it will create, warnings), uploads only new files
   and switches the live version.

wrangler must be installed in the project (`npm install -D wrangler`); without it the CLI uploads the build as static
files and says so. `--json` shows the detection, the build commands and what was sent.

- **Where it runs**: `https://<slug>.kumodeck.app/`, `https://<slug>--dev.kumodeck.app/` and your custom domains — not
  under `/play/`. POST requests (forms, server actions) reach the app.
- **Static or app**: an environment serves whichever version is live. `kumodeck rollback` switches between static and app
  versions instantly.
- **Databases and files**: D1, KV, R2 and Queue producers in `wrangler.jsonc` are created per environment and shared
  with [Functions](/docs/guides/functions/index.md) by binding name.
- **Your app's database**: create the tables and query it with `kumodeck db migrate DB` and `kumodeck db query DB "SELECT …"`
  (the same as `kumodeck functions db …`). Functions do **not** need to be on. `db migrate` reads the folder from
  `migrations_dir` of that database in the app's `wrangler.jsonc` (default `migrations`). A SQL mistake answers
  `d1_query_error` with the database's message: fix the SQL.
- **Logs**: what the app prints with `console.log` / `console.error` is kept for 7 days. Read it with `kumodeck logs`
  (together with Functions, mixed by time; `--source app` for the app only) or the MCP tool `functions_logs`
  ([Logs](/docs/guides/functions/index.md#logs)). Lines are billed at cost like Functions logs; `kumodeck deploy --no-logs` keeps
  none for that version. A version deployed before apps had logs answers `app_logs_unavailable`: deploy again. Code that
  throws while starting answers `app_script_error` with the exception in `details.error`.
- **Secrets**: `kumodeck functions secret put NAME` sets them for Functions and the app together. Do not put secrets in
  `vars` (the deploy warns about names that look like secrets).
- **Scheduled jobs**: the app's Worker cannot have crons. Put them in Functions.
- **Cookies**: KUMODeck removes `Domain=` from the app's `Set-Cookie`, so cookies stay on the app's own host.
- **Not available yet**: image optimization (serve images as they are), ISR, Durable Objects, service bindings, native
  modules. The deploy Skill lists what to use instead.
- **Cost**: requests and CPU of the app, billed at cost from your prepaid balance.

### Next.js

Next.js is built with **vinext** (Cloudflare's Next.js on Vite) — the recommended way — or with
[OpenNext](https://opennext.js.org/cloudflare) (`@opennextjs/cloudflare`), which stays as the fallback. Both run
**without ISR** for now. `kumodeck deploy` uses vinext when it is in `package.json` and OpenNext when only OpenNext is set
up; with neither, it stops and prints the lines for both (`next_adapter_choice`). `--next vinext` / `--next opennext`
chooses for one deploy.

**vinext (recommended)** — needs Node.js 22.18 or later:

1. Install it: `npm install -D vinext` (or pnpm / yarn / bun `add -D vinext`).
2. Run `npx vinext check` and change what it marks ✗.
3. Run this once (it writes `vite.config.ts` and `cloudflare.config.ts`; the three choices are needed, or it stops and asks):

   ```sh
   npx vinext init --platform=cloudflare --cdn-cache=none --data-cache=none --image-optimization=none
   ```
4. `kumodeck deploy`. The CLI runs `vinext check` and `vite build` (instead of steps 2–3 above) and uploads
   `.cloudflare/output/v0`. It does not change your files, and no Cloudflare account is needed.

- If `vinext check` finds something vinext does not support, the deploy lists it. With OpenNext installed, it deploys
  with OpenNext this time; change the listed items and deploy again to use vinext. Without OpenNext, it stops
  (`vinext_check_failed`). `kumodeck deploy --next vinext` tries vinext anyway.
- Image optimization (`imagesOptimizer`, `bindings.images()`) stops the deploy: vinext fails without it at runtime, so it
  cannot be left out. Init with `--image-optimization=none`; images are served as they are.
- Keep the assets binding named `ASSETS` (as `vinext init` writes it).

**OpenNext (the fallback)** — for apps vinext does not support yet, or when vinext does not work. For OpenNext, step 3
above is `opennextjs-cloudflare build` (it runs `next build`); the CLI then copies the pages built at build time into the
assets (`.open-next/cache` → `.open-next/assets/cdn-cgi/_next_cache`).

1. Install `@opennextjs/cloudflare` and `wrangler`. If `wrangler.jsonc` or `open-next.config.ts` is missing, the CLI
   writes the smallest one it needs and says so (it never overwrites). The assets binding must be named `ASSETS`.
2. An `open-next.config.ts` you already have must use the read-only cache from the assets. The CLI stops before
   building otherwise (it does not edit your files):

   ```ts
   import { defineCloudflareConfig } from "@opennextjs/cloudflare";
   import staticAssetsIncrementalCache from "@opennextjs/cloudflare/overrides/incremental-cache/static-assets-incremental-cache";

   export default defineCloudflareConfig({ incrementalCache: staticAssetsIncrementalCache });
   ```
3. Set `images: { unoptimized: true }` in `next.config` (no image optimization yet; images are served as they are).
4. `kumodeck deploy` (`kumodeck deploy --next opennext` when vinext is installed too).

- `revalidate` has no effect yet; server-rendered pages and pages built at build time work. R2 / KV / D1 caches and
  queues for ISR (`NEXT_INC_CACHE_*`, `NEXT_TAG_CACHE_*`, `NEXT_CACHE_*`) are refused.
- `services` (`WORKER_SELF_REFERENCE`) and `images` in OpenNext's `wrangler.jsonc` are not sent; the deploy says so.
- Edge middleware works. Node.js middleware gets a warning (it is experimental in OpenNext too); if it fails, go back to Edge.

Both: Route Handlers and Server Actions reach the app. A static export (`output: "export"`, then `kumodeck deploy out`)
still works. Next.js needs Node.js compatibility: it is on by default with a `compatibility_date` of 2026-08-04 or later
(`vinext init` also writes `nodejs_compat`). With an older date, KUMODeck adds `nodejs_compat` and says so
(`next_nodejs_compat_added`): add it to `compatibility_flags` in `wrangler.jsonc` too, so local runs match.

#### If Next.js does not work

Next.js on KUMODeck is in beta: it has not been checked on the production runtime yet. Every Next.js deploy says so
(the warning `vinext_beta` for vinext, `next_beta` for OpenNext; when a deploy fails, the error's `details.fallback`
points here too). If the app does not work after it is deployed, deploy it again in one of these ways.

**If it was deployed with vinext, deploy it with OpenNext** — install OpenNext if it is not there yet, then:

```sh
npm install -D @opennextjs/cloudflare wrangler
kumodeck deploy --next opennext   # development (deploy's default)
```

If it still does not work, try one of these two.

**1. Export it as a static site** — for apps that do not need server code on each request (no Route Handlers, Server
Actions, middleware or cookies read on the server). In `next.config`:

```ts
const nextConfig = { output: "export", images: { unoptimized: true } };
export default nextConfig;
```

```sh
npx next build        # writes the site to out/
kumodeck deploy out    # development (deploy's default)
```

Server code the app still needs can move to the project's own [Functions](/docs/guides/functions/index.md).

**2. Render each page on request** — remove ISR: delete `export const revalidate = …` from pages and layouts and
`next: { revalidate: … }` from `fetch` calls (use `cache: "no-store"` where the data must be fresh), and remove the
`NEXT_INC_CACHE_*` / `NEXT_TAG_CACHE_*` / `NEXT_CACHE_*` bindings from `wrangler.jsonc`. Then deploy as before:

```sh
kumodeck deploy        # development (deploy's default)
```

If it still does not work, keep the error's `code` and `details` from `kumodeck deploy --json` when you report it.

## Custom domains

Serve the production app on a domain you own, such as `play.mygame.com`. Your users, share links and X cards
(the card tags and image URLs) then use that domain — they never see a KUMODeck URL. KUMODeck handles the
certificate and the ownership check; you add two DNS records. Off by default, production only.

```sh
kumodeck features on customDomains && kumodeck config push --env production
kumodeck hosting domains add play.mygame.com     # prints the DNS records to add
kumodeck hosting domains status play.mygame.com  # check again after adding them
kumodeck hosting domains                         # list
kumodeck hosting domains remove play.mygame.com  # asks first (--yes in scripts)
```

`add` prints the two records exactly as your DNS provider asks for them:

```
CNAME  play.mygame.com               <the CNAME target it prints>
TXT    _kumo-verify.play.mygame.com  kumo-verify=<your account's value>
```

The CNAME points the name at KUMODeck; the TXT proves the domain is yours (the value is the same for every domain
on your account). Add **both at the same time** where you manage the domain (your registrar or DNS provider).
KUMODeck checks every hour and starts serving once they are found — usually minutes, sometimes a few hours.
Your `<slug>.kumodeck.app` URL keeps working, so links you already posted do not break.

- **Watch two things**: `status` (`pending` → `active`, or `failed`) and `verified` (ownership proven by the TXT,
  with the time it was last confirmed). While something is missing, the output lists what to do next.
- **Keep the TXT record** after the domain is live: KUMODeck re-checks it every day and stops serving on the domain
  if it is gone.
- **One TXT on a parent domain covers everything under it**: `_kumo-verify.mygame.com` also proves `play.mygame.com`,
  `www.mygame.com` and so on, so further subdomains need only their CNAME.
- A domain someone else merely signed up for is not theirs: whoever proves ownership with the TXT gets it. The only
  "already in use" refusal is when this app already uses the domain for the other purpose (Functions vs. hosting).

- **Root domains** (`mygame.com`): some DNS providers cannot put a CNAME at the root. Use ALIAS / ANAME / CNAME
  flattening if your provider has it, or a subdomain such as `play.mygame.com`.
- **What changes when it is active**: the URL returned by `kumodeck deploy`, share links, X card tags and image URLs
  use your domain (card tags you already put in `<head>`: get them again with `kumodeck share tags --env production` and
  replace them); sign-in redirects and `web.allowedOrigins` accept it automatically (https only).
- **Limits**: up to 5 per project. Development stays on its `--dev` URL.
- **Cost**: custom domains are billed daily at cost from your prepaid balance. Remove the ones you no longer use.
- Turning `customDomains` off stops serving on your domains (listing and removing still work).

The dashboard (Hosting) and the MCP tools `hosting_domains_list` / `hosting_domain_add` / `hosting_domain_remove` do the same.

## Changing the URL slug

The slug is the name in your game's URLs (`<slug>.kumodeck.app`). You can change it later — once every 30 days.
Undoing your last change within 24 hours does not count.

```sh
kumodeck slug                                        # current slug, next change date, old slugs and their redirects
kumodeck slug check sky-racers-2                     # free? what changes? (URLs, the two choices below)
kumodeck slug change sky-racers-2 --keep-redirect    # or --no-redirect: you must pick one
kumodeck slug redirect sky-racers on                 # turn the redirect of an old slug on (or off)
```

When you change it, you choose what happens to links that use the old slug — there is no default:

- **Keep old links working** (`--keep-redirect`): old URLs redirect (301) to the new ones, keeping the path and query
  (so share links still count). **$1 a month per old slug**, charged daily from your prepaid balance while it is on.
  It keeps working even if your balance runs out; it stops only when you turn it off.
- **Let them stop working** (`--no-redirect`): old URLs show a neutral "Not found" page. No charge.

Either way the old slug stays reserved for your game — nobody else can take it — so you can turn its redirect on or off
later. Custom domains do not change.

Changing the slug or a redirect asks you to confirm it is you (your password, or signing in again with Google etc.).
The CLI asks for your password in a terminal; accounts without a password change it in the dashboard (Hosting → URL slug).
The MCP tools `project_slug_get` / `project_slug_check` only read: an AI assistant cannot change your slug.

## How uploads work

1. The CLI hashes every file (sha256) and sends the manifest.
2. The server answers with the hashes it does not have **for this project** — only those are uploaded (8 in parallel,
   retried).
3. `finalize` + `activate` switch the live version atomically.

Redeploying a large app after a small change uploads only the changed files. Development and production share
uploads, so promoting the same build costs nothing.

## Caching

| File | Cache-Control |
|---|---|
| `*.html` | `no-cache` (revalidated every load — a deploy or rollback is visible immediately) |
| hashed files under `assets/`, `static/`, `chunks/` (e.g. `app.3f9a1c.js`) | `immutable`, 1 year |
| everything else | `max-age=0, must-revalidate` (cheap 304s) |

ETags are content hashes; `Range` and `HEAD` are supported. `/x` redirects to `/x/` when `x/index.html` exists;
a `404.html` at the root is used for missing paths.

## Security headers

Each app is served from **its own subdomain**, separate from the API and the dashboard, so apps cannot read each
other's storage or a developer's session. A permissive CSP keeps CDNs, `eval` and WebAssembly engines working while
blocking plugins and insecure (`http:`) loads. There is no `frame-ancestors` rule, so you can embed your app or game on
other sites such as itch.io. Development deployments send `X-Robots-Tag: noindex`.

## Limits

| Limit | Value |
|---|---|
| Files per deploy | 5,000 |
| Total size | 500 MB |
| Single file | 50 MB |
| Path length | 512 characters |
| Unfinished deploys | 20 per project (expire after 24 h) |
| Server-rendered app: Worker | 64 MiB (uncompressed) |
| Server-rendered app: asset file | 25 MiB |
| Server-rendered app: per request | 1,000 ms CPU, 50 subrequests |

## CI

Skip `kumodeck login` and pass a secret key; the environment comes from the key:

```sh
KUMO_API_URL=https://api.kumodeck.com KUMO_SECRET_KEY=$KUMO_SECRET_KEY npx kumodeck deploy dist
```

> **Coming soon** SPA fallback routing and per-project headers (COOP/COEP for `SharedArrayBuffer`) are planned.
