Self-Hosting Nuxt on Cloudflare Workers: Setup & Migration Guide

Why Self-Host on Cloudflare?
If you're deploying a Nuxt app to Cloudflare Workers, you have options. You can use NuxtHub (which provides a nice abstraction layer), or you can go direct with Wrangler.
Note: NuxtHub Admin is shutting down at the end of December 2025. The CLI and GitHub Actions are getting deprecated too. If you're currently using NuxtHub, you'll need to either switch to direct Cloudflare deployment even if you still use NuxtHub's self-hosted option.
I recently switched to direct Cloudflare deployment for my projects. Took some time to set up, and I've been happy with it after all. It was just the lack of good guides that made it harder.
This guide covers both scenarios: setting up a fresh project, or migrating from NuxtHub. I've written some scripts to handle the boring parts automatically.
Prerequisites
Install wrangler and nitro-cloudflare-dev:
pnpm add -D wrangler nitro-cloudflare-dev
npm install -D wrangler nitro-cloudflare-dev
You'll also need a Cloudflare account, an API token (read all is enough for the extract), and your Account ID.
Migrating an Existing App
If you're already running on NuxtHub, the extraction script pulls your config from Cloudflare automatically.
Run it:
pnpm run cf:extract
It lists your workers, you pick one, and it generates wrangler.jsonc with all your bindings (D1, KV, etc.). The full script is at the bottom of this post.
Now update nuxt.config.ts:
export default defineNuxtConfig({
modules: [
'@nuxtjs/tailwindcss',
// '@nuxthub/core', // ❌ Remove this
'@nuxt/ui',
// ... other modules
],
// Remove hub configuration
// hub: { ... }, // ❌ Remove this entire section
nitro: {
preset: 'cloudflare_module',
compatibility_flags: ['nodejs_compat'],
cloudflare: {
deployConfig: true,
nodeCompat: true,
wrangler: {
triggers: {
crons: ['*/2 * * * *'] // Nitro adds these during build
}
}
}
}
})
Change how you access D1 and KV. NuxtHub used hubDatabase() and hubKV(). Now you access them directly:
// Instead of hubDatabase()
const DB = process.env.DB || globalThis.__env__?.DB || globalThis.DB
return drizzle(DB, { schema })
// Instead of hubKV()
const KV = process.env.KV || globalThis.__env__?.KV || globalThis.KV
await KV.get('my-key')
The weird triple-check is because Cloudflare exposes bindings in different places depending on the environment. This pattern works everywhere.
Update your package.json scripts:
{
"scripts": {
"dev": "nuxt dev",
"build": "nuxi build",
"preview": "pnpm run build && wrangler dev",
"preview:only": "wrangler dev",
"preview:local": "wrangler dev --local --persist-to .wrangler/state",
"db:generate": "drizzle-kit generate",
"db:migrate": "wrangler d1 migrations apply my-db --remote",
"db:migrate:local": "wrangler d1 migrations apply my-db --local",
"db:seed": "node scripts/seed-database.mjs --remote",
"db:seed:local": "node scripts/seed-database.mjs --local",
"db:seed:all:local": "node scripts/seed-database.mjs --local --all",
"deploy": "pnpm run build && wrangler deploy",
"deploy:nobuild": "wrangler deploy",
"cf:extract": "node scripts/extract-worker-config.mjs",
"cf:setup": "node scripts/setup-cloudflare.mjs",
"cf:typegen": "wrangler types",
"env:sync": "node scripts/sync-env-to-wrangler.mjs"
}
}
Starting Fresh (No Existing NuxtHub App)
If you're setting up from scratch, run the setup script:
pnpm run cf:setup
It asks you to pick a D1 region (choose the one closest to your users), then creates:
- D1 database
- KV namespaces (production + preview)
- Analytics Engine dataset
You need to update wrangler.jsonc manually with the binding IDs.
For local development, create a .dev.vars file:
# Copy from .dev.vars.example
# Used by `wrangler dev` for local development
BETTER_AUTH_SECRET=your-secret-key-change-in-production
BETTER_AUTH_URL=http://localhost:3000
NUXT_TURNSTILE_SECRET_KEY=your-turnstile-secret-key
NODE_ENV=development
For production secrets, use wrangler:
wrangler secret put BETTER_AUTH_SECRET
wrangler secret put NUXT_TURNSTILE_SECRET_KEY
Environment Variable Sync Helper
Managing environment variables between .env and wrangler.jsonc can be tedious. I created a helper script that automates this:
pnpm run env:sync
This script:
- Reads your
.envfile - Automatically separates public vars from secrets
- Updates
wrangler.jsoncwith public variables - Prints wrangler CLI commands for secrets
Example output:
✅ Adding: BETTER_AUTH_URL
✅ Adding: GOOGLE_CLIENT_ID
🔐 Secret detected: BETTER_AUTH_SECRET
🔐 Secret detected: GOOGLE_CLIENT_SECRET
✅ wrangler.jsonc updated successfully!
Added 2 public variables
🔐 SECRETS DETECTED - Add manually using wrangler CLI:
wrangler secret put BETTER_AUTH_SECRET
# Enter value when prompted
wrangler secret put GOOGLE_CLIENT_SECRET
# Enter value when prompted
This saves time and ensures you never accidentally commit secrets to wrangler.jsonc. The script is included in the complete code section below.
Database Migrations and Seeding
Run migrations:
pnpm run db:migrate:local # local
pnpm run db:migrate # production
The seeding script I wrote lets you pick which seeders to run:
pnpm run db:seed:local
It shows you all available seeders, you type the numbers you want (like 1 3), and it runs them. Or type all to run everything.
Development and Deployment
For development:
pnpm run dev # Standard Nuxt dev server
pnpm run preview:local # Local Workers environment
For deployment:
pnpm run deploy # Build + deploy
pnpm run deploy:nobuild # Just deploy (if already built)
Auto-Deployment Setup
You don't need GitHub Actions. Cloudflare has auto-deployment built in.
Go to Workers & Pages in Cloudflare Dashboard → Settings → Builds & Deployments → Connect to Git.
Pick your repo, set build command to pnpm run build, output directory to .output, and you're done.
Now every push to main automatically builds and deploys. PRs get preview deployments. No YAML configs needed.
File Structure
my-nuxt-app/
├── scripts/
│ ├── setup-cloudflare.mjs # Creates resources
│ ├── extract-worker-config.mjs # Pulls from existing worker
│ ├── seed-database.mjs # Interactive seeding
│ └── sync-env-to-wrangler.mjs # Syncs .env to wrangler.jsonc
├── server/database/
│ ├── migrations/
│ └── schema.ts
├── seeders/ # SQL seed files
├── .dev.vars # Local secrets (gitignored)
├── .env # Environment variables
├── nuxt.config.ts
├── package.json
└── wrangler.jsonc # Cloudflare config
Complete Code
All the scripts and config files:
/**
* Cloudflare Workers Configuration
* Direct Cloudflare setup with Wrangler
*/
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "my-nuxt-app",
"main": "./.output/server/index.mjs",
"compatibility_date": "2025-10-30",
"compatibility_flags": ["nodejs_compat"],
/**
* Static Assets Binding
*/
"assets": {
"binding": "ASSETS",
"directory": "./.output/public/"
},
/**
* Observability & Analytics
*/
"observability": {
"enabled": true
},
/**
* D1 Database Binding
* To create: wrangler d1 create my-database --location=weur
*/
"d1_databases": [
{
"binding": "DB",
"database_name": "my-database",
"database_id": "YOUR_D1_DATABASE_ID_HERE",
"migrations_dir": "./server/database/migrations"
}
],
/**
* KV Namespace Binding
* To create: wrangler kv namespace create my-kv
* To create preview: wrangler kv namespace create my-kv --preview
*/
"kv_namespaces": [
{
"binding": "KV",
"id": "YOUR_KV_NAMESPACE_ID_HERE",
"preview_id": "YOUR_PREVIEW_KV_NAMESPACE_ID_HERE"
}
],
/**
* Cron Triggers
* Note: Nitro needs this in nuxt.config.ts as well
* in nuxt.config.ts > nitro.cloudflare.wrangler.triggers
*/
"triggers": {
"crons": ["*/2 * * * *"]
},
/**
* Environment Variables
*/
"vars": {
"NODE_ENV": "production"
}
}
Troubleshooting
Build outputting to dist instead of .output?
Remove @nuxthub/core from your modules, delete dist and .nuxt folders, rebuild.
D1 Database binding not found?
Check wrangler.jsonc has the right database_id and binding name is "DB". For local dev, use pnpm run preview:local.
KV binding not found?
Make sure wrangler.jsonc has id and preview_id set. Binding name should be "KV".
Getting 500 errors instead of 401/403?
Your error handler needs to call setResponseStatus(event, statusCode) to set the proper HTTP status.
Wrapping Up
After going through this migration myself, I am happy, because at least there is no a 3rd party dependency and it is free no matter how many websites I am deploying. The scripts I wrote (setup, extraction, seeding) handle all the tedious parts, and you get better control over your deployment.
Some notes:
- That triple-check pattern for bindings (
process.env.DB || globalThis.__env__?.DB || globalThis.DB) looks weird but it's necessary for different Cloudflare environments - If your keep using nuxthub, the build outputs to
distinstead of.output, you may need to adjust scripts accordingly
We hope this guide makes your migration to Cloudflare Workers as smooth as possible.
Need Help with Your Migration?
What This Blog Is About (And Why You Might Care)
Building SaaS products, deploying on a budget, and the stuff I wish someone had told me when I started. Docker, Nuxt, Django, and how to ship without burning cash.
Nuxt Content 404 issues? One config can fix it
Enabled prerendering for optimization. Pushed to production. Suddenly receive weird 404 error. One config line fixes it - autoSubfolderIndex
Stay in the loop
Get the latest articles and updates delivered straight to your inbox.