Introduction

When running a static blog, “scheduled publishing” and “build limits” often become problems at the same time. In particular, building on every commit can easily hit build limits on platforms like Cloudflare Pages.

In this post, I’ll walk you through the approach I’m actually using: running a publish check with GitHub Actions every 3 hours to handle scheduled publishing. The key idea is “check only at set intervals, and publish only when needed.”

Thumbnail image symbolizing a workflow that runs publish checks every 3 hours - a combination of GitHub Actions and Cloudflare Pages
Thumbnail image symbolizing a workflow that runs publish checks every 3 hours - a combination of GitHub Actions and Cloudflare Pages
Image: Generated with Nanobanana AI

Why a 3-Hour Check Cycle Was Necessary

1) To Handle Scheduled Publishing Reliably

Static sites fundamentally don’t automatically publish posts as time passes. A rebuild has to run for the publish status to change. So to do “scheduled publishing,” a build must be triggered at the right time.

2) To Avoid Cloudflare Build Limits

If you frequently refine posts or prepare multiple drafts, build counts can increase rapidly. A setup where every commit triggers a build exhausts your quota quickly. That’s why I switched to checking the status every 3 hours and only building when necessary.

Exceeding Cloudflare build limits is like hitting a red traffic light - a pause is needed
Exceeding Cloudflare build limits is like hitting a red traffic light - a pause is needed
Photo: Unsplash by Matias Argandona

How It Works

Here’s the overall flow:

  1. Every 3 hours, GitHub Actions runs (UTC-based)
  2. Checks the repository for posts scheduled for publishing
  3. If conditions are met, runs the publish script
  4. Commits and pushes changes → Cloudflare builds

This way, “scheduled publish checks” happen periodically, but actual publishing only occurs when the conditions are right.

Flow diagram showing the path from 3-hour check to conditional publishing - overview of the entire workflow
Flow diagram showing the path from 3-hour check to conditional publishing - overview of the entire workflow
Image: Generated with Nanobanana AI

When Visibility Is Decided in a Static Site Build

Static site generators like Hugo evaluate draft and date at build time. That means if the following conditions are met at the moment a build runs, the post is generated as a public page:

  • draft: false
  • date is in the past relative to the current time

So “scheduled publishing” only works at build time. Without a build, even if the scheduled time passes, the page won’t be generated. In other words, scheduled publishing = build timing design.

Flow diagram showing how draft and date are evaluated during a static site build to determine visibility - Hugo's build logic
Flow diagram showing how draft and date are evaluated during a static site build to determine visibility - Hugo's build logic
Image: Generated with Nanobanana AI

Designing the Publish Conditions

The core of my approach is the “publish conditions.” For example:

  • Is there a post that has been switched to draft: false?
  • Is the scheduled time (date) in the past relative to the current time?
  • Has it already been deployed in a previous commit?

The actual publish script only runs when these conditions are met. This way, unnecessary builds are minimized.

The condition check is handled by running .github/scripts/check-scheduled-posts.sh in the workflow. If this script returns true, it proceeds to the publish step.

Actual Check Logic Summary (My Workflow)

The script checks two things: scheduled posts and new comments.

  1. Scheduled Post Check

    • Only targets posts with draft: false
    • date must be in the past relative to current UTC time
    • Only includes posts whose publish time arrived within the last 6 hours (Excludes old posts to prevent unnecessary builds)
  2. New Comment Check (Summary) Only a simple check is performed to reflect comment updates. The detailed structure and operations are explained in a previous post. → Building a Dynamic Comment System on a Static Site: Giscus + GraphQL API

In other words, the build is triggered if either “scheduled publishing” or “comment updates” condition is met.


GitHub Actions Schedule Configuration

GitHub Actions supports periodic execution via schedule. I’ve configured it to run every 3 hours, and also added workflow_dispatch so it can be triggered manually when needed. (UTC-based)

yaml
on:
  schedule:
    - cron: "0 */3 * * *"
  workflow_dispatch:

