Skip to content
tecminds

Vercel Cron Jobs Not Running — Production-Only Deploys, CRON_SECRET 401s, trailingSlash 308s, and Middleware That Steals the Hit

We shipped a vercel.json cron to kick a nightly sweep on a Coolify-plus-Vercel Swiss SME stack. The Cron Jobs overview showed invocations. The job table did not move. Preview never fires, a CRON_SECRET newline 401s, trailingSlash 308s are final, and Auth.js middleware or Deployment Protection can steal the GET before the Route Handler. This is the field note.

TTobias LüscherCo‑Founder · TecMinds2026-09-10 · 15 min read

Vercel Cron Jobs Not Running — Production-Only Deploys, CRON_SECRET 401s, trailingSlash 308s, and Middleware That Steals the Hit

The most expensive scheduled job is the one the platform already marked as invoked. We shipped a vercel.json cron on a quiet afternoon — the Coolify-plus-Vercel hybrid we already run for Swiss SME products like Acurio — to GET /api/cron/sweep at 0 5 * * * and kick the backstop sweep that closes stale job rows the browser missed. The new Production deploy came up green. Project → Settings → Cron Jobs listed the schedule and a next run. The next morning the overview showed invocations. The job table did not move. Support opened it as "the nightly sweep is dead; the worker stalled." We spent the first hour in Coolify worker logs and pg_stat_activity. The Bun process was idle because nobody had asked it to work. The GET never reached the Route Handler. This is the writeup of Vercel Cron Jobs not running — Production-only deploys, CRON_SECRET Bearer 401s, a trailingSlash 308 the scheduler will not follow, Auth.js middleware that steals the hit, Deployment Protection that treats the cron as a stranger, UTC-only Hobby timing, and a maxDuration kill that looks like a successful invoke.

The stack is the one we already split once. Long-lived Coolify holds Postgres and the worker. Vercel holds the Next.js App Router and, after this incident, the clock that is allowed to poke it. The cron path looks like any other app/api/cron/sweep/route.ts. The invoke is not a browser. It is a platform GET that does not follow redirects, does not hold a session cookie, and does not retry a 308. A local curl that "works" is not that GET.

The Invocation That Never Became a GET

Vercel Cron Jobs run on the current Production deployment only. Preview never fires. A PR deploy that lists the same crons key in vercel.json will not invoke /api/cron/sweep at 05:00, will not write an invocation row you can trust, and will not prove the handler. We tested the schedule on a preview URL because that is where we test everything else. The cron overview stayed empty. We treated that as "the expression is wrong" and edited schedule three times. The expression was fine. The deployment was not Production.

The dashboard is the second costume. If Settings → Cron Jobs does not list the job with a next run, the crons config never landed in a Production deploy — a vercel.json edit on a branch, a cron added only in next.config, or a build that never wrote crons into .vercel/output/config.json. If the job is listed and the overview shows invocations, that is not "the sweep ran." That is "Vercel issued a GET." A 404 (the path does not exist on that deployment), a 401, a 308, a 307 to /login, or a function timeout all count as an invocation. The warmup-ping note already named the cousin: green logs on a layer that was never the one you needed. Here the green box is the cron overview. The origin handler is the layer that did not run.

There is no vercel dev / next dev scheduler. Locally you GET the route yourself. Your browser will follow a trailing-slash redirect and attach a session cookie. Production cron will do neither. The local 200 is how we closed the ticket the first time.

{
  "crons": [
    {
      "path": "/api/cron/sweep",
      "schedule": "0 5 * * *"
    }
  ]
}

That path is the URL Vercel will request, character for character. It is not "the folder under app/api." Change the route and forget this string, and the cron still invokes — against a path that 404s. Vercel will happily schedule a GET to a URL that does not exist.

The 401 That Looked Like "Cron Is Broken"

