Zum Inhalt springen
tecminds

Vercel Cron Jobs laufen nicht — nur Production, CRON_SECRET-401, trailingSlash-308 und Middleware, die den Hit klaut

Wir haben einen vercel.json-Cron ausgeliefert, der auf einem Coolify-plus-Vercel-Schweizer-KMU-Stack einen nächtlichen Sweep anstossen sollte. Die Cron-Jobs-Übersicht zeigte Invocations. Die Job-Tabelle bewegte sich nicht. Preview feuert nie, ein CRON_SECRET-Newline ergibt 401, trailingSlash-308 sind final, und Auth.js-Middleware oder Deployment Protection können den GET stehlen, bevor der Route Handler läuft. Das ist die Feldnotiz.

TTobias LüscherCo‑Founder · TecMinds2026-09-10 · 14 Min Lesezeit

Vercel Cron Jobs laufen nicht — nur Production, CRON_SECRET-401, trailingSlash-308 und Middleware, die den Hit klaut

Der teuerste Scheduled Job ist der, den die Plattform schon als invoked markiert hat. Wir haben an einem ruhigen Nachmittag einen vercel.json-Cron ausgeliefert — denselben Coolify-plus-Vercel-Hybrid, den wir für Schweizer-KMU-Produkte wie Acurio fahren — auf GET /api/cron/sweep um 0 5 * * *, damit er den Backstop-Sweep anstösst, der Job-Rows schliesst, die der Browser verpasst hat. Das neue Production-Deploy wurde grün. Project → Settings → Cron Jobs listete den Schedule und einen Next Run. Am nächsten Morgen zeigte die Übersicht Invocations. Die Job-Tabelle bewegte sich nicht. Support hat es als "der nächtliche Sweep ist tot; der Worker hängt" eröffnet. Die erste Stunde sassen wir in Coolify-Worker-Logs und pg_stat_activity. Der Bun-Prozess war idle, weil ihn niemand gefragt hatte. Der GET hat den Route Handler nie erreicht. Das ist die Aufzeichnung von Vercel Cron Jobs, die nicht laufen — nur Production-Deploys, CRON_SECRET-Bearer-401, ein trailingSlash-308, den der Scheduler nicht folgt, Auth.js-Middleware, die den Hit klaut, Deployment Protection, die den Cron als Fremden behandelt, UTC-only Hobby-Timing, und ein maxDuration-Kill, der nach erfolgreichem Invoke aussieht.

Der Stack ist der, den wir schon einmal geteilt haben. Langlebiges Coolify hält Postgres und den Worker. Vercel hält den Next.js App Router und, nach diesem Vorfall, die Uhr, die ihn anstupsen darf. Der Cron-Pfad sieht aus wie jedes andere app/api/cron/sweep/route.ts. Der Invoke ist kein Browser. Er ist ein Plattform-GET, der Redirects nicht folgt, kein Session-Cookie hält und einen 308 nicht retried. Ein lokaler curl, der "geht", ist dieser GET nicht.

Der Invoke, der nie ein GET wurde

Vercel Cron Jobs laufen nur auf dem aktuellen Production-Deployment. Preview feuert nie. Ein PR-Deploy, das denselben crons-Key in vercel.json listet, wird /api/cron/sweep um 05:00 nicht aufrufen, keine Invocation-Row schreiben, der du traust, und den Handler nicht beweisen. Wir haben den Schedule auf einer Preview-URL getestet, weil wir dort alles andere testen. Die Cron-Übersicht blieb leer. Wir haben das als "der Ausdruck ist falsch" behandelt und schedule dreimal editiert. Der Ausdruck war in Ordnung. Das Deployment war nicht Production.

Das Dashboard ist das zweite Kostüm. Wenn Settings → Cron Jobs den Job nicht mit einem Next Run listet, ist die crons-Config nie in einem Production-Deploy gelandet — ein vercel.json-Edit auf einem Branch, ein Cron nur in next.config, oder ein Build, der crons nie nach .vercel/output/config.json geschrieben hat. Wenn der Job gelistet ist und die Übersicht Invocations zeigt, heisst das nicht "der Sweep ist gelaufen." Das heisst "Vercel hat einen GET abgesetzt." Ein 404 (der Pfad existiert auf diesem Deployment nicht), ein 401, ein 308, ein 307 nach /login oder ein Function-Timeout zählen alle als Invocation. Die Warmup-Ping-Notiz hat den Cousin schon benannt: grüne Logs auf einer Schicht, die nie die war, die du gebraucht hast. Hier ist die grüne Box die Cron-Übersicht. Der Origin-Handler ist die Schicht, die nicht gelaufen ist.

