Field notes · cloudflare
Triggering Cloudflare deployments with a Cron Worker
A 20-line Cloudflare Worker can trigger one daily static-site rebuild, keep its deploy hook secret, and make publication failures observable in its logs.
Once I decided Cloudflare should own this blog’s publication clock, the actual
scheduler became pleasantly small. The production Worker is 20 nonblank lines.
It receives a scheduled event, reads one encrypted deploy-hook URL, sends one
POST, and fails without exposing the credential when the request does not
succeed.
The small size is not the interesting part. The useful design is the narrow contract around it. The Worker does not know which posts are due, how Astro filters content, or how Cloudflare builds the site. It only knows when to ask the existing build system to run.
Key takeaways
- Keep the scheduler responsible for one authenticated
POST. - Store the deploy-hook URL as a Worker secret, never as a source variable.
- Disable public Worker routes when the Worker only needs a cron trigger.
- Test missing-secret, network-failure, and non-success response paths.
- Verify the deployed cron and resulting build separately from the code.
What does the Cron Worker actually do?
The Worker turns a UTC cron event into one deploy-hook request. It does not publish a post directly. Cloudflare receives the hook, starts the configured build from the selected branch, and Astro decides which content belongs in that build based on its draft flag and publication date.
I structured the module as a small factory so the network call can be replaced in tests:
export function createScheduler(fetchImpl = fetch) {
return async function scheduled(_controller, env) {
if (!env.DEPLOY_HOOK_URL) {
throw new Error('Deploy hook secret is not configured')
}
const response = await fetchImpl(env.DEPLOY_HOOK_URL, {
method: 'POST',
})
if (!response.ok) {
throw new Error(`Deploy hook returned HTTP ${response.status}`)
}
}
}
export default {
scheduled: createScheduler(),
}
The production version also converts a network exception into a generic error and prevents a missing secret from being retried. Those details keep the hook URL out of logs and avoid repeated calls when the Worker cannot possibly succeed.
Cloudflare’s Cron Triggers documentation
defines the scheduled() handler used here. It also makes an operational detail
explicit: cron expressions use UTC. I schedule one daily request rather than
trying to maintain a separate trigger for every article.
Why use one daily rebuild instead of one cron per post?
A daily rebuild keeps the schedule stable while the content calendar changes. I can add, postpone, or remove future-dated Markdown files without editing the Worker. Astro remains the source of truth for eligibility, and Cloudflare only supplies a regular opportunity to publish what is due.
The checked-in trigger is:
{
"triggers": {
"crons": ["15 11 * * *"]
}
}
That expression requests a run at 11:15 UTC every day. It is intentionally not midnight in my local time zone. Midnight creates daylight-saving questions and can put a post’s date boundary too close to a build. A late-morning UTC build is easy for me to observe during the workday and still predictable year-round.
One daily build also absorbs missed dates gracefully. If a build fails on Tuesday, Wednesday’s run can publish the still-due article without changing the post. A per-post schedule would add state that has to be created, updated, and deleted for every editorial change.
This follows the architecture in Scheduling Astro Posts Without GitHub Actions: Astro owns the content rule, the hosting platform owns the clock, and the Worker connects the two.
How should the deploy-hook secret be handled?
Treat the complete deploy-hook URL as a password. Cloudflare’s
Deploy Hooks documentation
describes it as a unique URL that starts a build when it receives a POST. No
extra authorization header is required, which means possession of the URL is
the authorization.
I keep the URL out of three places:
- The repository and Worker configuration file.
- Test fixtures and snapshots.
- Error messages, including wrapped network exceptions.
I add it with Wrangler’s secret command so Cloudflare stores an encrypted binding:
npx wrangler secret put DEPLOY_HOOK_URL
The Worker reads env.DEPLOY_HOOK_URL at runtime. Tests pass an ordinary fake
environment value and a fake fetch function, so the real hook is never needed
locally.
The error boundary deserves attention. A failed request object can contain the full URL, depending on the runtime and library. Re-throwing the original error may put the secret in logs. My handler records only a generic network message or an HTTP status. That is enough to distinguish connection failure from a remote rejection without preserving the credential.
If the URL is ever committed, pasted into a public issue, or printed in a log, I would replace the deploy hook rather than merely delete the visible copy.
Why disable the Worker’s public URL?
This Worker does not serve a webpage or API. Its only entry point is the cron
trigger, so a public workers.dev route would add an unused surface. I disabled
both the development URL and preview URLs in the deployed configuration.
The relevant configuration is compact:
{
"workers_dev": false,
"preview_urls": false,
"observability": {
"enabled": true,
"head_sampling_rate": 1
}
}
Disabling public routes does not make the deploy-hook URL less sensitive. It simply keeps the scheduling Worker from accepting ordinary web traffic it does not need. The secret still authorizes builds, and logs still need to avoid it.
I enabled invocation logging because a timer that cannot be observed is just a hope. For a once-daily Worker, full sampling is a reasonable starting point. A high-volume service would need a different cost and retention decision.
What should the tests prove before deployment?
The tests should prove the scheduler’s contract without calling Cloudflare. My suite has five cases covering the exported handler, the one expected request, and each failure boundary. That gives me fast evidence before I touch a real secret or production schedule.
The behavioral checks are:
- The module exports a testable factory and a production scheduled handler.
- One invocation sends exactly one
POSTto the configured URL. - A missing secret stops before a network request.
- A non-success response exposes only the status code.
- A network failure does not expose the hook value.
The test fake records the URL and options passed to it:
const requests = []
const fakeFetch = async (url, options) => {
requests.push({ url, options })
return { ok: true, status: 200 }
}
const scheduled = createScheduler(fakeFetch)
await scheduled({}, { DEPLOY_HOOK_URL: 'https://example.invalid/hook' })
I then assert that requests contains one item and its method is POST. For
failures, I assert against the sanitized error text and also assert that the
fake secret is absent.
This style is the same constraint-first approach I use when deploying Flask on IIS: isolate the boundary that changes, make it testable, and leave the surrounding platform alone.
How do I verify the deployed path?
Local tests prove the handler, not the system. I verify deployment in layers: read back the Worker’s configuration, confirm the encrypted binding exists, confirm the production cron, observe a scheduled invocation, follow the resulting build, and finally request the expected public page.
Those checkpoints answer different questions:
| Checkpoint | What it proves |
|---|---|
| Unit test | The handler sends and rejects requests as designed |
| Worker readback | The intended script is deployed |
| Cron readback | Cloudflare has the expected UTC schedule |
| Invocation log | The scheduled event actually ran |
| Build history | The deploy hook started and completed a build |
| Public request | The due article reached the live site |
I do not collapse those into one green check. A deployed Worker can have no cron. A successful invocation can receive a failing hook response. A completed build can still exclude a draft or future post. The final public request is the only proof of publication.
Frequently asked questions
The implementation is small, but its security and verification boundaries are worth making explicit before it becomes unattended infrastructure.
Does the Worker need a public route for Cron Triggers?
No. A scheduled Worker can run from its configured Cron Trigger without a
public workers.dev URL. Disable routes that the Worker does not use.
Should the deploy hook be retried automatically?
Only with care. Repeated POST requests can start repeated builds. I begin with
one scheduled attempt and a daily natural retry, then add bounded retry behavior
only if the hook’s idempotency and Cloudflare’s limits are understood.
Can the same Worker publish several posts?
Yes. The Worker does not select posts. One build can publish every article whose date has arrived and whose draft flag is false.