systemd timers are the modern replacement for cron on Linux systems. They offer missed-run catch-up (if the system is off when a job was scheduled to run, it runs when the system comes back), structured logging via journald, and the full suite of systemd service features: restart policies, resource limits, user isolation, and dependency management.

TL;DR

Two unit files: a .service that runs your conversion script once and exits, and a .timer that fires the service on schedule. Enable the timer with systemctl enable --now ctf-convert.timer. View logs with journalctl -u ctf-convert.service.

systemctl enable --now ctf-convert.timer
systemctl list-timers ctf-convert.timer  # Next run time
journalctl -u ctf-convert.service -n 50  # Recent logs

The use case

You have a web server that accepts document uploads — DOCX, PPTX, XLSX — to /var/uploads/pending/. You want them converted to PDF every 5 minutes and moved to /var/uploads/ready/ for downstream processing. If the server reboots at 3:55am and the 4:00am conversion didn't run, systemd should run it as soon as the server comes back up.

The key difference from cron:

  • Missed run catch-upPersistent=true in the timer records the last run time on disk. If the system was down when the job should have run, it runs immediately on boot.
  • Structured logging — journalctl indexes output by unit, timestamp, and priority. journalctl -u ctf-convert.service --since yesterday gives you all conversion logs from yesterday.
  • Dependency managementAfter=network-online.target ensures the service doesn't run before the network is up.
  • Resource limitsCPUQuota and MemoryMax in the service unit prevent a runaway conversion job from starving other services.

Unit files and conversion script

First, the conversion script at /opt/scripts/ctf-upload-convert.sh:

#!/usr/bin/env bash
set -euo pipefail

API_KEY="${CTF_API_KEY:?CTF_API_KEY not set}"
PENDING_DIR="${CTF_PENDING_DIR:-/var/uploads/pending}"
READY_DIR="${CTF_READY_DIR:-/var/uploads/ready}"
FAILED_DIR="${CTF_FAILED_DIR:-/var/uploads/failed}"
TARGET="${CTF_TARGET_FORMAT:-pdf}"
API_URL="https://changethisfile.com/v1/convert"

log() { echo "$*"; }  # systemd timestamps via journald
mkdir -p "$READY_DIR" "$FAILED_DIR"

shopt -s nullglob
files=("$PENDING_DIR"/*)
shopt -u nullglob

if [[ ${#files[@]} -eq 0 ]]; then
  log "No pending files"
  exit 0
fi

log "Processing ${#files[@]} file(s)"

for f in "${files[@]}"; do
  [[ -f "$f" ]] || continue
  filename=$(basename "$f")
  stem="${filename%.*}"
  out="$READY_DIR/${stem}.$TARGET"
  tmp="$out.tmp.$$"

  log "Converting: $filename"

  http_status=$(curl -sf \
    --max-time 120 \
    -w "%{http_code}" \
    -H "Authorization: Bearer $API_KEY" \
    -F "file=@$f" \
    -F "target=$TARGET" \
    -o "$tmp" \
    "$API_URL" 2>&1
  ) || { log "ERROR: curl failed for $filename"; mv "$f" "$FAILED_DIR/"; rm -f "$tmp"; continue; }

  if [[ "$http_status" == "200" ]] && [[ -s "$tmp" ]]; then
    mv "$tmp" "$out"
    rm "$f"  # Remove from pending
    log "OK: $filename -> $(basename "$out") ($(stat -c%s "$out") bytes)"
  else
    log "ERROR: HTTP $http_status for $filename"
    mv "$f" "$FAILED_DIR/"
    rm -f "$tmp"
  fi
done

log "Done"

The service unit at /etc/systemd/system/ctf-convert.service:

[Unit]
Description=ChangeThisFile upload converter
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=www-data
Group=www-data

# Environment from a file — keep API key out of the unit file
EnvironmentFile=/etc/ctf.env

ExecStart=/opt/scripts/ctf-upload-convert.sh

# Resource limits
CPUQuota=50%
MemoryMax=256M

# Timeout — kill if conversion takes more than 10 minutes total
TimeoutStartSec=600

StandardOutput=journal
StandardError=journal
SyslogIdentifier=ctf-convert

The timer unit at /etc/systemd/system/ctf-convert.timer:

[Unit]
Description=Run ChangeThisFile converter every 5 minutes

[Timer]
# Every 5 minutes
OnCalendar=*:0/5

# Catch up on missed runs (e.g., after reboot)
Persistent=true

# Randomize start by up to 30s to avoid thundering herd
RandomizedDelaySec=30

[Install]
WantedBy=timers.target

Error handling and monitoring

Failed files are moved to a separate directory rather than deleted, so nothing is lost. Check the failed directory periodically:

# How many failed conversions are there?
ls -la /var/uploads/failed/ | wc -l

# View conversion logs for the last hour
journalctl -u ctf-convert.service --since '1 hour ago'

# Watch live as conversions run
journalctl -u ctf-convert.service -f

To get alerted on service failures, add an OnFailure handler to the service unit:

[Unit]
Description=ChangeThisFile upload converter
OnFailure=ctf-convert-alert@%n.service

And a corresponding alert service:

[Unit]
Description=Alert on %i failure

[Service]
Type=oneshot
ExecStart=/bin/bash -c 'curl -s -X POST $SLACK_WEBHOOK -d "{\"text\": \"ctf-convert failed on $(hostname)\"}"'
EnvironmentFile=/etc/ctf.env

Timer scheduling options

systemd's OnCalendar syntax is more expressive than cron:

# Every 5 minutes
OnCalendar=*:0/5

# Daily at 2am
OnCalendar=*-*-* 02:00:00

# Weekdays at 8am
OnCalendar=Mon..Fri *-*-* 08:00:00

# Every hour, on the hour
OnCalendar=hourly

# Once per day (systemd shorthand)
OnCalendar=daily

# Validate your expression before deploying:
systemd-analyze calendar '*:0/5'

Always validate calendar expressions with systemd-analyze calendar before deploying. It shows the next 10 scheduled times, which catches syntax errors and off-by-one timezone issues.

For one-shot timers (run once at a specific time, then stop):

[Timer]
OnCalendar=2026-05-01 09:00:00
Unit=ctf-migrate.service

Production tips

  • Use Type=oneshot for batch jobs. The service runs, completes its work, and exits with status 0 or 1. systemd tracks the exit code and the timer fires on schedule regardless. Don't use Type=simple for a script that should run and exit.
  • EnvironmentFile keeps secrets out of unit files. Unit files are world-readable by default. Store CTF_API_KEY=ctf_sk_... in /etc/ctf.env with permissions 600 (root:root).
  • RandomizedDelaySec prevents thundering herd. If you have multiple servers running the same timer, add RandomizedDelaySec=60 so they don't all hit the API at the same second.
  • systemctl list-timers shows next run time. Use this to verify a timer is active and to see when it will next fire: systemctl list-timers ctf-convert.timer.
  • Persistent=true is essential for critical jobs. Without it, a missed run during a maintenance window is simply missed. With it, the job runs as soon as the system comes back online.

systemd timers give you everything cron gives you plus missed-run recovery, structured logs, resource limits, and the rest of the systemd service ecosystem — at the cost of two unit files instead of one crontab line. For production servers running critical conversion jobs, that tradeoff is almost always worth it. Get a free API key to start converting.