Skip to content

Cron schedule

Cron on the last day of the month

Standard 5-field cron has no syntax for the last day of the month. Schedule 0 0 28-31 * * and let the command run only when tomorrow is the 1st; Quartz, Spring, node-cron and Cloudflare accept L directly, as in 0 0 L * *.

Last day of the month
Expression0 0 28-31 * *
Runs12 times in 2027
systemd OnCalendar*-*~01 00:00:00

How it works

Months end on the 28th, 29th, 30th or 31st, and the day-of-month field can only list fixed numbers. 0 0 31 * * misses five months; 0 0 28-31 * * fires up to four times at the end of every month. POSIX and Vixie cron never added a “last” keyword.

The portable fix is to fire on every candidate day and let a one-line test decide: if tomorrow’s day number is 01, today is the last day. In a crontab line the % in date +%d must be written \%, or cron cuts the command there.

Quartz introduced L in the day-of-month field, and it has spread: Spring’s CronExpression, node-cron 4.x and Cloudflare Cron Triggers all accept it, and systemd has its own form, *-*~01. The next-runs table below evaluates the L form.

Field-by-field breakdown
FieldValueMeaning
Minute0minute 0 (on the hour)
Hour0hour 0 (12 AM)
Day of month28-31days 28 to 31
Month*every month
Day of week*every day of the week

The table describes the portable part, 0 0 28-31 * *. The next-run times below use 0 0 L * *, the Quartz-style form, which fires only on the last day.

Next runs

Scheduler clock

As on GitHub Actions without timezone, Vercel, Cloudflare and Kubernetes with timeZone "Etc/UTC".

Next run times, computed in your browser from the current time
RunYour timeUTC
1Calculating… 
2  
3  
4  
5  

Ready-to-paste versions

The same schedule for each scheduler, with what differs on each one.

crontab (Linux, macOS, BSD)

crontab -e
# Days 28-31, but only act when tomorrow is the 1st (GNU date):
0 0 28-31 * * [ "$(date -d tomorrow +\%d)" = "01" ] && /usr/local/bin/job.sh

# BSD / macOS date:
0 0 28-31 * * [ "$(date -v+1d +\%d)" = "01" ] && /usr/local/bin/job.sh
  • % has to be escaped as \% inside a crontab line; an unescaped % ends the command and the rest goes to its stdin. The check runs at most four times a month and does nothing on three of them.

GitHub Actions

.github/workflows/scheduled.yml
on:
  schedule:
    - cron: '0 0 28-31 * *'
jobs:
  run:
    runs-on: ubuntu-latest
    steps:
      - name: Only on the last day of the month
        id: last
        run: echo "is_last=$([ "$(date -u -d tomorrow +%d)" = 01 ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
      - if: steps.last.outputs.is_last == 'true'
        run: ./job.sh
  • GitHub uses POSIX cron syntax with no L, so the workflow wakes up on days 28 to 31 and a step checks the date. No % escaping here: that rule is crontab-only.

Kubernetes CronJob

cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: month-end
spec:
  schedule: "0 0 28-31 * *"
  timeZone: "Etc/UTC"
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: job
              image: alpine:3.20
              command: ["/bin/sh", "-c"]
              args:
                - |
                  [ "$(date -d @$(( $(date +%s) + 86400 )) +%d)" = "01" ] || exit 0
                  exec /app/job.sh
  • Kubernetes’ schedule syntax has no L. The container checks whether tomorrow is the 1st and exits 0 otherwise; the arithmetic form of date -d @… works in BusyBox (Alpine) as well as GNU date.

Vercel Cron Jobs

vercel.json
{
  "crons": [
    { "path": "/api/month-end", "schedule": "0 0 28-31 * *" }
  ]
}
  • No L on Vercel. Schedule days 28-31 and have the function return early unless tomorrow (in UTC) is the 1st. That’s up to four runs a month, one a day, which the Hobby plan allows (per-hour precision there).

Cloudflare Workers

wrangler.toml
[triggers]
crons = ["0 0 L * *"]
  • Cloudflare supports Quartz-style L in the day-of-month field, so no workaround is needed. LW would be the last weekday of the month. Times are UTC.

node-cron (Node.js)

scheduler.js
import cron from 'node-cron';

// L = last day of the month (node-cron 4.x)
cron.schedule('0 0 0 L * *', async () => {
  await runMonthEnd();
}, { timezone: 'UTC' });
  • node-cron 4.x accepts L (and L-3, LW) in the day-of-month field.

systemd timer

job.timer + job.service
# /etc/systemd/system/job.timer
[Unit]
Description=Run job.service on the last day of each month

[Timer]
OnCalendar=*-*~01 00:00:00
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 '*-*~01 00:00:00', which prints the normalized form and the next elapse. AccuracySec defaults to 1min, so the start can drift by up to a minute.
  • Persistent=true runs the job at boot if the machine was off at the scheduled time, which plain cron doesn’t do.
  • The ~ means “counting from the end of the month”: *-*~01 is the last day, *-*~02 the one before it.

Spring @Scheduled and Quartz

Java
@Scheduled(cron = "0 0 0 L * *", zone = "UTC")
public void monthEnd() { }

// Quartz CronTrigger:
CronScheduleBuilder.cronSchedule("0 0 0 L * ?")
  • Spring (5.3+) and Quartz both support L in the day-of-month field; L-2 is two days before the last day and LW the last weekday.

Pitfalls

0 0 31 * * runs seven times a year

Only January, March, May, July, August, October and December have a 31st. cron doesn’t move a run to the nearest existing day; it just doesn’t fire.

The unescaped %

date +%d works in a shell and silently breaks in a crontab, where % means newline. Write date +\%d in the crontab line, or move the logic into a script file.

Consider the 1st instead

Many month-end jobs are really “after the month closes” jobs. Running 0 0 1 * * and processing the previous month avoids the last-day problem entirely and sees the complete last day of data.

Frequently asked questions

How do I run a cron job on the last day of the month?
Schedule 0 0 28-31 * * and prefix the command with [ "$(date -d tomorrow +\%d)" = "01" ] &&. Where L is supported (Quartz, Spring, node-cron, Cloudflare), use L in the day-of-month field.
Does crontab support L for the last day?
No. cronie, Debian cron and other Vixie-derived crons don’t support L. It comes from Quartz.
What is the last day of the month in systemd?
OnCalendar=*-*~01 00:00:00. The tilde counts days from the end of the month.
How do I run on the last weekday of the month?
Use LW in Quartz, Spring, node-cron or Cloudflare. In plain cron, run on days 26-31 on weekdays and check in the script that no later weekday remains in the month.

Last reviewed by Arielton Oberek.