Skip to main content
RunBook Academy

LinuxXXXVI · Scheduled OperationsSchedule inventory

Auditing the schedule - what actually runs on this host

Intermediate⏱ ~17 minsystemctlcrontabrun-parts

What you'll learn

  • Enumerate every scheduling mechanism on a host, not just the one you were told about
  • Read systemctl list-timers --all and explain what a dash in the NEXT column means
  • Attribute a cron entry to the file and user that own it
  • Find scheduled jobs that no longer run, and jobs nobody remembers deploying

Prerequisites

Verified against Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL 9.x · Rocky Linux 9.x · AlmaLinux 9.x · Linux kernel 6.1 LTS / 6.6 LTS · systemd 255+ · OpenSSH 8.7p1 (RHEL 9) / 9.6p1 (Ubuntu 24.04) · nftables 1.0.x · chrony 4.x · Pacemaker 2.1.x · Corosync 3.1.x · 2026-08-11

Not yet marked complete on this device.

“What runs on this box at 3 a.m.?” is a question almost nobody can answer from memory, and the honest answer on an inherited host is usually “more than you think, and less than you were promised”. Jobs accumulate: a crontab entry from 2019, a systemd timer shipped by a package, a cron.daily script installed by a config-management run that was later reverted, a per-user crontab belonging to a person who left.

This lesson builds the inventory. It is the audit you run before a migration, after inheriting a host, and during any incident where “did something run?” is a live question.

There are more mechanisms than one

A host can schedule work through at least six paths, and each has its own storage, its own owner and its own log destination:

MechanismWhere it livesRuns as
Per-user crontab/var/spool/cron/crontabs/USERThat user
System crontab/etc/crontabColumn 6 names the user
Drop-in cron files/etc/cron.d/*Column 6 names the user
Periodic script dirs/etc/cron.{hourly,daily,weekly,monthly}Usually root
systemd timersUnit search pathUnit’s User=, default root
One-shot at jobs/var/spool/cron/atjobsSubmitting user

Checking only one of these is the commonest audit failure. The job you cannot find is nearly always in the mechanism you did not look at.

systemd timers

Start here on any modern host, because packages increasingly ship timers rather than cron files:

systemctl list-timers --all
NEXT                        LEFT     LAST                        PASSED   UNIT                   ACTIVATES
Tue 2026-08-11 16:00:00 UTC 2min 41s Tue 2026-08-11 15:50:03 UTC 7min ago sysstat-collect.timer  sysstat-collect.service
Tue 2026-08-11 16:06:09 UTC 8min     Tue 2026-08-11 15:38:28 UTC 18min ago fwupd-refresh.timer   fwupd-refresh.service
Tue 2026-08-11 23:25:07 UTC 7h       Tue 2026-08-11 15:30:50 UTC 26min ago apt-daily.timer       apt-daily.service
Wed 2026-08-12 00:00:00 UTC 8h       Tue 2026-08-11 00:00:06 UTC 15h ago  dpkg-db-backup.timer   dpkg-db-backup.service
-                           -        Mon 2026-08-10 09:14:22 UTC 1 day ago certbot-renew.timer    certbot-renew.service

Read the columns as a pair of questions. LAST/PASSED answers “did it run, and when”; NEXT/LEFT answers “will it run again”.

Confirm the enablement state separately, because “loaded” and “enabled” are different facts:

systemctl list-unit-files --type=timer --state=enabled,disabled
UNIT FILE                    STATE    PRESET
apt-daily.timer              enabled  enabled
certbot-renew.timer          disabled enabled
dpkg-db-backup.timer         enabled  enabled
fwupd-refresh.timer          enabled  enabled
sysstat-collect.timer        enabled  enabled

certbot-renew.timer is disabled against a PRESET of enabled - somebody turned it off, and the preset says the distribution did not intend that. A mismatch between STATE and PRESET is one of the highest-yield lines in this whole audit.

To see what a timer actually does, including drop-ins:

systemctl cat sysstat-collect.timer
# /usr/lib/systemd/system/sysstat-collect.timer
# sysstat-12.7.7 systemd unit file:
#        Activates activity collector every 10 minutes

[Unit]
Description=Run system activity accounting tool every 10 minutes

[Timer]
OnCalendar=*:00/10

[Install]
WantedBy=sysstat.service

systemctl cat concatenates the vendor unit and every drop-in under /etc/systemd/system/UNIT.d/, in the order systemd applies them. Reading the vendor file alone can tell you the opposite of what runs, because a two-line drop-in can change OnCalendar= entirely.

cron, in all its locations

No single command lists cron jobs, so enumerate the locations:

# System-wide files
sudo cat /etc/crontab
sudo run-parts --list /etc/cron.d 2>/dev/null || sudo ls -1 /etc/cron.d

# The periodic directories, showing only what would actually run
for d in hourly daily weekly monthly; do
  echo "== /etc/cron.$d"
  sudo run-parts --test "/etc/cron.$d"
done
== /etc/cron.daily
/etc/cron.daily/apt-compat
/etc/cron.daily/dpkg
/etc/cron.daily/logrotate
== /etc/cron.weekly
/etc/cron.weekly/man-db

run-parts --test prints the scripts that would run without running them. That distinction matters: run-parts skips files whose names contain characters outside its accepted set, and skips anything not executable. A script sitting in /etc/cron.daily that ls shows and run-parts --test does not is a job that silently never runs.

Per-user crontabs

These are the ones audits miss, because they are not in /etc and root’s own crontab does not show them:

# Every user with a crontab, and its contents
sudo ls -1 /var/spool/cron/crontabs
for u in $(sudo ls -1 /var/spool/cron/crontabs); do
  echo "== crontab for $u"
  sudo crontab -l -u "$u"
done
== crontab for root
0 4 * * * /usr/local/sbin/rotate-archives.sh
== crontab for deploy
*/5 * * * * /home/deploy/bin/sync-artifacts.sh >> /home/deploy/sync.log 2>&1

