Back to tutorials
Tutorial

Systemd Timer Setup Guide Tutorial (2026): Replace Cron Jobs on a Hosting VPS with Reliable, Logged Schedules

Systemd timer setup guide tutorial for hosting VPS: replace cron with logged schedules, safe restarts, and missed-run catch-up.

By Anurag Singh
Updated on Oct 09, 2026
Category: Tutorial
Share article
Systemd Timer Setup Guide Tutorial (2026): Replace Cron Jobs on a Hosting VPS with Reliable, Logged Schedules

Cron works—until it doesn’t. On a hosting VPS, the bigger risk is “silent failure.” A backup script can start, then die halfway through. A WordPress cron wrapper may never fire. A job can get skipped during a reboot and nobody notices.

This systemd timer setup guide tutorial shows how to replace cron with systemd timers you can audit. Your schedules survive restarts. Every run logs to the journal.

You’ll build three timers you can use immediately:

  • a logrotate verification check
  • a WordPress task runner
  • a lightweight health report that emails you only when something is off

The examples assume Ubuntu 24.04/26.04 or Debian 12/13 on a VPS.

The same pattern applies on AlmaLinux/Rocky, as long as you’re on systemd.

What you’ll build (and why timers are a better fit than cron)

  • Predictable scheduling with OnCalendar= (cron-like) plus catch-up with Persistent=true after reboots.
  • Per-job logging in journalctl, plus optional file logs for long-running scripts.
  • Guardrails: timeouts, retry behavior, and “don’t run two copies at once.”
  • Safer operations: timers run under a dedicated user with a tight execution environment.

If you manage multiple sites (or reseller workloads), timers also simplify migrations.

Keep unit files in your repo. Deploy them with your tooling. Reapply them after a move without rebuilding a mystery crontab.

Running customer sites? Start with a stable base. A HostMyCode VPS gives you predictable CPU/RAM for scheduled tasks like backups, rotations, and periodic maintenance—without shared-host caps getting in the way.

Prerequisites and quick checks

Before you write any units, confirm you’re running systemd. Also confirm the system clock is correct:

ps -p 1 -o comm=
# should print: systemd

timedatectl status
# confirm correct timezone and NTP sync

If you’ve seen TLS errors, “ran at the wrong time” behavior, or cron oddities after drift, fix time sync first.

It’s usually quick. Use: VPS time sync troubleshooting.

How systemd timers map to cron (mental model)

A timer triggers a service unit.

Think “schedule file” plus “task file.”

  • /etc/systemd/system/myjob.service defines what runs, as which user, and with what limits.
  • /etc/systemd/system/myjob.timer defines when it runs, and whether it should catch up.

Two timer modes cover most hosting needs:

  • Calendar timers (OnCalendar=) match cron-style schedules.
  • Monotonic timers (OnBootSec=, OnUnitActiveSec=) run relative to boot or the last run.

For backups, rotations, and scheduled reports, calendar timers are usually the right choice.

Create a dedicated user for scheduled jobs

Don’t run routine automation as root unless you have a clear reason.

Create a low-privilege account. Then grant only what the job needs.

sudo adduser --system --group --home /var/lib/svc-jobs svc-jobs
sudo install -d -o svc-jobs -g svc-jobs -m 0750 /var/log/svc-jobs

Some tasks do need root (logrotate checks, package updates, firewall reloads).

In those cases, keep the service running as root. Then lock it down with systemd hardening options later in this tutorial.

Example 1: A logged, catch-up daily job (log rotation verification)

“Disk full” incidents are rarely sudden. They usually creep in when logs stop rotating. Or /var grows quietly until it’s too late.

This daily verification timer does not rotate logs.

It warns you when rotation is likely broken.

If you’re already out of space, fix the emergency first. Then put the guardrail in place.

HostMyCode’s cleanup walkthrough is here: disk space troubleshooting.

Step 1: Create the script

Create /usr/local/sbin/logrotate-verify.sh:

sudo install -m 0755 /dev/stdin /usr/local/sbin/logrotate-verify.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

