← all posts

Vercel Cron jobs not running on self-hosted Next.js (Docker, Coolify, VPS): 4 fixes

The crons in vercel.json do nothing outside Vercel, and nothing warns you. Four ways to run them on a self-hosted Next.js app, with the CRON_SECRET header, the instrumentation.ts double-run trap and Docker's HOSTNAME gotcha.

10 min read

You moved a Next.js app off Vercel, onto a VPS, a Docker host or Coolify. The pages render, the API works, vercel.json is still in the repo. Three weeks later someone asks why the expired sessions table has two million rows in it. The /api/cron/cleanup route is fine. It just hasn’t been called since the day you moved.

Outside Vercel, nothing reads vercel.json. We grepped the whole of Next.js 16.4.2’s dist folder for the filename and got no matches. next start doesn’t warn about it, and there is no log line for a job that was never called.

0mentions of vercel.json anywhere in Next.js 16.4.2's runtime
2×every job ran once a route imported the cron module too
UTCthe only timezone Vercel reads your schedules in

Short answer

Vercel Cron is a feature of Vercel’s platform, not of Next.js: a scheduler outside your app sends a GET to each cron path with Authorization: Bearer $CRON_SECRET. Self-hosted, you have to supply that scheduler. The usual choices are a host crontab line per job that curls the route, Coolify’s Scheduled Tasks if you’re on Coolify, a scheduler like node-cron started from instrumentation.ts, or an external pinger like cron-job.org. Whichever you pick, convert the schedules from UTC and make sure only one process fires each job.

If you want to keep vercel.json as the schedule and get a log of every run, RunWisp, an open-source cron replacement that ships as a single binary, reads it through its npm package @runwisp/js. Setup below.

Reads vercel.jsonRun historyFailure alertsRuns once with 2 replicasNothing new to run
Host crontab + curl✗✗✗✓✓
Coolify Scheduled Tasks✗✓✓✓✓
node-cron in instrumentation✗✗✗✗✓
External pinger (cron-job.org)✗✓✓✓✗
RunWisp scheduleVercelCrons()✓✓✓✗✗

“Runs once with 2 replicas” means two copies of the app behind one load balancer. Host cron and the pingers call the URL once, and one replica answers. Anything inside the app runs once per copy.

What Vercel was doing for you

The replacement only has to match what Vercel did, and Vercel did less than most people assume. From its docs:

  • It sends a GET to the path on your production deployment, with the user agent vercel-cron/1.0 and, if you set CRON_SECRET, Authorization: Bearer <secret>.
  • Schedules are UTC. On Hobby, 0 8 * * * can fire any time between 08:00 and 08:59.
  • A failed run is not retried. A run that takes longer than the interval can overlap the next one.
  • It doesn’t follow redirects: a 3xx ends the run.
  • Delivery is best effort, so a run can be missed or arrive twice.

The thing you actually lose is the Cron Jobs tab: a list of invocations with their logs, one click from the project. Every option below calls the route on schedule. Fewer of them tell you how it went.

Your route handler probably looks like Vercel’s example, and none of the fixes need you to change it:

// app/api/cron/cleanup/route.ts
export async function GET(request: Request) {
    const auth = request.headers.get("authorization");
    if (!process.env.CRON_SECRET || auth !== `Bearer ${process.env.CRON_SECRET}`) {
        return new Response("Unauthorized", { status: 401 });
    }
    const removed = await cleanupSessions();
    return Response.json({ removed });
}

1. Host crontab and curl

One line per entry in vercel.json, on the machine that runs the app:

# m h dom mon dow   (this box is in UTC; check with timedatectl)
0 * * * *  curl -fsS --max-time 300 -H @/etc/myapp/cron-auth http://127.0.0.1:3000/api/cron/cleanup

/etc/myapp/cron-auth holds one line, Authorization: Bearer <your CRON_SECRET>, and is chmod 600. curl -H @file reads headers from a file, which keeps the secret out of ps and out of a crontab that gets pasted into chat. Cron doesn’t load your .env, so $CRON_SECRET in the line would be empty anyway.

Four things to check:

The timezone moved. 0 3 * * * in vercel.json meant 03:00 UTC. In a crontab it means 03:00 on the server’s clock. If the box isn’t on UTC, convert the hour, and remember the offset changes twice a year.