Since it runs near the top of each hour, it aligns well with scheduled publishing. However, GitHub Actions execution times can be delayed, so it’s safer to set the scheduled time with a few minutes of buffer. Note that the cron above is UTC-based, so in Korean Standard Time (KST), it runs at 3-hour intervals (e.g., 09:00, 12:00, 15:00).

Note: It’s safest to run schedule workflows from the workflow file on the default branch.

Screenshot of the GitHub Actions workflow showing the schedule - cron expression and workflow_dispatch configuration
Screenshot of the GitHub Actions workflow showing the schedule - cron expression and workflow_dispatch configuration
Screenshot: scheduled-publish.yml

Key Points of the Actual Workflow

The workflow I use (scheduled-publish.yml) has four main stages:

  1. Checkout + remote sync
  2. Scheduled publish condition check (check-scheduled-posts.sh)
  3. Trigger build with empty commit if conditions are met
  4. Output summary report

Here’s a summary of the key structure:

yaml
jobs:
  check-and-publish:
    steps:
      - uses: actions/checkout@v4
      - run: git fetch origin main && git pull --rebase origin main
      - run: .github/scripts/check-scheduled-posts.sh
      - if: steps.check.outputs.should_build == 'true'
        run: |
          git commit --allow-empty -m "chore: trigger build for scheduled posts"
          git push

Thanks to this setup, no build occurs when there are no scheduled posts.


Benefits I’ve Gained from This Approach

1) Scheduled Publishing Works Reliably

Thanks to the “check at set intervals” structure, scheduled publishing is automated.

2) Build Limit Management

Since publishing only happens when needed, I was able to significantly ease the Cloudflare build limit pressure.

3) Simpler Operations Rhythm

Just write a post and switch to draft: false, and it gets automatically published in the next 3-hour cycle. This reduces operational burden. I’ve been applying this automation approach to other blog features as well. Like the multilingual language switcher UX improvement, I prefer structures that run on their own once set up, without ongoing maintenance.


GitHub/Cloudflare Free Tier Usage (as of 2026-01)

When planning your operations strategy, clearly understanding the free tier limits is the most important thing. Here’s a summary based on official documentation.

GitHub Actions (Free Tier Summary)

  • Public repos: Free when using default GitHub-hosted runners
  • Private repos: Free minutes/storage depending on plan
  • GitHub Free (personal): 2,000 min/month, 500MB storage
  • Per-minute multipliers: Linux 1x, Windows 2x, macOS 10x

Public repos are free with default runners, but larger runners may incur charges. Check the reference links below for detailed criteria.

Cloudflare Pages (Free Tier Summary)

  • Build limit: 500 builds/month
  • Build timeout: 20 minutes
  • Concurrent builds: Counted at the account level

Cloudflare Pages Free plan limits are summarized based on the official Limits documentation.

The core strategy of this post is to keep within the 500-build limit by “building only when needed.”


[1] GitHub Actions billing and usage (public repo free, plan-specific free minutes/storage, larger runner charges)
    https://docs.github.com/en/actions/concepts/billing-and-usage

[2] GitHub Actions product billing (free minutes/storage table by plan)
    https://docs.github.com/en/enterprise-cloud@latest/billing/concepts/product-billing/github-actions

[3] Cloudflare Pages limits (Free plan 500 builds, 20-minute timeout, concurrent builds counted per account)
    https://developers.cloudflare.com/pages/platform/limits/

Scheduled Publishing Tip: “Exact Times” Can Cause Delays

GitHub Actions is schedule-based, but delays of a few minutes can occur. Especially if you set the scheduled time to exactly on the hour, the schedule might have already passed by the time it runs, causing the post to be published in the next 3-hour cycle.

Here are the workarounds I use:

  1. Set the scheduled time 5-10 minutes earlier than the target Example: If you want to publish at 12:00, set date: 11:50.

  2. Use workflow_dispatch for manual triggering when needed If precise timing matters, trigger a build manually right after the scheduled time.

  3. (Optional) Set a tighter schedule interval If you have build limit headroom, you can reduce from 3 hours to 1 hour. However, you’ll need to recalculate build count and cost implications.