STATE=/var/lib/logrotate/status
CONF=/etc/logrotate.conf

if [[ ! -f "$CONF" ]]; then
  echo "ERROR: missing $CONF"; exit 2
fi

# Dry-run with verbose output; exit non-zero on config issues.
/usr/sbin/logrotate -d -v "$CONF" >/tmp/logrotate.verify.out 2>&1 || {
  echo "ERROR: logrotate dry-run failed";
  tail -n 80 /tmp/logrotate.verify.out;
  exit 3;
}

# Basic sanity: state file exists and is writable by root.
if [[ ! -f "$STATE" ]]; then
  echo "WARN: $STATE not found yet (first run?)"
fi

# Quick high-signal check: largest logs in /var/log
echo "Top logs in /var/log (size):"
find /var/log -type f -printf '%s %p\n' 2>/dev/null | sort -nr | head -n 15 | awk '{printf "%.1fMiB %s\n", $1/1024/1024, $2}'

echo "OK: logrotate verification completed"
EOF

Step 2: Create the service unit

Create /etc/systemd/system/logrotate-verify.service:

sudo install -m 0644 /dev/stdin /etc/systemd/system/logrotate-verify.service <<'EOF'
[Unit]
Description=Verify logrotate config and spot runaway logs

[Service]
Type=oneshot
User=root
Group=root
ExecStart=/usr/local/sbin/logrotate-verify.sh

# Guardrails
TimeoutStartSec=2min
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7

# Basic hardening (safe for oneshot scripts)
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/log /var/lib/logrotate /tmp
EOF

Step 3: Create the timer

Create /etc/systemd/system/logrotate-verify.timer:

sudo install -m 0644 /dev/stdin /etc/systemd/system/logrotate-verify.timer <<'EOF'
[Unit]
Description=Daily logrotate verification

[Timer]
OnCalendar=*-*-* 03:15:00
Persistent=true
RandomizedDelaySec=10m

[Install]
WantedBy=timers.target
EOF

Step 4: Enable and test

sudo systemctl daemon-reload
sudo systemctl enable --now logrotate-verify.timer

# Run immediately for a first pass
sudo systemctl start logrotate-verify.service

# View output
journalctl -u logrotate-verify.service --no-pager -n 200

Why this helps: if logrotate breaks due to a bad snippet, a permission change, or a filesystem problem, you’ll see it in the journal on the next run.

Cron often “solves” this by emailing output.

That only works if local mail is configured, which on many VPS builds it isn’t.

Example 2: Replace a WordPress “pseudo-cron” with a real timer

WordPress relies on wp-cron.php.

It runs only when someone visits the site.

On low-traffic installs, scheduled posts, backups, and WooCommerce cleanup can drift by hours.

The common fix is disabling WP-Cron and calling it from a scheduler. With systemd, you get cleaner logs and better controls than stuffing more entries into a crontab.

This fits nicely with VPS-hosted WordPress.

If you’re migrating off shared hosting, keep the cutover predictable: WordPress migration tutorial.

Step 1: Disable WP-Cron in wp-config.php

Edit your site’s wp-config.php (example path):

sudo nano /var/www/example.com/public_html/wp-config.php

Add:

define('DISABLE_WP_CRON', true);

Step 2: Create a wp-cron runner script

Create /usr/local/sbin/wp-cron-runner.sh (adjust domain and docroot):

sudo install -m 0755 /dev/stdin /usr/local/sbin/wp-cron-runner.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

SITE_URL="https://example.com"
DOCROOT="/var/www/example.com/public_html"

# Prefer WP-CLI if available; otherwise fall back to hitting wp-cron.php
if command -v wp >/dev/null 2>&1; then
  cd "$DOCROOT"
  wp cron event run --due-now --quiet
  echo "OK: ran due WP-Cron events via WP-CLI"
else
  curl -fsS "${SITE_URL}/wp-cron.php?doing_wp_cron=1" >/dev/null
  echo "OK: triggered wp-cron.php via HTTP"
fi
EOF