-f doesn’t catch redirects. It fails on 4xx and 5xx only. If middleware bounces unauthenticated requests to /login with a 307, curl exits 0 and the job “succeeds” without running. Exclude /api/cron from the middleware’s matcher, or add -w '%{http_code}\n' and look at what comes back.

In Docker, localhost can be the wrong address. If the crontab is on the host, call the published port. If it runs inside the container against a standalone build, set HOSTNAME=0.0.0.0: Docker sets HOSTNAME to the container ID, server.js binds to it, and 127.0.0.1 inside the container gets connection refused. The official Next.js Dockerfile sets it for this reason.

The output goes nowhere. Cron mails it to a local mailbox nobody reads, so a 500 at 03:00 is silent. Append >> /var/log/myapp-cron.log 2>&1 at least. Where cron output goes and how to get alerted when a job fails cover the rest.

If you need no history, this is the right answer. It’s one line, there’s nothing to deploy, and with two replicas behind a proxy each job still runs once.

On Coolify: Scheduled Tasks

Coolify has this built in. Under your application, Scheduled Tasks runs a command inside the running container on a cron schedule, keeps recent executions with their output, and can notify you through the “Scheduled Task Failure” event on email, Discord, Telegram or Slack. The schedule uses the server’s timezone, not UTC.

The Node images Next.js usually ships on have no curl (node:*-slim has neither curl nor wget), so use Node itself:

node -e "fetch('http://127.0.0.1:3000/api/cron/cleanup',{headers:{authorization:'Bearer '+process.env.CRON_SECRET}}).then(async r=>{console.log(r.status,await r.text());process.exit(r.ok?0:1)})"

It reads CRON_SECRET from the container’s environment, prints the status and body, and exits 1 on anything but 2xx so Coolify marks the run failed. The HOSTNAME=0.0.0.0 note above applies here too. If you’re on Coolify and this is all you need, stop reading.

2. A scheduler inside the app, from instrumentation.ts

Next calls register() in instrumentation.ts once when the server starts, which makes it the hook people reach for. It runs in every runtime, so the Node-only import has to sit behind a NEXT_RUNTIME check:

// instrumentation.ts
export async function register() {
    if (process.env.NEXT_RUNTIME === "nodejs") await import("./lib/cron");
}
// lib/cron.ts
import cron from "node-cron";
import { cleanupSessions } from "./sessions";

cron.schedule(
    "0 * * * *",
    async () => {
        try {
            console.log(`removed ${await cleanupSessions()} expired sessions`);
        } catch (err) {
            console.error("cleanup-sessions failed", err);
        }
    },
    { timezone: "UTC", noOverlap: true },
);

The job calls the function directly, so there’s no HTTP and no secret, and the cron route can go. timezone: "UTC" keeps your vercel.json schedules meaning what they meant.

The double-run trap

Then one day a route needs to call cleanupSessions() too, and someone imports it from lib/cron.ts because that’s where it lives. We set this up on Next.js 16.4.2 with node-cron 4.6.0 and a five-second schedule, ran next start, and requested that route once:

✓ Ready in 51ms
[lib/cron] module loaded
[node-cron] tick 20:42:05.012Z pid=1171569
[node-cron] tick 20:42:10.025Z pid=1171569
[lib/cron] module loaded                      <- first request to the route
[node-cron] tick 20:42:15.003Z pid=1171569
[node-cron] tick 20:42:15.003Z pid=1171569
[node-cron] tick 20:42:20.006Z pid=1171569
[node-cron] tick 20:42:20.006Z pid=1171569

Same process, two schedulers. Next keeps separate module copies for instrumentation and for requests, so the route’s import ran cron.schedule() again. Nothing errors. Your cleanup just runs twice an hour, which is harmless until the job sends emails. (The module also loaded once during next build, while Next collected route data.)

Two fixes. Keep the scheduler in a module that only instrumentation.ts imports, with the job functions somewhere else. Or guard it with a flag on globalThis, which both copies share:

const g = globalThis as typeof globalThis & { cronStarted?: boolean };
if (!g.cronStarted) {
    g.cronStarted = true;
    cron.schedule(/* … */);
}

With the guard, the module loaded twice in the same test and ticked once.

What it still doesn’t do

