note · scheduling
Pushkar Kumar 7 min read
Migrating from cron to systemd timers
Converting crontab entries into systemd .timer and .service units — OnCalendar syntax, logging, randomized delays, catch-up runs and the migration gotchas.
On this page
Cron has run the world’s scheduled jobs for forty years, and on most servers it still works fine. But the moment a job needs structured logging, a controlled environment, dependency ordering, or a way to catch up after the machine was off, the cron model starts to creak. That’s where systemd timers come in — and if your distribution already runs systemd (Debian, Ubuntu, RHEL, Fedora, Arch, SUSE all do), you have a more capable scheduler sitting unused.
This post walks through what actually changes when you migrate, with real units you can adapt.
Why bother moving off cron
Cron is a single line. That brevity is its appeal and its ceiling:
- Logging. A cron job’s output goes wherever you redirect it, and if you forget, it’s emailed to a mailbox nobody reads. A systemd service writes to the journal automatically —
journalctl -u myjob.serviceshows you every run, with timestamps and exit codes. - Environment. Cron runs with a deliberately minimal
PATHand almost no environment, which is the classic “works in my shell, fails in cron” trap. A service unit declares its environment explicitly. - Missed runs. If the host is asleep or powered off at the scheduled minute, cron simply skips the job. A timer with
Persistent=trueruns it as soon as the machine is back. - Overlap and resources. systemd won’t start a second copy of a job while the first is still running, and you can attach
CPUQuota=,MemoryMax=, and other resource controls to a unit.
You don’t need to migrate everything. But for jobs where a silent failure costs you, timers are worth the two files they require.
The two-file model
A cron line does scheduling and execution in one place. systemd splits this into a service (what to run) and a timer (when to run it). They share a base name.
Take this crontab entry — run a backup script every day at 02:30:
30 2 * * * /usr/local/bin/backup.shThat becomes two units in /etc/systemd/system/.
The service, backup.service:
[Unit]
Description=Nightly backup
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.shThe timer, backup.timer:
[Unit]
Description=Run nightly backup at 02:30
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
[Install]
WantedBy=timers.targetType=oneshot tells systemd the job is expected to run, finish, and exit — the right type for almost every cron-style task. The timer’s [Install] section is what makes systemctl enable work; without WantedBy=timers.target, the timer won’t arm at boot.
Enable and start it:
sudo systemctl daemon-reload
sudo systemctl enable --now backup.timerNote you enable the timer, not the service. The timer pulls in the service when it fires.
Translating the schedule: OnCalendar
The hardest part of the migration is the schedule field, because systemd uses OnCalendar= rather than the five cron fields. The format is DOW YYYY-MM-DD HH:MM:SS, and it’s genuinely more readable once you learn it. Some common mappings:
# cron # OnCalendar
*/15 * * * * *-*-* *:0/15:00 (every 15 minutes)
0 * * * * *-*-* *:00:00 (hourly, on the hour)
30 2 * * * *-*-* 02:30:00 (daily at 02:30)
0 4 * * 1 Mon *-*-* 04:00:00 (Mondays at 04:00)
0 0 1 * * *-*-01 00:00:00 (1st of the month)
0 9 * * 1-5 Mon..Fri *-*-* 09:00:00 (weekdays at 09:00)There are also convenient shorthands — hourly, daily, weekly, monthly — so OnCalendar=daily is equivalent to midnight every day. The single most useful command during migration is systemd-analyze calendar, which parses an expression and shows you the next firing times:
$ systemd-analyze calendar --iterations=3 'Mon..Fri *-*-* 09:00:00'
Original form: Mon..Fri *-*-* 09:00:00
Normalized form: Mon..Fri *-*-* 09:00:00
Next elapse: Mon 2026-06-08 09:00:00 UTC
From now: 4h 12min left
(next 3) Tue 2026-06-09 09:00:00 UTC
Wed 2026-06-10 09:00:00 UTCIf that output matches what your cron line did, the schedule is correct. If it doesn’t, you’ve caught the bug before it shipped.
Converting cron to systemd: the schedules that don’t map one-to-one
The table above covers plain numeric fields. Three kinds of cron line need more care, because a literal conversion runs on a different day or never runs at all.
The @ shorthands. systemd has its own named schedules, and they mostly agree with cron’s. One does not:
# cron # systemd
@hourly OnCalendar=hourly (*-*-* *:00:00)
@daily OnCalendar=daily (*-*-* 00:00:00)
@weekly OnCalendar=weekly (Mon *-*-* 00:00:00, cron uses Sunday)
@monthly OnCalendar=monthly (*-*-01 00:00:00)
@yearly OnCalendar=yearly (*-01-01 00:00:00)
@reboot OnBootSec=1min (a monotonic timer, not OnCalendar)cron’s @weekly is 0 0 * * 0, Sunday midnight. systemd’s weekly is Monday midnight. If a report has to land before Monday morning, write OnCalendar=Sun *-*-* 00:00:00 instead of trusting the name. @reboot has no calendar form at all: use OnBootSec= in the timer, or drop the timer and give the service WantedBy=multi-user.target.
Day-of-month and day-of-week together. When both fields are restricted, cron runs the job if either one matches. 0 9 1 * 1 fires on the 1st of every month and on every Monday. OnCalendar=Mon *-*-01 09:00:00 fires only when the 1st falls on a Monday, a few times a year. To keep cron’s behaviour, list both expressions; a timer accepts several OnCalendar= lines and fires on any of them:
[Timer]
OnCalendar=*-*-01 09:00:00
OnCalendar=Mon *-*-* 09:00:00Offset steps and Sunday as 7. 5-59/10 * * * * starts at minute 5, which becomes *:05/10. cron accepts both 0 and 7 for Sunday; systemd wants the name, Sun. Run every converted line through systemd-analyze calendar before you enable it. That check costs seconds, and it’s how you catch the shorthands that look equivalent but aren’t.
The gotchas that actually bite
Timezone. Cron uses the system local time. systemd timers do too by default, but OnCalendar is evaluated in the timer’s timezone, which can surprise you on servers set to UTC. Pin it explicitly with OnCalendar=Mon *-*-* 04:00:00 America/New_York if local time matters, and remember that daylight-saving transitions can skip or double a run.
Thundering herd. Cron fires 0 * * * * jobs at exactly :00 across your whole fleet. Add RandomizedDelaySec= to spread the load:
[Timer]
OnCalendar=hourly
RandomizedDelaySec=300
Persistent=trueThat jitters each run by up to five minutes — invaluable when a hundred hosts hit the same API.
Environment and working directory. Cron’s sparse environment trips people up; so does assuming a working directory. Be explicit in the service:
[Service]
Type=oneshot
WorkingDirectory=/opt/app
Environment=PATH=/usr/local/bin:/usr/bin:/bin
EnvironmentFile=-/etc/app/env
ExecStart=/opt/app/run.shThe leading - on EnvironmentFile means “don’t fail if the file is missing,” mirroring cron’s forgiving behavior.
Per-user jobs. A user crontab maps to a user unit. Drop the files in ~/.config/systemd/user/, enable with systemctl --user enable --now myjob.timer, and run loginctl enable-linger $USER so the timer survives logout.
Verifying the migration
After enabling, confirm the timer is armed and inspect its history:
systemctl list-timers --all # see next/last run for every timer
journalctl -u backup.service --since today # read the job's output
sudo systemctl start backup.service # trigger a manual run to test nowsystemctl start backup.service runs the job immediately, independent of the schedule — the cleanest way to confirm the service half works before trusting the timer.
Don’t hand-translate every field
The mechanical part — turning five cron fields into an OnCalendar line and scaffolding the .timer/.service pair — is exactly the kind of thing that’s easy to get subtly wrong by hand, especially with step values, ranges, and day-of-week edge cases. Our Cron to systemd Converter does it in the browser: paste a crontab line, get a ready-to-edit timer and service unit with the correct OnCalendar expression and migration notes, with nothing uploaded anywhere.
Related posts
-
8 min read
chown command in Linux: change file owner and group
The chown command in Linux explained: user:group syntax, chown -R and symlinks, --reference, chgrp, chmod vs chown, and fixing Docker volume permission errors with numeric IDs.
-
10 min read
chmod command in Linux: syntax, examples and common mistakes
The chmod command in Linux explained: octal and symbolic modes, chmod +x, chmod -R and its traps, setuid, setgid and sticky bits, umask, and how to fix Permission denied.
-
6 min read
GitHub Actions cron runs in UTC: convert your schedule and check the real next run time
GitHub Actions cron timezone UTC explained: why schedule: cron: always fires on UTC, how DST shifts the wall-clock time, and how to check the real next run.