Deploy
This page lists what a production deployment of the Moonspell server needs, in an order where each step only depends on the ones before it. It describes the hosted product for readers and contributors. A full deployment also needs the private bundling CLI (for the sandbox snapshot) and the private phone apps, so a public clone can deploy the server, the web app and the edge, but cannot run builds. See What is open and what is private.
Secrets go in your host's secret stores, never in the repo. .env.example lists every server variable with a comment. Generate long random strings with something like openssl rand -base64 48.
1. Database
- Create a Postgres database (the code uses
@neondatabase/serverless, so Neon) and take its connection string asDATABASE_URL. - Apply the schema:
npm run db:migrate. It appliesdb/migrations/*.sqlin name order, once each, records them inschema_migrations, and readsDATABASE_URLfrom the environment or.env.local. Run it again after adding a migration.
2. Server
The server is one Next.js project at the repo root. vercel.ts pins the framework to Next.js, and package.json needs Node 24. Nova (eve, at /eve/v1/*) and the workflows (Workflow SDK, src/workflows/) ship inside it through withEve(withWorkflow(...)) in next.config.ts.
- Create the project and add a Blob store (
BLOB_READ_WRITE_TOKEN). - Production must be reachable without deployment protection, because the edge proxies it and Apple fetches the app site association file through it.
- Set the environment (bare values, without quotes):
| Variable | Value |
|---|---|
DATABASE_URL | From step 1 (secret) |
BLOB_READ_WRITE_TOKEN | From the Blob store |
APP_SESSION_SECRET | Long random string (secret; changing it signs everyone out) |
INTERNAL_API_SECRET | Long random string (secret) |
PUBLIC_BASE_URL | The public host, the edge's origin |
CRESCENT_APP_URL | Empty on production (the production domain is used); set it on previews |
CRESCENT_EDGE_URL, REALTIME_SECRET | Step 5 (the secret is shared with the edge) |
ANTHROPIC_API_KEY | Step 7 (secret) |
CRESCENT_WORKER_MODEL | Optional, default claude-opus-5-5 |
DAYTONA_API_KEY, DAYTONA_SNAPSHOT, DAYTONA_API_URL | Step 4 (the URL only for a non-default endpoint) |
GITHUB_APP_ID, GITHUB_APP_PRIVATE_KEY, GITHUB_APP_INSTALLATION_ID, GAMES_GITHUB_ORG | Step 3 |
CLOUDFLARE_GATEWAY_URL, CLOUDFLARE_APPS_ACCOUNT_ID, CLOUDFLARE_GATEWAY_SECRET | Optional, step 6 |
CRESCENT_PLAY_ORIGIN | Optional: a dedicated host for running web games (see Builds and versions) |
GOOGLE_WEB_CLIENT_ID, GOOGLE_IOS_CLIENT_ID, GOOGLE_ANDROID_CLIENT_ID, CRESCENT_REQUIRE_SIGN_IN | Sign-in (see Architecture) |
CRESCENT_METERING and the other credit variables | See Credits, which has its own order |
CRESCENT_STARTER_MANIFEST_URL, CRESCENT_STARTER_ANDROID_MANIFEST_URL, CRESCENT_STARTER_WEB_MANIFEST_URL, CRESCENT_STARTER_COVER_URL | The starter game (below); unset, new accounts start with no games |
HIGGSFIELD_CLIENT_ID, HIGGSFIELD_REFRESH_TOKEN | Optional: covers, sounds, art and 3D models; unset, games keep their placeholders |
CRESCENT_SUPPORT_EMAIL | The contact on /support and /privacy |
CRESCENT_APP_STORE_ID, CRESCENT_TESTFLIGHT_URL, APPLE_APP_BUNDLE_ID, ANDROID_CERT_SHA256 | The phone apps' store and link settings |
- Before deploying, check locally:
npm run typecheck,npm run lint,npm test,npm run build.
3. Game repos
Every game lives in its own private repo in GAMES_GITHUB_ORG, named cm-<slug>-<id> (repoNameFor in src/lib/games.ts).
- Create the org.
- Create a GitHub App with repository permissions Administration: read and write (to create repos) and Contents: read and write. No webhooks.
- Install it on the org with access to all repositories, so it can reach the repos it creates.
- Set
GITHUB_APP_ID,GITHUB_APP_PRIVATE_KEY(a one-line value with literal\nworks) andGITHUB_APP_INSTALLATION_ID.
4. The sandbox snapshot
Runs start from a sandbox snapshot built by scripts/create-run-snapshot.ts: Node 24, git, Claude Code, the bundling CLI and the game template with its dependencies, exported once while building so a snapshot that cannot export never ships.
npm run snapshot:pack # packs the private CLI into scripts/vendor/
npm run snapshot -- [name] [cpu] [memGB] [diskGB] # builds the snapshot, with DAYTONA_API_KEY in .env.local
Set DAYTONA_SNAPSHOT to the name. Rebuild it whenever templates/crescent-game/ changes, since new games start from the template inside it; a new name per build lets live runs keep the old one. snapshot:pack needs the private CLI and stops with one sentence in a public clone.
5. Edge Worker
edge/ is a Cloudflare Worker with one Durable Object class, RunChannel (edge/wrangler.jsonc).
- Set
vars.ORIGIN_URLinedge/wrangler.jsoncto the server's production URL, with no trailing slash. - Deploy:
cd edge && npm install && npm run typecheck && npx wrangler deploy. npx wrangler secret put REALTIME_SECRETwith the server's value. Until it is set,/rt/*answers 503.- With credits on, also
ANTHROPIC_API_KEYandINTERNAL_API_SECRETas secrets (Credits). - Set
CRESCENT_EDGE_URLandPUBLIC_BASE_URLon the server to the edge's origin and redeploy. - Check:
curl -si https://<edge>/rt/health # "realtime":true
curl -si https://<edge>/.well-known/apple-app-site-association # 200, application/json, no location header
curl -si https://<edge>/ # the web app
A custom domain is attached through routes in edge/wrangler.jsonc, so the same Worker and Durable Objects keep serving it.
6. Game backends (optional)
Games get their own Cloudflare Worker through a Cloudflare gateway that accepts tokens scoped to one game's resources. The gateway is a separate service, not part of this repo. With all three of CLOUDFLARE_GATEWAY_URL, CLOUDFLARE_APPS_ACCOUNT_ID and CLOUDFLARE_GATEWAY_SECRET set, runs build games with live leaderboards, presence and rooms (cloudflareRunEnv in src/lib/cloudflare-gateway.ts). With any of them missing, games keep their data on the phone and Nova does not promise online play. What a game backend is made of is in Backend overview.
7. Anthropic
Nova and the worker both use ANTHROPIC_API_KEY (with credits on, the worker reaches it through the edge instead). Use a dedicated key with a spend limit: the builder runs player-written requests with full permissions in its sandbox, up to 160 plus 40 turns per run.
8. The starter game
npm run starter:upload exports the template's game and uploads it, then prints the starter variables to set. It needs the private CLI. Run it again whenever the template's game changes.
9. Smoke test
- Open the web app, sign in, and ask Nova for a game.
- The building card shows live progress (the edge works), then turns into a play card, and the game plays.
- The workflow's runs show each step; the sandbox comes and goes; the games org has a new repo with the commit.
- Open the version's share link in a private window: the shared-game page shows the version.
- Ask for a change to the same game: version 2 builds from the repo.
The phone apps' store setup (signing, App Clip, store records) belongs to the private apps and is not covered here.