Tutorial

cron vs systemd timers: Automate Your Linux VPS

Automation12 min read13 steps

On a Linux VPS, automation is not optional: nightly backups, TLS certificate renewal, log cleanup, health checks — these tasks run themselves or they don't run at all. Cron has ruled for decades, but systemd timers offer deeper integration with the service manager. This guide compares both tools, explains when to choose one over the other, and walks you through creating or migrating a systemd timer step by step.

Contents· Why automate tasks on a VPS?1/12
  1. 01Why automate tasks on a VPS?
  2. 02Typical use cases on a VPS
  3. 03cron: a quick refresher
  4. 04systemd timers: an introduction
  5. 05cron vs systemd timers: comparison table
  6. 06When to keep cron?
  7. 07When to switch to systemd timers?
  8. 08Create a systemd timer from scratch (example: daily backup)
  9. 09Migrate an existing cron job to systemd
  10. 10Validate and debug your OnCalendar expressions
  11. 11Common systemd timer troubleshooting
  12. 12Conclusion

Why automate tasks on a VPS?

A VPS is a server running continuously, often without direct human supervision. That is precisely why automating recurring tasks is essential: nobody will be around at 3 AM to trigger a backup or renew a Let's Encrypt certificate. A forgotten task can mean data loss, an expired certificate taking the site offline, or a disk full because log rotation never ran.

In practice, five categories of tasks come up on every serious Linux VPS: Nginx log rotation (logrotate or a dedicated script) to prevent /var/log/nginx/ from growing into gigabytes over a few weeks; orphaned Docker image cleanup (docker image prune -af) to reclaim disk space without manual intervention; PostgreSQL backups (pg_dump to remote storage) to guarantee an acceptable RPO in case of failure; SSL certificate checking before expiry, to trigger a certbot renewal rather than discover a broken HTTPS site; and scheduled lightweight scraping or indexing scripts that need to run at a fixed time. The two main tools available on Linux — cron and systemd timers — allow you to schedule these operations reliably.

