Cron Jobs

Cron over REST

Trigger VitNode's cron endpoint from an external scheduler - the URL, the CRON_SECRET bearer token, a curl call, and what the response means.

Every cron run in VitNode is one HTTP request, so the simplest adapter is no adapter at all: point something at the endpoint once a minute. That something can be GitHub Actions, a managed scheduler, a Kubernetes CronJob or a systemd timer. Because it lives outside your app, this is also the only option that works on serverless hosting, where nothing stays alive long enough to hold a timer.

Cloud (serverless)Self-hosted
✅ Supported✅ Supported

Quick start

curl -X POST https://example.com/api/@vitnode/core/cron \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $CRON_SECRET"
Cron jobs processed. Executed 2 jobs.

Both headers are load-bearing. The Authorization one is the endpoint's only auth; the Content-Type one gets you past CSRF protection, which treats a POST with no content type as a form submission and answers 403 Forbidden.

Set it up

Set a real CRON_SECRET

The endpoint compares the bearer token against CRON_SECRET and refuses anything else with a 403. Leave the variable unset and VitNode falls back to default-cron-secret-change-in-production - a value published in this documentation, which makes the endpoint effectively open to anyone who can guess your domain.

.env
CRON_SECRET=b7c1f0e2d9a84c53b6e1f7a02d48c9e5

This is the one secret an external scheduler carries

Use at least 16 random characters, and store it in your scheduler as a secret rather than in a URL. Do not expect the AdminCP to nag you about it: the Integrations screen only reports the default secret once a cron adapter is registered in the API config, and an external scheduler registers none - so the cron card there says "No cron adapter configured" and stops. On this deployment shape, remembering the secret is on you.

Point a scheduler at the endpoint

Once a minute, whatever the schedules on your jobs say. The endpoint runs what is due at the moment it is called, so the tick interval is the finest resolution any job can have - and core's own process-queue job is * * * * *, so a slower tick means queue tasks sit around waiting.

Your scheduler needs three things, and this is where providers differ:

RequirementWhy
POSTthe route declares method: "post" and nothing else
a custom Authorization headerthe bearer token is the whole of the auth
a custom Content-Type headerapplication/json, or CSRF protection rejects the request

A GitHub Actions workflow is the version with no account to create:

.github/workflows/cron.yml
name: VitNode cron
on:
  schedule:
    - cron: '*/5 * * * *'
  workflow_dispatch:

jobs:
  tick:
    runs-on: ubuntu-latest
    steps:
      - run: |
          curl --fail-with-body -X POST "$API_URL/api/@vitnode/core/cron" \
            -H "Content-Type: application/json" \
            -H "Authorization: Bearer $CRON_SECRET"
        env:
          API_URL: ${{ vars.API_URL }}
          CRON_SECRET: ${{ secrets.CRON_SECRET }}

Five minutes, not one: GitHub's shortest schedule interval is five minutes, and runs on a busy shared queue are often later than that. It is a fine way to keep daily jobs honest and a poor way to drain a queue.

Verify it from the AdminCP

Send the request by hand once. A 200 with a job count means the endpoint accepted you; open /admin/core/advanced/cron and the rows you expected to run now show a Last Run of a moment ago.

Then leave the scheduler to it and check back. Last Run drifting behind a job's schedule is the symptom of a tick that is not arriving, and it is the only signal that reflects an external scheduler - see the gotcha about the Integrations screen below.

Endpoint reference

PropertyValue
MethodPOST
Path/api/@vitnode/core/cron
AuthAuthorization: Bearer $CRON_SECRET - the prefix is stripped before comparing, so a bare secret matches too
Bodynone
Calling it twicesafe: the second call runs only what has become due since the first
ResponseBodyMeans
200Cron jobs processed. Executed N jobs.the tick completed; N counts the jobs whose handler did not throw
403ForbiddenCSRF: a POST whose content type is missing, text/plain or a form type, with no trusted Origin
403Invalid cron authorizationthe bearer token does not match CRON_SECRET
500Error processing cron jobsthe bookkeeping itself failed - usually the database

Gotchas

Vercel Cron Jobs cannot call this endpoint

Vercel triggers a cron path with a GET; this route only answers POST, so a vercel.json entry pointed straight at it never runs a job. Use a scheduler that can send a POST with headers - the Vercel deployment guide says the same thing from the other side.

A 200 does not mean every job succeeded

A handler that throws is logged and skipped - the tick still answers 200, and the failed job is simply missing from the count. Worse, its lastRun is not written, so it is due again on the next tick. Read the failures in the Debug Panel; the response only tells you the endpoint was reached.

Ticking from two places doubles the ticks

An external scheduler and the Node CRON adapter are alternatives, not layers. Configure both and two callers race for the same due jobs, which is the situation idempotent handlers exist for.

The Integrations screen still calls cron inactive

/admin/core/system/integrations reports cron as active when the API config registers a cron adapter - it is reading the config, not counting ticks. Tick from outside and that card stays grey however healthy the schedule is, and the queue card with it, because the queue's status is derived from cron's. The honest signal for an external scheduler is the Last Run column on /admin/core/advanced/cron.

The endpoint is public in the sense that it answers

There is no allowlist and no signature - a correct bearer token is the whole gate, and the route is mounted on your public API alongside everything else. If your platform can restrict the path to your scheduler's egress addresses, that is a second lock worth having.

Next