Install Dee Wan
This guide installs Dee Wan CMS into your own Cloudflare account. An installation is the admin Worker, the public read Worker, the admin UI on Cloudflare Pages, one control D1 database, media storage, and the credentials behind them. It is created once, has one claim link, and serves many sites (see the product vocabulary).
To look around first without a Cloudflare account, use the local trial instead: it runs the whole product against a local database on your machine.
You install with the command-line installer: npm run setup for an interactive run, and
npm run install:cms -- <verb> for planned, resumable and scriptable runs. It needs a Cloudflare
API token. It keeps your answers in backend/wrangler.jsonc and its progress in a step ledger, and
runs the list of steps that npm run plan prints.
A browser-based installer also exists, but it needs a Cloudflare OAuth application you register yourself and is not a supported installation path. See Web installer preview.
Dee Wan 1.0 is pre-release. Installing against real Cloudflare is one of the acceptance gates that remain open. See the 1.0 product boundary for what is and is not covered.
GitHub credentials depend on the workflow. Configure the admin GitHub App separately from the CLI device flow. The installer’s optional App link does not configure the Worker. Use the authentication matrix, then follow first site to published content.
Before you start
Section titled “Before you start”What you need
Section titled “What you need”- A Cloudflare account with Workers, D1, Pages, and R2 or Cloudflare Images.
- Node 22 (22.14 or later) or Node 24 (24.10 or later). The repository ships an
.nvmrc(24.18.1), sonvm useselects the right version. - npm exactly as
package.jsondeclares it (11.16.0), which is the npm that the.nvmrcNode ships with. - Git, to clone the repository with its submodules.
- Optionally, your own domain. To use it, the domain must be a zone in the same Cloudflare account.
Without one, the installation uses the addresses Cloudflare provides (
pages.devandworkers.dev). - Optionally, an inference provider key for AI features. DeepInfra is the default; an OpenAI-compatible provider can be configured after installation.
Cloudflare Access (Zero Trust) is not required. The installer offers it as an extra perimeter in front of the admin, and it is off unless you turn it on.
Get the code and install dependencies
Section titled “Get the code and install dependencies”git clone --recurse-submodules https://github.com/dee-wan-cms/dee-wan.gitcd dee-wannode scripts/install-dependencies.mjsnode scripts/install-dependencies.mjs frontendThe first dependency-install command installs the root and backend dependencies. The second installs the admin UI dependencies,
which the installer builds and deploys. Always install with node scripts/install-dependencies.mjs,
never with a bare npm install or npm ci. It refuses any other npm version, installs the locked
dependency tree with install scripts disabled, and then rebuilds only the packages whose scripts
are approved.
A fresh clone needs no manual build step: setup, deploy and migrate:d1 build the packages and
the Prisma client if they are missing.
What gets created
Section titled “What gets created”These are the steps the installer runs, in order. npm run plan prints the same list for your
configuration, with the state of each step.
| Step | What it creates or does |
|---|---|
| Check Node and Wrangler | Checks only. |
| Resolve the Cloudflare API token | Checks only. |
| Choose the Cloudflare account | Checks only. |
| Preflight — token scopes, plan, existing resources | Checks only. Stops the run before anything is created if a check fails. |
| Create the control D1 database | A D1 database (default name dee-wan-cms-db). |
| Apply migrations to D1 | The schema, and the seeded roles and workflow. |
| Provision the private import archive bucket | A private R2 bucket for import archives. The installer creates it only when you choose it. |
| Connect the initial site to its hostname | A public hostname for the initial site. |
| Build the admin UI | Builds only. |
| Deploy the admin UI to Pages | A Pages project (default name dee-wan-cms). |
| Deploy the admin Worker | A Worker (default name dee-wan-cms-backend). |
| Generate and store the session signing secret | A Worker secret. |
| Provision media storage | An R2 media bucket, or a Cloudflare Images binding. |
| Deploy the public API | The public read Worker (named after the admin Worker, with -public appended) and its hostname. |
| Deploy the website form intake | The form intake Worker (named after the admin Worker, with -intake appended). Skipped when DEPLOY_FORM_INTAKE is false; website forms then take no submissions. |
| Generate and store the site-token signing key | A Worker secret on the admin and public Workers. |
| Attach the Worker routes | Two Worker routes on your zone: <admin host>/api/* and <admin host>/.well-known/oauth-*. The installer creates them only with your own domain. Teardown removes them only while they still point at this Worker. |
| Point DNS at the deployment | DNS records under your domain. The installer creates them only with your own domain and DNS setup turned on. |
| Put Cloudflare Access in front of the admin | An Access application and policy. The installer creates them only when you turn Access on. |
| Check the deployment answers | Checks only. |
| Mint the first administrator’s claim link | A single-use link, shown once. |
Resources such as the Workers, the Pages project, the D1 database and the R2 buckets are billable Cloudflare resources in the account you choose.
With your own domain, the admin UI is served at cms.<your-domain> and the public API at
content.<your-domain> by default. Without one, the admin UI is served at
<pages-project>.pages.dev and the public API at
<admin-worker>-public.<your-workers.dev-subdomain>.workers.dev.
Command-line installer
Section titled “Command-line installer”Credentials
Section titled “Credentials”The installer needs a Cloudflare API token. npm run setup prompts for one if none is set, prints
the permissions it needs with a reason for each, and gives a link to Cloudflare’s token editor with
those permissions selected. Include all accounts and zones.
| Permission | Needed for | Why |
|---|---|---|
| Account · Account Settings · Read | Every install | Read the account name and settings. |
| Account · D1 · Edit | Every install | Create the database and apply migrations. |
| Account · Workers Scripts · Edit | Every install | Deploy the admin, public-read and form-intake Workers, their secrets and custom domains. |
| Account · Cloudflare Pages · Edit | Unless the admin UI deploy is turned off | Create and deploy the admin UI project. |
| Account · Workers R2 Storage · Edit | Media in R2, or the import bucket | Create the media and import buckets. |
| Zone · Zone · Read | Your own domain | Find the zone your domain belongs to. |
| Zone · Workers Routes · Edit | Your own domain | Route the admin and site hostnames to the Workers. |
| Zone · DNS · Edit | Your own domain with DNS setup on | Point the admin hostname at the deployment. |
| Zone · Zone WAF · Edit | Your own domain (optional) | Install the sign-in rate-limit rule and the optional public API rule. Without it both are skipped with a warning; the Worker’s own sign-in limiter still applies. |
| Account · Access: Apps and Policies · Edit | Cloudflare Access only | Create the Access application and policy in front of the admin. |
| Account · Access: Organizations, Identity Providers, and Groups · Read | Cloudflare Access only | Read your Access team domain. |
npm run setup and npm run doctor print this list for the choices already in your config. The
Access rows are requested only once you turn Access on. Any other choice you have not made yet is
requested and marked with its condition, and a choice you turned off is not requested. The pre-filled token link selects the same rows, except Zone · Zone WAF · Edit, which
the printed list tells you to add by hand. Verifying the token needs no extra permission.
If you use Cloudflare Images, setup recommends a separate token with only the Cloudflare Images:
Edit permission.
To skip the prompts, set the token and account in the environment:
export CLOUDFLARE_API_TOKEN=… # skips the token promptexport CLOUDFLARE_ACCOUNT_ID=… # skips the account pickerCredentials are resolved in this order: a variable exported in your shell, then the repository’s
.env file, then values in backend/wrangler.jsonc. When the environment overrides a different
value in the config, the installer says so.
The token is checked before anything is created. An interactive run allows three attempts. A non-interactive run stops at the first failure. Either way, a token that cannot be verified means no Cloudflare resources are created.
Application secrets are never stored in wrangler.jsonc. See Secrets.
Interactive install: npm run setup
Section titled “Interactive install: npm run setup”npm run setupsetup walks through the installation and saves each answer to backend/wrangler.jsonc as it
goes, so a second run does not ask again. In order, it:
- Finds or creates
backend/wrangler.jsoncand resolves the token and account. If the token reaches several accounts, it asks which one to use. - Asks which D1 plan the account uses. Free allows 500 MB per database. Paid allows 10 GB per database.
- Runs preflight and prints a report. If a check fails, it stops before creating anything (“Setup stopped before creating any Cloudflare resources. Fix the FAIL rows above.”). If resources will be created or modified, it asks you to confirm.
- Asks where the installation will live (your own domain, or the addresses Cloudflare provides),
then the domain, the admin subdomain (default
cms) and the public content API subdomain (defaultcontent). - Asks whether to set up DNS, whether to set up Cloudflare Access (default no), and whether to install a rate-limiting rule on the public API (default no).
- Asks for the Pages project name and the Worker name.
- Asks for the storage provider, Cloudflare R2 or Cloudflare Images. For R2 it creates the
bucket (default
dee-wan-cms-images), enables public access through the managedr2.devaddress or a custom media domain, and checks that uploaded files are actually served. - Asks whether to enable data import, which creates a private R2 bucket for uploaded archives (default no).
- Creates the D1 database and offers to apply the schema (default yes). A fresh install needs no separate migrate step.
- Builds and deploys the admin UI, the admin Worker, the public read Worker and the form intake
Worker, stores the Worker secrets, and attaches the route, DNS records and Access application
you chose. It writes
backend/wrangler.public.jsoncandbackend/wrangler.intake.jsonconce the account, zone and database id are settled, and never overwrites an existing one. - Offers to run health checks.
- Asks for the first administrator’s email, if
BOOTSTRAP_ADMIN_EMAILis not already set, and prints the claim link.
It ends with “Setup complete!”, a configuration summary and the next steps. If a required part is missing, it ends with “Setup did not complete” and names what is missing instead.
setup also accepts --access / --no-access and --public-rate-limit /
--no-public-rate-limit to answer those questions on the command line, for example
npm run setup -- --no-access.
Plan first: npm run plan
Section titled “Plan first: npm run plan”npm run plannpm run plan -- --jsonplan prints every step an installation would perform and the state each one is already in. It
works only from the configuration and ledger on disk: it touches no network, creates nothing and
costs nothing. --json prints a machine-readable plan with a planVersion.
Each step has one of these states:
| Mark | State | Meaning |
|---|---|---|
✓ |
done | Recorded as completed. |
• |
to do | Will run. |
– |
not applicable | Skipped for this configuration, with the reason. |
✗ |
blocked | Cannot run yet, for example because no Cloudflare API token was found. |
? |
unresolved | A previous run asked for this and never recorded an answer, or it failed. apply checks the account before repeating it, and refuses rather than guesses. |
↻ |
re-checked | A check that runs on every apply. |
The installer verbs: npm run install:cms
Section titled “The installer verbs: npm run install:cms”npm run install:cms -- <verb> [options]| Verb | What it does | Changes the account |
|---|---|---|
plan |
Says what an install would do. The same as npm run plan. |
No |
apply |
Runs the install. | Yes, creates billable resources |
resume |
Continues an install that stopped part-way. Refuses if no step has been completed yet. | Yes, creates billable resources |
doctor |
Preflight checks. Changes nothing. | No |
verify |
Drives a deployment end to end. Never point it at production. Add --read-only there. |
Creates a site, a model and content, unless --read-only |
teardown |
Removes what the install created. A dry run unless --apply is passed. |
Only with --apply |
manifest |
Prints a redacted record of the install (account, resource names, step history) for handing it over. It never contains a credential. | No |
apply and resume run the same engine as npm run setup. The step ledger is what makes a
second run continue rather than repeat: each completed step is recorded in a ledger file in the
repository root, one file per Cloudflare account (.dee-wan-cms-install.<account>.json). A
ledger file that cannot be read completely stops the run instead of being ignored.
Options:
| Option | Meaning |
|---|---|
--config <file> |
Which installation to act on. Default wrangler.jsonc. A bare name is looked up in backend/. |
--account <id> |
The Cloudflare account to target, explicitly. Every step the command runs targets that account. |
--json |
Machine-readable output. Implies --yes. |
--yes, -y |
Never prompt. Fail instead of asking. |
verify also forwards --read-only, --public, --admin, --email, --model and
--disposable, and teardown forwards --apply and --yes. Any other option, a repeated option,
or an option given with an empty value (such as --account= from an unset shell variable) is
refused, not ignored.
apply, resume, teardown and verify (unless --read-only) need an account. They take it
from --account, then CLOUDFLARE_ACCOUNT_ID, then account_id in the config, and refuse if
none is named.
Exit codes
Section titled “Exit codes”install:cms exits with a fixed code, so a script can branch on the result:
| Code | Name | Meaning | What to do |
|---|---|---|---|
| 0 | ok | The install is complete. | Nothing. |
| 1 | usage | The command line was wrong, or resume found nothing to resume. |
Fix the command. |
| 2 | blocked | Something only you can supply is missing, such as a token or an account. | Supply it, then run again. |
| 3 | failed | The run failed. | Read the output and fix the cause before repeating. |
| 4 | incomplete | The run did not fail, but work remains. | Safe to retry as it is, usually with resume. |
| 5 | unresolved | A step was started and its outcome is unknown. | Look at the account before repeating anything. |
plan uses the same codes to describe the current state: 2 when a step is blocked, 5 when one is
unresolved, 4 when work is left, and 0 when everything is done.
Running unattended
Section titled “Running unattended”With --yes, --json, DEEWAN_YES=1 or CI=true, every prompt takes its default. A question
with no safe default is an error that names the setting to add, instead of a guess:
- Where the installation lives: set
"DOMAIN_MODE": "custom"with"DOMAIN", or"DOMAIN_MODE": "cloudflare", undervarsinbackend/wrangler.jsonc. - The D1 plan: set
"D1_PLAN": "free"or"paid"undervars, or exportD1_PLAN. - The account, when the token reaches several: set
account_idin the config or exportCLOUDFLARE_ACCOUNT_ID. - The first administrator: set
BOOTSTRAP_ADMIN_EMAILundervars. An unattended run does not ask for it, and without it no claim link can be minted.
For example:
npm run install:cms -- apply --account <account-id> --jsonSharing a Cloudflare account
Section titled “Sharing a Cloudflare account”The default resource names (dee-wan-cms-backend, dee-wan-cms-db and so on) are fixed, so two
installations in one Cloudflare account collide. Set DEEWAN_RESOURCE_PREFIX to namespace every
resource a run provisions:
DEEWAN_RESOURCE_PREFIX=test-a1b2 npm run setup # creates test-a1b2-dee-wan-cms-db, …The prefix must contain only lowercase letters, digits and dashes, start with a letter or digit, and be at most 24 characters long.
Set it for a fresh installation, not an existing one. Wrangler finds the D1 database by the name in
backend/wrangler.jsonc, so the prefix only works once setup has written the prefixed names into
that file. See Running the installer against a shared account.
Teardown does not decide by name. It deletes only what the install record says this installation created. See Starting over or tearing down.
After installing
Section titled “After installing”Claim the installation
Section titled “Claim the installation”Nobody has an account until the installation is claimed. npm run setup prints a single-use claim
link once, with the email address it is bound to and when it expires. It expires after 15 minutes.
Only its hash is stored, so it cannot be printed again.
Open it and create the first administrator as described in Create and replace the first administrator.
If the link expires before anyone uses it, run the installer again (npm run setup or
npm run install:cms -- resume). An expired claim link counts as not done, so the run mints a new
one as long as the installation has not been claimed. npm run claim:link can also replace it. See
Create and replace the first administrator.
Verify it works
Section titled “Verify it works”npm run healthruns post-deploy checks against the deployed addresses. With Access enabled, it also checks the perimeter, using the service token inCF_ACCESS_CLIENT_IDandCF_ACCESS_CLIENT_SECRET.npm run verify -- --read-only --public <public-api-url>checks a deployment without writing anything. Add--model <slug>to read one published model.npm run verify -- --admin <url> --public <url> --email <address> --disposable, withVERIFY_PASSWORDin the environment, creates a site, a model and content. Use it only on a disposable deployment: the admin API can deactivate a site but not delete it.npm run doctoris a read-only preflight that names anything missing and how to fix it.npm run planshould report nothing left to do.
Add secrets for optional features
Section titled “Add secrets for optional features”Secrets are set with wrangler secret put from the backend/ directory, never in a file. For
example, the AI features need a DeepInfra key:
npx wrangler secret put DEEPINFRA_API_KEYFor an OpenAI-compatible provider, set AI_PROVIDER="openai-compatible", AI_BASE_URL and AI_TEXT_MODEL as Worker variables, then add AI_API_KEY with wrangler secret put. Optional AI_VISION_MODEL and AI_IMAGE_MODEL enable those capabilities; deployment-owned model lists use AI_TEXT_MODELS, AI_VISION_MODELS and AI_IMAGE_MODELS. See Inference provider.
See Secrets for every optional secret and setting: mail, the GitHub App for repository delivery, cache purge, AI limits and error reporting.
Large media uploads: the admin streams documents, audio, video and other files up to 100 MB, the
request body limit of Cloudflare’s Free and Pro plans. Business allows 200 MB and Enterprise up to
5 GB (Workers limits, read
2026-10-12). Set it only if your plan allows more: "MEDIA_REQUEST_BODY_LIMIT_BYTES": "200000000"
under vars. Values above 5 GB are capped at 5 GB. Settings → Site → Media files and the upload
dialog show the current limit. Images and every whole-file path (management API, plugins,
legacy buffered imports) stay at 32 MiB. Chunked native site/history transfers carry larger accepted files; see Import and migration.
Deploy changes
Section titled “Deploy changes”The installer deploys everything once. After you change code or configuration, deploy again:
npm run deploy # admin UI, admin Worker, public read Worker and form intake Workernpm run healthdeploy refuses to run without backend/wrangler.public.jsonc. An installation that deliberately
serves no public content deploys with npm run deploy -- --admin-only. See
Deploy.
deploy also ships backend/wrangler.intake.jsonc when it exists. Without it, or with
--no-form-intake, it deploys no intake Worker and says that website forms take no submissions. With
a non-default admin config, name the intake config with --intake-config <file>.
Website forms post to /v1/interactions/form.submit on the site’s own host. A route
<host>/v1/interactions/* sends those posts to the intake Worker; it is more specific than the public
API’s <host>/v1/*. Managed delivery attaches it to each site host when the deployment service runs
with MANAGED_RENDER_INTAKE_WORKER_SCRIPT set to the intake Worker’s name. For any other site host,
add that route to the intake Worker yourself. npm run provision:site binds each site’s database to
the intake Worker too, when backend/wrangler.intake.jsonc exists.
Upgrading
Section titled “Upgrading”To move an installation to a newer release, from the repository root:
git pullnpm run submodules:sync # update packages/ to the new pinsnode scripts/install-dependencies.mjs # reinstall root and backend dependenciesnode scripts/install-dependencies.mjs frontend # reinstall admin UI dependenciesnpm run build:packages && npm run prisma:generate # rebuild the vendored packages and Prisma clientnpm run migrate:d1 # apply new migrations to the control databasenpm run tenant:upgrade # only if you used npm run provision:sitenpm run deploynpm run health- The rebuild step is needed because
setup,deployandmigrate:d1build the packages only when they are missing, not when they are out of date. migrate:d1prints the config and database it is about to change before it runs. Add-- --config <file>for an installation that is notbackend/wrangler.jsonc, and pass the same--config(with--public-config) todeploy.tenant:upgradebrings existing per-site databases forward. Runnpm run tenant:upgrade -- --planfirst to see what each site is missing without writing anything.deployrefuses to ship the admin Worker while the control database has pending migrations, or when it cannot tell, so running it beforemigrate:d1stops rather than deploying new code over an old schema.--skip-migration-checkoverrides this. Use it only when you know the new code does not need the pending migrations.
See Database migrations for what the migration commands do and how to recover from a failed one.
Starting over or tearing down
Section titled “Starting over or tearing down”npm run teardown deletes what setup created for an installation. Without --apply it is a dry
run: it lists every resource it would delete and every resource it will keep, with the reason, and
changes nothing.
npm run teardown # dry run of the default installationnpm run teardown -- --apply # delete itWhich installation
Section titled “Which installation”Without options, teardown acts on the default installation: backend/wrangler.jsonc, with
backend/wrangler.public.jsonc and backend/wrangler.intake.jsonc. For another installation, name
its config files:
npm run teardown -- --config <admin config> --public-config <public config> --intake-config <intake config>A companion config you do not name is treated as absent. Teardown never borrows the default installation’s companion files.
What it removes
Section titled “What it removes”- the admin, public-read and form-intake Workers, with their routes and custom domains.
- the Pages project.
- the D1 databases.
- the R2 buckets. Each is emptied, then deleted.
- the Access application.
- the DNS records and rate-limit rules setup created.
- the managed renderers recorded for the selected sites.
What it keeps
Section titled “What it keeps”Setup writes an install record, .dee-wan-cms-install.<account>.json in the repository root. It is
the same file as the step ledger. Teardown deletes only resources that the record says this
installation created and that still match the identity recorded for them. It lists and keeps:
- resources setup found already in place and adopted, including R2 buckets.
- resources that changed since setup recorded them.
- resources setup did not confirm creating, for example after an interrupted run. Running setup again confirms them.
- resources it cannot read. Teardown deletes nothing until they answer.
- the D1 databases and R2 buckets while any Worker of the installation stays deployed.
Teardown never deletes your local config files.
Deleting
Section titled “Deleting”With --apply, you choose whether to export each D1 database to a local .sql file first. Then
teardown names the Cloudflare account and asks yes or no. The answer defaults to no.
Unattended, pass --yes, plus --export-dir <directory> or --skip-export. Without --yes, an
unattended run answers no and deletes nothing.
Teardown deletes in this order: routes, custom domains, DNS records and the Access application first, then the Workers and the Pages project, then the R2 buckets and D1 databases. It confirms each deletion by reading the resource back.
If something fails, the install record stays and teardown lists what remains. Fix the cause and run it again. The record is removed only when everything it lists is gone.
An install record written by an older installer version is refused with a message. Move that file aside before running teardown.
npm run install:cms -- teardown runs the same script for the chosen config and account. It
forwards --apply, --yes, --public-config, --intake-config, --export-dir and
--skip-export.
Troubleshooting
Section titled “Troubleshooting”Start with npm run doctor and npm run plan. doctor names what is missing and how to fix
it. plan shows which step is done, left, blocked or unresolved, without touching the network.
Building the admin UI fails because a package or vite cannot be found. The admin UI’s
dependencies are not installed. Run node scripts/install-dependencies.mjs frontend.
The installer says DOMAIN is set but DOMAIN_MODE is not. A domain without a mode is
refused, not read as a custom domain. Under vars in backend/wrangler.jsonc, set
"DOMAIN_MODE": "custom" to use that domain, or set "DOMAIN_MODE": "cloudflare" and remove
DOMAIN. Then run again. Nothing was created.
A run stopped part-way. Resume it with npm run install:cms -- resume. Completed steps are
not repeated. resume refuses when nothing has been completed yet.
Use apply then.
Exit code 4 (incomplete). The run did not fail, and it is safe to retry without changes. Read the output for what was left, usually something to fix at Cloudflare, then resume.
Exit code 3 (failed). Read the output, fix the cause, then run again.
Exit code 5, or a step marked ? (unresolved). A previous run asked Cloudflare for something
and never recorded the answer, or it failed. The installer checks the account before repeating the
step, and refuses if it cannot tell. Look at the named resource in your Cloudflare account, resolve
it, then run again.
Preflight fails. setup stops before creating anything. Fix the rows marked FAIL in the report
and run again.
The token cannot be verified. Check its permissions, its expiry, and that it covers the account. Nothing is created until verification succeeds.
“the API token lacks Zone → WAF → Edit”. A rate-limit rule was skipped. The install continues
without it. Add the permission and run setup again if you want the rule.
An unattended run stops with a missing answer. The message names the setting to add to
backend/wrangler.jsonc (or the environment variable to export). Add it and run again. See
Running unattended.
“Setup did not complete: this deployment has no way in.” No claim link could be minted, or the
session signing secret is not installed. Set BOOTSTRAP_ADMIN_EMAIL under vars in
backend/wrangler.jsonc (exactly one address), fix any cause the output names, and run setup
again.
deploy refuses with pending migrations. Apply them with npm run migrate:d1, then deploy
again. See Upgrading.
The claim link expired. Run the installer again to mint a new one. See Claim the installation.
Next step
Section titled “Next step”Open the claim link and set up the first administrator.
Unreleased / pre-MVP; no stable version
Source revision: fe23db481c422b40e317b92fc98116b4c0c26a87 (modified checkout)
Build date (UTC): 2026-10-18T09:22:34.630Z
Upgrade target: unavailable: modified checkout. Release notes