Back |Guide·

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

How to deploy Nuxt directly to Cloudflare Workers with wrangler.jsonc. Includes automated scripts for setup, migrations, and deployment. Works for new projects or migrating from NuxtHub.
NuxtCloudflareSelf-HostingDevOps
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

You'll also need a Cloudflare account, an API token (read all is enough for the extract), and your Account ID.

💡 Note: If this is your first time using Cloudflare Workers, the 5mb free tier limit might be restrictive for Nuxt apps. You might get an error about exceeding the size limit during deployment. Good news is that a single plan allows to deploy many apps, so you can share it across projects.

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:

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:

package.json
{
  "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 .env file
  • Automatically separates public vars from secrets
  • Updates wrangler.jsonc with 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:

wrangler.jsonc
/**
 * 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 dist instead 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?

Migrating to Cloudflare Workers or need help hosting your Nuxt site? We can help you set everything up, migrate your data, and get you deployed without the headaches.

Stay in the loop

Get the latest articles and updates delivered straight to your inbox.

Need a Landing Page?

Modern landing pages with optional modules (blog, docs, forms, i18n). Let's discuss your project.

Build Your MVP

Full-stack SaaS development. Expert in database design, multi-tenancy, and scalable architecture.

Deployment Help

Dockerize your backend, set up CI/CD pipelines, deploy to Cloudflare or Hetzner. Early-stage setup.

Suggest a SaaS Tool

Missing a calculator or tool? Suggest what you'd like to see on our site.