Es gibt keinen vercel dev / next dev-Scheduler. Lokal GETest du die Route selbst. Dein Browser folgt einem Trailing-Slash-Redirect und hängt ein Session-Cookie an. Production-Cron macht beides nicht. Der lokale 200 ist, wie wir das Ticket das erste Mal geschlossen haben.

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

Dieser path ist die URL, die Vercel anfragt, Zeichen für Zeichen. Er ist nicht "der Ordner unter app/api." Änderst du die Route und vergisst diesen String, invoked der Cron weiter — gegen einen Pfad, der 404t. Vercel scheduled bereitwillig einen GET auf eine URL, die nicht existiert.

Der 401, der nach "Cron ist kaputt" aussah

Wenn das Projekt eine CRON_SECRET-Environment-Variable hat, schickt Vercel Authorization: Bearer <CRON_SECRET> am Invoke. Der Route Handler muss sie prüfen. Das ist der richtige Default. Es ist auch, wie ein Cron, der in der Übersicht "läuft", als 401 stirbt, bevor irgendeine Sweep-Arbeit passiert.

Das Secret ist ein Header-Wert. Ein Trailing-Newline aus einem Password-Manager-Paste, ein umschliessendes Quote, das du in der Vercel-UI erst nach Reveal siehst, oder ein Zeichen, das der Authorization-Header nicht trägt — und der Vergleich scheitert. Die Vercel-Docs sagen das in einem Satz: keine ungültigen, Newline- oder Sonderzeichen, die nicht in einem Authorization-Header leben können. Wir haben ein 1Password-Passwort eingefügt, das mit einem Zeilenumbruch endete. Die Env-UI sah gut aus. Der Bearer, den Vercel schickte, und das process.env.CRON_SECRET, das die Function las, waren nicht derselbe String. Jeder Invoke war 401. Der Handler hat nichts Brauchbares geloggt, weil wir erst nach dem Check geloggt haben.

Env-Änderungen hängen sich nicht an das laufende Production-Deployment. CRON_SECRET setzen oder rotieren, dann redeployen. Wir haben rotiert, in der Cron-UI auf Run gedrückt und zwanzig Minuten denselben 401 bekommen, weil das Isolate noch den alten Wert hatte — oder keinen, sodass Bearer ${undefined} der String "Bearer undefined" war. Preview kann das Secret haben und Production nicht, oder umgekehrt, wenn der Env-Scope nur "Preview" ist. Cron trifft Preview nie. Der Production-Scope ist der einzige, der zählt, und er zählt erst nach einem 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();
  }

  // Sweep anstossen; keinen 12-Minuten-Job hinter diesem GET verstecken
  const result = await enqueueSweep();
  return NextResponse.json({ ok: true, result });
}

Nicht im Handler trim()en und das Dashboard schmutzig lassen. Trimmst du nur beim Lesen, schickt Vercel weiter die rohe Env im Bearer und du 401st für immer. Den Wert in der Project-Env säubern, redeployen, dann exakte Strings vergleichen. Ein timing-safe Compare ist in Ordnung; ein startsWith("Bearer") ohne Secret-Check ist, wie die Route in der Woche öffentlich wird, in der jemand die Equality "vorübergehend" auskommentiert.

export const dynamic = 'force-dynamic' gehört hierher wegen des langweiligen Cousins: ein gecachter 200 ohne Logs sieht aus wie ein Cron, der gelaufen ist und nichts getan hat. Die offizielle Troubleshooting-Notiz nennt das. Wir haben es einmal getroffen, nach einem "erfolgreichen" Invoke, der nie cron.unauthorized oder enqueueSweep gedruckt hat, weil die Plattform ein memoisiertes leeres JSON ausgeliefert hat.

Der 308, dem trailingSlash nicht folgt

