- Aug 13, 2026
- 7 min read
Publish Future-Dated Posts on Cloudflare Pages With a Cron Worker
I wrote a batch of posts for one of my Hugo sites and dated them across the coming weeks, expecting each to appear on its day. Half of them never did. They were not broken. Nothing had rebuilt the site since their date passed.
Why scheduled posts don’t publish themselves
Two facts combine:
- Static site generators decide visibility at build time. Hugo skips
pages with a future
dateunlessbuildFutureis enabled. Astro has no such rule built in: it only applies if your collection code filters on the date (see Astro: add the filter yourself below). Wherever the filter exists, “now” means the moment the build ran. - Cloudflare Pages builds when you push. A git-connected project has no notion of “a post became publishable at midnight”.
So a post dated next Tuesday stays invisible until the next build after Tuesday — which may be days later, whenever you next push something.
On my site, 5 of 10 posts for one month were future-dated. They only went live if I happened to commit after their date.
The fix: rebuild every day
Pages offers deploy hooks: a URL that, when POSTed, starts a build of one project and one branch. A Cloudflare Worker with a cron trigger can call it daily.
cron 23:30 UTC
→ Worker site-rebuilds
→ POST each HOOK_<SITE> secret
→ Pages builds main → posts dated "today" go live
→ on failure: ntfy alert (optional)
Why 23:30 UTC? The sites are written from Poland. 23:30 UTC is 01:30 in summer and 00:30 in winter — always just after local midnight, whatever the daylight saving time, so a post dated “today” is publishable when the build runs.
Cost: one build per site per day. The Pages free tier allows 500 builds per month, so a handful of sites fits easily.
The whole Worker
The Worker treats every secret named HOOK_<SITE> as a deploy hook. Adding a
site means adding one secret, with no code change:
export function hooksFrom(env: Env): { name: string; url: string }[] {
return Object.entries(env)
.filter(([key, value]) =>
key.startsWith("HOOK_") && typeof value === "string" && value.startsWith("https://"))
.map(([key, value]) => ({ name: key.slice(5).toLowerCase(), url: value as string }))
.sort((a, b) => a.name.localeCompare(b.name));
}
export default {
async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext) {
ctx.waitUntil(triggerAll(env));
},
};
triggerAll POSTs each hook, logs triggered or FAILED with the status
code, and — if an ntfy URL is configured — sends one alert listing the
failures. A network error counts as a failure rather than crashing the run, so
one bad hook doesn’t stop the others.
The configuration is short:
name = "site-rebuilds"
main = "src/index.ts"
workers_dev = false
preview_urls = false
[triggers]
crons = ["30 23 * * *"]
workers_dev = false matters: this Worker has no HTTP handler and no reason
to be reachable at a public *.workers.dev address.
Deploy hooks are secrets
A deploy hook URL is a credential, but a pleasantly limited one: whoever has it can only trigger a rebuild of one branch of one project. That is exactly the least privilege this job needs. Still, it goes into a Worker secret, never into git:
# create the hook: project → Settings → Builds → Deploy hooks
npx wrangler secret put HOOK_FOSSENGINEER
To check it worked without waiting until midnight, trigger the hook once and
look at the deployments list: the new build shows the trigger deploy_hook
instead of a git push.
npx wrangler pages deployment list --project-name <project>
npx wrangler tail site-rebuilds # live logs around 23:30 UTC
One limitation: deploy hooks only exist for git-connected Pages projects.
Sites deployed by direct upload (wrangler pages deploy) need their own
scheduled build-and-upload job instead.
Gotcha: two pages, one URL
The day I set this up I hit a confusing bug. A post existed twice: a short
stub, and the real article, both declaring the same url: in their front
matter. The real one was dated tomorrow, so only the stub was eligible at
build time — visitors saw the stub.
My first reflex was to mark the stub draft: true. That produced a 404:
with the stub hidden and the real post still in the future, nothing claimed
the URL at all. The right fix was to retire the stub entirely and let the
daily rebuild publish the real post on its date.
General lesson: with scheduled content, ask “what owns this URL at today’s build?”, not “what owns it eventually?”.
Astro: add the filter yourself
Added October 2026, when this site joined the daily rebuild.
This site is Astro, and for months a future-dated article went live on the
next push, date or no date. Astro’s content collections return every entry;
hiding future ones is up to you. I filter in both places that use the
collection: the article list and getStaticPaths of the article page. A
future article then has no page, no list entry and no sitemap URL.
/**
* Future-dated articles stay hidden until their day (Polish time).
* Compare calendar dates in Europe/Warsaw: at 23:30 UTC it is already
* "tomorrow" there but not yet in UTC.
*/
export function isPublished(entry: { data: { date?: Date } }, now = new Date()): boolean {
if (!entry.data.date) return true;
const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Warsaw" }).format(now); // YYYY-MM-DD
return entry.data.date.toISOString().slice(0, 10) <= today;
}
---
export async function getStaticPaths() {
const articles = (await getCollection("articles")).filter((a) => isPublished(a));
return articles.map((article) => ({ params: { id: article.id } }));
}
---
The timezone trap. The obvious filter is date <= new Date(). A
frontmatter date like 2026-10-11 becomes midnight UTC. The rebuild runs
at 23:30 UTC on the 10th, which is already the 11th in Poland, but 23:30 on
the 10th is still before midnight UTC on the 11th. The post would wait a
whole extra day for the next build. Comparing calendar dates in the readers’
timezone fixes it. I checked both sides of the boundary, in summer and winter
time:
| Build time (UTC) | Post dated | Published? |
|---|---|---|
| 10 Oct, 21:59 (23:59 in Poland) | 11 Oct | No |
| 10 Oct, 23:30 (01:30 in Poland) | 11 Oct | Yes |
| 1 Dec, 23:30 (00:30 in Poland) | 2 Dec | Yes |
The en-CA locale is a convenience: it formats dates as YYYY-MM-DD, so plain
string comparison orders them correctly.
Adding the site to the rebuild was then configuration only: one more deploy
hook stored as HOOK_JALCOCERTECH, picked up by the same Worker.
Alternatives I considered
| Option | Why not (for me) |
|---|---|
buildFuture: true | Publishes everything immediately; defeats scheduling |
| GitHub Actions cron pushing an empty commit | Pollutes history; ties scheduling to the repo host |
| Rebuilding by hand | Exactly the problem I was solving |
| A cron job on the homelab | Works, but depends on the homelab being up; the Worker runs at the edge |
Takeaways
- Scheduled posts in a static site are only as fresh as the last build.
- A deploy hook plus a cron Worker is about fifty lines of code and costs one build per day.
- Pick the cron time from the reader’s midnight, accounting for daylight saving, and compare dates in that timezone too.
- Astro won’t hide future posts for you: filter the list and
getStaticPaths. - Name secrets by convention (
HOOK_<SITE>) so adding a site is configuration, not code. - Alert only on failure; a daily “it worked” message is noise.
The alert reuses the same self-hosted ntfy as my lead forms (locked down behind a tunnel), and the Worker sits next to the forms Worker that writes leads to KV.
Next: one canonical address per site — apex vs www and pages.dev redirects.