Making Nitro Scheduled Tasks Work Everywhere: A Complete Guide
You've built your Nuxt app with a beautiful Nitro scheduled task. It works perfectly in development. You deploy to production and... nothing happens. Sound familiar?
I spent hours figuring out why my auto-unban cron job worked locally but failed in production. Here's everything I learned about making Nitro tasks work across every deployment platform.
The Problem
Nitro's scheduledTasks feature is amazing:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
scheduledTasks: {
'0 0 * * *': ['my-task']
}
}
})
But here's the catch: Nitro scheduled tasks only work in long-running server environments. They don't work on serverless platforms like Netlify, Vercel, or Cloudflare Pages because these platforms spin down between requests.
The Solution: Hybrid Scheduling
The key is separating your business logic from your scheduling mechanism. One codebase, multiple schedulers.
Architecture
Shared Logic (server/utils/cron)
↓
├→ Nitro Task (dev/VPS)
├→ API Endpoint (protected)
└→ Platform Schedulers (call endpoint)
Step-by-Step Implementation
1. Extract Your Business Logic
Never put logic directly in tasks. Create a reusable utility:
// server/utils/cron/scheduled-job.ts
export async function runMyScheduledJob() {
// Your actual logic here
const result = await doSomething()
return { success: true, data: result }
}
Why? You'll call this from multiple places.
2. Create the Nitro Task (Dev/VPS)
// server/tasks/my-job.ts
export default defineTask({
meta: {
name: 'my-job',
description: 'Does something important'
},
run: async () => {
const result = await runMyScheduledJob()
console.log('Task completed:', result)
return { result }
}
})
Enable in config:
// nuxt.config.ts
export default defineNuxtConfig({
nitro: {
experimental: {
tasks: true
},
scheduledTasks: {
'0 0 * * *': ['my-job']
}
}
})
Works on: Local dev, VPS, dedicated servers, Railway, Render
3. Create Protected API Endpoint
This is your bridge to serverless platforms:
// server/api/cron/my-job.post.ts
export default defineEventHandler(async (event) => {
// Security check
const authHeader = getHeader(event, 'authorization')
const cronSecret = useRuntimeConfig().cronSecret
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
throw createError({
status: 401,
message: 'Unauthorized'
})
}
// Same logic as Nitro task
const result = await runMyScheduledJob()
return result
})
Add to runtime config:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
cronSecret: '' // Set via NUXT_CRON_SECRET env var
}
})
Critical: Generate a strong secret:
# In your terminal
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
4. Platform-Specific Schedulers
Vercel
Create vercel.json:
{
"crons": [{
"path": "/api/cron/my-job",
"schedule": "0 0 * * *"
}]
}
Vercel automatically adds Authorization: Bearer ${CRON_SECRET} header. Just set the CRON_SECRET env var in your dashboard.
Pricing note: Requires Pro plan ($20/month).
Netlify
Create scheduled function:
// netlify/functions/scheduled-job.mts
import type { Config } from '@netlify/functions'
export default async () => {
// Netlify has `process.env.URL` as your deployed site URL
const response = await fetch(`${process.env.URL}/api/cron/my-job`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CRON_SECRET}`
}
})
if (!response.ok) {
throw new Error(`HTTP ${response.status}`)
}
console.log('Cron executed successfully with response:', await response.json())
}
export const config: Config = {
schedule: '0 0 * * *'
}
Set env vars:
SITE_URL: Your production URLCRON_SECRET: Your secret
Pricing note: Available on Pro plan ($19/month).
Cloudflare Pages
Use Cloudflare Workers Cron Triggers:
// functions/scheduled/my-job.ts
export const onRequest: PagesFunction = async (context) => {
// Verify it's actually Cloudflare calling
const cronHeader = context.request.headers.get('cf-cron')
if (!cronHeader) {
return new Response('Unauthorized', { status: 401 })
}
const response = await fetch(`${context.env.SITE_URL}/api/cron/my-job`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${context.env.CRON_SECRET}`
}
})
return response
}
Add to wrangler.toml:
[triggers]
crons = ["0 0 * * *"]
Railway / Render / VPS
These run long-lived servers, so Nitro tasks work out of the box. No extra setup needed!
Just deploy and the scheduledTasks config handles everything.
5. Environment Variables Checklist
Create .env.example:
# Required for API endpoint protection
NUXT_CRON_SECRET=
# Required for serverless platforms only
NUXT_PUBLIC_SITE_URL=
Production setup:
# All platforms
NUXT_CRON_SECRET=your-generated-secret
# Serverless only (Netlify, Vercel, Cloudflare)
NUXT_PUBLIC_SITE_URL=https://yourdomain.com
Testing Your Setup
Test Locally
# Terminal 1: Start dev server
npm run dev
# Terminal 2: Trigger manually
curl -X POST http://localhost:3000/api/cron/my-job \
-H "Authorization: Bearer your-secret"
Test Nitro Task
# Run task immediately
npx nuxi dev --task my-job
Test in Production
After deploying, trigger your endpoint:
curl -X POST https://yourdomain.com/api/cron/my-job \
-H "Authorization: Bearer your-secret"
Check logs on your platform's dashboard.
Debugging Tips
Task Not Running Locally?
- Verify
experimental.tasks: truein config - Check task file location:
server/tasks/ - Look for console output when server starts
- Run manually:
npx nuxi dev --task my-job
Task Not Running in Production?
- VPS/Railway/Render: Check server logs. Tasks should log when they run.
- Vercel: Check Functions logs. Verify Pro plan. Ensure
CRON_SECRETenv var set. - Netlify: Check Functions logs. Verify Pro plan. Check both
SITE_URLandCRON_SECRET. - All platforms: Test the endpoint manually with curl to verify logic works.
Getting 401 Errors?
- Check
CRON_SECRETenv var is set correctly - Verify no extra spaces in the secret
- Ensure
NUXT_CRON_SECRETin config, not justCRON_SECRET - For Vercel: secret must be
CRON_SECRETexactly (notNUXT_CRON_SECRET)
Real-World Example: Auto-Unban System
Here's how I implemented a user auto-unban system using this pattern:
// server/utils/cron/unban.ts
export async function processExpiredBans() {
const now = new Date()
const result = await db
.update(users)
.set({ ban_expires_at: null })
.where(lte(users.ban_expires_at, now))
return {
unbannedCount: result.rowsAffected,
timestamp: now
}
}
Benefits of this approach:
- ✅ Same unban logic everywhere
- ✅ Easy to test (just call the function)
- ✅ Works on any platform
- ✅ Can trigger manually via API if needed
- ✅ Logs show consistent results
Choosing Your Platform
| Platform | Nitro Tasks | Serverless Cron | Setup Complexity | Cost |
|---|---|---|---|---|
| Railway/Render | ✅ Native | N/A | Easy | ~$5-10/mo |
| VPS | ✅ Native | N/A | Medium | ~$5+/mo |
| Vercel | ❌ | ✅ Pro plan | Easy | $20/mo |
| Netlify | ❌ | ✅ Pro plan | Medium | $19/mo |
| Cloudflare | ❌ | ✅ Included | Hard | Free-$5/mo |
Bonus: Multiple Tasks
Running multiple scheduled jobs? Same pattern:
// nuxt.config.ts
scheduledTasks: {
'0 0 * * *': ['unban-users', 'cleanup-sessions'],
'0 */6 * * *': ['sync-data']
}
Create separate API endpoints:
/api/cron/unban-users
/api/cron/cleanup-sessions
/api/cron/sync-data
Each calls its respective util function. One secret protects all endpoints.
Wrapping Up
The key insight: Nitro tasks are great, but not universal. By extracting your logic and providing multiple scheduling mechanisms, you get:
- ✅ Write once, run anywhere
- ✅ Easy local testing
- ✅ Platform flexibility
- ✅ Manual triggering when needed
- ✅ Consistent behavior
Starter kit using this pattern: NuxtStart
Further reading:
From Zero to Production: Setting Up a Nuxt.js SaaS the Right Way
Learn how to build a production-ready Nuxt.js SaaS from scratch. Discover 8 critical systems you need, common pitfalls to avoid, and how to ship 3x faster with proven patterns.
Scalable Nuxt.js Webhook Handling: The Strategy Pattern
Learn how to implement scalable webhook handling in Nuxt.js using the Strategy Pattern to separate concerns and improve maintainability.