Read the spool file names through crontab -l -u rather than cat, so you get the same parsing cron uses. Then cross-check the owners against the password database - a crontab belonging to a deleted user is a job that either fails every five minutes or, worse, runs as a UID that has since been reissued to somebody else:

for u in $(sudo ls -1 /var/spool/cron/crontabs); do
  id "$u" >/dev/null 2>&1 || echo "ORPHAN CRONTAB: $u has no passwd entry"
done

Also check who is even permitted to schedule:

cat /etc/cron.allow /etc/cron.deny 2>/dev/null

If /etc/cron.allow exists, only the users listed in it may use crontab, and everyone else is refused regardless of cron.deny. On a hardened host this is often where a “my cron job will not install” ticket ends.

One-shot at jobs

at schedules a single future run and is easy to forget because it leaves nothing in a config file:

sudo atq
14      Wed Aug 12 02:00:00 2026 a deploy
15      Fri Aug 14 22:30:00 2026 a root

Job 15 is a root job somebody queued for a Friday night. Inspect it before deciding what it is:

sudo at -c 15 | tail -20

atq and at come from the at package, which is not installed by default on many minimal images. atq returning “command not found” means one-shot jobs cannot exist on this host, which is a valid audit result - record it rather than skipping the check.

Reconcile the inventory

An inventory is only useful compared against an expectation. Three comparisons find almost everything:

1. Scheduled but never succeeding. For timer-driven jobs, the service unit records it:

systemctl list-units --type=service --state=failed
systemctl --failed --no-legend | awk '{print $1}'

2. Scheduled but producing no output. The strongest check, because it catches jobs that never started at all. Compare the job’s artefact against the clock, not against the unit state:

# Anything in the backup target older than 2 days is a missed run
find /srv/backups -maxdepth 1 -name '*.tar.zst' -mtime +2 -printf '%TY-%Tm-%Td %p\n'

3. Running but not in any runbook. Take each entry from the inventory and find its owner. Jobs with no owner are the audit’s actual output: either somebody adopts them or they are removed. For cron entries the file itself usually tells you who deployed it:

sudo head -5 /etc/cron.d/rotate-archives
# Managed by Ansible - roles/archives/templates/rotate.cron.j2
# Contact: platform-team
SHELL=/bin/bash
MAILTO=platform-team@example.com
0 4 * * * root /usr/local/sbin/rotate-archives.sh

One pass, all mechanisms

Bringing it together as a single audit script:

#!/bin/bash
# Enumerate every scheduled job on this host. Read-only.
set -uo pipefail

echo "=== systemd timers (--all shows inactive ones too)"
systemctl list-timers --all --no-legend

echo "=== timer enablement vs distribution preset"
systemctl list-unit-files --type=timer --state=enabled,disabled

echo "=== /etc/crontab"
cat /etc/crontab

echo "=== /etc/cron.d"
ls -1 /etc/cron.d 2>/dev/null

echo "=== periodic directories, only what run-parts would execute"
for d in /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly; do
  [ -d "$d" ] && run-parts --test "$d"
done

echo "=== per-user crontabs"
for u in $(ls -1 /var/spool/cron/crontabs 2>/dev/null); do
  echo "-- $u"
  crontab -l -u "$u" 2>/dev/null
done

echo "=== at queue"
atq 2>/dev/null || echo "at not installed"

Run it with sudo, keep the output, and diff it after every change window. A schedule that changes when nobody changed it is the finding.

Knowledge check

Knowledge check · 5 questions

  1. Q1. systemctl list-timers --all shows a timer with a dash in the NEXT column. What does that mean?

  2. Q2. A file named backup.sh placed in /etc/cron.daily and marked executable will be run daily.

  3. Q3. Which locations must a complete audit of scheduled work on a host examine? Select all that apply.

  4. Q4. Why read a timer with systemctl cat rather than opening the file under /usr/lib/systemd/system?

  5. Q5. Which check most reliably catches a scheduled job that never started at all?

Passing score: 75%. Answers are checked in this browser.