DEV Community

EvvyTools
EvvyTools

Posted on

How to Migrate a Classic Crontab to Systemd Timers Without Changing Job Behavior

Most Linux distributions ship systemd as the init system, and most of them ship at least one systemd timer out of the box. Despite that, plenty of production hosts still rely on classic Vixie cron crontabs that have not been touched in years. The expressions work, the jobs fire, and nobody wants to be the engineer whose migration broke the nightly report.

The good news is that the migration can be done in pieces, one job at a time, with a clean rollback at every step. The trick is to verify behavior carefully and to resist the temptation to "improve" anything during the migration. Cosmetic cleanups are what introduce regressions. Move first, clean up later, in a different change.

Mechanical wall clock face with brass markers close
Photo by Chris F on Pexels

What you actually get by switching

Systemd timers are not a wholesale upgrade over cron, but they fix a handful of long-standing annoyances:

  • Explicit per-timer timezone, independent of the host clock. No more "we run hosts in UTC because cron can't handle DST."
  • Output is captured in the journal automatically. No more >> /var/log/whatever.log 2>&1 boilerplate or silently lost stderr.
  • systemctl list-timers shows every scheduled job, the last firing, and the next firing, in one place.
  • Missed jobs can be re-run on boot via Persistent=true. If the host was off when the schedule fired, the job runs as soon as the host is back, instead of being lost.
  • The same systemd ecosystem covers logging, dependencies, and resource limits, so a scheduled job inherits the same operational tooling as a regular service.

You give up: the one-line concise expression. A systemd timer is split across two files (a .service and a .timer) and the calendar syntax is more verbose than a five-field cron line. For one-shot scripts that nobody ever maintains, the verbosity is a tax. For jobs that matter, the verbosity is documentation.

The systemd project's own reference, available at systemd.io, is the authoritative source for syntax. The relevant man pages are systemd.timer(5) and systemd.time(7).

The translation, step by step

Start with one job. Pick one that is well understood and easy to roll back. A nightly database backup or a log-rotation job is a fair candidate. Something that touches money or sends customer email is not the first choice.

Step 1: Read the cron expression and confirm the current behavior. Paste the expression into a tool that shows the next several firing times in your local timezone. The Cron Expression Builder and the crontab.guru reference both do this. Write down the expected firing times for the next two weeks. You will use this list to validate the timer.

Step 2: Write a .service file for the work. This is the systemd unit that defines what runs. If your crontab line is:

0 3 * * * /opt/myapp/backup.sh
Enter fullscreen mode Exit fullscreen mode

The matching service file at /etc/systemd/system/myapp-backup.service is:

[Unit]
Description=Nightly backup for myapp
Documentation=https://internal.example/runbooks/myapp-backup

[Service]
Type=oneshot
ExecStart=/opt/myapp/backup.sh
User=myapp
Environment=PATH=/usr/local/bin:/usr/bin:/bin
WorkingDirectory=/opt/myapp
Enter fullscreen mode Exit fullscreen mode

