Five fields, three traps: reading a cron expression
Cron syntax is small enough to fit on a business card, which is why every article about it is a table and a list of examples. That is not where the time goes. The time goes into three behaviours that are perfectly documented, entirely unintuitive, and produce jobs that run at the wrong time without anyone noticing for months.
The five fields
┌─ minute 0-59
│ ┌─ hour 0-23
│ │ ┌─ day 1-31
│ │ │ ┌─ month 1-12
│ │ │ │ ┌─ weekday 0-7 (0 and 7 both mean Sunday)
│ │ │ │ │
* * * * * command
Four operators: * for every value, , for a list, - for a range, and / for a step. That is the entire language.
Trap one: the step applies to what is on its left
*/15 in the minute field means every fifteenth minute of the whole range, so 0, 15, 30, 45. Almost everyone reads it as "every fifteen minutes", and in that field the two happen to coincide.
They stop coinciding as soon as the range is not the full one. 0-30/15 is 0, 15 and 30, then nothing for half an hour. And */40 in the minute field gives 0 and 40, then a twenty-minute gap before the next hour starts over at 0. The step counts from the beginning of its range and it does not wrap, so any step that does not divide sixty produces an uneven schedule.
The same in the hour field is the one that shows up in production. 0 */5 * * * looks like every five hours. It fires at 0, 5, 10, 15 and 20, then waits four hours instead of five. A daily job written this way is off by an hour every day and nobody ever checks.
Trap two: day-of-month and day-of-week are an OR
Every other pair of fields is combined with AND. These two are not, and it is the oldest surprising behaviour in cron.
If both the day-of-month and the day-of-week fields are restricted, the job runs when either matches. So:
0 3 1 * 1 # 3am on the 1st of the month, OR every Monday
That is not "the first Monday of the month". It is roughly five runs a month. The intended thing cannot be expressed in cron at all; you write a weekly job and have the script check the date itself.
The AND behaviour returns as soon as one of the two is *, which is why most schedules never hit this.
Trap three: the timezone is not yours
A system crontab runs in the machine's timezone, which on most servers and every container image is UTC. Your control panel may display Paris time while the daemon thinks in UTC. Both are internally consistent, so no error appears, and a job scheduled for eight in the morning runs at nine or at ten depending on the season.
Daylight saving makes it worse in a way that is worth knowing before it happens rather than after. On a machine that is in a shifting timezone, the spring change deletes an hour: a job at 02:30 simply does not run that day. The autumn change repeats an hour, and depending on the implementation the job runs twice. If the job sends invoices, running it twice is not a rounding error.
The fix is to schedule in UTC and convert in your head, or to set the timezone explicitly where the daemon supports it. Not to hope.
The fourth trap, which is not syntax
Everything above produces a job that runs at the wrong time. The failure that costs more is the job that does not run at all, and it has no symptom.
A successful cron job is silent. A cron job that was never scheduled is silent. A cron job whose interpreter moved is silent, because the error went to an email address nobody configured. A cron job on a container that was rebuilt without its crontab is silent. Four different states, one observable behaviour: nothing.
This is qualitatively different from a website going down. A site that stops answering produces an error for every visitor, immediately. A backup that stops running produces nothing at all, and you find out when you need the backup.
The standard advice is to redirect output to a log. That catches the job that runs and fails. It cannot catch the job that never started, because a job that never started writes nothing to a log, and an empty log looks exactly like a quiet week.
The only thing that detects absence is expecting presence: the job announces itself when it finishes, and something else notices when the announcement does not arrive. Which is a whole subject on its own, and it has its own article coming, because "we assumed it was running" is the most expensive sentence in this trade.
A short checklist before you commit an expression
Read the step operators out loud with the word "of": every fifteenth minute of the hour, every fifth hour of the day. If the sentence sounds wrong, the schedule is wrong.
If both day fields are restricted, rewrite it. You almost certainly wanted an AND.
Check what timezone the daemon believes it is in, on the actual machine, not in the panel.
And write down what the job's absence would look like. If the answer is "nothing", you do not have a scheduled job, you have a hope.
None of this is about status codes, but the underlying failure is the same one as a code read without its context: the absence of an error is not evidence of success, and every monitoring decision follows from taking that seriously. That argument, made in full and about the one code nobody questions, is why a 200 proves so little.