Cron schedule
Cron every year
To run a cron job once a year, use @yearly (or @annually), which is 0 0 1 1 *: 00:00 on January 1. For another date, set the day and month fields, e.g. 0 9 15 3 * for March 15 at 09:00.
| Expression | 0 0 1 1 * |
|---|---|
| Macro | @yearly |
| Runs | 1 times in 2027 |
| systemd OnCalendar | yearly |
How it works
Two fields do the work: day of month 1 and month 1. crontab(5) defines @yearly and @annually as this line, and Kubernetes and Spring accept both names. systemd’s yearly keyword is *-01-01 00:00:00.
A yearly job runs so rarely that the schedule is the easy part. What fails is everything around it: an expired token, a renamed bucket, a server that was decommissioned in March. Make the job runnable on demand and run it by hand once when you set it up.
Midnight on New Year’s is also the least staffed moment of the year. Unless the job must run at 00:00, pick a working day, like 0 10 2 1 * for 10:00 on January 2.
| Field | Value | Meaning |
|---|---|---|
| Minute | 0 | minute 0 (on the hour) |
| Hour | 0 | hour 0 (12 AM) |
| Day of month | 1 | day 1 |
| Month | 1 | January |
| 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 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 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 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 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 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 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 yearly, January 1 at midnight
[Timer]
OnCalendar=yearly
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 'yearly', 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 1 *", zone = "UTC")
public void runJob() {
// ...
}
// Quartz CronTrigger:
CronScheduleBuilder.cronSchedule("0 0 0 1 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
New Year in UTC vs local
January 1 00:00 UTC is still December 31 in the Americas. A yearly archive that should include all of December 31 local time needs to run later, in the right zone.
February 29 runs every four years
0 0 29 2 * fires only in leap years (2028, 2032, …), and never in 2100, which isn’t a leap year. cron doesn’t shift it to the 28th.
Missed once means missed for a year
If the host is down at the scheduled minute, plain cron skips it until next year. Use systemd Persistent=true or anacron, and alert when the run doesn’t happen.
Frequently asked questions
- What is the cron expression for once a year?
- 0 0 1 1 *, or @yearly / @annually, runs at midnight on January 1.
- Is @annually the same as @yearly?
- Yes. crontab(5) defines both as 0 0 1 1 *.
- How do I run a cron job every year on a specific date?
- Set minute, hour, day of month and month: 0 9 15 3 * runs every March 15 at 09:00.
- Can cron run a job once on a specific date and never again?
- Not by itself; cron repeats every year. Use at for one-off jobs, or have the job remove itself or check the year.
Last reviewed by Arielton Oberek.