Set User= to whatever user the crontab is running as. Set Environment=PATH= to whatever PATH the cron job has been relying on (often just the cron daemon's default). Set WorkingDirectory= if the script expects a particular CWD.

Step 3: Write a .timer file for the schedule. The matching timer at /etc/systemd/system/myapp-backup.timer is:

[Unit]
Description=Schedule for myapp nightly backup

[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
Unit=myapp-backup.service

[Install]
WantedBy=timers.target
Enter fullscreen mode Exit fullscreen mode

The OnCalendar syntax is YEAR-MONTH-DAY HOUR:MINUTE:SECOND. For the cron equivalent 0 3 * * *, the calendar expression is *-*-* 03:00:00 or the shorthand daily with an OnCalendar= override; for more complex schedules systemd accepts day-of-week names, ranges, and lists. The systemd.time(7) man page lists the full grammar.

If the cron host runs in local time and you want the timer to behave the same way, you do not need to add anything. If you want to pin the timer to a specific timezone (which is usually the right call), add OnCalendar=*-*-* 03:00:00 America/New_York. The timezone is part of the calendar expression itself.

Step 4: Enable the timer in a staging environment. Run systemctl daemon-reload, then systemctl enable --now myapp-backup.timer. Confirm with systemctl list-timers that the timer is listed and the next firing matches what you wrote down in Step 1. Run systemctl status myapp-backup.timer to see whether anything is wrong with the file.

Step 5: Trigger the service once manually. Run systemctl start myapp-backup.service and confirm the work runs to completion. The journal will have the output: journalctl -u myapp-backup.service. If the service fails, the failure is in the environment or the command, not in the timer.

Step 6: Wait for the first scheduled firing. Let the timer fire on its own at least once. Confirm via the journal that the work ran. This is the step people skip and regret.

Step 7: Remove the cron entry. Only after the timer has fired on schedule, remove the matching line from the crontab. Do not delete it; comment it out with a note pointing to the timer name. If something goes wrong in the next month, you want to be able to uncomment it in thirty seconds.

Calendar grid showing dates with markers and pen
Photo by Pixabay on Pexels

Validate two weeks of firings

The most common migration regression is a schedule that is slightly wrong because the cron-to-systemd translation was almost right. 0 3 * * 1-5 is "3 AM Monday through Friday" in cron. The matching OnCalendar=Mon..Fri 03:00:00 is correct, but OnCalendar=Mon-Fri 03:00:00 is a parse error (the separator is two dots, not a hyphen). A typo here produces no error at startup and a schedule that never fires.

Two protections:

  1. Always run systemctl list-timers myapp-backup.timer after enabling and confirm the next firing matches the expected list from Step 1.
  2. Use systemd-analyze calendar "<your calendar expression>" --iterations=10 to print the next ten firings of a calendar expression without enabling anything. This is the systemd-side equivalent of pasting a cron expression into a builder and reading the next runs. Do it before you write the timer file.

If the two lists agree, the schedule is correct. If they disagree, the cron expression and the calendar expression are not the same schedule, and the migration is wrong.

When not to migrate

Not every cron job is worth converting. Quick one-shot tasks owned by a single person, jobs that are about to be retired anyway, and crontabs on systems where systemd is not the init are all reasonable to leave alone. The migration earns its keep on jobs that the team actually maintains, where the journal capture and explicit timezone and listable schedule pay off over and over.

For the schedule-side of cron itself (the OR-bug between day-of-month and day-of-week, the silently skipped 2 AM hour on DST days, step values that do not mean what they look like), the companion guide on reading cron expressions goes through each failure mode. The rest of the small utilities at EvvyTools cover the related debugging shapes for other low-glamour, high-impact problems.

A few sharp edges in the systemd calendar syntax

Two specific calendar-expression patterns trip people up the most often during a migration. The first is the day-of-week list. In a cron expression, 1-5 is "Monday through Friday." In a systemd calendar expression, the equivalent is Mon..Fri, with two dots between the day names, not a hyphen and not the numeric range. Writing Mon-Fri is a parse error that produces a timer file that loads without complaining and then never actually fires.

The second is the time-of-day list. In a cron expression, 0,30 in the minutes field means "minute 0 and minute 30 of every hour." In a systemd calendar expression, the equivalent is *-*-* *:00,30:00, with the comma inside the minute field and an explicit zero seconds appended. The terseness of cron makes the cron version easier to read at a glance; the explicitness of systemd makes the systemd version harder to typo in a non-obvious way, which is the whole point.

The fastest defense against either of these typos is to run systemd-analyze calendar "<your expression>" --iterations=10 before enabling the timer and compare the output against the list of expected firings you wrote down for the cron expression. If the two lists agree, the translation is correct. If they disagree, fix the calendar expression now rather than after deployment, and write the comparison into the migration runbook so the next engineer on the team does the same check.

Top comments (0)