I found it more practical to design around reliable publishing rather than “exact timing.”


Cloudflare Build Trigger Method

When scheduled publishing is needed, the workflow creates an empty commit to trigger a Cloudflare Pages build. Even without actual changes, a build occurs, making it suitable for scheduled publishing.

bash
git commit --allow-empty -m "chore: trigger build for scheduled posts"
git push

Since it’s recorded in the repository, it’s also easy to track when and why a build was triggered.


Strategies to Avoid Exceeding Free Limits

GitHub Actions Strategies

  1. Stick to Linux runners macOS/Windows have higher per-minute multipliers, consuming free minutes faster. It’s safer to stick to Linux when possible.

  2. Minimize schedules + conditional execution Running only when conditions are met, as in this setup, significantly reduces minute consumption.

  3. Reduce unnecessary artifacts/cache Repository storage also counts toward the free limit, so don’t retain artifacts beyond what’s needed.

Cloudflare Pages Strategies

  1. Consolidate build triggers Unifying trigger methods (deploy hooks, empty commits, etc.) prevents duplicate builds.

  2. Align publishing and build schedules Schedule publish times to match the build cycle so that “multiple posts are included in a single build.”

  3. Prevent build failures Manage image optimization and caching proactively to stay within the build timeout (20 minutes).

Cloudflare API Key (Token) Integration

The current workflow triggers Cloudflare Pages builds via empty commits, so a Cloudflare API key isn’t strictly required. However, in the following cases, using an API token or Deploy Hook can be clearer:

  • When you want to trigger a build from an external event without a GitHub commit
  • When you want fine-grained control over build triggers

Method A) Using Deploy Hooks (Simplest)

Cloudflare Pages Deploy Hooks trigger a build with a single URL call. Since there’s no separate authentication, the URL itself must be kept secret. Therefore, storing this URL in GitHub Secrets is the safe approach.

bash
curl -X POST "$CLOUDFLARE_DEPLOY_HOOK_URL"

Method B) Using Cloudflare Pages API (Token-Based)

To use the Pages API, create an API Token and grant Cloudflare Pages permissions at the Edit level. Store this token in GitHub Secrets.

Example (Secrets names):

  • CLOUDFLARE_API_TOKEN
  • CLOUDFLARE_ACCOUNT_ID
  • CLOUDFLARE_PROJECT_NAME
bash
curl "https://api.cloudflare.com/client/v4/accounts/$CLOUDFLARE_ACCOUNT_ID/pages/projects/$CLOUDFLARE_PROJECT_NAME/deployments" \
  --request GET \
  --header "Authorization: Bearer $CLOUDFLARE_API_TOKEN"
Screenshot of creating an API token in the Cloudflare dashboard - Pages Edit permission configuration
Screenshot of creating an API token in the Cloudflare dashboard - Pages Edit permission configuration
Screenshot: Cloudflare API tokens

Storing Keys in GitHub Secrets

In GitHub Actions, store keys only in Secrets and use them as environment variables in the workflow. You can register them via the UI or the gh CLI.

bash
gh secret set CLOUDFLARE_API_TOKEN
Screenshot of registering a Cloudflare API token in GitHub Secrets - secure key management
Screenshot of registering a Cloudflare API token in GitHub Secrets - secure key management
Screenshot: GitHub Actions secrets

Caution: Never output tokens or hook URLs to logs via echo. Secrets are masked by default, but directly printing them can lead to security incidents.


Future Improvements I’m Considering

Currently, only the “publish check” is automated. Going forward, I’m also considering:

  • Pre-deployment automated validation (links, alt text, metadata length) — As I experienced with the code block accessibility improvement, relying on manual checks inevitably leads to things being missed.
  • Failure notifications (Slack/Discord alerts)

Wrapping Up

Running a publish check every 3 hours with GitHub Actions practically solves the scheduled publishing problem for static blogs. At the same time, it helps address operational issues like Cloudflare build limits.

In static site operations, “automation” turned out to be not a luxury but a survival strategy. If you’re dealing with similar challenges, this approach might help.