Crons
Schedule your site's worker to run on a recurring UTC schedule — daily digests, hourly polling, retention emails
Cron triggers fire on a schedule (UTC). Use them for daily reminders, weekly digests, hourly polling, or any recurring background job — without standing up a separate cron service.
Crons run on the same per-site worker as Functions,
so they share the same bindings (env.SITE_DB, secrets, variables).
How the tick reaches your code depends on what your worker exports:
| Your worker | How a cron arrives |
|---|---|
Exports scheduled() (hand-written worker.ts) | shiply calls scheduled(event, env, ctx) — see Handler |
Exports only fetch() (Next.js, SvelteKit, Astro, any framework bundle) | shiply sends a request to the path you registered — see Framework apps |
Framework builds can't add a scheduled export — the adapter owns the
entry point — so for those the registered path is the whole mechanism.
Declare schedules in .shiply/crons.json
{
"crons": [
{ "path": "/api/cron/daily-reminder", "schedule": "0 9 * * *" },
{ "path": "/api/cron/hourly-sync", "schedule": "0 * * * *" }
]
}On shiply publish, shiply registers these as Cloudflare Worker cron
triggers.
crons.jsonis the source of truth on publish. A deploy replaces the site's whole cron set with what the file declares. A cron you added afterwards withshiply cron setis dropped by the next deploy unless it's also in the file — so keep the file in your build output, not just in your repo root.
Schedule syntax
Standard 5-field crontab, UTC only:
| Expression | Meaning |
|---|---|
0 9 * * * | Every day at 09:00 UTC |
*/5 * * * * | Every 5 minutes |
0 * * * * | Top of every hour |
0 0 1 * * | First of each month at 00:00 UTC |
0 0 * * 0 | Every Sunday at 00:00 UTC |
Full reference: Cloudflare cron triggers.
Handler
Add a scheduled method to your worker default export. Dispatch on
event.cron to handle multiple schedules in one worker:
// worker.ts
export default {
async fetch(request: Request, env: Env): Promise<Response> {
// ... your normal request handling
return env.ASSETS.fetch(request)
},
async scheduled(event: ScheduledEvent, env: Env, ctx: ExecutionContext) {
// event.cron === "0 9 * * *" — match on this to dispatch
if (event.cron === '0 9 * * *') {
await sendDailyReminders(env)
}
if (event.cron === '0 * * * *') {
await syncFromUpstream(env)
}
},
}scheduled runs in the same V8 isolate as your fetch handler with the
same bindings — including any secrets and the attached
D1 database (env.SITE_DB).
Framework apps (no scheduled export)
A Next.js / SvelteKit / Astro build exports only fetch — the adapter
owns the entry point, so you can't add a scheduled handler. When the
schedule fires and your worker has no scheduled export, shiply calls
the registered path as a request into your own app, in-isolate (no
network hop, so it never touches your public routes from outside):
POST /api/cron/daily-reminder
x-shiply-cron: 0 9 * * *
authorization: Bearer <your CRON_SECRET> # only when the site has onePOSTfirst, because tick routes usually mutate. If your handler answers405, shiply immediately retries the same path asGET— so a read-style route handler works without changes.x-shiply-croncarries the schedule expression that fired, so one route can serve several schedules.- The request host is
shiply-cron.internal, not your domain. If your handler builds absolute URLs from the incomingHost, read your public hostname from a variable instead.
So write an ordinary route:
// app/api/cron/daily-reminder/route.ts
export async function POST(req: Request) {
if (req.headers.get('authorization') !== `Bearer ${process.env.CRON_SECRET}`) {
return new Response('unauthorized', { status: 401 })
}
await sendDailyReminders()
return new Response('ok')
}
x-shiply-cronis not authentication. It's a plain request header, and your cron path is a normal route on a public site — anyone cancurlit and set that header themselves. Authenticate the real way:shiply secret set <slug> CRON_SECRET <random>and compare theAuthorization: Bearer …value, as above. shiply attaches that header automatically once the secret exists.
Deployed before 2026-07-27? Cron delivery lives in the per-site worker that
shiply publishuploads, so a site last deployed before that date won't deliver until it's published once more.shiply cron seton its own won't start it — the schedule registers and the dashboard lists it, but nothing fires. Oneshiply publishfixes it.
Manage from the CLI
shiply cron ls <slug> # list configured crons
shiply cron set <slug> /api/cron/daily "0 9 * * *" # add or replace one
shiply cron rm <slug> /api/cron/daily # remove oneManage from MCP
| Tool | Purpose |
|---|---|
list_crons | Read configured crons for a site |
set_cron | Add or replace a cron trigger |
remove_cron | Drop a cron trigger by path |
Limits
- 20 cron triggers per site (shiply cap; Cloudflare's per-Worker limit on the underlying paid plan is higher).
- 30 s CPU per scheduled run — same isolate budget as fetch handlers.
- No sub-minute schedules.
* * * * *(every minute) is the smallest interval; Cloudflare doesn't accept second-precision crontabs.