Scheduling Content Publication With Cron-Triggered Builds
A static site has no runtime, so there is nothing to check the clock and reveal a page at nine o'clock. That is usually presented as a limitation; it is actually a useful property, because it means an unpublished page is not merely hidden — it does not exist in the deployed artifact at all.
The pattern has two halves: exclude future-dated content at build time, and rebuild on a schedule so pages appear when their moment arrives. This guide covers both, plus the details that decide whether it works reliably — timezones, no-op deploys, and keeping genuinely embargoed content out of the repository's public history. It is part of Content Workflows for Documentation Teams.
Prerequisites
- Content with a reliable date field in front matter, including a timezone offset.
- A CI system that supports scheduled workflows (GitHub Actions
schedule, GitLab pipeline schedules, or a host-side cron calling a deploy hook). - A deploy that is cheap enough to run on a schedule — see Incremental Builds and Build Caching for SSGs if a full build is expensive.
Filter at Build Time
Every generator expresses the filter differently, but they all do it in one place:
// Astro — content collection filter
const guides = await getCollection('guides', ({ data }) =>
!data.draft && data.date.getTime() <= Date.now());
// Eleventy — collection filter in eleventy.config.js
eleventyConfig.addCollection('guides', (api) =>
api.getFilteredByGlob('content/guides/**/*.md')
.filter((item) => item.date <= new Date()));
{{/* Hugo — buildFuture is false by default, so this is automatic */}}
{{ range where .Site.RegularPages "Section" "guides" }}
Hugo is the odd one out in a good way: it excludes future-dated content by default (buildFuture: false), so scheduling works with no code at all. Check the setting explicitly rather than assuming, because a --buildFuture flag left in a script silently publishes everything.
Rebuild on a Schedule
# .github/workflows/scheduled-publish.yml
name: Scheduled publish
on:
schedule:
- cron: '5 * * * *' # hourly at :05 — avoid :00, when runners are busiest
workflow_dispatch: # manual trigger for "publish it now"
concurrency:
group: publish
cancel-in-progress: false # never cancel a deploy mid-flight
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- name: Anything newly eligible?
id: check
run: node scripts/pending-publish.mjs # exits 1 when there is nothing to do
- name: Build and deploy
if: steps.check.outcome == 'success'
run: npm run build && npx wrangler deploy
Two details in that file matter more than they look. Scheduling at :05 rather than :00 avoids the moment when every hosted runner in the world is contending for capacity, which on a busy platform can delay a job by several minutes. And cancel-in-progress: false prevents the next hour's run from killing a deploy that is halfway through.
Skip the Build When Nothing Changed
An hourly cron that always builds costs 24 builds a day to publish perhaps two pages a week. A pre-check makes the common case free:
// scripts/pending-publish.mjs — exit 0 if something is newly eligible, 1 otherwise
import { readFile } from 'node:fs/promises';
import { globSync } from 'node:fs';
import matter from 'gray-matter';
const lastRun = new Date(process.env.LAST_RUN || Date.now() - 3600_000);
const now = new Date();
let pending = [];
for (const file of globSync('content/**/*.md')) {
const { data } = matter(await readFile(file, 'utf8'));
if (!data.date || data.draft) continue;
const d = new Date(data.date);
if (d > lastRun && d <= now) pending.push({ file, date: d.toISOString() });
if (data.expires) {
const e = new Date(data.expires);
if (e > lastRun && e <= now) pending.push({ file, expires: e.toISOString() });
}
}
if (!pending.length) { console.log('nothing newly eligible'); process.exit(1); }
console.log(`publishing ${pending.length}:`, pending.map((p) => p.file).join(', '));
The alternative, useful when the build is cheap, is to build unconditionally and compare a hash of the output with what is deployed, skipping the deploy when they match. That also catches the case where a build produces identical output for an unrelated reason — a dependency bump that changes nothing visible.
| Approach | Build minutes/day | Publish precision | Catches unrelated drift |
|---|---|---|---|
| Hourly build, always deploy | ~48 min | 1 hour | No |
| Hourly build, deploy on output change | ~48 min | 1 hour | Yes |
| Hourly pre-check, build only when needed | ~2 min | 1 hour | No |
| 5-minute pre-check, build when needed | ~6 min | 7 min | No |
Get the Timezone Right
The most common scheduling bug is a date with no offset. date: 2026-08-08 means midnight in some timezone, and hosted CI runs in UTC, so a page intended for 9 a.m. in Berlin publishes at 2 a.m. or not until the next day depending on how the generator parses it.
# Unambiguous — publishes at 09:00 Central European Summer Time
date: 2026-08-08T09:00:00+02:00
Store the offset, compare in UTC, and display in whatever the reader's locale is. If your CMS writes dates without an offset, fix that in the CMS configuration rather than in the templates — one setting instead of a rule everyone has to remember.
Measured Impact
A documentation site publishing roughly eight scheduled pages a month, GitHub Actions, two-minute builds:
| Configuration | Builds/month | Minutes/month | Worst-case publish lag |
|---|---|---|---|
| Manual publish (merge when ready) | — | — | Hours to days, human-dependent |
| Hourly cron, always build | 720 | 1,440 | 62 min |
| Hourly cron with pre-check | 738 checks, 9 builds | 30 | 62 min |
| 15-minute cron with pre-check | 2,952 checks, 9 builds | 58 | 17 min |
The pre-check row is the one to adopt: publish latency is unchanged and build minutes drop by 98%, because a check that parses front matter takes about two seconds while a build takes two minutes. The 15-minute variant costs twice as much in checks and quarters the worst-case lag, which is worth it for release-day content and not for anything else.
Pitfalls & Rollback
- Dates without an offset. The single most common cause of content appearing at the wrong hour.
- Hiding future content client-side. It is in the HTML, therefore it is published. Filter at build.
- Cancelling in-progress deploys. A scheduled run that cancels a running deploy can leave a host mid-promotion.
- Building on the hour. Runner contention at
:00adds minutes of unpredictable delay; schedule a few minutes past. - No manual trigger. Always keep a
workflow_dispatchso someone can publish immediately without editing a date. - Rollback: the schedule is one workflow file. Disabling it stops future publication with no effect on what is already live, and content simply publishes on the next manual deploy instead.
Conclusion
Scheduled publishing on a static site is a filter plus a cron, and its main virtue is that unpublished content is genuinely absent rather than merely hidden. Store dates with an explicit offset, filter them out of the build, run a cheap pre-check on a schedule and only build when something is newly eligible, and keep a manual trigger for the day someone needs a page out now. Combine it with the debounced webhook from Wiring a Headless CMS to a Static Build and the publishing path is fully automatic.
FAQ
How precise can scheduled publishing be on a static site?
As precise as your cron interval plus the build and deploy time. An hourly cron with a two-minute build publishes within about an hour of the target; a five-minute cron publishes within about seven minutes. Precision costs build minutes, so pick the loosest interval the content actually needs.
Can I hide future content with JavaScript instead?
No. Anything rendered into the HTML is in the artifact and visible to anyone who reads the source, which defeats an embargo entirely. Future-dated content must be excluded at build time so it never reaches the deployed files.
How do I avoid deploying when nothing has changed?
Compare a hash of the build output with the previously deployed one, or check whether any content is newly eligible before building at all. The cheap version is a pre-build check that exits early when no page's publish date has passed since the last run.
What timezone does the schedule use?
Cron on hosted CI runs in UTC, and content dates are often written in local time. Store dates with an explicit offset and compare in UTC; a date with no timezone is the most common cause of content publishing a few hours early or late.
Does this work for un-publishing too?
Yes, and it is the same mechanism: filter out pages whose expiry date has passed and let the next scheduled build remove them. Add a redirect for the removed URL so the page does not simply start returning 404.
Related
- Parent: Content Workflows for Documentation Teams — the workflow this automates the end of.
- Wiring a Headless CMS to a Static Build — where the dates are entered.
- Netlify Build Hooks for Content Updates — triggering the same build from outside CI.
- Incremental Builds and Build Caching for SSGs — making a scheduled build cheap enough to run often.
- Keeping Redirects Working After an SSG Migration — what to do with an expired page's URL.