# GitLab Scheduled Pipelines: Delays, Inactive Schedules, and Alerts

By Dan Ochoa · Published 2026-10-30 · Updated 2026-10-30
Canonical: https://cronradar.com/blog/gitlab-scheduled-pipelines

> How GitLab pipeline schedules work: cron and time zone, why scheduled pipelines start late, why schedules go inactive, schedule rules, and alerts.

**A GitLab pipeline schedule runs your `.gitlab-ci.yml` pipeline on a five-field cron pattern for a branch or tag you choose; create one under Build > Pipeline schedules.** It doesn't start on the minute. An internal pipeline schedule worker queues due pipelines only when it runs, every 5 minutes on GitLab.com and every 10 minutes on a default self-managed instance; on GitLab.com Free, a schedule whose minute isn't a multiple of 5 waits for the next full hour. GitLab says load can push a start "several minutes to over an hour after their scheduled time". A schedule also goes inactive when its owner is blocked or removed from the project, and an inactive schedule produces no pipeline to fail.

This guide covers GitLab 19.5.3, the current release on [GitLab's release notes](https://docs.gitlab.com/releases/patches/patch-release-gitlab-19-5-3-released/) (checked October 30, 2026), and cites the [GitLab 19.5 documentation](https://docs.gitlab.com/19.5/) for GitLab.com and self-managed instances. The same problems on other CI systems are covered in [GitHub Actions scheduled workflows](https://cronradar.com/blog/github-actions-scheduled-workflows) and [Jenkins scheduled builds](https://cronradar.com/blog/jenkins-scheduled-builds).

## Create a Pipeline Schedule

In the project, select **Build** > **Pipeline schedules** > **New schedule** and fill in the fields ([scheduled pipelines](https://docs.gitlab.com/19.5/ci/pipelines/schedules/#create-a-pipeline-schedule)):

- **Interval Pattern:** a preconfigured interval or a custom cron pattern.
- **Target branch or tag:** the ref the pipeline runs for.
- **Inputs:** values for the `spec:inputs` in your configuration, "used every time the scheduled pipeline runs", at most 20.
- **Variables:** CI/CD variables "available only when the scheduled pipeline runs". GitLab recommends inputs instead, and a project setting can refuse schedule variables altogether (see the invalid-configuration section below).

You need the Developer, Maintainer, or Owner role, a verified primary email, merge permission if the target branch is protected, and a `.gitlab-ci.yml` with valid syntax. Whoever creates the schedule becomes its owner, and "the pipeline runs with your permissions." A project can hold 10 schedules by default ([number of pipeline schedules](https://docs.gitlab.com/19.5/administration/cicd/limits/#number-of-pipeline-schedules)); GitLab.com allows 10 on Free and 50 on paid tiers ([GitLab.com settings](https://docs.gitlab.com/19.5/user/gitlab_com/#cicd)).

### Cron Syntax and Time Zone

The pattern has five fields: minute, hour, day of month, month, and day of week, 0 to 6 for Sunday to Saturday ([cron syntax](https://docs.gitlab.com/19.5/topics/cron/)). GitLab parses it with fugit, which also accepts `0 0 * * 1#2` for the second Monday and `0 9 * * sun%2` for every other Sunday, syntax GitLab warns "might not work in all cron testers" ([how GitLab parses cron](https://docs.gitlab.com/19.5/topics/cron/#how-gitlab-parses-cron-syntax-strings)). [Cron expression syntax](https://cronradar.com/blog/cron-expression) covers the standard fields and how other schedulers' dialects differ.

Each schedule carries its own zone. The [pipeline schedules API](https://docs.gitlab.com/19.5/api/pipeline_schedules/#create-a-new-pipeline-schedule) calls it `cron_timezone`, a "time zone supported by `ActiveSupport::TimeZone`, for example: `Pacific Time (US & Canada)`", with a default of `UTC`. To create a schedule in UTC with an access token in `GITLAB_TOKEN`:

```bash
curl --request POST \
  --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  --form "description=Nightly report" \
  --form "ref=main" \
  --form "cron=17 3 * * *" \
  --form "cron_timezone=UTC" \
  --url "https://gitlab.com/api/v4/projects/$PROJECT_ID/pipeline_schedules"
```

GitLab's documentation doesn't say what a schedule in a zone with daylight saving does in the hour that is skipped in spring or repeated in autumn. [Cron and daylight saving time](https://cronradar.com/blog/cron-daylight-saving-time) shows how other schedulers differ there; a `cron_timezone` of `UTC` avoids the question.

## How Often GitLab Actually Starts a Scheduled Pipeline

GitLab's limits page explains the gap. Scheduled pipelines "do not always run exactly when scheduled. An internal process, called the 'pipeline schedule worker', queues all the scheduled pipelines, but does not run continuously." A due pipeline is "only queued the next time the worker runs", and "scheduled pipelines can't run more frequently than the worker" ([maximum scheduled pipeline frequency](https://docs.gitlab.com/19.5/administration/cicd/limits/#maximum-scheduled-pipeline-frequency)).

| Setting                        | GitLab.com                                                       | Self-managed default                         |
| ------------------------------ | ---------------------------------------------------------------- | -------------------------------------------- |
| Pipeline schedule worker       | `*/5 * * * *`                                                    | `3-59/10 * * * *` (:03, :13, :23, and so on) |
| Pipelines per schedule per day | 24 on Free (once an hour at most), 288 on paid (every 5 minutes) | `0`, meaning no daily limit                  |

The GitLab.com values come from its [CI/CD settings](https://docs.gitlab.com/19.5/user/gitlab_com/#cicd). On self-managed, `ci_daily_pipeline_schedule_triggers` defaults to `0` ([`db/structure.sql`](https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/db/structure.sql#L28299)), and GitLab ignores a limit that isn't positive ([`plan_limits.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/models/plan_limits.rb#L57-65)), so only the worker sets the pace. What they do to a schedule:

- **The worker adds a wait.** A `17 3 * * *` schedule is queued at the 03:20 worker run on GitLab.com paid tiers, and at 03:23 on a default self-managed instance. On GitLab.com Free, the 24-a-day limit moves it to 04:00: GitLab keeps the schedule's own time only when a worker run falls on it, and otherwise uses the next full hour ([next-run calculation](https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/services/ci/pipeline_schedules/calculate_next_run_service.rb#L11-25)). GitLab's tests expect a `59 14 * * *` schedule under that limit to run at 15:00 ([spec](https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/spec/services/ci/pipeline_schedules/calculate_next_run_service_spec.rb#L76)), while `15 3 * * *` runs at 03:15.
- **Schedules faster than the limits are slowed.** "Schedules that try to run pipelines more frequently than the limit are slowed to a maximum frequency": 1,440 minutes divided by the daily limit ([pipelines per schedule](https://docs.gitlab.com/19.5/administration/cicd/limits/#limit-the-number-of-pipelines-created-by-a-pipeline-schedule-each-day)). With GitLab.com Free's 24, that's once every 60 minutes, so a `*/15 * * * *` schedule runs at most hourly. On a default self-managed instance, a `*/5` schedule can't run more often than the worker's 10 minutes.
- **Load adds more.** The worker processes schedules in batches "with a small delay between each batch to distribute system load. This can cause pipeline schedules to start several minutes to over an hour after their scheduled time, depending on system load."

Then the pipeline's jobs wait for a runner like any others. A pending job is dropped after 1 hour with no matching runners (`stuck_pending_no_matching_runners`) and after 24 hours with matching ones (`stuck_pending_with_matching_runners`) ([dropped jobs](https://docs.gitlab.com/19.5/ci/jobs/job_troubleshooting/#job-dropped-after-being-stuck-or-exceeding-its-timeout)). On instance runners with a compute quota, which GitLab.com enables for Free namespaces at 400 minutes a month ([compute minutes](https://docs.gitlab.com/19.5/ci/pipelines/compute_minutes/)), a used-up quota means "instance runners stop processing new jobs", and GitLab notes this "can look like a runner availability problem" ([enforcement](https://docs.gitlab.com/19.5/ci/pipelines/instance_runner_compute_minutes/#enforcement)).

**Stagger schedules.** GitLab's troubleshooting advice is to replace popular patterns such as `0 * * * *` with ones spread across the hour, like `5 * * * *`, `15 * * * *`, and `25 * * * *` ([distribute pipeline schedules](https://docs.gitlab.com/19.5/ci/pipelines/schedules/#distribute-pipeline-schedules-to-prevent-system-load)). [Cron jitter](https://cronradar.com/blog/cron-midnight-load-spreading) covers choosing offsets; on GitLab.com Free, keep them to multiples of 5 minutes so a worker run falls on them.

**On self-managed, the limits are yours.** Set `gitlab_rails['pipeline_schedule_worker_cron']` in `gitlab.rb` and reconfigure GitLab to change the worker; GitLab's example, `0 */12 * * *`, caps every schedule at twice a day. To add a daily limit, set it in the Rails console with `Plan.default.actual_limits.update!(ci_daily_pipeline_schedule_triggers: 1440)`, where 1440 allows one pipeline a minute, and `Plan.default.actual_limits` [lists the current values](https://docs.gitlab.com/19.5/administration/instance_limits/#list-all-instance-limits).

### Measure the Delay

The API lists each pipeline a schedule created, with its `created_at` ([pipelines of a schedule](https://docs.gitlab.com/19.5/api/pipeline_schedules/#list-all-pipelines-triggered-by-a-pipeline-schedule)). For the daily `17 3 * * *` UTC schedule, this prints how many minutes after 03:17 each recent pipeline was created:

```bash
curl --silent --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  --url "https://gitlab.com/api/v4/projects/$PROJECT_ID/pipeline_schedules/$SCHEDULE_ID/pipelines?sort=desc" \
  | jq '.[].created_at | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601 | (. % 86400 - (3 * 3600 + 17 * 60)) / 60 | floor'
```

That covers the worker wait and batching. The runner wait comes after it, and the `start` check-in further down measures both. A day missing from the list is a day the schedule created no pipeline.

## Why a Pipeline Schedule Stops Running

| Cause                                                      | What you see                                       | Fix                                                                                                 |
| ---------------------------------------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| The owner was blocked or removed from the project          | `Inactive`, and an email to Owners and Maintainers | A Maintainer or Owner selects **Take ownership**, then edits the schedule and selects **Activated** |
| The schedule was deactivated                               | `"active": false` in the API                       | Update it with `active=true`                                                                        |
| The project or group is marked for deletion                | No scheduled pipelines                             | [Restore it](https://docs.gitlab.com/19.5/user/project/working_with_projects/#restore-a-project)    |
| `workflow:rules` or job `rules` add no jobs for `schedule` | No pipeline at the scheduled time                  | Add a rule for `$CI_PIPELINE_SOURCE == "schedule"`                                                  |
| The target branch's configuration is invalid               | A pipeline with a `yaml invalid` badge             | Fix the file and validate before merging                                                            |
| No runner takes the jobs                                   | Jobs `pending`, then dropped                       | Check runner availability, tags, and compute quota                                                  |

**Ownership.** "Scheduled pipelines execute with the permissions of the user who owns the schedule." If one turns `Inactive` unexpectedly, "the schedule owner might have been blocked or removed from the project" ([troubleshooting](https://docs.gitlab.com/19.5/ci/pipelines/schedules/#scheduled-pipeline-becomes-inactive)). Before each run, GitLab checks that the owner can still create pipelines in the project; if not, it creates no pipeline, emails the project's Owners and Maintainers, and deactivates the schedule ([`run_pipeline_schedule_worker.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/workers/run_pipeline_schedule_worker.rb#L51-55)). The docs say taking ownership reactivates it, but in 19.5.3 the UI and the API only change the owner ([`take_ownership_service.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/services/ci/pipeline_schedules/take_ownership_service.rb#L14), [`pipeline_schedules.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/lib/api/ci/pipeline_schedules.rb#L228-239)). After **Take ownership**, [edit the schedule](https://docs.gitlab.com/19.5/ci/pipelines/schedules/#edit-a-pipeline-schedule), select **Activated**, and save, or [update it](https://docs.gitlab.com/19.5/api/pipeline_schedules/#update-a-pipeline-schedule) with `active=true`. **Take ownership** requires the Maintainer or Owner role; the API equivalent is `POST /projects/:id/pipeline_schedules/:pipeline_schedule_id/take_ownership` ([ownership API](https://docs.gitlab.com/19.5/api/pipeline_schedules/#update-ownership-of-a-pipeline-schedule)). Before someone leaves, they can find every active schedule they own under their avatar > **Edit profile** > **Account** > **Scheduled pipelines you own**, available since GitLab 18.4 ([your scheduled pipelines](https://docs.gitlab.com/19.5/ci/pipelines/schedules/#view-your-scheduled-pipelines)).

**A manual run uses your permissions.** "When you run a scheduled pipeline manually, it uses your permissions instead of the schedule owner's permissions" ([run manually](https://docs.gitlab.com/19.5/ci/pipelines/schedules/#run-manually)), so its jobs get your access, not the owner's, to things like [protected environments and the CI/CD job token](https://docs.gitlab.com/19.5/ci/pipelines/schedules/#create-a-pipeline-schedule). GitLab still checks the owner first: if the owner can no longer create pipelines, a manual run creates no pipeline and deactivates the schedule ([`play_service.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/services/ci/pipeline_schedules/play_service.rb#L16), [`run_pipeline_schedule_worker.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/workers/run_pipeline_schedule_worker.rb#L51-55)). The API's `play` endpoint runs a schedule immediately, and "the next scheduled run of this pipeline is not affected."

To list a project's inactive schedules and who owns them, use the `scope` filter of the [list endpoint](https://docs.gitlab.com/19.5/api/pipeline_schedules/#list-all-pipeline-schedules):

```bash
curl --silent --header "PRIVATE-TOKEN: $GITLAB_TOKEN" \
  --url "https://gitlab.com/api/v4/projects/$PROJECT_ID/pipeline_schedules?scope=inactive" \
  | jq -r '.[] | [.id, .description, .cron, .cron_timezone, .owner.username] | @tsv'
```

[Scheduled job inventory](https://cronradar.com/blog/scheduled-job-inventory) covers collecting schedules across projects.

## Rules and Workflow Conditions for Scheduled Pipelines

`CI_PIPELINE_SOURCE` is `schedule` in scheduled pipelines ([pipeline sources](https://docs.gitlab.com/19.5/ci/jobs/job_rules/#ci_pipeline_source-predefined-variable)). GitLab's own example puts one job in scheduled pipelines only and another in push pipelines only ([run jobs for scheduled pipelines](https://docs.gitlab.com/19.5/ci/jobs/job_rules/#run-jobs-for-scheduled-pipelines)):

```yaml
nightly-report:
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
  script:
    - ./scripts/nightly-report.sh

build:
  rules:
    - if: $CI_PIPELINE_SOURCE == "push"
  script:
    - make build
```

The conditions that decide whether a schedule produces anything:

- **`workflow:rules` come first.** "The `workflow` keyword is evaluated before jobs" ([workflow](https://docs.gitlab.com/19.5/ci/yaml/workflow/#workflow-rules-examples)), and "when no rules evaluate to true, the pipeline does not run" ([`workflow:rules`](https://docs.gitlab.com/19.5/ci/yaml/#workflowrules)). A workflow that allows only `push` and `merge_request_event` blocks every schedule; add `- if: $CI_PIPELINE_SOURCE == "schedule"`.
- **No jobs, no pipeline.** "A pipeline does not run if no jobs are added to it at the end of the evaluation" ([debugging](https://docs.gitlab.com/19.5/ci/debugging/#no-pipeline-or-the-wrong-type-of-pipeline-runs)), and "if a pipeline contains only jobs in the `.pre` or `.post` stages, it does not run."
- **Branch rules match schedules too.** A branch pipeline "can be triggered by a change to a branch, an API call, or a scheduled pipeline" ([workflow](https://docs.gitlab.com/19.5/ci/yaml/workflow/#switch-between-branch-pipelines-and-merge-request-pipelines)), and `CI_COMMIT_BRANCH` is "available in branch pipelines, including pipelines for the default branch" ([predefined variables](https://docs.gitlab.com/19.5/ci/variables/predefined_variables/)), so a job with `if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH` also runs in schedules on the default branch. GitLab's [reuse example](https://docs.gitlab.com/19.5/ci/jobs/job_rules/#reuse-rules-in-different-jobs) puts `if: $CI_PIPELINE_SOURCE == "schedule"` with `when: never` first to exclude them.
- **`changes` is always true.** "All files are considered to have changed when a scheduled pipeline runs, so jobs might always be added to scheduled pipelines that use `changes`" ([troubleshooting](https://docs.gitlab.com/19.5/ci/jobs/job_troubleshooting/#jobs-or-pipelines-run-unexpectedly-when-using-changes)).
- **Several schedules, one file.** `CI_PIPELINE_SCHEDULE_DESCRIPTION`, added in 17.8, holds "the description of the pipeline schedule" and is available to `rules` ([predefined variables](https://docs.gitlab.com/19.5/ci/variables/predefined_variables/)). Inputs set on each schedule, shown at the end of this guide, don't depend on a description someone may edit.

## What Happens When the Configuration Is Invalid

A schedule needs valid syntax when it's created, but a later commit to its target branch can still break the file. Then "the pipeline shows a `yaml invalid` badge and does not start running" ([verify syntax](https://docs.gitlab.com/19.5/ci/debugging/#verify-syntax)). No job runs, including any job meant to report the failure.

- **Validate before merging.** The pipeline editor checks syntax automatically, and the CI Lint tool can simulate a pipeline. The simulation "runs as a Git `push` event on the default branch" ([simulate a pipeline](https://docs.gitlab.com/19.5/ci/yaml/lint/#simulate-a-pipeline)), so jobs whose rules require `schedule` don't appear in it. To check those, select **Run** next to the schedule, which triggers it "immediately instead of waiting for the next scheduled time", and inspect the pipeline it creates.
- **Inputs need defaults.** "If any input is missing a default, the pipeline fails when it triggers automatically," for example in merge request and branch pipelines ([pipeline inputs](https://docs.gitlab.com/19.5/ci/inputs/#for-a-pipeline)). A schedule supplies its own values, but the other pipelines in the project don't.
- **Schedule variables can be refused.** Variables set on a schedule are pipeline variables, and **Minimum role to use pipeline variables** can block them. Its `no_one_allowed` value is the default "for new projects in new namespaces on GitLab.com", and with it, "no pipelines can run with pipeline variables" ([restrict pipeline variables](https://docs.gitlab.com/19.5/ci/variables/#restrict-pipeline-variables)). GitLab recommends inputs over pipeline variables and suggests disabling pipeline variables when you use inputs ([pipeline variables](https://docs.gitlab.com/19.5/ci/variables/#use-pipeline-variables)).

## Failed-Pipeline Notifications

GitLab has two ways to email about a failed pipeline:

- **Personal notifications.** "CI/CD pipeline event notifications are sent to the pipeline creator": a failed pipeline at the **Watch** level or with **Pipeline fails** selected under **Custom**, and a fixed one only with **Pipeline is fixed** under **Custom** ([pipeline events](https://docs.gitlab.com/19.5/user/profile/notifications/#cicd-pipeline-events)). GitLab sends nothing to blocked, banned, or deactivated accounts.
- **Pipeline status emails.** Under **Settings** > **Integrations** > **Pipeline status emails**, a Maintainer or Owner enters a list of recipients and can select **Notify only broken pipelines**, **Notify only when status changes**, and which branches to notify for. "Pipeline notifications triggered by blocked users are not delivered" ([pipeline status emails](https://docs.gitlab.com/19.5/user/project/integrations/pipeline_status_emails/)).

Both report on pipelines that exist, so a schedule that's inactive, blocked by `workflow:rules`, or in a project marked for deletion triggers neither. When GitLab deactivates a schedule because the owner can no longer create pipelines, it does email the project's Owners and Maintainers ([`run_pipeline_schedule_worker.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/workers/run_pipeline_schedule_worker.rb#L94-97), [`notification_service.rb`](https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/app/services/notification_service.rb#L616-620)). That gap is what a [dead man's switch](https://cronradar.com/blog/cron-dead-mans-switch) covers: the pipeline checks in, and an outside monitor alerts when a check-in doesn't arrive on schedule.

## Check In to CronRadar From the Pipeline

CronRadar has no SDK or integration for GitLab and doesn't discover pipeline schedules, so each schedule reports with HTTP check-ins from its own jobs.

1. **Store the key.** In **Settings** > **CI/CD** > **Variables**, add `CRONRADAR_API_KEY` with **Visibility** set to **Masked and hidden** ([mask](https://docs.gitlab.com/19.5/ci/variables/#mask-a-cicd-variable), [hide](https://docs.gitlab.com/19.5/ci/variables/#hide-a-cicd-variable)). A masked value must be a single line with no spaces and at least 8 characters, which a CronRadar key, `ck_app_` followed by letters, digits, and underscores, is. GitLab replaces a masked value with `[MASKED]` in job logs, and a hidden one "cannot be revealed in the UI again." **Protect variable** limits the key to pipelines on protected branches and tags ([protect](https://docs.gitlab.com/19.5/ci/variables/#protect-a-cicd-variable)), and "the default branch for your repository is protected by default" ([protected branches](https://docs.gitlab.com/19.5/user/project/repository/branches/protected/)), so a schedule targeting it still gets the key. Keep it out of `.gitlab-ci.yml`: GitLab says tokens "should be stored in the settings in the UI, not in the `.gitlab-ci.yml` file" ([variables in the UI](https://docs.gitlab.com/19.5/ci/variables/#define-a-cicd-variable-in-the-ui)).
2. **Create the monitor first.** In the CronRadar dashboard, create a monitor with the key `nightly-report`, the schedule `17 3 * * *`, and a 3,600-second grace period. It stays `pending`, and if the first expected run plus the grace period passes with no check-in, CronRadar sends a never-executed alert. A monitor created by its own first check-in can't flag a schedule whose rules never let it run.
3. **Add the check-in jobs**, with the schedule's `cron_timezone` set to `UTC`:

```yaml
# .gitlab-ci.yml
stages:
  - report

.cronradar:
  variables:
    PING_URL: https://cron.life/api/v1/ping/nightly-report
    MONITOR_SCHEDULE: '17 3 * * *' # the pipeline schedule's cron, in UTC
    GRACE_SECONDS: '3600'
  before_script:
    - |
      check_in() {
        curl --fail --silent --show-error --max-time 5 --output /dev/null \
          --request POST --get --user "$CRONRADAR_API_KEY:" \
          --data-urlencode "schedule=$MONITOR_SCHEDULE" \
          --data-urlencode "gracePeriod=$GRACE_SECONDS" \
          --data-urlencode "run_id=$CI_PIPELINE_ID" \
          "$@" || true
      }

cronradar-start:
  extends: .cronradar
  stage: .pre
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
      allow_failure: true
  script:
    - check_in "$PING_URL/start"

nightly-report:
  stage: report
  timeout: 30 minutes
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
  script:
    - ./scripts/nightly-report.sh

cronradar-complete:
  extends: .cronradar
  stage: .post
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
      when: on_success
      allow_failure: true
  script:
    - check_in "$PING_URL/complete"

cronradar-fail:
  extends: .cronradar
  stage: .post
  rules:
    - if: $CI_PIPELINE_SOURCE == "schedule"
      when: on_failure
      allow_failure: true
  script:
    - check_in --data-urlencode "message=Scheduled pipeline failed: $CI_PIPELINE_URL" "$PING_URL/fail"
```

- **`start` runs first.** `.pre` "is the first stage in a pipeline" ([`.pre`](https://docs.gitlab.com/19.5/ci/yaml/#stage-pre)), so `start` arrives when a runner takes the pipeline's first job, after the worker wait, any batching delay, and the runner queue.
- **One result job runs.** `when: on_success` runs a job "only when no jobs in earlier stages fail", and `when: on_failure` "only when at least one job in an earlier stage fails" ([`rules:when`](https://docs.gitlab.com/19.5/ci/yaml/#ruleswhen)). Two `.post` jobs are needed because each job takes one condition. `fail` alerts immediately. Jobs with `allow_failure: true` in earlier stages "are considered successful, even if they failed" ([`when`](https://docs.gitlab.com/19.5/ci/yaml/#when)), so their failures report `complete`.
- **Timeouts report as failures.** A job that runs past its `timeout` fails ([`timeout`](https://docs.gitlab.com/19.5/ci/yaml/#timeout)); without one, the project timeout applies, 60 minutes by default ([job timeout](https://docs.gitlab.com/19.5/ci/pipelines/settings/#set-a-limit-for-how-long-jobs-can-run)). Set `timeout` a little above the longest normal run; [cron job timeouts](https://cronradar.com/blog/cron-job-timeout) covers timeouts on the calls that hang.
- **Monitoring can't fail the pipeline.** Each request gives up after 5 seconds and `|| true` ignores its errors. With `allow_failure: true`, a check-in job that fails anyway shows a warning while "the pipeline is successful" ([`allow_failure`](https://docs.gitlab.com/19.5/ci/yaml/#allow_failure)), and later stages still run. The jobs use your default image; if it has no curl, `|| true` hides the error and no check-in arrives, so give `.cronradar` an `image:` that has curl.
- **The helper is shared.** `before_script` and `script` "execute together in a single shell" ([`before_script`](https://docs.gitlab.com/19.5/ci/yaml/#before_script)), so each job can call `check_in`.
- **One pipeline, one run ID.** `CI_PIPELINE_ID` "is unique across all projects on the GitLab instance" ([predefined variables](https://docs.gitlab.com/19.5/ci/variables/predefined_variables/)), and `start` and the result send the same value.
- **Self-registering.** `schedule` and `gracePeriod` apply only when a check-in creates the monitor, so a deleted monitor comes back on the next run. Change an existing monitor's values in its settings.
- **No pipeline is what alerts.** An inactive schedule, a pipeline blocked by rules, a `yaml invalid` pipeline, a pipeline left with only the check-in jobs (which doesn't run), and jobs that never leave `pending` all send nothing; once the expected time plus the grace period passes, the monitor goes `critical` as missed. If `start` arrives and neither result does, it goes `critical` as stuck once the next scheduled time plus the grace period passes.

Alerts go to email, Slack, a webhook, Discord, or PagerDuty. In a real project, keep each existing job's own rules: the check-in jobs only need to share the pipeline with them.

### Choosing the Grace Period

With `start` as the first job, the grace period covers only the start delay ([grace periods](https://cronradar.com/blog/cron-grace-period) explains why). On GitLab that delay has three parts: the worker wait, up to the worker's 5 or 10 minutes, or up to an hour on GitLab.com Free for a minute that isn't a multiple of 5; batching under load, which GitLab puts at "several minutes to over an hour"; and the runner queue.

- **Daily or less often: 3,600 seconds.** That absorbs a badly delayed run, and hearing about a nightly report an hour late rarely changes anything. Raise it if the `created_at` measurements above come close.
- **Hourly or more often: less than the interval, more than the worker.** Every check-in moves the expected time to the schedule's next occurrence, so with a grace longer than the interval, the next run checks in before a missed one is flagged; [GitHub Actions scheduled workflows](https://cronradar.com/blog/github-actions-scheduled-workflows) works through that trade-off. For an hourly `17 * * * *` schedule on GitLab.com paid tiers or a default self-managed instance, 1,200 seconds flags each missing run, at the cost of an alert whenever a run starts more than 20 minutes late. On GitLab.com Free that schedule runs on the next full hour, 43 minutes late, so every run would alert; `15 * * * *` runs at :15 there. A grace shorter than the worker's wait turns ordinary runs into alerts.
- **Send the schedule GitLab can keep.** On GitLab.com Free, a `*/15 * * * *` schedule runs once an hour, on the hour, so a CronRadar monitor expecting `*/15` would go `critical` between every pair of runs. Schedule no more often than your plan's limit and your instance's worker allow, and send that cron. On Free, either use a minute that's a multiple of 5 or send CronRadar the time GitLab runs: `0 4 * * *` for `17 3 * * *`.

### Schedules With a Time Zone, or Several Schedules

CronRadar evaluates every schedule in UTC ([schedule syntax](https://docs.cronradar.com/schedules)). With `cron_timezone` set to `UTC`, send the cron unchanged. A schedule in a zone with daylight saving runs at a different UTC time in summer and winter: `0 6 * * *` in Berlin is 04:00 UTC in summer and 05:00 UTC in winter, so by CronRadar's time zone rule, which [GitHub Actions scheduled workflows](https://cronradar.com/blog/github-actions-scheduled-workflows) works through, send `0 4 * * *` with 7,200 seconds of grace instead of 3,600.

CronRadar's syntax has no `#` or `%`. For fugit's second Monday, `0 0 * * 1#2`, send `0 0 8-14 * 1`, which CronRadar reads as a Monday between the 8th and the 14th because it requires a date to match both day fields. Don't schedule GitLab itself with both day fields restricted: GitLab 19.5.3 parses schedules with fugit 1.11.2 ([`Gemfile.lock`](https://gitlab.com/gitlab-org/gitlab/-/blob/v19.5.3-ee/Gemfile.lock#L839)), which follows crontab(5) and runs the schedule "when either field matches" ([fugit README](https://github.com/floraison/fugit/blob/v1.11.2/README.md#the-first-monday-of-the-month)).

For several schedules in one project, give each its own monitor with [pipeline inputs](https://docs.gitlab.com/19.5/ci/inputs/#define-input-parameters-with-specinputs). Add a header to the top of `.gitlab-ci.yml`, with defaults so push and merge request pipelines still start:

```yaml
spec:
  inputs:
    monitor_key:
      default: nightly-report
    monitor_schedule:
      default: '17 3 * * *'
---
.cronradar:
  variables:
    PING_URL: https://cron.life/api/v1/ping/$[[ inputs.monitor_key ]]
    MONITOR_SCHEDULE: '$[[ inputs.monitor_schedule ]]'
    GRACE_SECONDS: '3600'
```

This replaces the `variables` of `.cronradar`; its `before_script` and the jobs stay as above. Then set `monitor_key` and `monitor_schedule` in each schedule's **Inputs**.

## Frequently Asked Questions

### Why is my GitLab scheduled pipeline not running?

Open **Build** > **Pipeline schedules**. A schedule marked `Inactive` may have an owner who was blocked or removed from the project; a Maintainer or Owner can select **Take ownership**, then edit the schedule and select **Activated**, because taking ownership alone leaves it inactive. If the schedule is active, look for a `yaml invalid` pipeline on its target branch, `workflow:rules` or job `rules` that add no jobs when `CI_PIPELINE_SOURCE` is `schedule`, and jobs stuck `pending` without a runner or compute minutes. Schedules also stop when the project or group is marked for deletion.

### Why does my GitLab scheduled pipeline start late?

GitLab queues due pipelines only when its pipeline schedule worker runs: every 5 minutes on GitLab.com, and every 10 minutes (`3-59/10 * * * *`) by default on self-managed instances. On GitLab.com Free, a schedule whose minute isn't a multiple of 5 waits for the next full hour. Under load, batching can add several minutes to over an hour, and the jobs then wait for a runner. Stagger schedules away from the top of the hour, in multiples of 5 minutes on Free, and measure the delay from each scheduled pipeline's `created_at`.

### Can a GitLab pipeline schedule run every minute?

Not on GitLab.com. Its worker runs every 5 minutes, and Free namespaces are limited to 24 pipelines per schedule per day, which slows a schedule to once an hour at most; paid tiers allow 288, one every 5 minutes. On a self-managed instance, an administrator can run the worker every minute with `pipeline_schedule_worker_cron`. There's no daily limit by default; if one has been set, `ci_daily_pipeline_schedule_triggers` must be 1440 to allow a pipeline a minute.

### What time zone do GitLab pipeline schedules use?

Each schedule has its own `cron_timezone`, which defaults to UTC when you create the schedule through the API. GitLab's documentation doesn't describe what happens in an hour that daylight saving time skips or repeats, so UTC is the predictable choice, and it matches what CronRadar expects.

### How do I run a job only in GitLab scheduled pipelines?

Give the job `rules: - if: $CI_PIPELINE_SOURCE == "schedule"`, and use the same condition with `when: never` to keep other jobs out of scheduled pipelines. If the file has `workflow:rules`, one of them must also match schedules, or the scheduled pipeline doesn't run at all.

### How do I get alerted when a GitLab schedule doesn't run?

GitLab's pipeline emails cover pipelines that exist, so a pipeline blocked by rules or a schedule someone deactivated sends nothing. When GitLab itself deactivates a schedule because its owner can no longer create pipelines, it emails the project's Owners and Maintainers. Add check-in jobs in the `.pre` and `.post` stages that report to an external monitor, which alerts when a check-in doesn't arrive within a grace period of the scheduled time. With CronRadar, create the monitor before the first run so even a schedule that never runs produces a never-executed alert, and a `fail` check-in alerts at once.
