</>CodeScrub

How to Schedule Cron Jobs in GitHub Actions, AWS, and Kubernetes

Copy-paste cron configurations for GitHub Actions, AWS EventBridge, Google Cloud Scheduler, Kubernetes CronJobs, and Vercel. Platform-specific syntax and gotchas.

9 min read
  • cron
  • github-actions
  • aws
  • kubernetes
  • devops
  • scheduling

title: "How to Schedule Cron Jobs in GitHub Actions, AWS, and Kubernetes" description: "Copy-paste cron configurations for GitHub Actions, AWS EventBridge, Google Cloud Scheduler, Kubernetes CronJobs, and Vercel. Platform-specific syntax and gotchas." date: "2026-06-06" tags: ["cron", "github-actions", "aws", "kubernetes", "devops", "scheduling"] relatedTool: "cron-generator" published: true

The 5-field cron syntax is universal — the same 0 9 * * MON-FRI works everywhere — but every platform wraps it differently. GitHub Actions uses schedule triggers in YAML. AWS uses EventBridge rules with an extra field. Kubernetes has its own manifest. This guide gives you copy-paste configs for five platforms, plus the gotchas that'll save you a failed deploy. If you need a refresher on the syntax itself, the cron expression examples reference covers it.

GitHub Actions

Full workflow file, scheduled to run weekdays at 9 AM UTC:

name: Scheduled Job
on:
  schedule:
    - cron: '0 9 * * MON-FRI'  # Weekdays at 9 AM UTC

jobs:
  run-task:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo "Running scheduled task"

Gotchas:

  1. All schedules run in UTC — not the runner's or repository's local timezone. "9 AM" means 9 AM UTC. To run at 9 AM MST (UTC-7), schedule 0 16 * * MON-FRI. Daylight-saving transitions will shift the local wall-clock time; account for that if the exact local hour matters.
  2. Minimum interval is 5 minutes. A pattern like * * * * * is silently throttled, and dense schedules (every 5 minutes across many repos) may be skipped entirely during peak load.
  3. Scheduled workflows only run on the default branch (usually main). A schedule defined in a workflow file on a feature branch does nothing — the trigger fires from the default branch's copy of the file, which may not exist there yet.
  4. Runs can be delayed by 5–15 minutes during peak load. GitHub explicitly documents this. Never rely on cron for anything that requires precise timing; use it for eventually-consistent recurring work like nightly backups or hourly reports.
  5. Long-idle repositories have their schedules disabled after 60 days of no activity. Push a commit occasionally, or move critical schedules to a paid platform.
  6. Add workflow_dispatch alongside schedule so you can manually trigger the workflow from the Actions UI. Without it, testing a change to your scheduled workflow means waiting for the next scheduled fire, editing the cron to "now + 5 min" and hoping, or pushing dummy commits. Every scheduled workflow should be manually runnable.

AWS EventBridge (CloudWatch Events)

Create a rule via the AWS CLI:

aws events put-rule \
  --name "daily-report" \
  --schedule-expression "cron(0 9 * * ? *)" \
  --state ENABLED

The Terraform equivalent for infrastructure-as-code shops:

resource "aws_cloudwatch_event_rule" "daily_report" {
  name                = "daily-report"
  schedule_expression = "cron(0 9 * * ? *)"
}

Gotchas:

  1. AWS uses 6-field cron with mandatory ?. The extra field is year. More importantly, you must use ? in either day-of-month or day-of-week — never * in both, and never ? in both. The rule is that one of the two day fields is unspecified, since restricting both would be ambiguous. For a weekday schedule, use cron(0 9 ? * MON-FRI *)? in day-of-month, MON-FRI in day-of-week.
  2. Everything runs in UTC. No timezone option on the rule itself. Offset the hour in the expression or convert in your Lambda handler.
  3. Format is cron(minutes hours day-of-month month day-of-week year) — the wrapping cron() syntax is required; a bare expression is a parse error.
  4. rate() syntax exists for simple intervals and is often clearer: rate(5 minutes), rate(1 hour), rate(7 days). Use it for anything that doesn't need to fire at a specific time of day.
  5. EventBridge Scheduler is a newer, separate service with better features (timezone support, one-time schedules, flexible time windows). If you're building something new, prefer aws scheduler create-schedule over EventBridge Rules — the older rule-based cron is now considered legacy for scheduling use cases.

Google Cloud Scheduler

Create a job that hits an HTTP endpoint on a schedule:

gcloud scheduler jobs create http daily-report \
  --schedule="0 9 * * MON-FRI" \
  --time-zone="America/Edmonton" \
  --uri="https://your-app.com/api/report" \
  --http-method=POST