If the project has a CRON_SECRET environment variable, Vercel sends Authorization: Bearer <CRON_SECRET> on the invoke. The Route Handler must verify it. That is the right default. It is also how a cron that "runs" in the overview dies as 401 before any sweep work.

The secret is a header value. A trailing newline from a password-manager paste, a wrapping quote you can see in the Vercel UI only after you click Reveal, or a character the Authorization header will not carry, and the comparison fails. Vercel docs say this in one sentence: no invalid, newline, or special characters that cannot live in an authorization header. We pasted a 1Password password that ended with a line break. The env UI looked fine. The Bearer Vercel sent and the process.env.CRON_SECRET the function read were not the same string. Every invoke was 401. The handler logged nothing useful because we logged only after the check.

Env changes do not attach to the running Production deployment. Add or rotate CRON_SECRET, then redeploy. We rotated, hit Run in the cron UI, and got the same 401 for twenty minutes because the isolate still had the old value — or no value, so Bearer ${undefined} was the string "Bearer undefined". Preview can have the secret and Production can lack it, or the reverse, if the env scope is "Preview" only. Cron never hits Preview. The Production scope is the only one that matters, and it matters only after a Production deploy.

import { NextResponse } from "next/server";

export const dynamic = "force-dynamic";
export const maxDuration = 60;

function unauthorized() {
  return new Response("Unauthorized", { status: 401 });
}

export async function GET(request: Request) {
  const secret = process.env.CRON_SECRET;
  const header = request.headers.get("authorization");

  if (!secret || header !== `Bearer ${secret}`) {
    console.info("cron.unauthorized", {
      hasSecret: Boolean(secret),
      hasHeader: Boolean(header),
    });
    return unauthorized();
  }

  // kick the sweep; do not hide a 12-minute job behind this GET
  const result = await enqueueSweep();
  return NextResponse.json({ ok: true, result });
}

Do not trim() in the handler and leave the dashboard dirty. If you trim only on read, Vercel still sends the raw env in the Bearer and you 401 forever. Clean the value in the project env, redeploy, then compare exact strings. A timing-safe compare is fine; a startsWith("Bearer") with no secret check is how the route becomes public the week someone "temporarily" comments the equality.

export const dynamic = 'force-dynamic' belongs here for the boring cousin: a cached 200 with no logs looks like a cron that ran and did nothing. The official troubleshooting note calls this out. We hit it once after a "successful" invoke that never printed cron.unauthorized or enqueueSweep because the platform served a memoized empty JSON.

The 308 trailingSlash Does Not Follow

Cron invocations do not follow redirects. A 3xx is a finished job. Your browser will follow http://localhost:3000/api/cron/sweep/api/cron/sweep/ and show you a 200. Production cron will accept the 308 and stop. That is the whole bug, and it is the one the Vercel cron troubleshooting guide spells out under "Cron jobs and redirects."

trailingSlash: true in next.config is how we bought it. Marketing wanted slash-canonical URLs. Next started 308ing every path that lacked the slash. vercel.json still said "/api/cron/sweep". The cron overview went green. The function log for the handler stayed empty. The platform log showed 308. We did not look at the status column for an hour because the overview does not shout "redirect, not handler."

The fix is one character, in the string the scheduler actually requests:

{
  "crons": [
    {
      "path": "/api/cron/sweep/",
      "schedule": "0 5 * * *"
    }
  ]
}

Or turn trailingSlash off for the app, or exempt /api. What you must not do is "test it in the browser" and call the path correct. The browser is a client that follows 308s. Vercel Cron is not. Same trap as a middleware NextResponse.redirect to /login or to the locale-prefixed /en/api/cron/sweep. The scheduler will not chase it.

A locale prefix is the other 308 we have seen. next-intl / a [locale] segment that 308s /api/cron/sweep to /de/api/cron/sweep will kill the invoke the same way. Put the cron route outside the locale tree, or put the final URL — slash, locale, and all — in vercel.json.