Step 3: Create the service with concurrency control

On slower sites, overlapping runs can pile up fast.

Use a lock file so only one run is active at a time.

sudo install -m 0644 /dev/stdin /etc/systemd/system/wp-cron-example.service <<'EOF'
[Unit]
Description=Run WordPress cron for example.com

[Service]
Type=oneshot
User=www-data
Group=www-data
WorkingDirectory=/var/www/example.com/public_html

# Prevent overlap
ExecStart=/usr/bin/flock -n /run/wp-cron-example.lock /usr/local/sbin/wp-cron-runner.sh

# Guardrails
TimeoutStartSec=2min
CPUQuota=30%
MemoryMax=256M

NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/www/example.com/public_html /run
EOF

Step 4: Create the timer

Every 5 minutes is a common baseline for WordPress:

sudo install -m 0644 /dev/stdin /etc/systemd/system/wp-cron-example.timer <<'EOF'
[Unit]
Description=Schedule WordPress cron for example.com

[Timer]
OnCalendar=*:0/5
Persistent=true
RandomizedDelaySec=30s

[Install]
WantedBy=timers.target
EOF

Step 5: Enable and verify

sudo systemctl daemon-reload
sudo systemctl enable --now wp-cron-example.timer
systemctl list-timers --all | grep wp-cron-example

Tip for multi-site VPS: copy the unit pair per site, or template it (advanced).

If you host customer sites, keep some jitter (RandomizedDelaySec).

That way ten sites don’t all spike CPU on the same second.

Example 3: A weekly “only email me if something’s wrong” health report

Noisy alerts train you to ignore alerts.

For small hosting stacks, a weekly report that only emails on threshold breaches is a better default.

This example checks disk usage, inode usage, and failed systemd units.

It then mails root (or your ops alias).

If outbound mail matters for your stack, get DNS deliverability basics right first (SPF/DKIM/DMARC, rDNS).

Two good references: MX record setup tutorial and reverse DNS setup.

Step 1: Install a minimal mailer (if needed)

On Ubuntu/Debian, mailutils uses a local MTA.

If you already run Postfix/Exim, skip the install.

sudo apt update
sudo apt install -y mailutils

Step 2: Create the checker script

Create /usr/local/sbin/vps-weekly-health.sh:

sudo install -m 0755 /dev/stdin /usr/local/sbin/vps-weekly-health.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

ALERT=0
HOST=$(hostname -f 2>/dev/null || hostname)
REPORT=$(mktemp)

cleanup() { rm -f "$REPORT"; }
trap cleanup EXIT

{
  echo "Weekly VPS health report: $HOST"
  echo "Generated: $(date -Is)"
  echo

  echo "== Disk usage (top filesystems) =="
  df -hT -x tmpfs -x devtmpfs | awk 'NR==1 || $6 ~ /^\/$/ || $6 ~ /^\/var/ || $6 ~ /^\/home/ {print}'
  echo

  echo "== Inode usage (top filesystems) =="
  df -i -x tmpfs -x devtmpfs | awk 'NR==1 || $6 ~ /^\/$/ || $6 ~ /^\/var/ || $6 ~ /^\/home/ {print}'
  echo

  echo "== Failed systemd units =="
  systemctl --failed --no-pager || true
  echo
} > "$REPORT"

# Threshold checks
# Disk >= 85% on any real filesystem
if df -P -x tmpfs -x devtmpfs | awk 'NR>1 {gsub(/%/,"",$5); if ($5 >= 85) exit 1}' ; then :; else ALERT=1; fi

# Inodes >= 85%
if df -Pi -x tmpfs -x devtmpfs | awk 'NR>1 {gsub(/%/,"",$5); if ($5 >= 85) exit 1}' ; then :; else ALERT=1; fi

# Any failed units
if systemctl --failed --quiet; then ALERT=1; fi

if [[ "$ALERT" -eq 1 ]]; then
  SUBJECT="[ALERT] VPS health issues on $HOST"
  mail -s "$SUBJECT" root < "$REPORT"
  echo "ALERT: mailed report"