One scheduler per process. Two replicas, PM2 cluster mode, or a blue-green deploy where old and new containers overlap for a minute: each one runs every job. That needs a lock (Redis, or a Postgres advisory lock), or the schedule has to move out of the web process. Preventing overlapping runs has the lock patterns.

No history. The output is mixed into your server logs, with no record of which runs happened.

Deploys cut runs short. next start exits right away on SIGTERM, so a job in progress when you deploy just stops. NEXT_MANUAL_SIG_HANDLE=true lets you handle the signal yourself.

For a single long-running server with a couple of idempotent jobs, that’s fine. It’s the most common setup for a reason.

3. An external pinger

Services like cron-job.org, Upstash QStash or a scheduled GitHub Actions workflow call a public URL on a schedule, which is what Vercel did. Send the Authorization header, keep the CRON_SECRET check, and you change nothing in the app.

They keep the history for you, and they run when your box is down, so a failure is a failed request and not silence. The trade-offs are that the route has to be reachable from the internet, a slow job can hit the service’s request timeout (cron-job.org’s free plan cuts requests off after about 30 seconds), and GitHub’s schedule: trigger runs at most every 5 minutes, is often late under load, and is switched off after 60 days without activity in a public repo.

4. Keep vercel.json and run it with RunWisp

RunWisp (our project, so weigh accordingly) is an open-source cron replacement that ships as a single binary, with a web UI that records every run. Since 1.7 it has an npm package, @runwisp/js, that starts it next to your app and reads your existing vercel.json.

npm install @runwisp/js
// next.config.ts
import type { NextConfig } from "next";
import { withRunWisp } from "@runwisp/js/next";

const nextConfig: NextConfig = {};

export default withRunWisp(nextConfig);
// lib/tasks.ts
import { RunWisp } from "@runwisp/js";
import { scheduleVercelCrons } from "@runwisp/js/next";
import vercel from "../vercel.json";

const runwisp = new RunWisp({ exitOnSignal: Boolean(process.env.NEXT_MANUAL_SIG_HANDLE) });
scheduleVercelCrons(runwisp, vercel);
// instrumentation.ts
export async function register() {
    if (process.env.NEXT_RUNTIME === "nodejs") await import("./lib/tasks");
}

Each entry in crons becomes a task named after its path, so /api/cron/cleanup is api-cron-cleanup. On schedule it requests the path on http://localhost:$PORT with vercel-cron/1.0 as the user agent and your CRON_SECRET as the bearer token, so the route handler above works unchanged. Schedules stay UTC, a missed time isn’t run late, and runs may overlap, as on Vercel. We ran it on the same Next.js 16.4.2 app with an every-minute entry:

✓ Ready in 50ms
RunWisp dashboard: http://127.0.0.1:9477, password: npx runwisp password --data .runwisp
[cleanup] hit at 2026-10-21T20:46:00.019Z ua=vercel-cron/1.0
{"removed":3}
[cleanup] hit at 2026-10-21T20:47:00.005Z ua=vercel-cron/1.0
{"removed":3}

The response body goes into the run’s log, and a status other than 2xx fails the run. We restarted the app without CRON_SECRET set, and the next run was recorded as a failure with the reason in it:

$ npx runwisp logs api-cron-cleanup
Unauthorized
Error: GET /api/cron/cleanup: 401 Unauthorized
[WARN] run failed task=api-cron-cleanup run=01M5G3H201DJYZ28KPK1JV829T exit=1 reason=failed dur=27ms

That’s the run list Vercel’s Cron Jobs tab gave you, plus a web UI at :9477 with a “run now” button, and alerts through Slack, Discord, Telegram, email or a webhook if you add a notifier. Copies of the app that share a data directory, like PM2 cluster workers, share one RunWisp, so each job fires once.

In Docker: set HOSTNAME=0.0.0.0 (see above), put the data directory (RUNWISP_DATA, default ./.runwisp) on a volume so the history survives a redeploy, and set RUNWISP_HOST=0.0.0.0 plus a RUNWISP_PASSWORD if you want to reach the dashboard from outside the container. Build the image on the platform you deploy to, since the binary is installed for the machine that runs npm install.

Later you can drop the HTTP hop and define tasks in code, with retries and timeouts per task. The Next.js guide covers both.