Gotchas:

  1. Timezone is a first-class option. Use --time-zone with a full IANA timezone name (America/Edmonton, Europe/London, Asia/Tokyo). This is a major advantage over GitHub Actions and legacy EventBridge — the schedule automatically handles DST transitions in that region without you doing math.
  2. Standard 5-field unix-cron syntax. No ?, no year field. If you're copying an AWS expression over, drop the trailing year and swap ? back to * in whichever day field held it.
  3. Minimum interval is 1 minute and Google enforces this cleanly — no silent throttling like GitHub Actions.
  4. Jobs can target HTTP, Pub/Sub, or App Engine. For serverless work, hitting an HTTP endpoint is the easiest pattern. Add --oidc-service-account-email if the endpoint is authenticated behind IAM.
  5. Retries are configurable per job with --max-retry-attempts, --min-backoff, and --max-backoff. Set these deliberately for anything idempotent; leave the defaults for anything that isn't safe to run twice.

Kubernetes CronJobs

A complete manifest with the fields you'll actually want to set:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: daily-cleanup
spec:
  schedule: "0 3 * * *"    # 3 AM UTC daily
  timeZone: "America/Edmonton"
  concurrencyPolicy: Forbid
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 1
  startingDeadlineSeconds: 300
  jobTemplate:
    spec:
      template:
        spec:
          containers:
            - name: cleanup
              image: busybox:1.36
              command: ["/bin/sh", "-c", "echo Cleanup done"]
          restartPolicy: OnFailure

Gotchas:

  1. Standard 5-field cron — no ?, no year field. Timezone support via .spec.timeZone reached stable in Kubernetes 1.27. On older clusters, offset the hour in the expression and run your workloads mentally in UTC.
  2. concurrencyPolicy is the field that catches everyone. Allow (the default) lets overlapping runs pile up if a job takes longer than its interval — a great way to bring down a cluster. Set Forbid to skip a run when the previous is still going, or Replace to kill the previous and start fresh. Pick one deliberately; the default is rarely what you want.
  3. Missed-schedule cliff at 100. If a CronJob misses 100 or more scheduled times (cluster was down, node was drained, whatever), the controller marks it as too far behind and refuses to run at all. .spec.startingDeadlineSeconds gives you a window past the scheduled time during which a late run is still considered valid. Set it explicitly for anything critical.
  4. Jobs are Pods that need to exit successfully. A container that runs forever or exits non-zero marks the Job as failed. If your workload is a long-running process, wrap it in a script that terminates when the work is done and set a activeDeadlineSeconds as a safety net.
  5. History limits matter for etcd. successfulJobsHistoryLimit: 3 and failedJobsHistoryLimit: 1 keep old Job objects around only briefly. Without limits, a job that runs every minute for a year creates half a million objects and slows your API server to a crawl.
  6. Test on-demand with kubectl create job --from=cronjob/name. This spins up a one-off Job using the CronJob's template right now, without waiting for the schedule. It's the single most useful command for iterating on a CronJob and no one seems to know about it.

Vercel Cron Jobs

Vercel reads scheduled endpoints from vercel.json (or the newer vercel.ts config):

{
  "crons": [
    {
      "path": "/api/daily-report",
      "schedule": "0 9 * * *"
    }
  ]
}

Gotchas:

  1. All schedules run in UTC. No timezone option. Convert in your handler if the local hour matters.
  2. path must point to an existing serverless function (an API route in your project). A path that 404s doesn't fail the deploy but will silently fail every run — check the Functions log tab after the first scheduled fire.
  3. Plan limits apply and shift over time. As of the current tiers, Pro allows more concurrent crons and finer-grained schedules than Hobby; check Vercel's cron documentation for exact numbers before you architect a system around them.
  4. Execution is subject to your function's timeout. With Fluid Compute the default is 300s across plans, which is enough for most scheduled work; if your job runs longer, split it or push it to a queue.
  5. The invoker is unauthenticated by default. Verify the authorization: Bearer <CRON_SECRET> header in your handler if the endpoint would be dangerous to hit publicly — Vercel provides CRON_SECRET as an environment variable for this exact purpose.

Platform Comparison

Quick reference for picking or migrating between platforms:

| Feature | GitHub Actions | AWS EventBridge | GCP Scheduler | Kubernetes | Vercel | | --- | --- | --- | --- | --- | --- | | Syntax | 5-field standard | 6-field + ? | 5-field standard | 5-field standard | 5-field standard | | Timezone | UTC only | UTC only (Rules) | IANA timezone | IANA (v1.27+) | UTC only | | Min interval | 5 minutes | 1 minute | 1 minute | 1 minute | Plan-dependent | | Free tier | Yes | 14M invocations/mo | 3 jobs free | Cluster cost | Included on Hobby | | Best for | Repo-scoped tasks | AWS-native workloads | HTTP endpoints, GCP-native | In-cluster batch work | Serverless functions |

Build Your Cron Expression

Not sure about the syntax? Use the CodeScrub Cron Generator to build expressions visually, see the next five run times, and get a plain-English explanation of what your schedule actually does. Verify the intent before you push the YAML — most scheduled-job bugs come from a subtly wrong expression that only manifests on the day of the month you weren't watching for.

Bottom line

The cron expression is the easy part; it's the same everywhere. The platform-specific wrapping, timezone handling, and failure modes are where things go wrong at 3 AM. Bookmark this page for your next scheduled job, and build the expression itself in the CodeScrub Cron Generator so you know what it does before you commit.

Ad space · blog-article-mid