else
  echo "OK: no issues found"
fi
EOF

Step 3: Create the service + timer

sudo install -m 0644 /dev/stdin /etc/systemd/system/vps-weekly-health.service <<'EOF'
[Unit]
Description=Weekly VPS health report (mail on issues only)

[Service]
Type=oneshot
User=root
Group=root
ExecStart=/usr/local/sbin/vps-weekly-health.sh
TimeoutStartSec=3min
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/tmp
EOF

sudo install -m 0644 /dev/stdin /etc/systemd/system/vps-weekly-health.timer <<'EOF'
[Unit]
Description=Schedule weekly VPS health report

[Timer]
OnCalendar=Sun *-*-* 06:40:00
Persistent=true
RandomizedDelaySec=30m

[Install]
WantedBy=timers.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now vps-weekly-health.timer
sudo systemctl start vps-weekly-health.service
journalctl -u vps-weekly-health.service --no-pager -n 120

Common pitfalls (and quick diagnostics)

  • Timer enabled, but nothing runs: check systemctl list-timers --all and confirm the next run time. Also confirm the timer has WantedBy=timers.target.
  • Service fails with “permission denied”: your hardening settings may be too strict. Remove ProtectSystem=strict first. Confirm it runs. Then add restrictions back carefully.
  • Jobs overlap: add a lock via flock (shown above) or set RefuseManualStart= if you want to prevent ad-hoc runs.
  • Missed runs after reboot: set Persistent=true on the timer.
  • You can’t find logs: use journalctl -u yourjob.service. If the script logs to files, keep them under /var/log/.... Also include that path in ReadWritePaths=.

Operational checklist: migrating from cron to systemd timers safely

  1. Inventory cron: crontab -l, sudo crontab -l, and files under /etc/cron.d, /etc/cron.daily, etc.
  2. Move one job at a time. Disable its cron entry only after the timer runs cleanly twice.
  3. Give each job a dedicated service name. You’ll thank yourself later during incidents.
  4. Add guardrails: timeouts, memory limits, and a lock.
  5. Document where the unit files live (/etc/systemd/system) and keep copies in your config repo.

Summary: use systemd timers as your “auditable cron” layer

On a hosting VPS, reliability comes from boring jobs that don’t skip.

That includes maintenance scripts, WordPress scheduled tasks, periodic verification, and simple reporting.

Systemd timers give you visibility via journalctl.

They also add safety controls like timeouts and locks. And they behave predictably across reboots.

If you want to standardize these patterns on infrastructure you can grow into, start on a managed VPS hosting plan from HostMyCode. You get a stable baseline for scheduled maintenance, plus help as you roll the same job set across multiple sites.

If you’re replacing cron across customer sites or setting up repeatable maintenance on a new server, HostMyCode is a practical place to run it. Pick a HostMyCode VPS for full control, or choose managed VPS hosting if you want a second set of eyes on updates, security, and routine ops.

FAQ

Can systemd timers fully replace cron on a VPS?

Yes. For most hosting automation (maintenance scripts, WordPress tasks, backups, reports), timers are a direct replacement with better logging and reboot catch-up.

Where should I store unit files for server-wide timers?

Use /etc/systemd/system/ for custom services and timers. Avoid editing files under /lib/systemd/system because package updates can overwrite them.

How do I see the exact output from a timer run?

Run journalctl -u yourjob.service. Add -n 200 for the last 200 lines, or --since "today" to filter by time.

How do I prevent two runs from overlapping?

Wrap the command with flock, for example: ExecStart=/usr/bin/flock -n /run/myjob.lock /usr/local/sbin/myjob.sh. This is simple and works well for oneshot jobs.

Do I still need server hardening if I’m using timers?

Yes. Timers don’t secure your server; they just schedule work. If you’re tightening SSH and baseline security, follow a step-by-step hardening plan like this server hardening tutorial.

Systemd Timer Setup Guide Tutorial (2026): Replace Cron Jobs on a Hosting VPS with Reliable, Logged Schedules | HostMyCode