# Scheduled Publishing with GitHub Actions Every 3 Hours

> Solve two challenges at once on a static blog: scheduled publishing and Cloudflare build limits. Run GitHub Actions publish checks every 3 hours and trigger builds only when needed.

**Published:** 2026-01-30 | **Updated:** 2026-01-30

---


## 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."

{{< img src="images/contents/og_bg_thumb.png" alt="Thumbnail image symbolizing a workflow that runs publish checks every 3 hours - a combination of GitHub Actions and Cloudflare Pages" caption="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.**

{{< img src="images/contents/traffic-signal-build-limit.jpg" alt="Exceeding Cloudflare build limits is like hitting a red traffic light - a pause is needed" caption="Photo: <a href='https://unsplash.com/photos/traffic-light-showing-red-signal-tK082PWHpuk' target='_blank' title='Opens in new window'>Unsplash</a> by <a href='https://unsplash.com/@exoloomx' target='_blank' title='Opens in new window'>Matias Argandona</a>" >}}

---

## 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.**

{{< img src="images/contents/scheduled-publish-flow.png" alt="Flow diagram showing the path from 3-hour check to conditional publishing - overview of the entire workflow" caption="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.**

{{< img src="images/contents/build-visibility-decision.png" alt="Flow diagram showing how draft and date are evaluated during a static site build to determine visibility - Hugo's build logic" caption="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](/posts/blog-comment-visibility-features/)

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.**

{{< img src="images/contents/github-actions-schedule.png" alt="Screenshot of the GitHub Actions workflow showing the schedule - cron expression and workflow_dispatch configuration" caption="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](/posts/language-switcher-banner/), 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."

---

## Reference Links (Official Documentation)

```
[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"
```

{{< img src="images/contents/cloudflare-api-token-create.png" alt="Screenshot of creating an API token in the Cloudflare dashboard - Pages Edit permission configuration" caption="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
```

{{< img src="images/contents/github-secrets-cloudflare-token.png" alt="Screenshot of registering a Cloudflare API token in GitHub Secrets - secure key management" caption="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](/posts/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.

