Scheduled GitHub Actions for Your Project’s Small Recurring Jobs

Scheduled GitHub Actions for Your Project's Small Recurring Jobs

Twenty seconds of work, once a day. That is what a check like “are these three sites still answering” actually costs, and it is a hopeless fit for a rented server that then idles for the other twenty-three hours and fifty-nine minutes. It is an equally poor fit for a machine at home, which goes down whenever the power does. So the small, boring, recurring job never gets automated at all, and somebody keeps doing it by hand. If the job belongs to a software project you already keep on GitHub, such as your website’s repository checking that the deployed site still answers or rebuilding and republishing it, there is a tidy option: GitHub Actions can run a workflow on a schedule, on a fresh GitHub-hosted Linux virtual machine, whether or not anybody pushed a commit. Most people meet Actions as a build-and-test tool, and that framing matters: GitHub’s terms for hosted runners cover work related to producing, testing, deploying or publishing the software project in that repository, so this is a scheduler for your project’s chores, not a free general-purpose server. Within that scope there is no server to patch, no uptime to worry about, and the logs are already kept for you.

the idea in one paragraph

Flow: a YAML workflow file defines a schedule, GitHub queues the run, a fresh virtual machine executes the steps and is discarded, and the log is kept.
Good fits are short, network-facing jobs tied to the project in the repository.

You put a YAML file in a repository under .github/workflows/. It declares when to run and what to do. GitHub spins up a fresh virtual machine, runs your steps, throws the machine away, and keeps the log. Because the runner is disposable, every run starts from a known clean state, which removes a whole family of “it worked last month” problems that accumulate on a long-lived box.

Good fits are short, network-facing jobs tied to the project in the repository: checking that the site this repository deploys still responds, rebuilding and publishing a static site, refreshing a small data file the project itself uses (from a source whose terms allow automated access), or renewing something the project depends on through its API. Poor fits are jobs needing access to machines on your home network, long-running processes, anything handling data you’d rather not hand to a third-party runner, and chores unrelated to the repository’s project, which GitHub’s terms exclude on hosted runners. If the data already lives in a spreadsheet, Google Apps Script is usually the shorter path than wiring up API credentials here.

UTC by default, and the timezone key that fixes it

Conversion table from IST to UTC cron expressions for 6 AM, 9 AM, noon, 6 PM, 10 PM and midnight, which falls on the previous UTC day.
The midnight row produces the genuinely confusing date bugs.

Schedules in Actions use standard POSIX cron syntax, and by default the clock is UTC. Write 0 9 * * * with nothing else and your job fires at 2:30 PM IST. GitHub now lets a schedule carry an optional IANA timezone, so the cleanest fix is to say what you mean: timezone: 'Asia/Kolkata'. India has no daylight saving time, so that never shifts. If you leave the key out, or you’re maintaining an older workflow, convert to UTC: India is five and a half hours ahead, so every conversion lands on a half-hour, which is why the minute field is almost always 30.

Without a timezone key, these are the conversions:

You want (IST)UTCcron expression
6:00 AM00:3030 0 * * *
9:00 AM03:3030 3 * * *
12:00 noon06:3030 6 * * *
6:00 PM12:3030 12 * * *
10:00 PM16:3030 16 * * *
Midnight18:30 previous day30 18 * * *

If you rely on UTC cron, that last row is the one that produces genuinely confusing bugs. A job meant to summarise “today” and scheduled for midnight IST runs at 18:30 UTC, and any date arithmetic your script does in UTC will still say yesterday. Format dates explicitly in Asia/Kolkata inside the script rather than trusting the runner’s clock, which is the same discipline that keeps scheduled jobs on a Linux box honest about timezones. The timezone key controls when the run starts; don’t assume it changes the runner’s own clock.

a workflow that checks a site every morning

Workflow YAML scheduled at 09:00 Asia/Kolkata with workflow_dispatch, running a step that fails unless the homepage returns HTTP 200.
A non-200 status fails the job, and GitHub emails you. Shortened from the article’s version.
Comparison: scheduled Actions suit daily digests, site checks and rebuilds, but not intervals under five minutes or jobs needing punctual starts.
Five minutes is the shortest interval Actions accepts.

Create .github/workflows/site-check.yml. The workflow_dispatch line is important: it adds a Run button so you can test immediately instead of waiting until tomorrow.

name: Daily site check

on:
  schedule:
    # 09:00 IST, stated directly
    - cron: '0 9 * * *'
      timezone: 'Asia/Kolkata'
  workflow_dispatch:

jobs:
  check:
    runs-on: ubuntu-latest
    steps:
      - name: Fetch the homepage
        run: |
          # the site this repository builds and deploys
          code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 https://example.com/)
          echo "HTTP status: $code"
          test "$code" = "200"

The final test is doing the real work. If the status isn’t 200 the step exits non-zero, the job is marked failed, and GitHub emails you. That is a working uptime alert in fifteen lines, with no infrastructure. It won’t page you within a minute, so it isn’t a substitute for proper monitoring on something business-critical, but for a personal site it beats hearing about the outage from a visitor.

the five minute floor, and realistic expectations about timing

The shortest interval Actions accepts is five minutes. More importantly, scheduled runs are queued rather than guaranteed punctual, and during busy periods they can start noticeably late. For a daily digest, irrelevant. For something that must happen exactly on the hour, use a machine you control.

saving results back into the repository

