Cron job every 5 minutes: the */5 line that survives production
Last updated: July 2026 · Yohann Kipfer — I build and run Cron-Ping.
*/5 * * * * runs a job every 5 minutes. 288 times a day, at :00, :05, :10 and so on. To make cron ping a URL, this is the line to use:
*/5 * * * * curl -fsS --max-time 10 --retry 3 -o /dev/null https://example.com/cronNot */5 * * * * curl https://example.com/cron. That's the version every tutorial prints, and it lies to you: curl exits 0 on an HTTP 500, so a broken endpoint looks exactly like a working one. Every flag below is there because something broke without it.
First, which one is your problem?
“Cron job to ping a URL” means three different things. Every article I've read picks one and pretends the other two don't exist, which is why people leave those pages with the wrong line. Pick yours:
You want cron to CALL a URL.
Trigger an endpoint, rebuild a cache, keep a free-tier instance awake. The URL is the work. Read on. That's the line above, and the flags matter more than you'd think.
You want to KNOW your cron actually ran.
The URL isn't the work, it's the witness. Nothing arriving is the alert. That's a dead man's switch, and it's the only one of the three that cron itself cannot do for you.
Jump to the heartbeat pattern ↓You don't have a server at all.
No box, no crontab, nothing to run crontab -e on. Then you don't want cron, you want cron-as-a-service. cron-job.org is free and does the job. Come back for #2 once it runs, because hosted schedulers go quiet too.
What */5 * * * * actually says
Five fields, space-separated, read left to right. The stars mean “every”.
| Field | Value | Reads as |
|---|---|---|
| minute | */5 | every 5th minute of the hour: 0, 5, 10 … 55 |
| hour | * | every hour |
| day of month | * | every day |
| month | * | every month |
| day of week | * | every weekday |
*/5 means “every 5th minute counted from 0”, not “5 minutes after the last run finished”. If your job kicks off at 14:03 by hand, the next cron run is still 14:05. Two minutes later, not five. Cron owns the clock, your job doesn't.
That also explains a trap: */7 gives you :00, :07 … :56, then the hour rolls over and the next run is :00, a 4-minute gap instead of 7. Steps only spread evenly when they divide 60. Safe values: 1, 2, 3, 4, 5, 6, 10, 12, 15, 20, 30. That's why everyone uses 5.
The sixth field that eats an afternoon
# ~/crontab, edited with 'crontab -e' — FIVE fields
*/5 * * * * /opt/job.sh
# /etc/cron.d/myjob or /etc/crontab — SIX fields, there's a user column
*/5 * * * * root /opt/job.shPaste the five-field version into /etc/cron.d/ and cron reads /opt/job.sh as the username. Two more rules for that directory, both silent when broken: the filename can only contain letters, digits, underscores and hyphens (myjob.sh is ignored, myjob is read), and the file needs a trailing newline or the last line is dropped.
Pick a schedule, say what runs, tick what you need. The output is the line to paste into crontab — %-escaped, timeout-capped, and honest about exit codes.
*/5 * * * * curl -fsS --max-time 10 --retry 3 -o /dev/null https://example.com/cron/refresh-cache…
The curl flags, one by one
This is the part tutorials skip. Bare curl https://url in a crontab is not a monitoring line, it's a rumour.
| Flag | What it does | Without it |
|---|---|---|
| -f, --fail | Exit 22 on HTTP 400+ instead of 0 | A 500 exits 0. Your && chain fires as if everything is fine. This is the #1 silent failure in this whole page. |
| -s, --silent | No progress meter | Cron mails you a progress bar. 288 times a day. |
| -S, --show-error | Keep the error message on stderr despite -s | -s alone swallows the reason too. You get a failure with no explanation. |
| --max-time 10 | Hard cap on one attempt | curl hangs on a half-open TCP connection until the kernel gives up — minutes. On a 5-minute cycle, runs start stacking. |
| --retry 3 | Retry transient errors and 5xx | One DNS hiccup = one missed run = one false alert. |
| -o /dev/null | Throw the response body away | The body ends up in cron's mail, or in your log file. Every 5 minutes. |
One caveat on the combination: --max-time bounds an attempt, --retry gives you more attempts, and curl sleeps between them (1s, then 2s, then 4s). So --max-time 10 --retry 3 is not a 10-second budget — plan for something like 45 seconds worst case. Fine under a 5-minute cycle. Not fine under a 1-minute one: add --retry-max-time 20 and stop guessing.
The % that silently destroys your line
One sentence in man 5 crontab, easy to miss, and it costs people an afternoon: in a crontab command, an unescaped % becomes a newline, and everything after the first one is fed to the command on stdin.
Looks right, does nothing
*/5 * * * * curl -fsS -d "day=$(date +%F)" https://example.com/hookThat does not post day=2026-07-16. Cron runs curl -fsS -d "day=$(date + and pipes F)" https://example.com/hook into it as standard input. No syntax error, no mail, no log line. It just quietly does the wrong thing forever.
Escape every one
*/5 * * * * curl -fsS -d "day=$(date +\%F)" https://example.com/hookIt bites date +%F, date +%Y-%m-%d, any URL with %20 or %2F in it, and every printf format string. It's also the real answer to “the command works when I paste it in my shell but not in cron” — your shell doesn't do this, only crontab does. The builder above escapes them for you and says so.
Its sibling: the & in a query string
Same family of bug, one layer down. Cron hands your command to /bin/sh, so an unquoted & means “background everything before me and move on”:
# curl runs in the background against .../hook?a=1 — and &b=2 is thrown away
*/5 * * * * curl -fsS https://example.com/hook?a=1&b=2
# double quotes fix it (single quotes would also kill $(date …) substitution)
*/5 * * * * curl -fsS "https://example.com/hook?a=1&b=2"No error either, of course. You get a 200 on half the URL you meant. Double quotes rather than single, so $(date +\%F) still expands — that's why the builder above quotes with " when it spots an &.
288 runs a day, and cron does not care if the last one finished
At 5-minute spacing, each run has 300 seconds before the next one starts. Most days your job takes 3. Then an upstream API goes slow, a run takes 6 minutes, and cron starts the next one anyway. Now two overlap. Then three. Then the box is swapping and every run is slow, which makes more of them overlap — that's a pileup, and it doesn't recover on its own. A backup script doing this to a live database is how you get an unrestorable dump at 4am.
Cron has no concurrency control. None at all. Add it yourself:
*/5 * * * * /usr/bin/flock -n /var/lock/myjob.lock /opt/myjob.sh-n means: if the lock is already held, exit immediately (code 1) instead of waiting. That's what you want — a skipped run is a fact you can measure, a queue is a fire. Drop the -n and flock waits politely, which just moves the pileup into a queue and delays the crash.
Absolute path on purpose. Cron gives you a PATH of /usr/bin:/bin and nothing else. No .bashrc, no .profile, none of the environment you tested in. flock happens to live in /usr/bin, but the script it wraps probably calls node, php or aws, and those usually don't.
How often should it run?
Runs add up faster than people expect. The last two columns are the ones that matter.
| Interval | Expression | Per hour | Per day | Per 30 days | Grace to set |
|---|---|---|---|---|---|
| Every minute | * | 60 | 1 440 | 43 200 | 3 min |
| Every 5 minutes | /5 * | 12 | 288 | 8 640 | 10 min |
| Every 10 minutes | /10 * | 6 | 144 | 4 320 | 20 min |
| Every 15 minutes | /15 * | 4 | 96 | 2 880 | 25 min |
| Every 30 minutes | /30 * | 2 | 48 | 1 440 | 45 min |
The grace column is the delay before “no ping yet” becomes “alert”. Rule of thumb: twice the interval, or the interval plus your worst observed run time, whichever is larger. Set it to exactly the interval and one 4-second network blip pages you at 3am. You'll mute that alert within a week, and you're back to no monitoring with extra steps. Cron-Ping's free plan won't let you go under 60 seconds of grace at all. That's deliberate, for exactly this reason.
And if you're pinging somebody else's API: */5 is 8 640 requests a month from a single box. Free tiers notice. So do rate limiters.
Cron will never tell you it didn't run
There's no “job missed” event. If crond is stopped, if a config-management run clobbered the crontab, if the disk is full and the shell can't fork, if someone typed crontab -r — the observable outcome is identical to a quiet, healthy system. Silence. That's the whole problem, and it's why “check the logs” is not an answer: cron logs that it started a job. It does not log the exit code, and it logs precisely nothing when it never started.
What cron does have is mail. Put MAILTO=you@example.com at the top of the crontab and every run that writes to stdout or stderr emails you. At 288 runs a day, that's 288 emails. You'll filter them to a folder in about two days and stop opening the folder in about two weeks. Mail isn't monitoring. It's monitoring you'll be trained to ignore.
Exit codes are the part worth keeping:
*/5 * * * * /opt/job.sh && echo "ok" || echo "job.sh exited $?" >&2&& runs on exit 0, || on anything else. Which is exactly why a missing -f on curl is so nasty: without it, an HTTP 500 is “exit 0”, and your success branch cheerfully reports success.
The other reading: ping a URL to prove the cron ran
Flip it around. Instead of cron calling your app, cron calls a URL whose only job is to notice when the call stops coming. The absence is the signal. A dead man's switch.
*/5 * * * * /opt/job.sh && curl -fsS --max-time 10 -o /dev/null https://cron-ping.com/p/YOUR-TOKENRead it left to right: the job runs, exits 0, curl fires, the check is marked alive. Job crashes → no exit 0 → no curl → no ping → past the grace period we email or Slack you. Box loses power → no ping either. That's the point: it catches the failures no logging on that machine could catch, because the machine is the thing that failed.
Add the failure branch so “crashed” and “never started” stop looking the same:
*/5 * * * * /opt/job.sh \
&& curl -fsS --max-time 10 -o /dev/null https://cron-ping.com/p/YOUR-TOKEN \
|| curl -fsS --max-time 10 -o /dev/null https://cron-ping.com/p/YOUR-TOKEN/failThere's also /p/YOUR-TOKEN/start, called before the job, if you want the duration measured rather than guessed.
The honest limit
This tells you the job exited 0. It does not tell you the backup it wrote can be restored. A script that exits 0 on an empty dump will ping green forever, and you'll find out in the worst possible week. Make the script verify its own output — check the dump is bigger than 1 KB, check the row count — and then the ping means something. A heartbeat is only as honest as the exit code behind it.
Every 5 minutes on other platforms
Same intent, different footguns. The third column is the part the docs bury.
| Platform | Every 5 minutes | The catch |
|---|---|---|
| Linux crontab (Vixie / cronie) | /5 * | One minute is the floor. No concurrency control, no missed-run notice, no exit code in the logs. |
| systemd timer | OnCalendar=*:0/5 | Persistent=true fires a missed run at boot — lovely on a laptop, surprising on a server that was down for a day. RandomizedDelaySec spreads load but blurs your schedule, so widen the grace to match. |
| GitHub Actions | - cron: '/5 *' | 5 minutes is the documented floor. Runs are queued under load — GitHub calls out the top of every hour — and dropped rather than delayed forever. And a scheduled workflow is disabled after 60 days without repo activity. Silently. |
| Vercel Cron | "schedule": "/5 *" | On Hobby, cron jobs are triggered once a day whatever you write in the expression. Minute-level schedules need Pro. The expression doesn't error — it just doesn't mean what it says. |
| Kubernetes CronJob | schedule: "/5 *" | The default concurrencyPolicy: Allow overlaps runs — set Forbid. And if the controller misses more than 100 scheduled starts, it stops scheduling and logs “too many missed start times” instead of catching up. |
| Docker | no cron in the image | Either run cron -f as PID 1 in its own container, or let the host crontab do docker exec. Cron inside a container has no MTA, so stderr goes nowhere at all — the mail safety net you didn't like is now gone too. |
| cPanel / shared hosting | same five fields, in a form | Plenty of hosts quietly floor you at 5 or 15 minutes. And the PHP binary is rarely php — it's something like /usr/local/bin/ea-php82. which php over SSH before you file a support ticket. |
| Windows Task Scheduler | no cron syntax | Trigger → repeat every 5 minutes for a duration of 1 day. And tick “Run whether user is logged on or not”, or it won't run on a headless box. |
| Render / Railway / Fly.io | cron syntax, managed | Each run starts a container. 288 cold starts a day is 288 billed starts a day — check what that costs before you pick 5 minutes out of habit. |
Timezone and daylight saving
Cron runs in the server's timezone, which is very often UTC and almost never the one in your head. Check it with timedatectl before you argue with it. Then either accept UTC — genuinely the easiest choice. Or pin it at the top of the crontab:
CRON_TZ=Europe/Paris
*/5 * * * * /opt/job.shCRON_TZ= works on Vixie cron and cronie, so Debian, Ubuntu, RHEL and friends. It is not POSIX. A busybox crontab in an Alpine container ignores the line and keeps happily running in UTC — no warning, obviously. Test it, don't assume it.
DST, precisely — because the usual advice is wrong half the time
A */5 job has a wildcard hour, so it just follows the wall clock. Nothing skipped, nothing doubled, DST is a non-event. The jobs that hurt are the fixed-hour ones. Take 30 2 * * * on the spring-forward night: 02:30 never happens, and Debian's cron runs it shortly after the jump instead. On the autumn night 02:30 happens twice, and cron runs it once. So your 5-minute heartbeat is fine and your 02:30 backup is the one worth a second look. That distinction is in man 8 cron and almost nowhere else.
Testing it without waiting 5 minutes
Nobody should be debugging a cron line by watching a clock. The loop is 20 seconds long:
crontab -eopens your user crontab. If vi ambushes you,EDITOR=nano crontab -e. On save you must seecrontab: installing new crontab. No message means nothing was installed.- Don't debug through cron. Run the exact command by hand first, with the
\%escapes taken back out — if it fails there, cron was never the problem. - Then reproduce cron's environment, because that's where the other 90% hides. Cron gives you a bare
PATHand none of your shell config. - Only then put it back in the crontab. If you must see a real cron run, set the minute field to two minutes from now for one shot, then restore it.
# What cron actually gives your script — the usual culprit
env -i /bin/sh -c '/opt/job.sh'
# Print cron's real environment once, then read it
*/5 * * * * env > /tmp/cron-env.txtBefore any of that: crontab -l > ~/crontab.bak. crontab -r deletes your entire crontab without a confirmation prompt, and on a keyboard r sits right next to e. Ask me how I know.
Where the logs are
| System | Command |
|---|---|
| Debian / Ubuntu | journalctl -u cron -f — or grep CRON /var/log/syslog |
| RHEL / Rocky / Alma | journalctl -u crond -f — or /var/log/cron |
| Alpine (busybox) | /var/log/messages — and crond needs -L /var/log/cron.log to log at all |
| macOS | log show --predicate 'process == "cron"' --last 1h |
Read those lines carefully: cron logs that it started the command. There is no exit code in there. A job that starts and dies one second later looks identical to a job that starts and works.
FAQ
What is the cron expression for every 5 minutes?
*/5 * * * *. It runs at minute 0, 5, 10, 15 … 55 of every hour, every day — 288 runs a day. The five fields are minute, hour, day of month, month, day of week, and */5 in the first one means “every 5th minute counted from 0”.
How do I make a cron job ping a URL?
*/5 * * * * curl -fsS --max-time 10 --retry 3 -o /dev/null https://example.com/cron. The -f is not optional: without it curl exits 0 on an HTTP 500 and your job reports success on a broken endpoint. --max-time must be well under your interval.
Can cron run every 5 seconds?
No. One minute is cron's floor, by design — there's no seconds field. Use a systemd timer with OnUnitActiveSec=5s, or a supervised long-running process with a sleep loop. At 5-second granularity, cron is the wrong tool and a sleep 5; sleep 5 hack in a crontab line is worse.
Why does my cron job work in the terminal but not in cron?
Three causes, in order of frequency: cron's PATH is only /usr/bin:/bin and it never reads your .bashrc; you used a relative path and cron's working directory isn't yours; or there's an unescaped % in the line, which cron turns into a newline. Test with env -i /bin/sh -c 'your-command'.
Is */5 the same as 0,5,10,15,20,25,30,35,40,45,50,55?
Same runs, yes. */5 is a Vixie cron extension rather than POSIX, so a strict POSIX cron — some old Solaris and AIX boxes — only understands the list. On Linux, macOS and busybox, */5 works and is what everyone reads faster.
How do I know if my cron job actually ran?
Cron won't tell you: it has no “missed run” event, and its logs record starts, not exit codes. Either grep the log for the start line each time, or have the job ping a URL on success and let a service alert you when the ping doesn't arrive within the grace period.
Want to know when that line stops running?
That's the whole product. One URL to curl at the end of your job, an email or a Slack message when the ping doesn't arrive. 10 checks free, no card, EU hosting.
Create a free checkFree plan: 10 checks, email alerts, 7-day history. Pro is €9/month if you outgrow it.