A script that works when you run it by hand is only half finished. The other half is making it run by itself, every day, without you remembering.
There are at least four reasonable ways to do that, and they fail in different ways. Pick the wrong one and you may not notice until the script has been quietly dead for a month.
This guide compares cron, systemd timers, GitHub Actions and cloud functions, helps you choose, and gives you a small template that makes a script safe to run unattended, whichever scheduler you use.
If you'd rather not write any code at all, there are no-code tools too. I compared them with GitHub Actions in Zapier vs GitHub Actions for small business workflows.
The quick decision
Start with one question: where does the script need to run?
- It needs files or programs on your own computer. Use a scheduler on that computer: cron, or Task Scheduler on Windows.
- It runs on a Linux server or a Raspberry Pi you control. Use a systemd timer, or cron if you prefer something simpler.
- It only needs the internet, runs daily or weekly, and the code can live in a repository. Use GitHub Actions.
- It needs exact timing, runs very often, or needs more than a repository offers. Use a cloud scheduler with a cloud function.
Most small automations fit the first three.
The four options side by side
| cron | systemd timer | GitHub Actions | Cloud scheduler + function | |
|---|---|---|---|---|
| Runs on | Your computer or server | A Linux machine | GitHub's servers | The cloud provider's servers |
| Machine must be on? | Yes | Yes | No | No |
| Cost | Free | Free | Free for public repos, an allowance for private ones | Free tier, but needs a billing account |
| Setup effort | Low | Medium | Low to medium | High |
| Catches up after downtime? | No | Yes, with one setting | No | Depends on setup |
| Logs | You build them | Built in (journald) | Kept for each run | The provider's log tools |
| Timing | To the minute | To the minute | Can be delayed | Precise |
| Biggest gotcha | Tiny environment, silent failures | Linux only | Can stop on its own | Setup and permissions |
Before you schedule anything
A script you run by hand has you watching it. A scheduled one doesn't, so it needs a few habits.
- Don't assume where it starts. A scheduler may start your script in a different folder from the one you tested in.
- Keep a log. When it fails at 3 a.m., the log is all you have.
- Don't run twice at once. If yesterday's run is still going, today's shouldn't pile on top of it.
- Stop a run that hangs. A script stuck on a dead connection shouldn't wait forever.
- Exit with the right code. A failure should look like a failure to whatever is running it.
Here's a small template that does all five. Save it as scheduled_job.py and put your own work inside work():
"""A template for a script that a scheduler runs while nobody is watching."""
import faulthandler
import logging
import os
import sys
import time
from logging.handlers import RotatingFileHandler
from pathlib import Path
BASE = Path(__file__).resolve().parent # the script's own folder, whatever folder the scheduler starts in
LOG_FILE = BASE / "job.log"
LOCK_FILE = BASE / "job.lock"
TIMEOUT = 600 # seconds. A run that takes longer than this stops itself
STALE_AFTER = TIMEOUT * 2 # a lock older than this was left behind by a crashed run
log = logging.getLogger("job")
log.setLevel(logging.INFO)
for handler in (RotatingFileHandler(LOG_FILE, maxBytes=1_000_000, backupCount=3), logging.StreamHandler(sys.stdout)):
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
log.addHandler(handler)
def take_lock():
"""Create the lock file. Returns False if another run is still going."""
try:
if time.time() - LOCK_FILE.stat().st_mtime > STALE_AFTER:
log.warning("Removing a stale lock from a run that never finished")
LOCK_FILE.unlink()
except FileNotFoundError:
pass
try:
os.close(os.open(LOCK_FILE, os.O_CREAT | os.O_EXCL | os.O_WRONLY)) # fails if the file already exists
return True
except FileExistsError:
return False
def work():
"""Your script goes here."""
log.info("Doing the work...")
return "done"
if not take_lock():
log.warning("The previous run is still going, so this one is skipping")
sys.exit(0)
crash_log = open(LOG_FILE, "a")
faulthandler.dump_traceback_later(TIMEOUT, exit=True, file=crash_log) # a hung run stops itself and shows where
started = time.time()
try:
log.info("Started")
result = work()
log.info("Finished in %.1f seconds: %s", time.time() - started, result)
except Exception:
log.exception("The run failed")
sys.exit(1)
finally:
faulthandler.cancel_dump_traceback_later()
LOCK_FILE.unlink(missing_ok=True)
What each part does:
-
BASEmakes every file path relative to the script itself, so it works from any starting folder. - The log is written next to the script and rotated, so it never grows forever. The last few megabytes are kept.
-
The lock file stops overlapping runs. If one is already going, the new run says so and exits without doing anything. A lock older than
STALE_AFTERis assumed to be left over from a crash and is replaced. -
The timeout uses Python's
faulthandler. If a run takes longer thanTIMEOUT, it writes where it was stuck into the log and stops. -
The exit code is 1 when
work()fails, with the full traceback in the log, so a scheduler or a monitor can tell something went wrong.
Whether a task deserves this effort in the first place is worth a quick think. My post on when not to automate is a useful gut check.
Option 1: cron
Cron is the classic. It's built into Linux and macOS, and Windows has Task Scheduler for the same job.
Run crontab -e to edit your schedule, and add a line like this:
17 6 * * 1-5 /usr/bin/python3 /home/yourname/scripts/scheduled_job.py >> /home/yourname/scripts/cron.log 2>&1
The first five fields say when to run: minute, hour, day of the month, month and day of the week. 17 6 * * 1-5 means "at 06:17, Monday to Friday". More examples:
| Schedule | Meaning |
|---|---|
*/15 * * * * |
Every 15 minutes |
0 18 * * * |
Every day at 18:00 |
23 7 * * 1 |
Mondays at 07:23 |
0 9 1 * * |
At 09:00 on the 1st of each month |
Find the right Python path with which python3. The >> cron.log 2>&1 at the end saves anything the script prints, including errors that happen before the script's own logging starts, like a typo in the code.
On Windows, schtasks or the Task Scheduler app does the same job. The idea is identical: run this program at this time.
Cron's gotchas:
- A tiny environment. Cron doesn't load your usual settings, so a command that works in your terminal may not be found. Use full paths for everything.
- No catching up. If the computer is off at 06:17, that run is skipped, and nobody makes it up.
- Silent failure. Without a log and an alert, a broken job looks the same as a working one.
-
A
%character in a cron line has a special meaning, and needs a backslash in front of it. - Time zones. Cron uses the machine's time zone. Around daylight saving changes, a job in the small hours may run twice or be skipped.
Option 2: a systemd timer
On a Linux machine, a systemd timer is a more modern alternative to cron. It takes more setup, and gives you two things cron doesn't: logging built in, and catching up after downtime.
You create two small files. The first describes what to run. Save it as ~/.config/systemd/user/my-job.service:
[Unit]
Description=My scheduled Python job
[Service]
Type=oneshot
WorkingDirectory=%h/scripts
ExecStart=/usr/bin/python3 %h/scripts/scheduled_job.py
TimeoutStartSec=15min
The second says when. Save it as ~/.config/systemd/user/my-job.timer:
[Unit]
Description=Run my job on weekdays
[Timer]
OnCalendar=Mon..Fri *-*-* 06:17:00
Persistent=true
RandomizedDelaySec=2min
[Install]
WantedBy=timers.target
Here %h means your home folder. Two settings in the timer matter most:
-
Persistent=truemeans that if the machine was off when a run was due, it runs soon after the machine starts again. -
RandomizedDelaySecadds a little random delay, which spreads load if many jobs share a machine.
Turn it on with these commands:
systemctl --user daemon-reload
systemctl --user enable --now my-job.timer
systemctl --user list-timers
The last one shows when it will next run. To read what the job printed, use journalctl --user -u my-job.service.
You can check a schedule before trusting it. systemd-analyze calendar "Mon..Fri *-*-* 06:17:00" prints the next time it will fire, and systemd-analyze verify checks your unit files for mistakes.
systemd's gotchas:
- It's Linux only.
-
User timers need you logged in. A timer in your user folder only runs while your session exists, unless you turn on lingering with
loginctl enable-linger. For a server, you can put the files in the system folder instead. - It's more to learn than a single cron line. For one simple job, cron may be enough.
Option 3: GitHub Actions
If the script only needs the internet, GitHub can run it for you, on GitHub's machines, with nothing left on at home. A scheduled workflow looks like this:
name: Daily job
on:
schedule:
- cron: '17 6 * * *' # every day at 06:17 UTC
workflow_dispatch: # adds a "Run workflow" button for testing
jobs:
run:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4 # use the latest major version
- run: python3 job.py
The schedule uses the same five-field format as cron. I explain the format, and how to set up a scheduled workflow step by step, in my Bluesky automation bot guide. My free Python uptime monitor is another example of a script that runs this way.
GitHub Actions' gotchas:
-
It can be late. Scheduled runs can be delayed when GitHub is busy, and the start of each hour is the busiest time. Use an odd minute like
17instead of0. - It can stop on its own. In a public repository, GitHub switches off scheduled workflows after 60 days without repository activity.
- The shortest interval is 5 minutes.
- Only the default branch counts. A schedule on another branch won't run.
- Times are in UTC unless you say otherwise, which may not match your clock.
- There's no catching up. A run that's skipped or dropped is simply gone.
- Free minutes aren't unlimited on private repositories. Public repositories don't use them up.
Because it's free and needs no machine of your own, it's usually my first choice for scripts that fit.
Option 4: a cloud scheduler and a function
All the big cloud providers offer the same two-part recipe: a scheduler that fires on a timetable, and a small function it calls. On AWS, that's EventBridge Scheduler and Lambda. On Google Cloud, it's Cloud Scheduler and Cloud Run functions. On Azure, a function with a timer trigger does both in one.
The free tiers are generous for this kind of job, though they change, so check the pricing pages before you rely on them. At the time of writing:
- AWS lists 1 million Lambda requests per month and 400,000 GB-seconds of compute as free, and 14 million EventBridge Scheduler invocations per month.
- Google Cloud lists 2 million Cloud Run functions requests per month as free, and the first 3 Cloud Scheduler jobs on a billing account.
A job that runs once a day is only about 30 invocations a month, so cost is rarely the obstacle. Setup is. You have to package your code, set up permissions, deploy it, and create a billing account with a payment card, even if you stay within the free tier.
The cloud's gotchas:
- More moving parts. Permissions and deployment take real time to get right.
- A billing account is required, so set a budget alert on day one to avoid surprises.
- Different tools for logs and alerts than the ones you already know.
It makes sense when you need precise timing, run very often, or already use that cloud. For a small daily script, it's usually more than you need.
Whatever you choose: make failure loud
Every one of these can fail without telling you:
- cron: the computer was asleep, or the command wasn't found
- systemd: the timer was never enabled
- GitHub Actions: the schedule was switched off after 60 quiet days
- Cloud: a permission or credential expired
The fix is the same each time. Log what happened, alert yourself when a run fails, and use a heartbeat so you also hear about runs that never happened. I explain all of that in how to monitor your automations when something breaks.
Time zones and daylight saving
Schedules can behave strangely around clock changes.
- cron and systemd use the machine's time zone.
- GitHub Actions uses UTC by default.
Two habits help. For servers, set the machine to UTC and write your schedule in UTC. And avoid scheduling anything between 1 a.m. and 3 a.m. local time, where clock changes can skip or repeat a run.
Examples: which one for what?
- A daily report sent to your inbox. GitHub Actions. It only needs the internet and runs once a day.
- Tidying your Downloads folder. cron or Task Scheduler on that computer, because it needs your files.
- Checking a website every 5 minutes. GitHub Actions works, but runs can be late. For strict timing, use a small server with a systemd timer or cron.
- An invoice that has to go out at 09:00 sharp. A server or the cloud, where timing is reliable.
-
A job on a Raspberry Pi that's sometimes switched off. A systemd timer with
Persistent=true, so missed runs are made up.
Quick answers
What's the shortest interval for each?
Cron and systemd can run every minute. GitHub Actions has a 5-minute minimum.
Does my computer need to stay on?
For cron and systemd, yes. For GitHub Actions and the cloud, no.
How do I test a schedule?
For cron, a cron-expression checker shows the next run times. For systemd, use systemd-analyze calendar. For GitHub Actions, add workflow_dispatch and press the "Run workflow" button.
What if a run takes longer than the gap between runs?
That's what the lock file in the template is for. The new run sees the old one is still going and skips.
Which one is cheapest?
For a small script, all four are effectively free. The real cost is the time it takes to set up and maintain.
Can I use more than one?
Yes. Many people run local jobs with cron and cloud-friendly jobs with GitHub Actions. Just keep a list of what runs where.
The short version
Choose where the script runs first: your own machine, a server you control, GitHub, or the cloud. Then make it safe to run alone, with a log, a lock, a timeout and an honest exit code.
Most of all, make sure you'd find out if it stopped. A scheduler that fails silently is worse than no scheduler, because you think the job is getting done.
You can find more practical automation guides on Procwire.
Top comments (0)