Skip to Content
Documentation

Cron

Cron

Add a recurring background job to a VitNode plugin with buildCron, register it on an API module, and pick what ticks the clock.

A cron job is a named handler with a cron expression, declared by a plugin. VitNode stores each job in the core_cron table, decides when it is due, and lists it in the AdminCP. In this guide the site-notes plugin gets a nightly job, and you run it once from the Debug Panel.

Before you begin

  • The site-notes plugin with its notes module and site_notes table, from the beginner path up to Database. Any plugin with a top-level API module works the same way.
  • Something that ticks the cron endpoint once a minute: the node-cron adapter on a long-lived server, or an external scheduler. Without one, jobs are registered but never run on their own. Run Job Now in the Debug Panel still works.

Add a cron job

Define the job

Create src/api/cron/count-notes.cron.ts. buildCron takes a name, a schedule and a handler that receives a Hono context:

plugins/site-notes/src/api/cron/count-notes.cron.ts
import { buildCron } from '@vitnode/core/api/lib/cron'
import { count } from 'drizzle-orm'

import { site_notes } from '@/database/notes'

export const countNotesCron = buildCron({
  name: 'count-notes',
  description: 'Log how many site notes exist, every night at 03:00',
  schedule: '0 3 * * *',
  handler: async c => {
    const [{ total }] = await c
      .get('db')
      .select({ total: count() })
      .from(site_notes)

    await c.get('log').debug(`site-notes holds ${total} notes`)
  },
})

c.get('log') has three levels: debug, warn and error. See Logging.

Register it on a top-level module

Add the job to the cronJobs array of a module passed to buildApiPlugin({ modules }):

plugins/site-notes/src/api/modules/notes/notes.module.ts
import { buildModule } from '@/api/builders'
import { countNotesCron } from '@/api/cron/count-notes.cron'

import { listNotesRoute } from './routes/list.route'
import { showNoteRoute } from './routes/show.route'

export const notesModule = buildModule({
  name: 'notes',
  routes: [listNotesRoute, showNoteRoute],
  cronJobs: [countNotesCron], 
})

Keep any other keys your module already has, such as queueTasks or events.

Only top-level modules are read. A job on a nested module, such as admin/notes, is never registered: buildApiPlugin throws in development to say so, and vitnode doctor reports it.

Restart the dev server

The API collects cron jobs when it starts and writes any new one to core_cron, so restart pnpm dev after adding or renaming a job. The row is keyed by plugin id, module and name, so renaming a job creates a new row and removes the old one.

Check the result

  1. Open Debug Panel → Cron & queue (/admin/core/debug/cron). The table has a count-notes row with plugin site-notes and the status Waiting for first run.
  2. Press the play button on that row (Run Job Now).
  3. Open Debug Panel → Log explorer (/admin/core/debug/logs). A new debug entry reads site-notes holds 3 notes (or however many you have). The entry's plugin is @vitnode/core, for the reason explained in the next section.

What a job's context gives you

A job runs inside core's cron request, not inside one of your routes:

  • c.get('db'), c.get('log'), c.get('queue') and c.get('cache') work as in a route. There is no signed-in user.
  • c.get('plugin') is @vitnode/core, so logs are tagged with core. A queue task still belongs to the plugin that defines it, so c.get('queue').dispatch(announceNoteTask, { noteId }) stores the row under site-notes, and a cache entry is stored under the plugin that defines it. An event emitted here records @vitnode/core as its emitter unless you pass { pluginId: manifest.id } to emit().
  • All jobs that are due on a tick run at the same time.
  • The schedule is only as precise as the tick. With a tick every minute, */10 * * * * * (every 10 seconds) still runs once a minute.

Choose what ticks the clock

The API runs no timer by default. Every cron run starts with a POST /api/@vitnode/core/cron that carries Authorization: Bearer <CRON_SECRET>, and that endpoint runs whatever is due.

OptionUse it whenGuide
@vitnode/node-cronYour API is a long-lived Node process (self-hosted, Docker)node-cron
External schedulerServerless hosting, or one scheduler for many instancesCron over REST
Your own adapterYou already run a job runner or need a different timerCustom adapter

Pick one. Two tickers do no harm, since each due run is claimed once, but they double the requests for nothing.

Set CRON_SECRET in production

Without CRON_SECRET, VitNode uses a built-in default. That works in development, so a fresh checkout ticks. Outside development the default and common placeholders (changeme, secret) are refused with 403, and no job runs, the queue included.

Manage cron jobs in the Debug Panel

Open Debug Panel → Cron & queue in the AdminCP (/admin/core/debug/cron), or type "Cron jobs" into the ⌘K admin search. Viewing it needs the cron → can_view staff permission.

  • The table lists every job with its Name, Plugin and Time: the schedule, a status badge, and the last and next run. It is sorted by next run, with a search box to find a job.
  • The play button on a row (Run Job Now) runs that job on demand. It needs the cron → can_run permission.

Health warnings

Cron fails quietly by nature: nobody notices a job that didn't run. So VitNode watches for it.

A job is overdue when its next scheduled run is more than 15 minutes in the past. Never-run jobs count from the moment they were registered.

The scheduler card at the top of the section sums it up:

What you seeWhat it means
Red Scheduler stoppedA job is overdue and nothing has run since. The scheduler is stopped or misconfigured, and the queue isn't draining either.
Amber N cron jobs are overdueThe scheduler ticks, but some jobs aren't finishing. A job that throws keeps its last successful run, so look for its error in the server logs.
Green All jobs on scheduleEverything ran on time. Go make a coffee.

Next to it you get the last run, the next run and how many jobs are registered. The table below lists every job with an Overdue, On schedule or Waiting for first run status.

The same check powers the Debug Panel Overview and the Cron Jobs and Queue Tasks cards at Core → System → Integrations.

Jobs must be idempotent

A job can run more often than its schedule suggests, so write handlers that are safe to run twice:

  • A failed run is retried. When a handler throws, VitNode hands the run back and the next tick runs it again, including whatever the first attempt already did.
  • Runs can overlap. Each due run is claimed by exactly one tick, even with several instances, but nothing waits for the previous run to finish. A job scheduled every minute that takes three minutes runs alongside itself.
  • A new job is not claimed yet on the very first tick after it is registered, so every instance that ticks at that moment runs it.

Record what you processed and skip it next time, for example with a processedAt column or a queue task with a dedupeKey. Move slow work into the queue. A cron handler should take seconds, not minutes.

buildCron options

Prop

Type