Moving off cron is worth doing, but a careless migration quietly drops jobs — and nobody notices until the backup that stopped running three weeks ago is the one you need.
The appeal of systemd timers over cron is real: every run lands in journald, failures are inspectable, units live in version control, and catch-up behaviour is explicit rather than folklore. But the migration itself is where estates lose jobs. Someone converts the obvious entries, forgets the @reboot line, skips the per-user crontabs entirely, disables cron, and declares victory. This is a methodical way to move a crontab to timer and service units on Ubuntu 24.04 (systemd 255) so that every job is accounted for, logged, and reviewable — with a verification step before you turn cron off.
Map the five crontab fields honestly
A crontab line is five time fields followed by a command: minute, hour, day-of-month, month, day-of-week. The trap is that day-of-month and day-of-week are OR-combined in cron when both are restricted — a subtlety systemd’s OnCalendar does not replicate silently, so check any entry that constrains both. Inventory every source first: the system crontab at /etc/crontab, drop-ins under /etc/cron.d/, the cron.daily/weekly/monthly directories, and each user’s crontab via crontab -l -u NAME. Miss a source and you miss its jobs.
Free · 4 minutes
Is your engineering team shipping safely, or quietly accumulating risk?
Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.
The field-to-OnCalendar mapping is mechanical once you see it. OnCalendar reads left to right as DayOfWeek Year-Month-Day Hour:Minute:Second:
17 3 * * *(03:17 daily) becomesOnCalendar=*-*-* 03:17:00.*/15 * * * *(every 15 minutes) becomesOnCalendar=*:0/15.0 9 * * 1(09:00 Mondays) becomesOnCalendar=Mon *-*-* 09:00:00.@reboothas no calendar equivalent — it maps toOnBootSec=in the timer, which is a different mechanism covered below.
Never trust a conversion you have eyeballed. systemd-analyze calendar parses an expression and prints the next elapses, which is the fastest way to catch a fat-fingered field:
# Validate an OnCalendar expression and see the next three runs
systemd-analyze calendar "Mon *-*-* 09:00:00" --iterations=3
# Original form: Mon *-*-* 09:00:00
# Normalized form: Mon *-*-* 09:00:00
# Next elapse: Mon 2026-08-31 09:00:00 UTC
# (in UTC): Mon 2026-08-31 09:00:00 UTC
Convert one entry into a service plus timer pair
Each cron entry becomes two units: a .service that says what to run and a .timer that says when. Give both the same base name so they read as a pair. The single most common runtime surprise is the environment: cron runs jobs with a minimal PATH and a shell, whereas a systemd service inherits neither your login profile nor a useful PATH unless you set one. Be explicit, or the script that worked under cron will fail with “command not found”.
# /etc/systemd/system/backup.service
[Unit]
Description=Nightly backup (migrated from root crontab)
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup.sh
User=root
# cron ran with a near-empty PATH; make it explicit here
Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
# /etc/systemd/system/backup.timer
[Unit]
Description=Run nightly backup at 03:17
[Timer]
OnCalendar=*-*-* 03:17:00
Persistent=true
RandomizedDelaySec=120
[Install]
WantedBy=timers.target
Use Type=oneshot for a job that runs to completion and exits — it is the correct type for the overwhelming majority of scheduled tasks, and it lets systemd track success and failure properly. RandomizedDelaySec spreads load so twenty hosts do not all fire at 03:17 exactly. Enable the timer, not the service — the timer pulls the service in when it fires:
systemctl daemon-reload
systemctl enable --now backup.timer
Because the units are plain text files, they belong in the same repository as the rest of your infrastructure. The discipline is the same one I have argued for around a pre-commit baseline that actually runs: schedules that live in version control are reviewable, diffable, and survive a rebuild, which a crontab edited by hand on a live box never is.
The bits people forget: @reboot, per-user jobs, and MAILTO
Three categories of cron entry get dropped in almost every migration I review.
@reboot jobs map to OnBootSec= rather than OnCalendar. A cache-warmer that ran at boot becomes a timer with [Timer] containing OnBootSec=2min, giving the network and dependencies time to settle rather than racing them. Do not translate @reboot to a calendar expression — it is not a clock event.
Per-user crontabs are the ones that vanish. A user’s jobs become user units under ~/.config/systemd/user/, enabled with systemctl --user enable --now. But user timers only run while the user has a session — unless you enable lingering, which lets their units run without an active login:
loginctl enable-linger deploy
systemctl --user --machine=deploy@ enable --now report.timer
Forget enable-linger and the job appears configured but never fires on an unattended server — exactly the silent failure a migration is supposed to prevent.
MAILTO and output do not carry over, and that is the point. Cron emailed you stdout and stderr; systemd captures both in journald. Reach for journalctl -u backup.service instead of a mailbox, and wire a handler for the case you actually care about — failure — with an OnFailure= directive pointing at an alerting unit. Routing every run through journald is what turns a pile of scripts into something you can actually observe, in the same spirit as instrumenting operational metrics rather than guessing at them.
Persistent=true, and why catch-up needs idempotent jobs
Cron makes no attempt to catch up: if the machine was off when a job was due, that run is simply gone. Some jobs do not care; a nightly backup that missed last night very much does. Persistent=true stores the last trigger time under /var/lib/systemd/timers and, for a calendar timer, fires the job immediately on next boot if the scheduled moment was missed. Set it on every job where a skipped run matters, and leave it off for high-frequency pollers where a late double-run is just noise.
The consequence is one people miss: a caught-up job runs late, possibly alongside the next scheduled run, so the job has to be safe to run at an unexpected time and safe to run twice. That is the same property that makes an operation safe to retry — if enabling catch-up would corrupt state or double-charge something, fix the job’s idempotency before you turn Persistent on, not after.
Verify parity before you disable cron
The migration is not finished when the timers exist; it is finished when you have proven every cron job has a live timer. Run cron and timers in parallel for a cycle, then reconcile systemctl list-timers against your original inventory line by line:
# Every active and inactive timer, next and last run times
systemctl list-timers --all
# NEXT LEFT LAST UNIT ACTIVATES
# Mon 2026-08-31 03:17:00 UTC 6h left - backup.timer backup.service
# Mon 2026-08-31 09:00:00 UTC 12h left - report.timer report.service
Count the timers. If your crontab held nine jobs and list-timers shows eight, you have found the dropped one before it bit you, not after. Only when the counts reconcile and you have seen at least one successful run of each in journalctl do you retire cron — comment the entries out first with crontab -e, keep the file for a fortnight, and disable the daemon last. Migrations that treat this migration the way they would any migration off a legacy system — parallel run, reconcile, then cut over — are the ones that do not lose a job in the dark.
The measure of a clean cron-to-timer migration is boring: the same jobs run at the same times, except now you can see them. If you cannot produce a one-to-one map from old crontab line to running timer, you have not migrated — you have gambled.
Build and rescue work
Hands-on delivery of this kind is handled by Sixteen Pillars Studio.
Looking at an acquisition, supplier, or major project?
The greatest risks are rarely visible in the executive summary. The Sixteen Pillars framework surfaces the technology risks that diligence usually misses.