Hugo leaves out any post dated later than the moment it builds. Date a post next Tuesday and push it today, and it stays off the site entirely, feed included. It appears on the first build on or after its date. We build when you push or press Deploy, so on the day itself nothing happens unless something triggers a build.

A GitLab pipeline schedule can be that trigger. Each morning it looks for a post dated today. If it finds one, it pushes an empty commit. That push reaches us through your webhook like any other, and your site rebuilds with the post in it.

You need a Hugo site that deploys from GitLab with the webhook set up (see Deploy from GitLab), and the Maintainer role on the project.

1. Date your posts

Put the publish date in the post’s front matter:

---
title: "My scheduled post"
date: 2026-11-10
---

A date with no time counts from midnight, UTC unless you have set timeZone in your Hugo config. Hugo holds future posts back by default. If your build command includes --buildFuture (or -F), take it out, or every post goes live as soon as you push it.

2. Make a project access token

The job pushes to your repository, so it needs a token that is allowed to.

  1. In your GitLab project, open Settings → Access tokens and choose Add new token.
  2. Name it publish-scheduled, set an expiry date, choose the Maintainer role and tick write_repository.
  3. Copy the token. GitLab shows it only once.

Maintainer is the role GitLab lets push to a protected branch, and main is protected unless you changed it.

3. Store the token as a CI/CD variable

In Settings → CI/CD → Variables, choose Add variable:

  • Key: PUBLISH_TOKEN
  • Value: the token
  • Visibility: Masked
  • Protect variable: ticked. The schedule runs on main, a protected branch, so it can still read the variable.

4. Add the job

Add this to .gitlab-ci.yml. Change content/posts if your posts live in another section.

publish-scheduled:
  image: alpine:latest
  rules:
    - if: '$CI_PIPELINE_SOURCE == "schedule"'
  variables:
    GIT_STRATEGY: clone
  before_script:
    - apk add --no-cache git
  script:
    - today=$(date -u +%Y-%m-%d)
    - |
      due=$(grep -rlE "^date *[:=] *\"?${today}" content/posts || true)
      if [ -z "$due" ]; then
        echo "No post dated $today."
        exit 0
      fi
      echo "Due today:"
      echo "$due"
      git fetch origin "$CI_DEFAULT_BRANCH"
      git checkout -B "$CI_DEFAULT_BRANCH" "origin/$CI_DEFAULT_BRANCH"
      git config user.email "publish-bot@example.com"
      git config user.name "publish-bot"
      git commit --allow-empty -m "Publish posts dated $today [skip ci]"
      git push "https://oauth2:${PUBLISH_TOKEN}@${CI_SERVER_HOST}/${CI_PROJECT_PATH}.git" "HEAD:$CI_DEFAULT_BRANCH"

The job runs only from a schedule, never on your own pushes. It finds posts in YAML or TOML front matter, as single files or page bundles. [skip ci] stops the empty commit from starting another pipeline; the webhook still fires, and that is what rebuilds your site.

If your file has other jobs with rules: changes, they will run each morning too: GitLab treats every file as changed in a scheduled pipeline. To keep one of them out, give it this as its first rule:

    - if: '$CI_PIPELINE_SOURCE == "schedule"'
      when: never

5. Create the schedule

In Build → Pipeline schedules, choose New schedule:

  • Description: Publish scheduled posts
  • Interval pattern: Custom, 0 7 * * * (07:00 every day)
  • Cron timezone: London
  • Target branch: main
  • Activated: ticked

Save it.

Check it works

Date a test post today and press the play button beside the schedule. The pipeline’s log lists the post it found, a commit called “Publish posts dated …” appears on main, and your StaticHost dashboard shows a new deployment for it. On a day with nothing due, the log says “No post dated …” and nothing is pushed.

Things to know

  • Times: If you give posts a time as well as a date (date: 2026-11-10T09:00:00Z), the post appears at the first build after that time, so run the schedule later than the latest time you use.
  • Any push publishes: A post that falls due also goes live with the next push you make after its time, schedule or not.
  • The token expires: When it does, the job fails and posts stop appearing on their day. Note the expiry date and replace the token before then.