Cron schedule
Cron every month
To run a cron job once a month, use @monthly or 0 0 1 * *, which fires at 00:00 on the 1st of every month. Any day from 1 to 28 also gives exactly 12 runs a year.
| Expression | 0 0 1 * * |
|---|---|
| Macro | @monthly |
| Runs | 12 times in 2027 |
| systemd OnCalendar | monthly |
How it works
The day-of-month field carries the schedule: 1 means the first, and * in the month field lets it match all twelve months. crontab(5) and Kubernetes define @monthly as exactly this, and systemd’s monthly keyword is the same time (*-*-01 00:00:00).
You can choose any day, but only 1 to 28 exist in every month. 0 0 15 * * runs mid-month, twelve times a year. Days 29, 30 and 31 silently skip the months that don’t have them.
For billing, invoicing and monthly reports, the 1st at 00:00 is when the previous month is complete. The job should compute “last month” rather than “this month”, which on the 1st has barely started.
| Field | Value | Meaning |
|---|---|---|
| Minute | 0 | minute 0 (on the hour) |
| Hour | 0 | hour 0 (12 AM) |
| Day of month | 1 | day 1 |
| Month | * | every month |
| Day of week | * | every day of the week |
Next runs
As on GitHub Actions without timezone, Vercel, Cloudflare and Kubernetes with timeZone "Etc/UTC".
| Run | Your time | UTC |
|---|---|---|
| 1 | Calculating… | |
| 2 | ||
| 3 | ||
| 4 | ||
| 5 |
Ready-to-paste versions
The same schedule for each scheduler, with what differs on each one.
crontab (Linux, macOS, BSD)
# m h dom mon dow command
0 0 1 * * /usr/local/bin/job.sh >> /var/log/job.log 2>&1- cron uses the server’s time zone. On cronie (Fedora, RHEL), a
CRON_TZ=Europe/Londonline above the entry changes the zone for the entries below it.
GitHub Actions
on:
schedule:
- cron: '0 0 1 * *'
# timezone: 'America/New_York' # optional; UTC when omitted
workflow_dispatch: {}- Runs in UTC unless you set
timezone, and only on the default branch. GitHub’s docs warn that scheduled runs can be delayed at busy times and that schedules in public repositories are disabled after 60 days without activity. - The start of every hour is GitHub’s busiest slot. If the exact minute doesn’t matter, move the
0to something like17to get picked up sooner. - With a
timezonethat observes DST, a time skipped by the spring-forward change advances to the next valid time (the docs’ example: 2:30 AM becomes 3:00 AM).
Kubernetes CronJob
apiVersion: batch/v1
kind: CronJob
metadata:
name: scheduled-job
spec:
schedule: "0 0 1 * *"
timeZone: "Etc/UTC"
concurrencyPolicy: Forbid
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: job
image: busybox:1.36
command: ["/bin/sh", "-c", "date; echo running"]spec.timeZoneis stable since Kubernetes 1.27; without it, the kube-controller-manager’s zone applies.concurrencyPolicy: Forbidskips a run while the previous Job is still active.
Vercel Cron Jobs
{
"crons": [
{ "path": "/api/cron", "schedule": "0 0 1 * *" }
]
}- Vercel always uses UTC, doesn’t accept names like
MONorJAN, and won’t let you set both day of month and day of week. - This fits the Hobby plan, but Hobby precision is per hour: the invocation can land anywhere within the scheduled hour.
Cloudflare Workers
[triggers]
crons = ["0 0 1 * *"]- Cron Triggers run on UTC. Changes can take up to 15 minutes to propagate across Cloudflare’s network.
node-cron (Node.js)
import cron from 'node-cron';
// 6 fields: the first one (seconds) is optional
cron.schedule('0 0 0 1 * *', async () => {
await runJob();
}, { timezone: 'UTC', noOverlap: true });- The schedule lives inside the Node process: if it’s down, nothing runs, and three replicas run the job three times.
noOverlapskips a tick while the previous run is still going.
systemd timer
# /etc/systemd/system/job.timer
[Unit]
Description=Run job.service monthly, on the 1st at midnight
[Timer]
OnCalendar=monthly
Persistent=true
[Install]
WantedBy=timers.target
# /etc/systemd/system/job.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/job.sh
# systemctl daemon-reload && systemctl enable --now job.timer- Check it with
systemd-analyze calendar 'monthly', which prints the normalized form and the next elapse.AccuracySecdefaults to 1min, so the start can drift by up to a minute. Persistent=trueruns the job at boot if the machine was off at the scheduled time, which plain cron doesn’t do.
Spring @Scheduled and Quartz
@Scheduled(cron = "0 0 0 1 * *", zone = "UTC")
public void runJob() {
// ...
}
// Quartz CronTrigger:
CronScheduleBuilder.cronSchedule("0 0 0 1 * ?")- In Spring, the first of the 6 fields is seconds; the rest follow standard cron (0 or 7 = Sunday).
- In Quartz, one of the two day fields must be
?, and weekdays run 1 = Sunday to 7 = Saturday, which is why the example uses names.
Pitfalls
The 31st skips five months
0 0 31 * * runs only in January, March, May, July, August, October and December: seven times a year. 0 0 30 * * skips February. If you mean the end of the month, see the last-day-of-month page.
Month-end work on the 1st
A report that runs on the 1st must query the previous month. Getting that wrong produces an empty report on the 1st and nobody notices until someone asks.
One missed run is a month of missing data
A monthly job that fails or doesn’t run leaves a gap for a whole month. Alert on failure, and on a machine that may be off, use systemd Persistent=true or anacron.
Frequently asked questions
- What is the cron expression for once a month?
- 0 0 1 * *, or @monthly, runs at midnight on the 1st of every month.
- How do I run a cron job on the 15th of every month?
- 0 0 15 * * runs at 00:00 on the 15th.
- Why doesn’t my cron job run on the 31st every month?
- Only seven months have 31 days, and cron skips days that don’t exist. Use a day from 1 to 28, or the last-day workaround.
- What is @monthly in Spring?
- Spring defines @monthly as 0 0 0 1 * *, the same time with a leading seconds field.
Last reviewed by Arielton Oberek.