Cron-Invocations folgen Redirects nicht. Ein 3xx ist ein fertiger Job. Dein Browser folgt http://localhost:3000/api/cron/sweep/api/cron/sweep/ und zeigt dir einen 200. Production-Cron akzeptiert den 308 und hört auf. Das ist der ganze Bug, und er ist der, den der Vercel-Cron-Troubleshooting-Guide unter "Cron jobs and redirects" ausbuchstabiert.

trailingSlash: true in next.config ist, wie wir ihn uns eingehandelt haben. Marketing wollte Slash-kanonische URLs. Next hat angefangen, jeden Pfad ohne Slash zu 308en. vercel.json sagte weiter "/api/cron/sweep". Die Cron-Übersicht wurde grün. Das Function-Log für den Handler blieb leer. Das Plattform-Log zeigte 308. Wir haben die Status-Spalte eine Stunde nicht angeschaut, weil die Übersicht nicht "Redirect, nicht Handler" schreit.

Der Fix ist ein Zeichen, in dem String, den der Scheduler wirklich anfragt:

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

Oder trailingSlash für die App aus, oder /api ausnehmen. Was du nicht tun darfst: "im Browser testen" und den Pfad korrekt nennen. Der Browser ist ein Client, der 308s folgt. Vercel Cron ist das nicht. Dieselbe Falle wie ein Middleware-NextResponse.redirect nach /login oder zum locale-präfixierten /de/api/cron/sweep. Der Scheduler jagt das nicht.

Ein Locale-Prefix ist der andere 308, den wir gesehen haben. next-intl / ein [locale]-Segment, das /api/cron/sweep nach /de/api/cron/sweep 308t, killt den Invoke auf dieselbe Weise. Die Cron-Route ausserhalb des Locale-Trees legen, oder die finale URL — Slash, Locale und alles — in vercel.json schreiben.

Middleware, die den Hit klaut

Globales middleware.ts läuft vor dem Route Handler. Auth.js- / NextAuth-Wrapper, die anonyme Requests nach /login schicken, behandeln den Cron-GET als ausgeloggten Besucher. Der Invoke wird ein 307 oder 308. Der Handler läuft nie. Die Auth.js-passwordChangedAt-Notiz hat uns schon beigebracht, dass Session-Fehler Kostüme tragen. Damals hat es echte User gebounced. Hier hat es einen Plattform-GET gebounced, der nie ein Session-Cookie vorzeigen wird.