Workflow excerpt with permissions contents write highlighted, checking out the repo and appending an IST-dated line to data/log.csv.
The runner is destroyed after each job, so anything you keep must be written somewhere.

Because the runner is destroyed after each job, anything you want to keep must be written somewhere. The neatest trick for small data the project itself uses is to commit it back into the repo, which gives you a versioned history of every run.

jobs:
  record:
    runs-on: ubuntu-latest
    permissions:
      contents: write
    steps:
      - uses: actions/checkout@v7

      - name: Append a dated line
        run: |
          mkdir -p data
          TZ=Asia/Kolkata date '+%Y-%m-%d %H:%M' >> data/log.csv

      - name: Commit if anything changed
        run: |
          git config user.name 'github-actions[bot]'
          git config user.email 'github-actions[bot]@users.noreply.github.com'
          git add data/log.csv
          git diff --staged --quiet || git commit -m 'chore: scheduled update'
          git push

Two details worth copying. permissions: contents: write is required, because the default token is read-only for this purpose and the push will otherwise fail with a permissions error that reads as if your credentials are wrong. And git diff --staged --quiet || means the commit only happens when something actually changed, which keeps your history meaningful instead of adding an empty commit every single day.

Note also TZ=Asia/Kolkata in front of date. That’s the fix for the UTC problem described earlier, applied at the exact point where it matters.

secrets, and a warning

Comparison: GitHub masks secrets in logs, but anyone who can push code or trigger workflows can still run code that uses them.
Use narrowly scoped tokens. Masking is a safety net, not a boundary.

API tokens go in Settings then Secrets and variables then Actions, and are referenced in the workflow as ${{ secrets.MY_TOKEN }}. GitHub masks them in logs, so an accidental echo prints asterisks rather than the value.

The warning: masking is a safety net, not a boundary. Anyone who can push to the repository, or open a workflow-triggering pull request in some configurations, is in a position to run code that can use those secrets. For a personal repo that’s fine. For a shared one, keep high-value credentials out of Actions entirely, and prefer tokens scoped to exactly one capability so a leak is survivable. That’s the same reasoning behind narrow permissions in my Ubuntu server security checklist.

what the free tier actually covers

Actions on public repositories runs on GitHub’s standard runners at no cost. Private repositories on the Free plan get a monthly allowance of runner minutes and storage, after which you either wait for the next cycle or pay. Since the figures change, read the billing page rather than trusting a number in an article, including mine.

One behaviour that is easy to miss and catches a lot of people: scheduled workflows in a public repository get disabled automatically after 60 days without repository activity, on the reasonable assumption that abandoned repos shouldn’t keep consuming capacity. If a job you rely on quietly stops months later, check whether the schedule was disabled before you start debugging your YAML. GitHub notifies the owner, so don’t filter those emails into a folder you never open.

common mistakes

Five GitHub Actions scheduling mistakes: IST hours without a timezone, expecting persistence, steps that exit zero on failure, scraping unrelated sites, and 60-day inactivity disabling schedules.
A runner has no route into your LAN. Keep home jobs on a home machine.
  • Writing IST hours without timezone: 'Asia/Kolkata'. Without the key the schedule is UTC; add it, or convert using the table above.
  • Expecting files to persist between runs. The machine is gone afterwards; commit results, upload an artifact, or push to storage.
  • Letting a step fail without failing the job. A curl that prints an error but exits zero looks like success on the dashboard.
  • Scraping someone else’s site because the runner is free. Apart from that site’s own terms, automated collection unrelated to your project falls outside what GitHub-hosted runners are for. Check your own deployments, and use official APIs, within their terms, for anything else.
  • Using it to reach something inside your house. A GitHub runner has no route to your LAN, and it shouldn’t. Keep those jobs on a local machine or a tool you host, like the setup in my n8n guide.

FAQ

Is this really allowed, or am I abusing a build service?

Scheduled workflows are a documented, first-class feature, but GitHub’s terms limit what GitHub-hosted runners may be used for: work related to producing, testing, deploying or publishing the software project in that repository, without a burden out of proportion to its benefit. A nightly check that the site this repository deploys is still up, or a scheduled rebuild of that site, fits. Using a repository as a free general-purpose cron box for unrelated chores, mining, serverless hosting or anything designed to run continuously does not, and GitHub can stop it. Read the terms linked under Sources before scheduling anything that isn’t about the repository’s own project.

Can I run Python or Node instead of shell?

Yes. The Ubuntu runner ships with several languages preinstalled, and actions/setup-python or actions/setup-node pin a specific version. Keep a requirements.txt or package.json in the repo and install in a step, exactly as you would locally.

How do I get alerts somewhere other than email?

Add a step that runs only on failure, using if: failure(), and have it post to a chat webhook with curl. Keep the webhook URL in a secret. This works well combined with the site-check example, giving you a message on your phone within minutes of a site going down.

Should I use this to deploy my website?

For building a static site and publishing it, yes, that’s a natural fit. For pushing files onto a shared host over FTP, it works but you’re storing hosting credentials in a third-party service, so weigh that. If you’re still choosing where the site lives, my guide to choosing web hosting for Indian websites covers server location, renewal pricing and backups.

Start with the fifteen-line site check, since it’s useful from day one and teaches the whole model. Once the pattern clicks, look for the other small chores your repository’s own project needs, and leave anything unrelated to that project on a machine you control.

Update history: added GitHub’s optional schedule timezone key, limited the examples to jobs for the repository’s own project as GitHub’s terms for hosted runners require, and moved actions/checkout to v7.

Sources