The limits:

  • It needs a server that keeps running. withRunWisp() fails the build on Vercel and with output: "export", where the tasks would never run.
  • It doesn’t cluster. Two servers, or two Docker replicas with their own data directories, each run every job. For that you want a lock or a single scheduler host.
  • It’s a daemon your app starts, with its own port and a data directory to keep. A crontab line is neither.
  • It’s not a job queue. If you need fan-out or per-item retries from code, use BullMQ or Inngest.

Which one to use

  • One server and you don’t care about history: a crontab line with curl.
  • On Coolify: its Scheduled Tasks. History and alerts are already there.
  • Want the schedule in your repo, one process, idempotent jobs: node-cron from instrumentation.ts, with the globalThis guard.
  • The box might be down when it matters, or you want someone else’s uptime: an external pinger.
  • Want vercel.json to stay the schedule, with a record of every run on your own box: RunWisp. The quick start covers the standalone daemon, and the code is on GitHub.

Common questions

Why are my vercel.json cron jobs not running?
Because outside Vercel nothing reads vercel.json. Vercel's platform has its own scheduler that sends a GET request to your production deployment on each schedule. next start, a standalone build, Docker, Coolify and every other host only run your server, so the crons section is ignored, with no warning and no log line. Your cron route handlers still work; nothing calls them. Vercel also doesn't run them under next dev or vercel dev.
How do I run Vercel cron jobs on a self-hosted Next.js app?
Something has to request each cron path on schedule. The simplest is one host crontab line per job: curl -fsS -H "Authorization: Bearer <secret>" http://127.0.0.1:3000/api/cron/cleanup. On Coolify, a Scheduled Task on the app does the same and keeps history. You can also start a scheduler like node-cron from instrumentation.ts behind a NEXT_RUNTIME === "nodejs" check, or have an external service like cron-job.org call a public URL.
Do I still need CRON_SECRET when self-hosting?
Only if the cron routes can be reached by someone else. Vercel sends CRON_SECRET as Authorization: Bearer <value>, and the usual route handler returns 401 without it. If your scheduler calls the route over HTTP, send the same header and keep the check. If you call the job's function directly from an in-process scheduler, no HTTP is involved and the route can go.
Why does my cron job run twice in Next.js instrumentation.ts?
Next loads separate copies of your modules for instrumentation.ts and for requests. If a route handler imports the module that calls cron.schedule() at the top level, the first request to that route loads it a second time and every job fires twice from the same process. We saw this on Next.js 16.4.2. Import the scheduler only from instrumentation.ts, or set a flag on globalThis before scheduling and check it first. Two replicas or PM2 cluster mode will still run it once per process.
What timezone do Vercel cron schedules use?
UTC, always. A host crontab uses the server's local timezone instead, so 0 3 * * * moves by your UTC offset when you copy it over unless the box is on UTC. node-cron uses the process timezone unless you pass { timezone: "UTC" }.
Is there a way to keep vercel.json as the schedule when self-hosting?
RunWisp, an open-source cron replacement that ships as a single binary, has an npm package, @runwisp/js, whose scheduleVercelCrons() reads the crons from vercel.json and calls each path on schedule with the CRON_SECRET header, in UTC, like Vercel. Every run is recorded with the response body, the status and how long it took, and a non-2xx response fails the run and can alert you. Its limits: it needs a server that stays up, so it refuses to build on Vercel or with output: "export"; it doesn't coordinate across several hosts, so two servers run each job twice; and it starts a daemon next to your app.
  1. Django periodic tasks without Celery or Redis: 5 Docker Compose setups

    You need clearsessions every night and a report every 15 minutes, and every tutorial adds Celery, beat and Redis. Five setups without them, each with a Compose file: a management command plus supercronic, APScheduler (and why it runs 3 times under gunicorn), Huey on SQLite, django-q2 on your database, and RunWisp.

  2. Laravel scheduler in Docker: schedule:run with cron, schedule:work or Supervisor?

    Four ways to run Laravel's scheduler in a container, tested on Laravel 12 and 13: which see your env vars, where the output goes, what docker stop does to a running task, and why schedule:run exits 0 when a task fails.

Get started
GPL 3.0 · no accounts · free to self-host

Start in 30 seconds. Ship on your terms.

A single Go binary. If it doesn't earn the disk space, rm it. No account, no signup, no catch.

Install in 30 seconds
curl -fsSL https://get.runwisp.com | sh