Middleware That Steals the Hit

Global middleware.ts runs before the Route Handler. Auth.js / NextAuth wrappers that send anonymous requests to /login treat the cron GET as a logged-out visitor. The invoke becomes a 307 or 308. The handler never runs. The Auth.js passwordChangedAt note already taught us that session failures love a costume. That one bounced real users. This one bounces a platform GET that will never present a session cookie.

The matcher is the usual miss. A matcher that skips api looks safe until someone tightens it so /api/* is rate-limited or "protected too." An allowlist that checks pathname === "/api/cron" misses /api/cron/sweep and /api/cron/sweep/. A check that only reads x-vercel-cron and then next() without a secret check in the handler leaves the URL public.

import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { auth } from "@/auth";

const CRON_PREFIX = "/api/cron";

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;
  const isCron =
    pathname === CRON_PREFIX ||
    pathname.startsWith(`${CRON_PREFIX}/`);

  if (isCron) {
    // do not redirect; the handler still verifies Bearer CRON_SECRET
    return NextResponse.next();
  }

  const session = await auth();
  if (!session) {
    const login = new URL("/login", request.url);
    return NextResponse.redirect(login);
  }

  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

The exemption is not the auth. The Route Handler still checks Authorization. Middleware that "just lets cron through" without a handler check is an unauthenticated write endpoint on the public internet. Middleware that redirects cron is a silent 3xx. You want the first without the second.

auth() in middleware has the same Next 15 costume as the cookies/params await note: a throw or a blanket redirect that looks like "the whole site is behind login," which is also what a stolen cron looks like. If every cron invoke 307s to /login and your browser test 200s because you are signed in, you are not testing the scheduler.

Deployment Protection and the SSO Wall

Standard Protection, password protection, and SSO on the Production deployment treat the cron GET as an anonymous visitor. The invoke is 401 before your CRON_SECRET check, before middleware, before the handler. Preview protection is a distraction — cron does not run on Preview. Production protection is the one that steals a job you already "saw" in the overview.

The bypass is a project setting, not a code comment. Protection Bypass for Automation (x-vercel-protection-bypass) is for your own scripts. Vercel's cron documentation expects the platform invoke to reach the deployment; when Standard Protection is on for Production, teams still see 401s until they disable protection on that production host, add an exception, or confirm in the function logs that the GET is dying at the protection layer and not in route.ts. The WAF is the same shape: a custom rule that blocks "unknown" User-Agents or missing cookies will eat the cron and leave a 403 in a different product surface.

If the cron overview says 401 and your handler's cron.unauthorized line does not print, you are not in the CRON_SECRET branch. You are in front of the app. Deployment Protection and middleware redirects both live there.

UTC, Hobby, and the Hour You Did Not Mean

Schedules are UTC only. 0 5 * * * is 05:00 UTC, not 05:00 Europe/Zurich. We picked 05:00 because "the office opens at seven and the sweep should finish first." In January that is 06:00 Zurich. In July it is 07:00. The first "it did not run before the standup" ticket was a timezone, not a dead cron.

Hobby is stricter and sloppier at once. Cron expressions that fire more than once per day fail the Production deploy (Hobby accounts are limited to daily cron jobs). The one daily run is not promised at minute zero: a job set for 0 5 * * * may fire anywhere inside 05:00–05:59 UTC so the platform can spread load. Pro is per-minute. If you needed a 15-minute poll on Hobby, the deploy never shipped, and the cron overview never listed the job. That looks like "Vercel Cron not running" and is a plan limit.

maxDuration: The Kill That Leaves a Partial Sweep

Cron duration is function duration. maxDuration — Hobby defaults measured in seconds, Pro higher, Fluid Compute higher still — kills the isolate when the budget is gone. The cron overview still records an invocation. The handler may have written twenty rows and not the two-hundredth. The next day's invoke starts again. We treated that as "the sweep is flaky" and stared at SKIP LOCKED before anyone read the function timeout.

The Stripe webhook claim is the right instinct here. A timeout after work already ran is a retry of a side-effect. Cron can overlap too: a slow run plus the next schedule, or a deploy that does not cancel the in-flight invoke. Claim the sweep, or enqueue and return. Do not hide a 12-minute Coolify-shaped job behind a Vercel GET and hope maxDuration is a suggestion. Raise the budget if the platform allows it; split the work if it does not. A silent kill is a 504 or a dropped connection with a green "invoked" chip.

export const maxDuration = 60; // seconds; the platform still owns the ceiling

If the job cannot finish in that ceiling, the cron's job is to enqueue, not to finish. The Coolify worker already knows how to do the long part.

The Checklist We Run After a Cron That "Ran"

Five checks, in this order, before anyone is allowed to restart the Coolify worker.

Production, not Preview. Settings → Cron Jobs lists the path and a next run only after a Production deploy. Preview will not fire. vercel build --prod and .vercel/output/config.json should contain crons. A 404 invoke means the path in vercel.json is not the route on that deployment.

Read the status, not the chip. 401 is CRON_SECRET or Deployment Protection. 307/308 is trailingSlash, locale prefix, or middleware redirect — cron does not follow. 404 is a wrong path. Timeout / 504 is maxDuration. 200 with no handler log is cache (force-dynamic) or a different isolate than the one you opened. Log before and after the Bearer check.

Exact Bearer, clean env, redeploy. No newline in CRON_SECRET. Production scope. Redeploy after every env edit. Handler compares Authorization to Bearer ${process.env.CRON_SECRET}. Do not trim one side.

Middleware exempts the prefix; the handler still authenticates. /api/cron and /api/cron/… and the trailing-slash twin. Auth.js must not redirect that GET. Deployment Protection / SSO / WAF must not 401 it in front of the app.

UTC and the budget. Write the schedule as UTC. On Hobby, once per day, any minute inside the hour. maxDuration is a kill switch. Enqueue the long sweep; do not run it in the invoke.

Three Rules That Survive the Next Scheduler Flag

Three rules survive this writeup and generalise past whatever Vercel calls a cron invoke next year.

An invocation is a GET, not a successful job. The overview chip means the platform requested a URL. 401, 308, 404, and a timeout are invocations. Prove the handler with a log line that only that module can print. A browser 200 is a different client.

The scheduler will not follow your app's manners. No session cookie, no 308 chase, no locale courtesy, no SSO dance. trailingSlash, Auth.js middleware, and Deployment Protection are all "works in the browser" and dead in the cron. Put the final URL in vercel.json. Exempt the path. Keep CRON_SECRET in the handler.

Production is the only clock, and the clock is UTC. Preview never fires. Env and crons changes are inert until a Production deploy. Hobby is daily and sloppy inside the hour. maxDuration will kill a Coolify-sized job and still look invoked. Enqueue; do not hope.

The composition is the note. We treated a dead nightly sweep as a stalled Coolify worker because the cron overview was green and the job table was not. Production was a Preview we used as a test clock, a CRON_SECRET with a newline, a trailingSlash 308, and an Auth.js matcher that redirected the one client that will never log in. One Production deploy, one clean Bearer, the slash the config actually 308s to, and a middleware exemption that still authenticates in the handler would have shown the hole in the first minute — the same minute we spent restarting Postgres.

If a Vercel Cron Job is "running" in the overview and the side-effect never happens — book a free AI Potenzial-Check. The backstop-sweep writeup is the work this GET was supposed to kick; the Auth.js JWT note is the reminder that a redirect to /login is a costume, not a proof the handler ran.

acurio · Hallucinated citations? Not in your manuscript.

Citation checker for Zotero. Finds hallucinated or partially supported sources in AI‑written text. Thesis packages from CHF 19, Swiss data processing.

NEXT STEPWas this useful?