Typical use cases on a VPS

  • Backups — MySQL or PostgreSQL dumps to remote storage (pg_dump -Fc mydb | gzip > /backup/mydb_$(date +%F).sql.gz), file archiving with rsync or rclone to an S3 bucket
  • TLS certificate renewal — certbot renew --quiet or acme.sh --cron scheduled twice a day, with automatic Nginx reload on success
  • Log rotation and cleanup — deleting files older than 30 days (find /var/log -mtime +30 -delete), weekly compression, purging application logs in production
  • Docker cleanup — docker image prune -af, removing orphaned volumes, rotating container logs to free up /var/lib/docker
  • Health checks — verifying a service is listening (curl -sf http://localhost:3000/health), auto-restarting if needed, alerting via webhook
  • Data synchronization — rsync to a second node, cache refresh, syncing static files to a CDN
  • Scheduled application tasks — sending reminder emails, computing periodic reports, content indexing, lightweight scraping scripts

cron: a quick refresher

Cron is present on virtually every Linux system. To schedule a task, edit the current user's crontab with crontab -e. The syntax uses five fields separated by spaces, followed by the command:

# ┌── minute (0-59)
# │  ┌── hour (0-23)
# │  │  ┌── day of month (1-31)
# │  │  │  ┌── month (1-12)
# │  │  │  │  ┌── day of week (0-7, 0=Sunday)
# │  │  │  │  │
  0  3  *  *  *  /usr/local/bin/backup.sh

Shortcuts @daily, @weekly, @monthly and @reboot simplify common cases. Files placed in /etc/cron.d/ belong to the system and can specify the execution user directly on the line. To review past executions, two paths: /var/log/syslog (Debian/Ubuntu) filtered with grep CRON /var/log/syslog, or journalctl -t CRON on systems with journald. One often-underestimated detail: cron's default PATH is minimal — /usr/bin:/bin — which forces the use of absolute paths in scripts, or redefining PATH= at the top of the crontab. The MAILTO variable controls output mailing; setting it to empty (MAILTO="") silently disables it. Cron keeps no native history of past runs and does not handle missed jobs if the machine was off.

systemd timers: an introduction

A systemd timer is a pair of two unit files: a .service file describing what to run, and a .timer file describing when to trigger it. The timer is activated with systemctl enable --now myservice.timer and managed by the service manager, exactly like any other systemd service.

The OnCalendar= directive accepts rich, readable syntax:
- daily — every day at midnight
- Mon *-*-* 03:00:00 — every Monday at 3 AM
- *-*-* 02:30:00 — every day at 2:30 AM
- *:0/15 — every 15 minutes

The AccuracySec= directive (default: 1 minute) lets systemd group nearby timers to save CPU wakeups — on a shared or low-power server, reducing this to 1s ensures precise trigger times. The Persistent=true directive in the [Timer] block is the feature that sets systemd timers apart from cron: if the machine was off at the scheduled time, the timer catches up the missed execution at next boot. All output goes through journald and is accessible via journalctl -u myservice.service. The command systemctl list-timers shows all active timers, their next deadline and last activation date.

cron vs systemd timers: comparison table

Scroll the table

Criterioncronsystemd timer
Schedule syntax5-field `* * * * *``OnCalendar=` readable (`daily`, `Mon 03:00`)
LogsMail output or manual redirect, `/var/log/syslog`Native journald, `journalctl -u`
Missed jobs (machine off)Lost (unless `anacron`)`Persistent=true` replays after reboot
Inter-service dependenciesNone`After=`, `Requires=`, `Wants=`
Isolation and resource limitsInherits shell env, minimal PATHCgroups, `MemoryMax=`, `CPUQuota=`
Failure notificationNone natively`OnFailure=notify.service` triggers an alert
Run as specific userUser field in `/etc/cron.d/``User=` and `Group=` in `.service`
DebuggingHard without logs`systemctl status`, `journalctl -xe`
AvailabilityAll Unix systems (containers, BSD, Alpine)Systems with systemd (Debian, Ubuntu, RHEL…)

When to keep cron?

Cron remains the right choice in several concrete situations. On older or minimal systems without systemd — some lightweight Docker containers, Alpine images, BSD systems — cron is often the only tool available and there is no reason to move away from it. In a multi-user environment where each developer manages their own tasks via crontab -e without root rights, cron is simpler to delegate: creating a systemd .service requires access to /etc/systemd/system/.

For simple one-liner scripts — a weekly certbot renew, a nightly rsync with no dependencies — the crontab stays readable and maintainable without multiplying files. Cron is also the right choice for onboarding a non-Linux team: the five-field syntax, documented everywhere, is more accessible than a systemd unit for someone new to system administration. Finally, if your cron tasks have been working for years without issues, migration brings no immediate value. The deciding criterion is the need for structured logs, inter-service dependencies, or resource constraints — if you don't need them, cron does the job.

When to switch to systemd timers?

Systemd timers become the clear choice as soon as the task goes beyond a simple isolated command. You need journald to trace every execution, its output and return code without manual plumbing? Systemd timers. Your backup script must only run after PostgreSQL is ready, declaring After=postgresql.service in the [Unit] block? Systemd timers. You want to cap the memory of a sync job so it does not starve other processes (MemoryMax=512M)? Systemd timers.

Inter-service dependencies are the strongest argument: a backup launched while the database is not yet available after a reboot produces an empty file with no visible error under cron. Systemd guarantees the order. Integrated logging via journald removes all the noise from manual redirections — journalctl -u backup.service --since yesterday gives the full history with return codes. Error handling with OnFailure= lets you trigger a notification (email, webhook) without polluting the script itself. And if the server restarts at 2:58 AM when the 3 AM backup was due, Persistent=true guarantees it will run at next boot — something cron cannot do without anacron.

Create a systemd timer from scratch (example: daily backup)

  1. Create the service file

    Open /etc/systemd/system/backup.service and enter:

    [Unit]
    Description=Daily PostgreSQL backup
    After=postgresql.service
    
    [Service]
    Type=oneshot
    User=postgres
    ExecStart=/usr/local/bin/backup.sh

    The oneshot type indicates the service exits after the script completes — the standard type for scheduled tasks.

  2. Create the timer file

    Create /etc/systemd/system/backup.timer with:

    [Unit]
    Description=Daily trigger for backup
    
    [Timer]
    OnCalendar=*-*-* 03:00:00
    Persistent=true
    
    [Install]
    WantedBy=timers.target

    Persistent=true ensures that if the machine was off at 3 AM, the backup runs at next boot.

  3. Reload systemd and enable the timer

    Run systemctl daemon-reload so systemd picks up the new unit files, then activate and immediately start the timer:

    systemctl enable --now backup.timer
  4. Check with systemctl status

    Check the timer state with systemctl status backup.timer — the output must show active (waiting) and display the next trigger date. Also check the service state after the first run with systemctl status backup.service.

  5. List all active timers

    The command systemctl list-timers --all displays a table of all system timers with three key columns: NEXT (next execution), LEFT (time remaining) and LAST (last execution). This is your dashboard for verifying that your timers fire as expected.

  6. Read the logs

    Access all service output with journalctl -u backup.service for the full history, or journalctl -u backup.service -n 50 --since today for the last 50 lines today. The script's return code is visible in the journald output.

  7. Add a failure notification (optional)

    To be alerted on failure, add in the [Unit] block of the .service file:

    OnFailure=notify-failure@%n.service

    This service can send an email, a Slack webhook, or an ntfy ping depending on your infrastructure. This is what cron cannot do natively without plumbing inside the script itself.

  8. Test the service manually

    Before waiting for the scheduled trigger, test immediate execution with systemctl start backup.service (without .timer). Check the result immediately with journalctl -u backup.service -n 20. If the script succeeds, the setup is validated.

Migrate an existing cron job to systemd

  1. Identify the cron job to migrate

    List your crontabs with crontab -l (current user) and cat /etc/cron.d/* (system). Note the exact command, the execution user, and the five-field schedule. For example: 30 2 * * 1 root /usr/bin/certbot renew --quiet means every Monday at 2:30 AM as root.

  2. Translate the schedule to OnCalendar

    The OnCalendar= format reads DayOfWeek Year-Month-Day Hour:Minute:Second. 30 2 * * 1 becomes Mon *-*-* 02:30:00.

    To validate the translation before applying it, use systemd-analyze calendar:

    systemd-analyze calendar 'Mon *-*-* 02:30:00'

    The command displays the next 10 calculated occurrences, eliminating any ambiguity about the actual behavior.

  3. Create the .service and .timer files

    Create /etc/systemd/system/certbot-renew.service with Type=oneshot, ExecStart=/usr/bin/certbot renew --quiet, and User=root. Then create /etc/systemd/system/certbot-renew.timer with OnCalendar=Mon *-*-* 02:30:00 and Persistent=true. Run systemctl daemon-reload && systemctl enable --now certbot-renew.timer.

  4. Test the service before disabling cron

    Test execution immediately with systemctl start certbot-renew.service and check the result via journalctl -u certbot-renew.service -n 20. Verify the timer appears in systemctl list-timers with a coherent next date. Only disable the cron line after validation.

  5. Disable the cron line

    Once the timer is validated, comment out or delete the line in the original crontab. For a file in /etc/cron.d/, rename it with a .disabled extension or delete it. Leave both running in parallel for a few days if the task is critical — cron and systemd timers do not interfere.

Validate and debug your OnCalendar expressions

Before activating a timer in production, always validate your OnCalendar= expression with systemd-analyze calendar:

systemd-analyze calendar "daily"
systemd-analyze calendar "Mon *-*-* 02:30:00"
systemd-analyze calendar "*:0/15"

Each command displays the next occurrence and the following ten — making a malformed expression or unexpected time offset immediately visible. For an overview of all your active timers with their next trigger date and time remaining, systemctl list-timers --all is your daily operational dashboard.

Common systemd timer troubleshooting

Five problems come up regularly when setting up timers.

1. "Unit not found" on systemctl enable — the .timer or .service file is not in /etc/systemd/system/ or has an incorrect name. Check the exact spelling with ls /etc/systemd/system/*.timer. A daemon-reload is mandatory before any enable or start on a new file.

2. Timer active but never triggers — does systemctl list-timers show a coherent date in the NEXT column? If NEXT is empty or in the past, a missing Persistent=true combined with a machine that hasn't rebooted since the timer was created is a common cause. Check systemctl status backup.timer: active (waiting) is the normal state.

3. ExecStart not found or permission denied — the PATH in a .service is minimal like cron. Always use the absolute path in ExecStart= (/usr/bin/certbot not certbot). Verify the script is executable (chmod +x /usr/local/bin/backup.sh) and that the user declared in User= has access to it.

4. Permissions on the script — if User=postgres but the script is owned by root without execute permission for postgres, the service will fail with code 203/EXEC. Check journalctl -u backup.service -n 20 to see the exact message.

5. Conflict between cron and systemd on the same task — if you are migrating progressively, make sure both are not active simultaneously for a destructive task (log purge, file rotation). Both will execute independently and double the operations. The check is simple: crontab -l | grep backup and systemctl is-active backup.timer.

Conclusion

Cron and systemd timers coexist without issue on the same server — you do not have to migrate everything at once. Keep cron for your simple tasks and existing user scripts, and adopt systemd timers for new automations that benefit from journald logs, inter-service dependencies or cgroup isolation. The systemd-analyze calendar command and systemctl list-timers are your best allies for validating and monitoring your schedules.

A Linux VPS ready for your automations

Our cloud VPS running Ubuntu and Debian include systemd, journald and the full modern stack. Deploy your timers, scripts and backups on reliable infrastructure, with full root access and responsive support.

Need help?

Browse our help center and FAQ, or reach our team — callback, WhatsApp or email. Support in French, English and Arabic.

Message us on WhatsAppopens in a new tab