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.
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.json | Run history | Failure alerts | Runs once with 2 replicas | Nothing 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
GETto the path on your production deployment, with the user agentvercel-cron/1.0and, if you setCRON_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 withoutput: "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 theglobalThisguard. - The box might be down when it matters, or you want someone else’s uptime: an external pinger.
- Want
vercel.jsonto 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?
How do I run Vercel cron jobs on a self-hosted Next.js app?
Do I still need CRON_SECRET when self-hosting?
Why does my cron job run twice in Next.js instrumentation.ts?
What timezone do Vercel cron schedules use?
Is there a way to keep vercel.json as the schedule when self-hosting?
Read next
-
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.
-
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.