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

Where future-dated content is excluded A pipeline showing three content items entering the build: two with past dates and one with a future date. The build filter excludes the future-dated item, so the deployed artifact contains only two pages. A later scheduled rebuild includes all three once the date has passed. Excluded from the artifact, not hidden in it guide-a · 12 Jul guide-b · 28 Jul guide-c · 8 Aug build filter date <= now deployed: guide-a, guide-b guide-c: no HTML, no sitemap entry nothing to discover Scheduled rebuild on 8 Aug → guide-c is now eligible and ships no runtime check, no CMS involvement, no manual step
Because the page is never rendered, there is no HTML to find, no sitemap entry to crawl and no payload to inspect — the strongest form of "not published yet" a website can offer.

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.

ApproachBuild minutes/dayPublish precisionCatches unrelated drift
Hourly build, always deploy~48 min1 hourNo
Hourly build, deploy on output change~48 min1 hourYes
Hourly pre-check, build only when needed~2 min1 hourNo
5-minute pre-check, build when needed~6 min7 minNo

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.

The same date field interpreted three ways A date written as 2026-08-08 with no timezone is interpreted as midnight UTC by the build, as 2 a.m. local by an author in Berlin, and as 8 p.m. the previous day by a reader in New York. Writing the offset explicitly makes all three agree on one instant. One field, three different instants date: 2026-08-08 build (UTC): 08 Aug 00:00 author in Berlin expects: 08 Aug 09:00 (+02:00) reader in New York sees: 07 Aug 20:00 date: 2026-08-08T09:00:00+02:00 one instant: 08 Aug 07:00 UTC build, author and reader all agree Daylight saving makes this worse twice a year — the offset, not the local time, is what to store
The failure is silent: nothing errors, the page simply appears at a time nobody intended, and the person who notices is usually the one who announced it.

Measured Impact

A documentation site publishing roughly eight scheduled pages a month, GitHub Actions, two-minute builds:

ConfigurationBuilds/monthMinutes/monthWorst-case publish lag
Manual publish (merge when ready)Hours to days, human-dependent
Hourly cron, always build7201,44062 min
Hourly cron with pre-check738 checks, 9 builds3062 min
15-minute cron with pre-check2,952 checks, 9 builds5817 min
Build minutes per month by scheduling strategy A horizontal bar chart on a shared scale. Always building hourly costs 1,440 build minutes a month. An hourly pre-check that builds only when needed costs 30 minutes. A fifteen-minute pre-check costs 58 minutes while cutting worst-case publish lag from 62 minutes to 17. Same publish latency, 98% fewer build minutes Hourly, always build lag 62 min 1,440 min Hourly + pre-check lag 62 min 30 min 15-min + pre-check lag 17 min 58 min Shared linear scale · 8 scheduled pages a month, 2-minute build, 2-second pre-check
The pre-check is the whole trick: parsing front matter costs seconds, so the expensive step only runs on the few occasions when something is genuinely due.

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 :00 adds minutes of unpredictable delay; schedule a few minutes past.
  • No manual trigger. Always keep a workflow_dispatch so 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.