How do you migrate a fleet of legacy cron jobs to systemd timers safely, and what concrete advantages does that migration actually buy you?
Quick Answer
Migrate incrementally, one job at a time, by converting each crontab entry into a paired systemd .service, defining the actual command and its execution environment and user, and .timer, defining the OnCalendar schedule, unit, verifying behavior in parallel with the old cron entry disabled but not deleted until the timer has proven itself over a full cycle. The concrete advantages are real and specific: systemd timers have built-in overlap prevention, structured logging via journald queryable by job name, explicit dependency ordering, and per-job resource limits via cgroups, none of which cron offers natively.
Detailed Answer
Think about upgrading a factory from a wall clock that rings a bell at fixed times, with each department expected to independently know what to do when they hear their bell, to a proper shift-management system. The new system knows which shifts depend on which other shifts finishing first, refuses to start a new shift on a machine that's still actively running the previous one, keeps a detailed log of exactly when each shift started, finished, and whether it hit any problems, and can cap how many resources any single shift is allowed to consume. The bell-based system technically worked for decades, but every one of those improvements requires the department itself to build custom logic; the shift-management system provides it all as a built-in platform feature instead.
Systemd timers were introduced specifically to bring init-system-level features to job scheduling, addressing exactly the gaps that make raw cron risky at scale: no native overlap protection, no structured queryable logs, cron output goes to whatever ad-hoc redirection or MAILTO you configured, if anything at all, no dependency awareness, cron can't express don't-run-this-backup-job-until-the-database-mount-is-confirmed-ready, and no resource governance, a runaway cron job can consume unbounded CPU and memory with nothing else in the system aware of or limiting it. Because systemd already manages every other service on a modern Linux host, extending it to also manage scheduled jobs means scheduled jobs get the same operational tooling, journald logging, systemctl status, cgroup resource limits, that every other service already benefits from.
A safe migration converts one job at a time. For a job like order-fulfillment-sync, you create /etc/systemd/system/order-fulfillment-sync.service, defining ExecStart, the User= to run as, and any Environment= variables previously relied upon implicitly from a login shell, and a paired .timer unit with OnCalendar= expressing the schedule, which supports cron-like patterns but also richer expressions. Then systemctl enable --now order-fulfillment-sync.timer while leaving the old crontab entry commented out, not deleted, as a documented rollback path. You let both the new timer and the disabled-but-present old crontab entry sit for at least one full schedule cycle, confirming via journalctl -u order-fulfillment-sync.service that it ran successfully and on time, before removing the old crontab entry entirely.
In production, once migrated, Type=oneshot combined with default systemd behavior means a new timer-triggered start is simply skipped, not queued, not force-started, if the service unit is still marked active from a previous run, solving the classic cron overlap problem without any custom flock wrapper needed. Dependency ordering, After= and Requires=, lets you correctly sequence something like a reconciliation job that must not start until a database backup unit has completed, which cron has no native way to express at all. Resource limits, MemoryMax= and CPUQuota= in the service unit, mean a single runaway scheduled job can be capped from starving the rest of the host, which raw cron-launched processes never had unless manually wrapped in cgroup tooling separately by hand.
The non-obvious gotcha: OnCalendar scheduling in systemd timers is NOT minute-by-minute polling like cron. Systemd calculates the next trigger time analytically and can, by default, add a small randomized delay, RandomizedDelaySec, or align timers to reduce simultaneous wake-ups across many services, AccuracySec, default 1 minute of slack, meaning a timer's actual fire time can be a little later than the exact OnCalendar value unless you explicitly set AccuracySec=1s for jobs where precise timing genuinely matters. Teams migrating from cron's exact-minute behavior are frequently surprised by this when a job that always ran at exactly 2:00:00 under cron starts firing at 2:00:47 under a naively-configured timer instead.