Tutorial: migrate scheduled cron jobs to systemd timers on Debian/Ubuntu
Linux & Sistemas · By Kevin Marcondes · Published on · Updated on · 8 min read
This walkthrough shows how to safely migrate scheduled cron jobs to systemd timers on Debian/Ubuntu. You will map cron syntax to OnCalendar, create .service and .timer units, validate logs, configure tolerances, and have a safe rollback plan. The focus is on practical steps and predictable behavior in production.
Prerequisites and migration goals
Migrating to systemd timers adds traceability, dependency control, and improved observability. You get centralized logs via the journal, easier retries and richer scheduling using OnCalendar.
Requirements: root or sudo access, a text editor (nano/vi), and a basic familiarity with systemctl and file permissions. If the job is critical, test first in a staging environment.
Practical benefits:
- Consolidated logs with journalctl and service-level status.
- Run as a dedicated user, with a defined working directory and clear environment variables.
- Rich schedules (calendar expressions, tolerances, randomized delay).
- Persistence to run missed tasks when the host was offline.
- Better integration with monitoring and alerting.
1) Inventory everything in cron (crontab and cron.d)
Before migrating, discover all active schedules for the system and users. Back up the contents for reference. Do not remove anything yet — we will migrate, validate, and only then disable the equivalent cron entries.
If you have jobs that interact with the database and may cause locks, review concurrency impact and maintenance windows beforehand; see the guide to auditing and optimizing cron jobs that deal with PostgreSQL locks in the audit and optimize cron jobs page.
# List root and a specific user's crontab
sudo crontab -l || echo "(no root crontab)"
crontab -l -u appuser || echo "(no appuser crontab)"
# List definitions in cron.d and system files
sudo ls -1 /etc/cron.d/ || true
sudo grep -R --line-number --color=never -E '^[^#].*' /etc/crontab /etc/cron.* 2>/dev/null || true
# Quick backup of crontabs
sudo crontab -l > ~/backup-root-crontab-$(date +%F).txt 2>/dev/null || true
crontab -l -u appuser > ~/backup-appuser-crontab-$(date +%F).txt 2>/dev/null || true
2) Map the cron schedule to OnCalendar (systemd)
OnCalendar accepts calendar expressions with second precision, ranges and named days/months. The table below helps convert common cron patterns. To verify an expression, use systemd-analyze calendar.
Remember timezone: systemd uses the system local time. Adjust with timedatectl if needed.
| Cron pattern | Intent | OnCalendar equivalent (example) |
|---|---|---|
| 0 2 * * * | Daily at 02:00 | --* 02:00:00 |
| 0 2 * * 1-5 | Weekdays at 02:00 | Mon..Fri --* 02:00:00 |
| */15 * * * * | Every 15 minutes | *:0/15 |
| 0 */6 * * * | Every 6 hours | --* 00,06,12,18:00:00 |
| 30 1 1 * * | On the 1st of every month at 01:30 | --01 01:30:00 |
# Test how systemd interprets the expression
systemd-analyze calendar "Mon..Fri *-*-* 02:00:00"
# Output shows the next run and a human-friendly interpretation
3) Create the corresponding .service unit for the job
Each cron job becomes a .service with the real action in ExecStart. Keep logic in versioned scripts and use absolute paths. Set the user, working directory and environment as needed. Avoid running as root unless necessary. Then adjust file permissions to avoid exposing secrets.
- Save units in /etc/systemd/system/ for system scope.
- Use User=appuser for application jobs.
- Define WorkingDirectory and Environment variables according to the script.
# /etc/systemd/system/backup-db.service
[Unit]
Description=Backup do banco de dados
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=appuser
WorkingDirectory=/opt/app
# Exemplo de variáveis (opcional)
Environment=PGHOST=/var/run/postgresql
Environment=PGUSER=backup
# Caminho absoluto do script/comando
ExecStart=/usr/local/bin/backup-db.sh
# (Opcional) limites de recursos
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=6
[Install]
WantedBy=multi-user.target
4) Create the .timer unit with OnCalendar and useful options
The .timer points to the .service and defines when to run. Persistent ensures missed runs during downtime are executed. RandomizedDelaySec reduces thundering herd across multiple hosts. AccuracySec controls the firing tolerance.
If the .timer and .service share the same base name, Unit= is not required, but you can keep it for clarity.
- OnCalendar defines the main schedule.
- Persistent=true runs missed instances after reboot.
- RandomizedDelaySec adds controlled jitter.
# /etc/systemd/system/backup-db.timer
[Unit]
Description=Timer do backup do banco (dias úteis 02:00)
[Timer]
OnCalendar=Mon..Fri *-*-* 02:00:00
Persistent=true
RandomizedDelaySec=5m
AccuracySec=1min
Unit=backup-db.service
[Install]
WantedBy=timers.target
5) Reload, enable and test (controlled dry‑run)
After creating the files, reload systemd, enable and start the timer. To test the logic, trigger the .service manually. Do not remove the original cron until you validate the full execution to avoid gaps or duplicate runs.
- Enable the timer to start it at boot.
- Use systemctl list-timers to check schedules.
- Trigger the .service manually for immediate testing.
sudo systemctl daemon-reload
sudo systemctl enable --now backup-db.timer
# List timers and see the next run
systemctl list-timers --all | grep backup-db || true
# Manual service test
sudo systemctl start backup-db.service
sudo systemctl status backup-db.service --no-pager
6) Validate execution and logs with journalctl
Watch logs by unit and confirm environment variables, paths and exit codes. Also check timezone and that the host keeps the correct time via NTP. To standardize application logs, consider structuring output as JSON; see ideas in structured JSON logs.
- journalctl -u shows the service history.
- status shows recent lines and the exit code.
- Confirm timezone and NTP for correct scheduling.
# Logs e status
date && timedatectl | sed -n '1,6p'
journalctl -u backup-db.service -n 100 --no-pager
journalctl -u backup-db.timer -n 50 --no-pager
# Última/Próxima execução do timer
systemctl status backup-db.timer --no-pager
7) Safe rollback to cron (if something goes wrong)
If the migration does not meet expectations, roll back carefully. Disable the timer before re-enabling cron to avoid duplicate executions. Document the reason and required fixes. Only remove units after cron is stable again.
- Disable and stop the timer.
- Re-enable the corresponding line in the crontab.
- Remove units and reload systemd only after stabilization.
# Desativar timer e serviço
sudo systemctl disable --now backup-db.timer
sudo systemctl stop backup-db.service || true
# Reativar no crontab (edite e descomente a linha antiga)
sudo crontab -e
# Opcional: remover units e recarregar
sudo rm -f /etc/systemd/system/backup-db.{timer,service}
sudo systemctl daemon-reload
8) Final checks and common troubleshooting
Use systemd diagnostic tools to confirm the schedule and investigate errors. Common problems include relative paths in ExecStart, file permission issues, missing environment variables, and incorrect timezone. For new hosts, review basic tuning and security with the VPS security checklist.
- Use systemd-analyze calendar to check the timer expression.
- Ensure absolute paths and executable permissions for scripts.
- Adjust User= and WorkingDirectory when necessary.
- If you missed runs, enable Persistent=true.
- Check for conflicts with cron still enabled.
- Synchronize time with NTP and confirm the timezone.
# Confirmar parsing da expressão do timer
systemd-analyze calendar "Mon..Fri *-*-* 02:00:00"
# Conferir unidades com erro
a=($(systemctl --failed --type=service --no-legend | awk '{print $1}')); printf '%s
' "${a[@]}"
# Timers ativos e próximos disparos
systemctl list-timers --all --no-pager
Frequently asked questions
Why use systemd timers instead of cron?
Systemd timers provide integrated logs, dependency control, missed-run tolerance (Persistent), jitter to avoid simultaneous spikes, and fine-grained unit management. This improves observability and automation, especially across many hosts or when compliance is required.
How do I convert a complex cron expression to OnCalendar?
Break the intent into explicit calendar rules or multiple OnCalendar lines. Use named days/months and ranges. Validate with systemd-analyze calendar to see the next run and confirm parsing. When needed, create more than one timer to cover variations.
Which user do timers run as?
Set the user in the .service unit with User=appuser. Avoid root when possible. For user-scoped timers, place units in ~/.config/systemd/user and manage them with systemctl --user. Ensure scripts, directories and log files have correct permissions.
How do I capture my job's output and errors?
By default stdout and stderr go to the journal. Inspect with journalctl -u my-unit.service. If you prefer dedicated files, redirect output in the script or use StandardOutput and StandardError in the unit. Consider structured logs for easier filtering and correlation.
What if the server was powered off when the job was scheduled?
Enable Persistent=true in the .timer. On reboot, systemd will run missed instances since the last check. Combine with RandomizedDelaySec to spread load and avoid thundering herd in clusters.
Can I disable cron after migrating everything?
Yes — after migrating, validating and documenting equivalence for all jobs, you can disable the corresponding cron entries. Do this gradually and monitor logs and outcomes to prevent execution gaps or duplicates.
Need a review of your cron-to-systemd migration?
If you want help analyzing specific cases, mapping complex schedules, or validating rollback plans, get in touch to discuss your environment and next steps.
Discuss your migrationSee also
- Tutorial: Implement Canary releases with Traefik in Docker Compose using headers and weighted routing for gradual PHP deployments
- Tutorial: Configure HAProxy with Let's Encrypt and health checks to load balance traffic between Docker containers on a VPS (step-by-step)
- Hands‑On Tutorial: Implement mTLS Between Nginx and Microservices with smallstep CA in Docker Compose
- Tutorial: configure cgroup v2 and systemd ResourceControl to limit CPU, memory and I/O for Docker containers on Debian