# Deploy

> The order in which the hosted Moonspell server, database, storage, game repos, sandbox snapshot, edge Worker and optional services are set up, and which environment variables each needs.

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](https://docs.moonspell.app/start/open-source.md).

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

1. Create a Postgres database (the code uses `@neondatabase/serverless`, so Neon) and take its connection string as `DATABASE_URL`.
2. Apply the schema: `npm run db:migrate`. It applies `db/migrations/*.sql` in name order, once each, records them in `schema_migrations`, and reads `DATABASE_URL` from 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`.

1. Create the project and add a Blob store (`BLOB_READ_WRITE_TOKEN`).
2. Production must be reachable without deployment protection, because the edge proxies it and Apple fetches the app site association file through it.
3. 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](https://docs.moonspell.app/moonspell/builds.md#playing-a-version-on-the-web)) |
| `GOOGLE_WEB_CLIENT_ID`, `GOOGLE_IOS_CLIENT_ID`, `GOOGLE_ANDROID_CLIENT_ID`, `CRESCENT_REQUIRE_SIGN_IN` | Sign-in (see [Architecture](https://docs.moonspell.app/moonspell/architecture.md#accounts)) |
| `CRESCENT_METERING` and the other credit variables | See [Credits](https://docs.moonspell.app/moonspell/credits.md#going-live), 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 |

4. 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`).

1. Create the org.
2. Create a GitHub App with repository permissions **Administration: read and write** (to create repos) and **Contents: read and write**. No webhooks.
3. Install it on the org with access to all repositories, so it can reach the repos it creates.
4. Set `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY` (a one-line value with literal `\n` works) and `GITHUB_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.

```bash
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`).

1. Set `vars.ORIGIN_URL` in `edge/wrangler.jsonc` to the server's production URL, with no trailing slash.
2. Deploy: `cd edge && npm install && npm run typecheck && npx wrangler deploy`.
3. `npx wrangler secret put REALTIME_SECRET` with the server's value. Until it is set, `/rt/*` answers 503.
4. With credits on, also `ANTHROPIC_API_KEY` and `INTERNAL_API_SECRET` as secrets ([Credits](https://docs.moonspell.app/moonspell/credits.md#going-live)).
5. Set `CRESCENT_EDGE_URL` and `PUBLIC_BASE_URL` on the server to the edge's origin and redeploy.
6. Check:

```bash
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](https://docs.moonspell.app/backend/overview.md).

## 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

1. Open the web app, sign in, and ask Nova for a game.
2. The building card shows live progress (the edge works), then turns into a play card, and the game plays.
3. The workflow's runs show each step; the sandbox comes and goes; the games org has a new repo with the commit.
4. Open the version's share link in a private window: the shared-game page shows the version.
5. 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.
