Deployment
Provision and deploy a complete EmuView instance on your own Cloudflare account with the one-command setup wizard.
#Deploy an EmuView instance
By the end of this guide you'll have a complete EmuView instance — dashboard, API, databases, caches, and file storage — running on your own Cloudflare account.
The setup wizard (npm run setup, which runs scripts/setup.mjs) provisions everything in one command. It supports multiple independent instances on the same Cloudflare account, and you can re-run it to resume if a step fails.
#Prerequisites
- A Cloudflare account
- Node.js installed, the EmuView repository cloned, and dependencies installed with
npm install - Your Account ID — a 32-character hex code shown in the Cloudflare dashboard sidebar
- An API token — create one at dash.cloudflare.com/profile/api-tokens using the "Edit Cloudflare Workers" template, then add D1 → Edit, R2 Storage → Edit, and Account Analytics → Read. Copy the token when it's shown — you can only see it once.
The token needs these permissions in total: D1:Edit, Workers Scripts:Edit, Workers KV:Edit, Workers R2:Edit, Cloudflare Pages:Edit, Account Analytics:Read.
Cloudflare Pages:Edit is only needed for instances deployed as a Pages project. New installs deploy the dashboard as a Worker with static assets instead, which Workers Scripts:Edit already covers. Existing Pages instances keep working and are not migrated by an update — see Deploy modes.
#Steps
#1. Run the setup wizard
$ npm run setup
The wizard walks you through five screens. To preview what it would create without touching your account, run it with --dry-run first:
$ node scripts/setup.mjs --dry-run
#2. Connect to Cloudflare
Enter your Account ID and API token when prompted. The wizard verifies them against the Cloudflare API before continuing, and tells you which permission is missing if validation fails.
#3. Name your instance
The instance name is used for every cloud resource and becomes part of your URLs. It must be 3–32 characters: lowercase letters, digits, and hyphens, starting with a letter and ending with a letter or digit.
For an instance named my-blog, your URLs will be:
- Dashboard:
https://my-blog.<your-subdomain>.workers.dev(orhttps://my-blog.pages.devfor a pre-existing Pages instance — the wizard shows the right one) - API:
https://my-blog-api.<your-subdomain>.workers.dev
#4. Set up the admin account
Enter an admin email, display name, and password (minimum 8 characters). This account gets full super_admin access. You'll also choose a signup mode:
| Mode | Who can create accounts |
|---|---|
disabled |
Nobody — you manage users yourself (recommended) |
invite |
Invite only — you send invitations |
open |
Anyone with the URL can register |
The wizard generates a BETTER_AUTH_SECRET automatically — you don't need to supply one.
#5. Review and confirm
The wizard shows a summary of everything it's about to create. Confirm, and it provisions the resources, runs all database migrations, seeds roles and permissions, builds the dashboard, deploys the API worker and the dashboard, sets secrets, and creates your admin account.
If setup finds existing resources matching the instance name, it warns you and asks before reusing them. Pass --reuse to skip that prompt.
#6. Sign in
Setup finishes by printing your dashboard URL, API URL, and admin credentials. Open the dashboard URL and sign in with the admin email and password you entered. Change your password in Settings → Profile after first login.
#What setup provisions
For an instance named my-blog, setup creates these resources on your account:
| Resource | Name | Purpose |
|---|---|---|
| D1 core database | my_blog_sveltesync |
System tables, collections, and metadata |
| D1 pool databases (5 by default) | my_blog_pool_0 … my_blog_pool_4 |
Data pools for your project databases |
| KV namespace | my_blog_policy_cache |
Access-policy caching |
| KV namespace | my_blog_session_cache |
Session caching |
| KV namespace | my_blog_lens_cache |
Lens caching |
| R2 bucket | my-blog-files |
Private file storage |
| R2 bucket | my-blog-public |
Public file storage |
| Worker | my-blog-api |
The API gateway worker |
| Dashboard Worker (or Pages project) | my-blog |
The dashboard (SvelteKit app) — see Deploy modes |
D1 and KV names use underscores because D1 database names don't allow hyphens; Workers, R2, and Pages names use hyphens.
Beyond the resources themselves, setup also:
- Applies every D1 migration from
migrations/to the core database - Creates a default project and seeds roles and permissions
- Writes the resource IDs into
workers/gateway/wrangler.toml,wrangler.jsonc, and.env - Sets six secrets on the API worker:
BETTER_AUTH_SECRET,BETTER_AUTH_URL,CLOUDFLARE_ACCOUNT_ID,CLOUDFLARE_API_TOKEN,SUPER_ADMIN_EMAIL, andTRUSTED_ORIGINS - Creates the super-admin user directly in D1
No state is stored anywhere else — instances are rediscovered from Cloudflare by their naming convention, so there's no local state file to lose.
#Command-line flags
| Flag | What it does |
|---|---|
--ci |
Non-interactive mode — reads answers from environment variables (CLOUDFLARE_ACCOUNT_ID, CLOUDFLARE_API_TOKEN, APP_NAME, ADMIN_EMAIL, ADMIN_NAME, ADMIN_PASSWORD, SIGNUP_MODE, BETTER_AUTH_SECRET) |
--dry-run |
Validate and preview resource names without creating anything |
--skip-deploy |
Provision resources and bootstrap data, but skip building and deploying (run npm run deploy:all later) |
--name <name> |
Pre-set the instance name |
--pools <n> |
Number of pool databases to create, 0–20 (default: 5) |
--reuse |
Allow reusing existing resources that match the instance name |
--verbose |
Show debug output |
#Email delivery
The short version: open Messages → Providers → Set up email. The guided setup connects a provider, proves it with a real test message, and walks you through DNS with live per-record checking — about five minutes from nothing to a delivered email. See Messages for the full walkthrough.
That applies to everything the platform sends, password resets included. The rest of this section documents the binding channels, which sit behind your configured providers as a fallback.
Every send — password reset, magic-link sign-in, Automate's send_email, and the messages API — walks the same ordered list:
- Configured providers, in the priority order you set in Messages → Providers, skipping any that is out of allowance, outside its sending hours, or in a cool-off after a recent failure.
EMAIL_SENDERservice binding — a worker you deploy that owns a real transport (Resend, Postmark, SES). The only binding that can mail arbitrary recipients without depending on Cloudflare account state.EMAILsend_email binding — uncomment the[[send_email]]block inworkers/gateway/wrangler.template.tomland redeploy. Requires Cloudflare Email Service on a zone in your account.- Cloudflare Email Service REST API — used automatically via the
CLOUDFLARE_ACCOUNT_IDandCLOUDFLARE_API_TOKENsecrets that setup already provisions. The zero-extra-config default, and the reason a fresh instance can mail its own verified addresses but nobody else.
Bindings always sit behind configured providers: a binding is whatever the deployment happens to have, and a provider is a deliberate choice.
Choosing is not failing over. Which transport carries a message is decided before anything is attempted, from local state only — so stepping past an exhausted provider is free and costs no failed send. But once a transport has been handed the message, a failure does not pass it to the next one, because a transport that accepts and then fails to report back would have the message delivered twice; for a password reset that is two working links. The single exception is a refusal at the door — a 4xx-class rejection proves nothing was accepted, so another transport may safely carry it. Anything ambiguous, including every 5xx and every timeout, is retried later instead, by which time the failed provider is in a cool-off and gets stepped over anyway.
For channels 2 and 3, set your verified sender in Settings → Email (email.from_address / email.from_name) — the platform default noreply@sveltesync.dev is a placeholder and will be rejected by Cloudflare for real deliveries.
#Per-channel capabilities
email_sender_binding |
email_binding |
rest_api |
|
|---|---|---|---|
| Arbitrary recipients | Yes | Only after a sending domain is onboarded to Cloudflare Email Service | Only after a sending domain is onboarded |
| Before domain onboarding | n/a | Verified Email Routing destination addresses only | Verified destinations only |
| API recipient ceiling | Set by your worker | 50 | 50 |
| Provider daily quota | Set by your worker / provider | Account-wide, unpublished, ramps with sending reputation | Same account quota as email_binding |
| EmuView daily quota | emails_per_day (default 200) |
emails_per_day |
emails_per_day |
email_binding and rest_api are the same Cloudflare product over two transports. They share one account-level quota — configuring both does not give you more capacity. Cloudflare does not publish a daily number; new accounts start conservative and scale with sending reputation, so treat the provider quota as unknown-and-variable and rely on the EmuView emails_per_day quota for predictability.
"API recipient ceiling" is not a batch size. It is what the upstream API would accept in one call. EmuView sends one message per recipient on every path, so mailing 50 people is 50 messages, 50 sends and 50 units of quota — the number above never divides a send or makes one cheaper. /health reports email_fan_out: "one_message_per_recipient" for the same reason.
#Detecting what will actually send
GET /health reports the resolved order, entry by entry:
{
"email_transports": [
{
"key": "resend",
"kind": "provider",
"priority": 0,
"available": false,
"reason": "rate_limited",
"usage": { "used": 200, "max": 200, "windowSeconds": 86400 }
},
{ "key": "smtp", "kind": "provider", "priority": 1, "available": true, "reason": null },
{ "key": "rest_api", "kind": "binding", "priority": 1000, "available": true, "reason": null }
],
"email_fan_out": "one_message_per_recipient",
"password_reset_available": true,
"email_channel": "rest_api",
"bindings": { "EMAIL": false, "EMAIL_SENDER": false }
}
Probe email_transports. The first entry with available: true is what carries the next message, and every other entry says why it was passed over — which is the difference between "mail is broken" and "Resend is 200/200 for the day and the mailbox takes over at midnight".
password_reset_available is the one-boolean version, and is what the forgot-password page reads so it can tell people to contact an administrator instead of promising a link that will not arrive.
email_channel and email_capabilities describe bindings only and are kept for compatibility. They cannot see your providers, so an instance sending perfectly well through Resend still reports email_channel: "rest_api" — setup.mjs sets the two CLOUDFLARE_* secrets on every install, so nearly every instance says that whether or not it means anything. It was a misleading field before providers existed and is more so now; treat it as "which binding exists", nothing more.
An empty email_transports means nothing is configured — password reset returns an error naming exactly which option to configure, and administrators get an in-app notification saying so. In local dev, reset and magic links are logged to the console instead.
#Proving it actually delivers
Configuration is not delivery, and /health can only report the former. Two ways to settle it:
- Messages → Providers → Test this provider — pinned to that one provider, so a success proves that provider works rather than proving something in the chain does.
- Settings → Email → Send test email — uses whatever the order would choose, which is exactly what a password reset does. This is the one that proves account recovery works.
The two answer different questions, and it is worth being deliberate about which you ran: a green provider test says nothing about account recovery if a different provider sits ahead of it.
A failure comes back named rather than generic — email_no_channel (nothing configured), email_no_capacity (configured, but everything is exhausted, asleep or unwell), email_recipient_not_allowed, email_rate_limited, email_send_failed — so the next step is readable off the result. Do this immediately after setting your sender; the alternative is discovering it when a locked-out user never receives a reset link.
Whichever route you take, every platform send is now recorded in Messages with a delivery timeline, so a failed password reset leaves evidence instead of silence.
The sender address and daily limit on that page write to the settings and quota the send path actually reads. (Before 2026-08-03 they wrote to a store nothing read, so editing them changed nothing — if you configured email before then and it never worked, re-save those fields.)
To mail arbitrary end users (forum notifications, digests, password resets for members who never verified a destination address), either onboard a sending domain to Cloudflare Email Service, or front a third-party provider with an EMAIL_SENDER service binding. send_email prefers EMAIL_SENDER automatically — no flow changes needed.
#Verifying the deploy
GET /health answers most of the post-deploy questions in one call. Two fields are worth checking explicitly, because neither shows up in ordinary use until something has already gone wrong.
Bindings — missing.required must be empty. Anything in missing.optional disables a feature (semantic search without AI, rate limiting without RATE_LIMITER) without erroring, so read it once rather than discovering it later.
The maintenance tick — your instance runs five maintenance tasks on an hourly cron trigger, including scheduled backups. A cron trigger is deployed configuration, separate from the code, and a Worker whose trigger is missing or whose handler is unreachable serves HTTP traffic perfectly while running none of them. Nothing in normal use will tell you.
Check it about an hour after deploying:
curl -s https://<your-instance>-api.<subdomain>.workers.dev/health | jq '.cron | {last_tick, stale, failing}'
{
"last_tick": "2026-07-27T03:00:04.812Z",
"stale": false,
"failing": []
}
A last_tick of null with "stale": true means no tick has ever landed — see Scheduled tasks aren't running. Immediately after a deploy, "stale": false with a null last_tick is expected: the check allows a grace window before the first tick is due.
Worth doing once at setup: add a Cloudflare notification for Workers cron trigger failures. It is the only alert that fires when the handler cannot run at all, and therefore cannot report its own failure.
#Deploy modes
The dashboard can be deployed two ways, and your instance is on exactly one of them:
| Mode | What it is | URL |
|---|---|---|
workers |
A Worker serving static assets | name.<subdomain>.workers.dev |
pages |
A Cloudflare Pages project | name.pages.dev |
New installs get workers. Pages cannot serve wildcard *.example.com
subdomains, which per-tenant hosting needs, and Workers static assets is where
Cloudflare is taking the platform.
Existing Pages instances stay on Pages. They are not migrated by an update.
The two are different Cloudflare resources: deploying the other kind would stand
up a second site at a new URL while your real one kept serving the old build,
and your TRUSTED_ORIGINS would still name the old address. Changing modes is a
deliberate move with DNS and origin consequences, so nothing does it for you.
How the tooling knows your mode, most authoritative first:
- Your Cloudflare account.
setupandupdateask whether a Pages project named after your instance exists — that survives fresh checkouts, CI runners, and--allruns over mixed fleets, none of which can trust a local file (npm installseedswrangler.jsoncfrom the template). - Your
wrangler.jsonc, when the account cannot be asked (a token without Pages permission) and the file is real instance config —pages_build_output_dirfor Pages,mainplusassetsfor Workers.npm run deploy:appandnpm run previewread this. - Nothing. When neither can answer, the tooling refuses and says why, rather than deploying the wrong kind of resource next to your live one.
To migrate an existing instance to workers:
- Run the update with an explicit mode:
node scripts/update.mjs --name my-blog --deploy-mode workers. This builds and deploys the dashboard as a Worker. (Editingwrangler.jsoncby hand is not enough — while your Pages project exists, the account probe keeps answering "pages".) - Add the Worker's URL to
TRUSTED_ORIGINSand point your DNS at it. Your Pages site keeps serving until you delete it, so verify the Worker first. - Delete the Pages project (
npx wrangler pages project delete my-blog). From then on the account probe answers "workers" and the--deploy-modeflag is no longer needed.
#Running more than one instance
Run setup again with a different --name to deploy another independent instance on the same account. Each instance gets its own databases, caches, buckets, API worker, and dashboard (a Worker, or a Pages project on pre-D3 instances) — nothing is shared.
A browser-based click-to-deploy portal (no CLI required) is planned but not yet available. For now, the setup wizard is the supported deployment path.
#What you learned
npm run setupprovisions and deploys a complete instance in one run- Setup creates D1 databases, KV namespaces, R2 buckets, an API worker, and a dashboard Worker (a Pages project on pre-D3 instances) — all named after your instance
- The wizard writes configuration files, sets worker secrets, and creates your admin account for you
- Instances are discovered from Cloudflare by naming convention — there's no local state file
#Next steps
- Upgrades — keep your instance up to date, manually or with a one-click update channel
- Rollback — understand what you can (and can't) revert if a deploy goes wrong
- Administration — users, roles, settings, monitoring, and backups for your new instance