LinuxXXXVI · Scheduled OperationsSchedule inventory
Auditing the schedule - what actually runs on this host
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
“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:
| Mechanism | Where it lives | Runs as |
|---|---|---|
| Per-user crontab | /var/spool/cron/crontabs/USER | That user |
| System crontab | /etc/crontab | Column 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 timers | Unit search path | Unit’s User=, default root |
One-shot at jobs | /var/spool/cron/atjobs | Submitting 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
Q1. systemctl list-timers --all shows a timer with a dash in the NEXT column. What does that mean?
Q2. A file named backup.sh placed in /etc/cron.daily and marked executable will be run daily.
Q3. Which locations must a complete audit of scheduled work on a host examine? Select all that apply.
Q4. Why read a timer with systemctl cat rather than opening the file under /usr/lib/systemd/system?
Q5. Which check most reliably catches a scheduled job that never started at all?
Passing score: 75%. Answers are checked in this browser.