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
Host: <your-slug>.shiply.now
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 your site's slug host —
<slug>.shiply.now— even when the site also serves a custom domain. It has to be a host your app recognises: framework routers and middleware reject requests for unknown hosts, so the tick has to arrive on a real one to be routed at all. If your handler builds absolute URLs from the incomingHostand you serve a custom domain, read your public hostname from a variable rather than trustingHost.
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.
Haven't published since 2026-07-29? Publish once. Cron delivery lives in the per-site worker that
shiply publishuploads, so both of these are fixed by one deploy and nothing else —shiply cron seton its own won't start a tick, because the schedule registers on the Cloudflare side while the delivery code stays whatever your last publish uploaded.
Last published Symptom Before 2026-07-27 Framework apps never deliver at all — the schedule registers and the dashboard lists it, but nothing fires. Before 2026-07-29 Delivery runs, but on a synthetic host ( shiply-cron.internal). Framework routers reject the unknown host, so the tick is dropped before it reaches your route — silently, with no error anywhere.
Changed your subdomain handle? Publish again. The delivery host is baked into your worker when you publish, so after
shiply set_handleit still carries the old<slug>.shiply.nowuntil the nextshiply publish. Your site keeps serving on the new handle either way — this only affects the host crons arrive on, and only matters if your app checks 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.