Vercel Cron Jobs: Hobby Limitleri, Güvenlik ve Hatalar
Vercel cron işleri iki tipik şekilde sorun çıkarır. Ya deploy "Hobby accounts are limited to daily cron jobs" hatasıyla düşer, ya da deploy geçer, iş panelde görünür ve hiçbir şey olmaz: log yok, hata yok, sonuç yok. Birincisi plan limitidir, hata mesajı da bunu söyler. İkincisi neredeyse her zaman Vercel'in belgelediği ama birkaç sayfaya dağıttığı birkaç yapılandırma ayrıntısından biridir.
Bu yazı bunları Next.js 16 App Router projesi için tek yerde topluyor: Hobby planın gerçekte neye izin verdiği, cron route'unun nasıl kurulup korunacağı, işin sessizce çalışmama sebepleri ve günde bir kez yetmediğinde ne yapılacağı. Rakamlar Ekim 2026'da Vercel dokümanından kontrol edildi. Daha önce değiştiler; güvenmeden önce tekrar bakın.
Vercel cron gerçekte nasıl çalışır
Kodunuzu çalıştıran bir zamanlayıcı süreç yok. Zamanı gelince Vercel, production deployment'ınızdaki bir yola HTTP GET isteği atar. Mekanizmanın tamamı bu, aşağıdaki sürprizlerin çoğu da buradan çıkıyor:
- İstek, normal bir route'a giden normal bir GET. Normal istekleri etkileyen her şey (yönlendirme, önbellek, firewall kuralları) cron çağrısını da etkiler.
- İstekler
vercel-cron/1.0user agent'ını ve tetikleyen ifadeyi taşıyanx-vercel-cron-schedulebaşlığını içerir. - Zamanlama her zaman UTC. Saat dilimi ayarı yok.
- Cron yalnız production deployment'larda çalışır. Preview deployment hiç çağrılmaz.
İş bir Vercel Function çağrısıdır, yani fonksiyon limitleri ve fiyatlandırması ona da uygulanır. Hobby'de bu, diğer fonksiyonlarla aynı 300 saniyelik süre sınırı demek (Fluid compute ile varsayılan ve azami süre ikisi de 300 saniye). İşiniz bundan uzun sürüyorsa Hobby'de hiçbir ayar yardımcı olmaz; seçenekleri Vercel zaman aşımı ve maxDuration yazısında anlattım.
Hobby limitleri, güncel rakamlar
| Hobby | Pro / Enterprise | |
|---|---|---|
| Proje başına cron işi | 100 | 100 |
| En kısa aralık | günde bir | dakikada bir |
| Zamanlama hassasiyeti | saat içinde (±59 dk) | dakika içinde |
Arama yaparsanız Hobby için "hesap başına 2 cron işi" diyen yazılar ve önbellekte kalmış sayfalar hâlâ çıkıyor. O eski limitti. Vercel'in güncel dokümanı her planda proje başına 100 cron işi diyor.
Hobby'de iki kural önemli.
Her ifade günde en fazla bir kez tetiklenebilir ve bu deploy sırasında kontrol edilir. 0 * * * * (saatlik) ya da */30 * * * * deploy'u yukarıdaki hatayla düşürür. Kontrol ifadeye bakıyor; günde iki kez tetiklenen 0 6,18 * * * gibi bir ifade de aynı kurala takılır.
Saat bir zaman değil, bir aralıktır. Vercel yükü dağıtmak için Hobby çağrılarını saate yayar. 0 1 * * * UTC 01:00 ile 01:59 arasında herhangi bir anda çalışabilir. İşin başka bir şeyden önce bitmesi gerekiyorsa (diyelim 09:00'daki bir rapor), en az bir saat önceye koyun.
UTC olduğu için Türkiye saatiyle 07:00'de çalışacak bir iş için ifade 0 4 * * * olur (Türkiye UTC+3, yaz saati uygulaması yok). Birkaç sözdizimi sınırı daha: MON, JAN gibi isimler desteklenmiyor; ayın günü ile haftanın gününü aynı anda veremezsiniz, biri * olmalı.
Cron route'unu kurmak
İşi proje kökündeki vercel.json dosyasında tanımlayın:
{
"$schema": "https://openapi.vercel.sh/vercel.json",
"crons": [
{
"path": "/api/cron/daily-cleanup",
"schedule": "0 3 * * *"
}
]
}Sonra route handler. Uç nokta herkese açık bir URL; yolu bulan herkes çağırabilir. Vercel'in çözümü CRON_SECRET: projeye bu adla bir ortam değişkeni ekleyin, Vercel her cron çağrısında değerini Authorization: Bearer <değer> olarak gönderir. Handler ikisini karşılaştırır:
// app/api/cron/daily-cleanup/route.ts
import { timingSafeEqual } from "node:crypto";
function isAuthorized(header: string | null): boolean {
const secret = process.env.CRON_SECRET;
if (!secret || !header) return false;
const expected = Buffer.from(`Bearer ${secret}`);
const received = Buffer.from(header);
return (
expected.length === received.length && timingSafeEqual(expected, received)
);
}
export async function GET(request: Request) {
if (!isAuthorized(request.headers.get("authorization"))) {
return new Response("Unauthorized", { status: 401 });
}
// ...asıl iş
return Response.json({ ok: true });
}Vercel'in kendi örneği düz !== karşılaştırması yapıyor, o da çalışır. timingSafeEqual sürümü yalnızca baştan kaç karakterin eşleştiğini süre farkıyla sızdırmamak için. Eşit uzunlukta buffer istediği için önce uzunluk kontrol ediliyor. Dokümandan iki önemli ayrıntı:
- Değişken yoksa reddedin.
!secretkontrolü, değişkenin unutulduğu bir deployment'ta işin herkese çalışması yerine 401 dönmesini sağlar. - Değeri başlığa uygun tutun. Vercel en az 16 karakterlik rastgele bir dize öneriyor. Sorun giderme rehberi,
CRON_SECRETiçindeki satır sonu ya da özel karakterleri başarısız çağrı sebebi olarak sayıyor. Uzun, yalnız harf ve rakamdan oluşan bir dize sorunu baştan kapatır. Terminalden kopyalarken sonda kalan satır sonuna dikkat edin.
CRON_SECRET ekledikten ya da değiştirdikten sonra yeniden deploy edin. Ortam değişkenleri çalışan deployment'a değil, yenilerine uygulanır.
İş neden çalışmıyor (ve loglar neden boş)
Çoğunu açıklayan tek bilgi şu: cron çağrısı bir yönlendirmeyle ya da önbellekten gelen yanıtla biterse loglarda görünmez. Yani boş log, işin çağrılmadığı anlamına gelmez. Çağrının, fonksiyonunuz hiç çalışmadan cevaplandığı anlamına da gelebilir.
1. Route'un önünde bir yönlendirme
Cron çağrıları yönlendirmeyi takip etmez. 3xx yanıtı nihaidir; iş "tamamlanır" ama kodunuz hiç çalışmaz. Next.js sitesinde en olası kaynak proxy dosyası (Next.js 16'dan önceki adıyla middleware): i18n kütüphanesinin eklediği dil öneki, ana alan adına yönlendirme ya da sondaki eğik çizgi kuralı.
Bu sitede örneğin proxy, *.vercel.app alan adlarını 308 ile ana alan adına yönlendiriyor. Vercel dokümanı da cron isteğinin hedefine örnek olarak bir *.vercel.app adresi veriyor. /api/... altındaki bir cron route'unu bu yönlendirmenin dışında tutan şey matcher:
export const config = {
matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};Matcher'ınız proxy'yi her istekte çalıştırıyorsa ya cron yolunu dışarıda bırakın ya da proxy'nin o yola dokunmadan geçirdiğinden emin olun. next.config içindeki trailingSlash: true için de aynısı geçerli: o durumda vercel.json içindeki yolun sonunda da eğik çizgi olmalı, yoksa çağrı yönlendirilir.
2. Önbellekten gelen yanıt
GET handler'ın yanıtı önbelleğe alınırsa sonraki cron çağrıları önbellekteki kopyayı alır ve kodunuz çalışmaz. Next.js 16'da kural Cache Components'ın açık olup olmamasına bağlı:
- Cache Components kapalıyken Route Handler'lar varsayılan olarak önbelleğe alınmaz; bunun için
dynamic = 'force-static'gibi bir ayarla açıkça istemeniz gerekir. Vercel'in sorun giderme rehberi yine de cron route'unaexport const dynamic = 'force-dynamic'eklemeyi öneriyor; bu modelde zararsız bir sigorta. - Cache Components açıkken
dynamicayarı kaldırıldı, bu öneri geçerli değil. Bunun yerine istek ya da önbelleklenmemiş veriye dokunmayan bir GET handler build sırasında önceden üretilir.request.headersokumak önceden üretimi durdurur. Yukarıdaki gibi ilk işCRON_SECRETkontrol eden bir handler istek anında çalışır.
Yetki kontrolünü en başa koymanın bir sebebi daha: route'u korur ve her çağrıda isteği okur.
3. Production değil
Cron yalnız production deployment'larda çalışır. Yeni bir işi preview URL'inde deneyip tetiklenmesini beklemek işe yaramaz. Ayrıca zamanlamalar deploy edilmiş yapılandırmadan okunur; vercel.json'u yerelde değiştirmek deploy edene kadar hiçbir şeyi değiştirmez.
4. Yol yok
vercel.json içindeki yol bir route'a karşılık gelmiyorsa (yazım hatası, taşınmış route, eksik eğik çizgi) Vercel isteği yine gönderir ve 404 alır. İş çalışmış sayılır. Çözüm runtime loglarında: istek yoluna göre filtreleyin ve 404 arayın.
5. İsteği bir şey engelliyor
Özel firewall (WAF) kuralları cron isteğini de diğer istekler gibi engelleyebilir. Vercel'in rehberi bunu hiç çalışmayan işlerin sebepleri arasında sayıyor. User agent ya da yola göre engelleyen kurallarınız varsa vercel-cron/1.0 ve cron yollarınızın geçtiğinden emin olun.
Atlanan ve iki kez çalışan çağrılar
Vercel cron'un gözden kaçması kolay iki özelliği var, handler'ın nasıl yazılacağını da bunlar belirliyor:
- Yeniden deneme yok. Çağrı başarısız olursa Vercel tekrar denemez. Sonraki deneme sonraki zamanlanmış çalışmadır; Hobby'de bu yarın demek.
- Teslim garanti değil (best effort). Ara sıra zamanlanmış bir çağrı fonksiyonunuza hiç ulaşmaz ve runtime logu da oluşmaz. Ara sıra da aynı zamanlanmış çalışma birden fazla kez gelir.
Yani iş hem atlanmaya hem iki kez çalışmaya dayanmalı. Vercel'in önerisi idempotent, "eksiği tamamlayan" iş: "bugünün kayıtlarını işle" yerine "henüz bitmemiş her şeyi işle". Atlanan bir çalışmayı sonraki telafi eder, tekrar gelen çalışma da yapacak bir şey bulamaz.
PostgreSQL'de tek bir sorgu hem işi seçip hem alındı diye işaretleyebilir:
UPDATE invoices
SET reminder_sent_at = now()
WHERE due_date < now()
AND reminder_sent_at IS NULL
RETURNING id, customer_email;İki çağrı bunu aynı anda çalıştırırsa, PostgreSQL'in varsayılan izolasyon seviyesinde (Read Committed) ikisi aynı satırı alamaz. İkincisi satır kilidinde bekler, WHERE koşulunu yeniden değerlendirir, reminder_sent_at'in artık boş olmadığını görür ve satırı atlar. Her satır tam olarak bir çağrıya döner.
Buradaki ödünleşim bilinçli: satır e-posta gitmeden önce işaretleniyor. Gönderim başarısız olursa o hatırlatma iki kez gitmek yerine kaybolur. Hatırlatmalar için bu genelde doğru tercih. Mutlaka gerçekleşmesi gereken bir iş için bir durum sütunu kullanın (pending, processing, done) ve sonraki çalışmanın uzun süre processing'de kalmış satırları yeniden almasına izin verin.
Bir iş sonrakiyle çakışacak kadar uzun sürebiliyorsa kilit ekleyin (Vercel dokümanı Redis kilidi öneriyor). Hobby'de günde bir çalışma ve 300 saniye sınırıyla çakışma çoğunlukla tekrarlanan teslimden ya da zamanlanmış iş sürerken elle tetiklemekten gelir. Yukarıdaki atomik güncelleme bunu zaten karşılıyor.
Bir gün beklemeden test etmek
Yerelde cron route'u sıradan bir route. Başlıkla çağırın:
curl -i -H "Authorization: Bearer $CRON_SECRET" \
http://localhost:3000/api/cron/daily-cleanupZamanlamalar next dev ya da vercel dev altında tetiklenmez; yerelde yalnız isteğin kendisi test edilebilir. Bir fark var: -L olmadan curl, cron gibi yönlendirmeyi takip etmez. Burada 3xx görüyorsanız cron'un da takılacağı sorun budur. Tarayıcı yönlendirmeyi sessizce takip eder ve sorunu gizler.
Deploy edilmiş iş için Vercel CLI işi anında tetikleyebiliyor (crons komutu bu yazı yazılırken beta):
vercel crons ls
vercel crons run /api/cron/daily-cleanupvercel crons run tanımları yerel vercel.json'dan değil deploy edilmiş projeden okur; yani yalnız production'da olan işleri bulur. Sonucu runtime loglarında, istek yoluna göre filtreleyerek kontrol edin.
Günde bir kez yetmediğinde
Önce cron'a gerçekten ihtiyacınız olup olmadığına bakın. İnsanların cron'a en sık başvurma sebebi zamanlanmış içerik yayınlamak, Next.js'te bunun için zamanlayıcı gerekmiyor. Tarih filtresi ve ISR'ın yenileme aralığı yeterli; bu sitedeki yazılar da böyle yayına giriyor. Kurulumu Vercel ücretsiz planla production yazısında anlattım.
Daha sık tetikleyici gerçekten gerekiyorsa seçenekler:
Pro'ya geçmek. Dakikalık zamanlama ve dakika hassasiyeti, kurulum aynı.
Route'u dışarıdan tetiklemek. Route sonuçta bearer token ile korunan bir HTTPS uç noktası. Bu başlıkla zamanlanmış istek atabilen her şey onu tetikleyebilir. Kod zaten GitHub'daysa en akla yakın seçenek GitHub Actions:
# .github/workflows/sync.yml
name: hourly-sync
on:
schedule:
- cron: "17 * * * *"
workflow_dispatch:
jobs:
trigger:
runs-on: ubuntu-latest
steps:
- name: Call the sync endpoint
run: |
curl --fail-with-body -sS \
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
https://example.com/api/cron/syncAynı değeri CRON_SECRET adlı bir repository secret olarak kaydedin; yukarıdaki handler değişmeden çalışır. GitHub'ın zamanlamasının da kendi sınırları var, hepsi kendi dokümanından:
- En kısa aralık 5 dakika.
- Çalışmalar yük altında gecikebilir, özellikle her saatin başında; yük yeterince artarsa kuyruktaki bazı çalışmalar düşürülür. Örnekte 0 yerine 17. dakikanın kullanılması bu yüzden.
- Zamanlanmış workflow'lar yalnız varsayılan branch'ten çalışır.
- Açık (public) repoda, 60 gün boyunca repoda hiçbir hareket olmazsa zamanlanmış workflow'lar otomatik kapatılır.
Yani Vercel'deki kural burada da geçerli: dış tetikleyici gecikebilir ya da bir çalışmayı atlayabilir, handler ikisine de dayanmalı. workflow_dispatch elle "Run workflow" düğmesi ekler; test için kullanışlı. Aynı CI'dan deploy için GitHub Actions ile Docker deploy yazısına bakabilirsiniz.
Bu iş için kurulmuş barındırılan HTTP zamanlayıcı servisleri de var. Production'da kullanmadım, o yüzden burada karşılaştırmıyorum. Hangisini seçerseniz seçin kontrol listesi aynı: bearer token, yönlendirme yok, idempotent handler.
Zamanlanmış işi Vercel'in dışına taşımak. Backend zaten bir sunucuda çalışıyorsa (Celery Beat'li bir Django uygulaması, sistem cron'u olan bir VPS) zamanlamanın yeri orası, üzerinde çalıştığı verinin yanı. Vercel o zaman yalnız frontend'i sunar, zamanlamadan haberi bile olmaz.
Özet
- Hobby'de her cron ifadesi günde bir kez tetiklenebilir. Daha sık ifadeler deploy'da düşer, çalışma da zamanlanan saatin içinde bir anda, UTC'ye göre olur.
- Cron işi production'a giden bir HTTP GET. Yönlendirme onu bitirir, önbellekten gelen yanıt atlatır, ikisi de loglarda görünmez. Önce proxy matcher'ını ve sondaki eğik çizgi ayarını kontrol edin.
- Route'u
CRON_SECRETile koruyun, değişken yoksa isteği reddedin. Next.js 16'da Cache Components açıksaforce-dynamickullanılamaz; handler'ı dinamik tutan şey zaten başlığı okumak. - Vercel yeniden denemez ve iki kez teslim edebilir. İşi atomik bir sahiplenmeyle "henüz bitmemiş her şeyi işle" diye yazın; atlanan ya da tekrarlanan çalışma sorun olmaktan çıkar.
- Günde birden fazla mı gerekiyor? Pro, GitHub Actions gibi bir dış tetikleyici (en kısa 5 dakika, gecikebilir ya da atlayabilir) ya da veriye sahip sunucudaki bir zamanlayıcı.