Article·  

Making Nitro Scheduled Tasks Work Everywhere: A Complete Guide

Guide to implementing Nitro scheduled tasks across all deployment platforms including Vercel, Netlify, Cloudflare, Railway, and VPS setups.

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 URL
  • CRON_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?

  1. Verify experimental.tasks: true in config
  2. Check task file location: server/tasks/
  3. Look for console output when server starts
  4. Run manually: npx nuxi dev --task my-job

Task Not Running in Production?

  1. VPS/Railway/Render: Check server logs. Tasks should log when they run.
  2. Vercel: Check Functions logs. Verify Pro plan. Ensure CRON_SECRET env var set.
  3. Netlify: Check Functions logs. Verify Pro plan. Check both SITE_URL and CRON_SECRET.
  4. All platforms: Test the endpoint manually with curl to verify logic works.

Getting 401 Errors?

  • Check CRON_SECRET env var is set correctly
  • Verify no extra spaces in the secret
  • Ensure NUXT_CRON_SECRET in config, not just CRON_SECRET
  • For Vercel: secret must be CRON_SECRET exactly (not NUXT_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

PlatformNitro TasksServerless CronSetup ComplexityCost
Railway/Render✅ NativeN/AEasy~$5-10/mo
VPS✅ NativeN/AMedium~$5+/mo
Vercel✅ Pro planEasy$20/mo
Netlify✅ Pro planMedium$19/mo
Cloudflare✅ IncludedHardFree-$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:

Copyright © 2026