Der Matcher ist der übliche Treffer. Ein Matcher, der api überspringt, sieht sicher aus, bis jemand ihn anzieht, damit /api/* mit rate-limited oder "auch geschützt" ist. Eine Allowlist, die pathname === "/api/cron" prüft, verpasst /api/cron/sweep und /api/cron/sweep/. Ein Check, der nur x-vercel-cron liest und dann next() ohne Secret-Check im Handler, lässt die URL öffentlich.

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) {
    // nicht redirecten; der Handler prüft weiter 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).*)"],
};

Die Exemption ist nicht die Auth. Der Route Handler prüft weiter Authorization. Middleware, die "Cron einfach durchlässt", ohne Handler-Check, ist ein unauthentifizierter Write-Endpoint im öffentlichen Internet. Middleware, die Cron redirectet, ist ein stiller 3xx. Du willst das Erste ohne das Zweite.

auth() in der Middleware hat dasselbe Next-15-Kostüm wie die cookies/params-await-Notiz: ein Throw oder ein pauschaler Redirect, der nach "die ganze Site ist hinter Login" aussieht — was auch ein gestohlener Cron ist. Wenn jeder Cron-Invoke nach /login 307t und dein Browser-Test 200t, weil du eingeloggt bist, testest du nicht den Scheduler.

Deployment Protection und die SSO-Mauer

Standard Protection, Password Protection und SSO auf dem Production-Deployment behandeln den Cron-GET als anonymen Besucher. Der Invoke ist 401 vor deinem CRON_SECRET-Check, vor der Middleware, vor dem Handler. Preview-Protection ist eine Ablenkung — Cron läuft nicht auf Preview. Production-Protection ist die, die einen Job klaut, den du in der Übersicht schon "gesehen" hast.

Der Bypass ist eine Project-Setting, kein Code-Kommentar. Protection Bypass for Automation (x-vercel-protection-bypass) ist für deine eigenen Scripts. Die Vercel-Cron-Doku erwartet, dass der Plattform-Invoke das Deployment erreicht; wenn Standard Protection für Production an ist, sehen Teams trotzdem 401s, bis sie Protection auf diesem Production-Host ausmachen, eine Exception setzen oder in den Function-Logs bestätigen, dass der GET an der Protection-Schicht stirbt und nicht in route.ts. Die WAF hat dieselbe Form: eine Custom-Rule, die "unbekannte" User-Agents oder fehlende Cookies blockt, frisst den Cron und hinterlässt einen 403 auf einer anderen Produktfläche.

Wenn die Cron-Übersicht 401 sagt und die cron.unauthorized-Zeile deines Handlers nicht druckt, bist du nicht im CRON_SECRET-Branch. Du bist vor der App. Deployment Protection und Middleware-Redirects leben beide dort.

UTC, Hobby und die Stunde, die du nicht gemeint hast

Schedules sind nur UTC. 0 5 * * * ist 05:00 UTC, nicht 05:00 Europe/Zurich. Wir haben 05:00 gewählt, weil "das Büro um sieben aufmacht und der Sweep vorher fertig sein soll." Im Januar ist das 06:00 Zürich. Im Juli 07:00. Das erste Ticket "es ist vor dem Standup nicht gelaufen" war eine Zeitzone, kein toter Cron.

Hobby ist strenger und schlampiger zugleich. Cron-Ausdrücke, die mehr als einmal pro Tag feuern, lassen das Production-Deploy scheitern (Hobby accounts are limited to daily cron jobs). Der eine tägliche Run ist nicht auf Minute null versprochen: ein Job auf 0 5 * * * kann irgendwo in 05:00–05:59 UTC feuern, damit die Plattform Last verteilen kann. Pro ist pro Minute. Wenn du auf Hobby einen 15-Minuten-Poll brauchtest, ist das Deploy nie rausgegangen, und die Cron-Übersicht hat den Job nie gelistet. Das sieht aus wie "Vercel Cron läuft nicht" und ist ein Plan-Limit.

maxDuration: Der Kill, der einen partiellen Sweep hinterlässt

Cron-Dauer ist Function-Dauer. maxDuration — Hobby-Defaults in Sekunden, Pro höher, Fluid Compute noch höher — killt das Isolate, wenn das Budget weg ist. Die Cron-Übersicht schreibt trotzdem eine Invocation. Der Handler kann zwanzig Rows geschrieben haben und nicht die zweihundertste. Der Invoke am nächsten Tag fängt wieder vorne an. Wir haben das als "der Sweep ist flaky" behandelt und auf SKIP LOCKED gestarrt, bevor irgendwer das Function-Timeout gelesen hat.

Der Stripe-Webhook-Claim ist hier der richtige Instinkt. Ein Timeout, nachdem die Arbeit schon gelaufen ist, ist ein Retry eines Side-Effects. Cron kann auch überlappen: ein langsamer Run plus der nächste Schedule, oder ein Deploy, das den laufenden Invoke nicht cancelt. Den Sweep claimen, oder enqueuen und zurückkehren. Keinen 12-Minuten-Coolify-förmigen Job hinter einem Vercel-GET verstecken und hoffen, maxDuration sei ein Vorschlag. Das Budget hochsetzen, wenn die Plattform es erlaubt; die Arbeit teilen, wenn nicht. Ein stiller Kill ist ein 504 oder eine abgerissene Connection mit einem grünen "invoked"-Chip.

export const maxDuration = 60; // Sekunden; die Plattform besitzt weiter die Decke

Wenn der Job in dieser Decke nicht fertig wird, ist der Job des Crons zu enqueuen, nicht fertigzumachen. Der Coolify-Worker kann den langen Teil schon.

Die Checkliste, die wir nach einem Cron abarbeiten, der "gelaufen" ist

Fünf Checks, in dieser Reihenfolge, bevor irgendwer den Coolify-Worker neu starten darf.

Production, nicht Preview. Settings → Cron Jobs listet den Pfad und einen Next Run erst nach einem Production-Deploy. Preview feuert nicht. vercel build --prod und .vercel/output/config.json sollten crons enthalten. Ein 404-Invoke heisst, der Pfad in vercel.json ist nicht die Route auf diesem Deployment.

Den Status lesen, nicht den Chip. 401 ist CRON_SECRET oder Deployment Protection. 307/308 ist trailingSlash, Locale-Prefix oder Middleware-Redirect — Cron folgt nicht. 404 ist ein falscher Pfad. Timeout / 504 ist maxDuration. 200 ohne Handler-Log ist Cache (force-dynamic) oder ein anderes Isolate als das, das du geöffnet hast. Loggen vor und nach dem Bearer-Check.

Exakter Bearer, saubere Env, Redeploy. Kein Newline in CRON_SECRET. Production-Scope. Nach jedem Env-Edit redeployen. Der Handler vergleicht Authorization mit Bearer ${process.env.CRON_SECRET}. Nicht eine Seite trimmen.

Middleware nimmt das Prefix aus; der Handler authentifiziert weiter. /api/cron und /api/cron/… und der Trailing-Slash-Zwilling. Auth.js darf diesen GET nicht redirecten. Deployment Protection / SSO / WAF dürfen ihn nicht vor der App 401en.

UTC und das Budget. Den Schedule als UTC schreiben. Auf Hobby einmal pro Tag, irgendeine Minute in der Stunde. maxDuration ist ein Kill-Switch. Den langen Sweep enqueuen; ihn nicht im Invoke ausführen.

Drei Regeln, die das nächste Scheduler-Flag überleben

Drei Regeln überleben diese Aufzeichnung und verallgemeinern sich über das hinaus, wie Vercel einen Cron-Invoke nächstes Jahr nennt.

Eine Invocation ist ein GET, kein erfolgreicher Job. Der Übersicht-Chip heisst, die Plattform hat eine URL angefragt. 401, 308, 404 und ein Timeout sind Invocations. Den Handler mit einer Log-Zeile beweisen, die nur dieses Modul drucken kann. Ein Browser-200 ist ein anderer Client.

Der Scheduler folgt den Manieren deiner App nicht. Kein Session-Cookie, keine 308-Jagd, keine Locale-Höflichkeit, kein SSO-Tanz. trailingSlash, Auth.js-Middleware und Deployment Protection sind alle "geht im Browser" und tot im Cron. Die finale URL in vercel.json. Den Pfad ausnehmen. CRON_SECRET im Handler behalten.

Production ist die einzige Uhr, und die Uhr ist UTC. Preview feuert nie. Env- und crons-Änderungen sind tot, bis ein Production-Deploy kommt. Hobby ist täglich und schlampig innerhalb der Stunde. maxDuration killt einen Coolify-grossen Job und sieht trotzdem invoked aus. Enqueuen; nicht hoffen.

Die Komposition ist die Notiz. Wir haben einen toten nächtlichen Sweep als hängenden Coolify-Worker behandelt, weil die Cron-Übersicht grün war und die Job-Tabelle nicht. Produktion war ein Preview, den wir als Test-Uhr benutzt haben, ein CRON_SECRET mit Newline, ein trailingSlash-308 und ein Auth.js-Matcher, der den einen Client redirectet hat, der sich nie einloggen wird. Ein Production-Deploy, ein sauberer Bearer, der Slash, zu dem die Config wirklich 308t, und eine Middleware-Exemption, die im Handler weiter authentifiziert, hätten das Loch in der ersten Minute gezeigt — derselben Minute, die wir mit dem Neustarten von Postgres verbracht haben.

Wenn ein Vercel Cron Job in der Übersicht "läuft" und der Side-Effect nie passiert — buche einen kostenlosen AI-Potenzial-Check. Die Backstop-Sweep-Aufzeichnung ist die Arbeit, die dieser GET anstossen sollte; die Auth.js-JWT-Notiz ist die Erinnerung, dass ein Redirect nach /login ein Kostüm ist, kein Beweis, dass der Handler gelaufen ist.

acurio · Halluzinierte Zitate? Nicht in deinem Manuskript.

Citation‑Checker für Zotero. Findet halluzinierte oder nur teilweise gestützte Quellen in KI‑geschriebenen Texten. Thesis‑Pakete ab CHF 19, Schweizer Datenverarbeitung.

NÄCHSTER SCHRITTHat dich das interessiert?