# Scheduled Job Inventory: Find Every Cron Job You Run

By Dan Ochoa · Published 2026-10-25 · Updated 2026-10-31
Canonical: https://cronradar.com/blog/scheduled-job-inventory

> List every cron job, systemd timer, Kubernetes CronJob, cloud schedule, and framework task you run, record each one, and check the list against monitoring.

**To find every scheduled job you run, list each place a schedule can live, read each one with that scheduler's own read-only command, and merge the results into one inventory that records each job's owner, schedule, time zone, location, dependents, and whether anything monitors it.** On Linux hosts that means every user's crontab, the system crontabs, the periodic directories, anacron, at, and systemd timers. Beyond the hosts, it means Task Scheduler, launchd, Kubernetes, cloud schedulers, CI, application frameworks, and databases. Do it before a migration, an audit, or setting up monitoring: a monitor knows only the jobs that report to it, so the job nobody listed is the one nothing watches.

Every command below is read-only; the docs and man pages behind them were checked on October 25, 2026.

## Linux Hosts: Crontabs, Periodic Scripts, and Timers

`crontab -l` shows one user's table. A host's full list comes from all of these:

| Where            | List it with                                                                                                                                       | Watch for                                                                                                                                                                                                                                                                                                                 |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| User crontabs    | `sudo ls /var/spool/cron/crontabs` (Debian, Ubuntu), `/var/spool/cron` (cronie: Fedora, RHEL, Arch), `/etc/crontabs` (BusyBox crond, Alpine)       | One file per user, readable by root ([Debian crontab(1)](https://manpages.debian.org/trixie/cron/crontab.1.en.html), [cronie crontab(1)](https://man7.org/linux/man-pages/man1/crontab.1.html)); `sudo crontab -l -u alice` prints one                                                                                    |
| System crontabs  | `/etc/crontab`, `/etc/cron.d/*`                                                                                                                    | A sixth field names the user. BusyBox crond reads neither ([crond.c](https://sources.debian.org/src/busybox/1:1.37.0-6/miscutils/crond.c/))                                                                                                                                                                               |
| Periodic scripts | `run-parts --test` on each `/etc/cron.*` directory, or `/etc/periodic/*` on Alpine                                                                 | Prints only what run-parts would run; what triggers each directory [differs by distribution](https://cronradar.com/blog/crontab-guide#the-periodic-directories-and-run-parts)                                                                                                                                                                  |
| anacron          | `/etc/anacrontab`                                                                                                                                  | Runs the periodic directories on cronie ([anacrontab](https://github.com/cronie-crond/cronie/blob/cronie-1.7.2/contrib/anacrontab)) and Debian ([anacrontab](https://sources.debian.org/src/anacron/2.3-43/debian/anacrontab/))                                                                                           |
| at jobs          | `sudo atq`, then `sudo at -c 12` to print job 12                                                                                                   | Run by root, `atq` lists everybody's jobs ([at(1)](https://manpages.debian.org/trixie/at/at.1.en.html), at 3.2.5)                                                                                                                                                                                                         |
| System timers    | `systemctl list-timers --all`; `systemctl list-unit-files --type=timer`                                                                            | Without `--all`, loaded but inactive timers are hidden ([systemctl-list-units.c](https://github.com/systemd/systemd/blob/v258/src/systemctl/systemctl-list-units.c)); `list-unit-files` adds installed timers that aren't loaded ([systemctl(1)](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html)) |
| User timers      | `ls /var/lib/systemd/linger`; `sudo systemctl --user --machine=alice@.host list-timers --all`; `find /home/*/.config/systemd/user -name '*.timer'` | A lingering user's manager runs timers with nobody logged in ([loginctl(1)](https://www.freedesktop.org/software/systemd/man/latest/loginctl.html)); `--machine=user@.host` needs systemd 248+ ([NEWS](https://github.com/systemd/systemd/blob/v258/NEWS))                                                                |

The [crontab guide](https://cronradar.com/blog/crontab-guide#list-every-cron-job-on-a-machine) has a script that prints all of this for one host, [systemd timers](https://cronradar.com/blog/systemd-timers) covers user timers and lingering, and [cron security](https://cronradar.com/blog/cron-security) covers hashing these files when the inventory doubles as a persistence audit.

## Collect a Fleet's Crontabs and Timers Into One CSV

This script runs a read-only POSIX `sh` collector on each host over SSH and writes one CSV row per crontab entry, periodic script, anacron job, and systemd timer.

```bash
#!/usr/bin/env bash
# collect-schedules.sh: list crontab entries and systemd timers on Linux hosts as CSV.
# Usage: ./collect-schedules.sh hosts.txt > schedules.csv
# Needs SSH and passwordless sudo on each host: other users' crontabs are root-only.
set -uo pipefail
hosts=${1:?usage: collect-schedules.sh HOSTS_FILE}

# Runs on each host. Prints tab-separated records: kind, source, owner, data.
IFS= read -r -d '' remote <<'REMOTE'
if [ -L /etc/localtime ]; then
  zone=$(readlink /etc/localtime); zone=${zone#*zoneinfo/}
elif [ -e /etc/localtime ]; then
  zone=$(cat /etc/timezone 2>/dev/null) || zone=unknown
else
  zone=UTC
fi
printf 'zone\t-\t-\t%s\n' "$zone"

tag() { # tag KIND FILE OWNER: print each line of FILE with its labels
  while IFS= read -r line || [ -n "$line" ]; do
    printf '%s\t%s\t%s\t%s\n' "$1" "$2" "$3" "$line"
  done < "$2"
}

seen=
for dir in /var/spool/cron/crontabs /var/spool/cron /etc/crontabs; do
  [ -d "$dir" ] || continue
  real=$(cd -P "$dir" && pwd)
  case " $seen " in *" $real "*) continue ;; esac
  seen="$seen $real"
  for f in "$dir"/*; do
    [ -f "$f" ] || continue
    case ${f##*/} in \#* | tmp.* | cron.update) continue ;; esac
    tag user "$f" "${f##*/}"
  done
done
for f in /etc/crontab /etc/cron.d/*; do
  [ -f "$f" ] && tag system "$f" -
done
[ -f /etc/anacrontab ] && tag anacron /etc/anacrontab root
for d in /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly \
         /etc/cron.yearly /etc/periodic/*; do
  [ -d "$d" ] && run-parts --test "$d" 2>/dev/null | while IFS= read -r s; do
    printf 'periodic\t%s\troot\t%s\n' "$d" "$s"
  done
done

timers() { # timers OWNER [systemctl options that select the service manager]
  owner=$1; shift
  systemctl "$@" list-units --type=timer --all --no-legend --plain --no-pager 2>/dev/null |
    while read -r unit _; do
      when=$(systemctl "$@" show --property=TimersCalendar --property=TimersMonotonic \
        --value "$unit" | sed -n 's/^{ \(.*\) ; next_elapse=.*/\1/p' | paste -s -d ';' -)
      service=$(systemctl "$@" show --property=Unit --value "$unit")
      run_as=$(systemctl "$@" show --property=User --value "$service")
      printf 'timer\t%s\t%s\t%s\t%s\n' "$unit" "${run_as:-$owner}" "$when" "$service"
    done
}
if [ -d /run/systemd/system ]; then
  timers root
  for u in $(ls /var/lib/systemd/linger 2>/dev/null); do
    timers "$u" --user --machine="$u@.host"
  done
fi
REMOTE

# Turns the records into CSV rows, quoting every field.
IFS= read -r -d '' parse <<'AWK'
BEGIN { FS = "\t"; OFS = "," }
function q(s) { gsub(/"/, "\"\"", s); return "\"" s "\"" }
function row(kind, src, run_as, sched, cmd) {
  print q(host), q(kind), q(src), q(run_as), q(sched), q(zone), q(tz[src]), q(cmd)
}
{
  kind = $1; src = $2; owner = $3; rest = $0
  for (i = 0; i < 3; i++) rest = substr(rest, index(rest, "\t") + 1)
}
kind == "zone"     { zone = rest; next }
kind == "timer"    { split(rest, t, "\t"); row("systemd timer", src, owner, t[1], t[2]); next }
kind == "periodic" { row("run-parts", src, owner, src, rest); next }
{
  line = rest
  sub(/^[ \t]+/, "", line)
  if (line == "" || substr(line, 1, 1) == "#") next
  if (line ~ /^[A-Za-z_][A-Za-z0-9_]*[ \t]*=/) {            # VAR=value line
    if (line ~ /^CRON_TZ[ \t]*=/) { v = line; sub(/^CRON_TZ[ \t]*=[ \t]*/, "", v); tz[src] = v }
    next
  }
  split(line, f, /[ \t]+/)
  if (kind == "anacron") {
    n = 3; sched = "anacron period " f[1]; run_as = "root"
  } else {
    n = (substr(line, 1, 1) == "@") ? 1 : 5
    sched = f[1]; for (i = 2; i <= n; i++) sched = sched " " f[i]
    run_as = owner
    if (kind == "system") { run_as = f[n + 1]; n++ }
  }
  cmd = line
  for (i = 0; i < n; i++) sub(/^[^ \t]+[ \t]+/, "", cmd)
  row(kind == "anacron" ? "anacron" : "cron", src, run_as, sched, cmd)
}
AWK

echo '"host","kind","source","run_as","schedule","host_zone","cron_tz","command"'
while read -r host || [ -n "$host" ]; do
  case $host in '' | \#*) continue ;; esac
  if ! ssh -o BatchMode=yes -o ConnectTimeout=10 "$host" 'sudo -n sh -s' <<< "$remote" |
    awk -v host="$host" "$parse"; then
    echo "collect-schedules: could not read every schedule on $host" >&2
  fi
done < "$hosts"
```

In the CSV:

- **`host_zone`** is the system zone from `/etc/localtime`. **`cron_tz`** is a `CRON_TZ=` line above the entry, which only cronie schedules by ([crontab(5)](https://man7.org/linux/man-pages/man5/crontab.5.html)).
- **Timer schedules** are systemd's normalized form, such as `OnCalendar=*-*-* 06:00:00`, in the system zone unless the expression names one ([systemd.time(7)](https://www.freedesktop.org/software/systemd/man/latest/systemd.time.html)). Reading them needs systemd 230 or later, for `show --value`.
- **Not covered:** at jobs, timers of users without lingering, and cron inside container images. Add those by hand.

## Windows Task Scheduler and macOS launchd

`Get-ScheduledTask` "lists all scheduled tasks, regardless of whether they're enabled or disabled" ([Microsoft Learn](https://learn.microsoft.com/en-us/training/modules/create-manage-scheduled-jobs-use-windows-powershell/2-create-run-scripts-scheduled-tasks)), and `-CimSession` takes a remote computer name ([Get-ScheduledTask](https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/get-scheduledtask)). `schtasks /query` adds each task's advanced properties with `/v`, writes CSV with `/fo csv`, and reads another computer with `/s`, a form Microsoft suggests for tracking tasks across machines ([schtasks query](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/schtasks-query)):

```powershell
# Every task outside Microsoft's own \Microsoft\ folders, enabled or not
Get-ScheduledTask | Where-Object TaskPath -notlike '\Microsoft\*' |
    Select-Object TaskPath, TaskName, State

# All tasks on app01 with advanced properties, as CSV
schtasks /query /s app01 /fo csv /v > app01-tasks.csv
```

[Windows Task Scheduler monitoring](https://cronradar.com/blog/windows-task-scheduler-monitoring) covers task history, Last Run Result codes, and alerts for the tasks you find.

On macOS, a launchd job runs periodically when its property list has `StartInterval` or `StartCalendarInterval`; the lists live in `/Library/LaunchDaemons`, `/Library/LaunchAgents`, each user's `~/Library/LaunchAgents`, and Apple's own `/System/Library` folders ([Apple](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/CreatingLaunchdJobs.html)). Plists can be binary, so convert before searching. cron is "still supported" but deprecated ([Apple](https://developer.apple.com/library/archive/documentation/MacOSX/Conceptual/BPSystemStartup/Chapters/ScheduledJobs.html)), so check `crontab -l` too.

```bash
# In bash, as root: scheduled launchd jobs outside /System
for f in /Library/LaunchDaemons/*.plist /Library/LaunchAgents/*.plist \
         /Users/*/Library/LaunchAgents/*.plist; do
  plutil -convert xml1 -o - "$f" 2>/dev/null |
    grep -qE '<key>Start(Calendar)?Interval</key>' && echo "$f"
done
```

## Kubernetes CronJobs

`kubectl get cronjobs -A` covers every namespace of one cluster ([kubectl get](https://kubernetes.io/docs/reference/kubectl/generated/kubectl_get/)), so loop over your contexts. Record `timeZone`: without it, the schedule follows kube-controller-manager's local zone (the field is stable since Kubernetes 1.27). A CronJob with `suspend: true` stays listed, but the controller starts no new Jobs ([CronJob](https://kubernetes.io/docs/concepts/workloads/controllers/cron-jobs/)).

```bash
for ctx in $(kubectl config get-contexts -o name); do
  kubectl --context "$ctx" get cronjobs -A --no-headers \
    -o custom-columns='NS:.metadata.namespace,NAME:.metadata.name,SCHEDULE:.spec.schedule,TZ:.spec.timeZone,SUSPEND:.spec.suspend' |
    awk -v c="$ctx" '{ print c, $0 }'
done

# Scheduling kinds that operators add
kubectl api-resources | grep -i cron
```

That finds kinds such as Argo Workflows' [CronWorkflow](https://argo-workflows.readthedocs.io/en/latest/cron-workflows/). [Kubernetes CronJob monitoring](https://cronradar.com/blog/kubernetes-cronjob-monitoring) covers what to watch once they're listed.

## Cloud Schedulers: AWS, Google Cloud, and Azure

Cloud schedules are regional. On AWS (CLI 2.38), scheduled rules are a legacy feature that exists only on the default event bus and runs in UTC; AWS recommends EventBridge Scheduler instead ([scheduled rules](https://docs.aws.amazon.com/eventbridge/latest/userguide/eb-create-rule-schedule.html)). `list-schedules` doesn't return expressions, so ask `get-schedule` for each ([list-schedules](https://docs.aws.amazon.com/cli/latest/reference/scheduler/list-schedules.html), [get-schedule](https://docs.aws.amazon.com/cli/latest/reference/scheduler/get-schedule.html)). Google's `jobs list` reads one location, the App Engine app's by default ([jobs list](https://docs.cloud.google.com/sdk/gcloud/reference/scheduler/jobs/list)). Run both per account or project:

```bash
# AWS: scheduled rules and Scheduler schedules in every region enabled for the account
for region in $(aws ec2 describe-regions --query 'Regions[].RegionName' --output text); do
  aws events list-rules --region "$region" \
    --query 'Rules[?ScheduleExpression].[Name,ScheduleExpression,State]' --output text |
    awk -v r="$region" '{ print r, "rule", $0 }'
  aws scheduler list-schedules --region "$region" \
    --query 'Schedules[].[GroupName,Name]' --output text |
    while read -r group name; do
      aws scheduler get-schedule --region "$region" --group-name "$group" --name "$name" \
        --query '[ScheduleExpression,ScheduleExpressionTimezone,State,Target.Arn]' \
        --output text < /dev/null |
        awk -v r="$region" -v s="$group/$name" '{ print r, "schedule", s, $0 }'
    done
done

# Google Cloud: Cloud Scheduler jobs in every location of the current project
for location in $(gcloud scheduler locations list --format='value(locationId)'); do
  gcloud scheduler jobs list --location="$location" \
    --format='csv[no-heading](name.basename(),schedule,timeZone,state)'
done
```

On Azure, search Logic Apps templates for the Recurrence trigger, `"type": "Recurrence"` in a workflow definition ([Recurrence](https://learn.microsoft.com/en-us/azure/connectors/connectors-native-recurrence)). Functions timer triggers live in code, in a different form per language ([timer trigger](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-timer)):

```bash
grep -rnE 'TimerTrigger|"timerTrigger"|app\.timer\(|timer_trigger' \
  --include='*.cs' --include='*.java' --include='function.json' \
  --include='*.js' --include='*.ts' --include='*.py' .
```

A schedule written as `%SettingName%` comes from an app setting. An `AzureWebJobs.<FunctionName>.Disabled` setting turns a function off, and deployment slots run their own copies; Microsoft notes it's common to disable timer functions in slots to prevent simultaneous executions ([disable functions](https://learn.microsoft.com/en-us/azure/azure-functions/disable-function)). [The Azure Functions timer trigger guide](https://cronradar.com/blog/azure-functions-timer-trigger) covers how those schedules run and how to alert on a missed one.

## CI Schedules: GitHub Actions and GitLab

A GitHub Actions schedule is an `on.schedule` entry in a workflow file. It runs in UTC unless it sets `timezone`, only from the default branch, and is disabled automatically in a public repository after 60 days without activity ([schedule event](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#schedule)). [GitHub Actions scheduled workflows](https://cronradar.com/blog/github-actions-scheduled-workflows) covers why those runs start late or get dropped. Search each clone's default branch, then check each workflow's state:

```bash
grep -lE '^[[:space:]]*schedule:' */.github/workflows/*.y*ml
gh workflow list --all --json name,path,state -R acme/billing-service
```

GitLab keeps pipeline schedules outside the repository. `GET /projects/:id/pipeline_schedules` returns each one's `cron`, `cron_timezone`, `next_run_at`, `active`, and `owner`, and the API documents only this per-project list ([pipeline schedules](https://docs.gitlab.com/api/pipeline_schedules/)), so loop over projects.

## Schedules Defined in Application Code

Framework schedulers keep registries you can read from inside the application. For Hangfire 1.8, read the recurring-job registry ([RecurringJobDto](https://github.com/HangfireIO/Hangfire/blob/v1.8.25/src/Hangfire.Core/Storage/RecurringJobDto.cs)); [Hangfire monitoring](https://cronradar.com/blog/hangfire-monitoring) covers the rest of its storage API:

```csharp
using Hangfire;
using Hangfire.Storage;

using var connection = JobStorage.Current.GetConnection();
foreach (var job in connection.GetRecurringJobs())
{
    var target = job.Job is null ? "(type not loadable)" : $"{job.Job.Type.Name}.{job.Job.Method.Name}";
    Console.WriteLine($"{job.Id}\t{job.Cron}\t{job.TimeZoneId}\t{target}\tnext={job.NextExecution:u}");
}
```

For Quartz.NET 3.x, `scheduler.GetJobKeys(GroupMatcher<JobKey>.AnyGroup())` returns every job, `GetTriggersOfJob(jobKey)` its triggers, and an `ICronTrigger` carries `CronExpressionString` and `TimeZone` ([IScheduler](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/IScheduler.cs), [ICronTrigger](https://github.com/quartznet/quartznet/blob/v3.22.7/src/Quartz/ICronTrigger.cs)). List jobs with no trigger too: they run only when something triggers them by hand.

Other frameworks:

```bash
# Celery 5.6: the beat_schedule setting (proj.celery is your Celery app)
python -c "from proj.celery import app; print(app.conf.beat_schedule)"

# django-celery-beat 2.9: tasks in the database, including ones added in the admin
python manage.py shell -c "from django_celery_beat.models import PeriodicTask
for t in PeriodicTask.objects.all(): print(t.enabled, t, t.task)"

# Laravel 13: each task and when it is next due
php artisan schedule:list

# Rails with whenever 1.1.4: schedule.rb as crontab lines, without touching the crontab
bundle exec whenever

# Rails with Solid Queue 1.7: tasks loaded from recurring.yml, plus dynamic ones
bin/rails runner 'SolidQueue::RecurringTask.find_each { |t| puts [t.key, t.schedule, t.class_name || t.command, t.static ? "static" : "dynamic"].join("\t") }'

# Spring Boot 4.1 with the scheduledtasks Actuator endpoint exposed, then the source
curl -s http://localhost:8080/actuator/scheduledtasks
grep -rn --include='*.java' --include='*.kt' '@Scheduled' src/main
```

Celery beat takes its entries from `beat_schedule` by default, while django-celery-beat keeps the schedule in the Django database ([periodic tasks](https://docs.celeryq.dev/en/v5.6.3/userguide/periodic-tasks.html), [models.py](https://github.com/celery/django-celery-beat/blob/v2.9.0/django_celery_beat/models.py)). Solid Queue rewrites its static task rows from the file whenever its scheduler starts ([recurring_schedule.rb](https://github.com/rails/solid_queue/blob/v1.7.0/lib/solid_queue/scheduler/recurring_schedule.rb)) and keeps dynamic tasks only in the database ([README](https://github.com/rails/solid_queue/blob/v1.7.0/README.md#scheduling-and-unscheduling-recurring-tasks-dynamically)). See also Laravel's [`schedule:list`](https://laravel.com/docs/scheduling), whenever's [README](https://github.com/javan/whenever/blob/v1.1.4/README.md), and Spring Boot's [scheduledtasks endpoint](https://docs.spring.io/spring-boot/api/rest/actuator/scheduledtasks.html). A `@Scheduled` grep misses tasks registered through a `TaskScheduler` in code.

## Database Schedulers: pg_cron, SQL Server Agent, and MySQL Events

Databases run schedules that no host or repository shows:

```sql
-- pg_cron 1.3+, in the database named by cron.database_name
SELECT jobid, jobname, schedule, command, database, username, active
FROM cron.job
ORDER BY jobid;

-- SQL Server Agent: every job with each of its schedules and the next run
SELECT j.name AS job_name, j.enabled AS job_enabled, SUSER_SNAME(j.owner_sid) AS job_owner,
       s.name AS schedule_name, s.enabled AS schedule_enabled, s.freq_type, s.freq_interval,
       s.freq_subday_type, s.freq_subday_interval, s.active_start_time,
       js.next_run_date, js.next_run_time
FROM msdb.dbo.sysjobs AS j
LEFT JOIN msdb.dbo.sysjobschedules AS js ON js.job_id = j.job_id
LEFT JOIN msdb.dbo.sysschedules AS s ON s.schedule_id = js.schedule_id
ORDER BY j.name;

-- MySQL 8.4: events in every schema the account can see
SELECT EVENT_SCHEMA, EVENT_NAME, DEFINER, EVENT_TYPE, INTERVAL_VALUE, INTERVAL_FIELD,
       EXECUTE_AT, STARTS, ENDS, TIME_ZONE, STATUS, LAST_EXECUTED
FROM information_schema.EVENTS
ORDER BY EVENT_SCHEMA, EVENT_NAME;
```

Each query returns only what its account may see. pg*cron's row-level security limits a role to its own jobs unless it is a superuser or has `bypassrls` ([README](https://github.com/citusdata/pg_cron/blob/v1.6.8/README.md)), and MySQL shows `information_schema` rows only for objects the account has privileges on ([INFORMATION_SCHEMA](https://dev.mysql.com/doc/refman/8.4/en/information-schema-introduction.html)). Agent's `sysjobschedules` refreshes every 20 minutes ([sysjobschedules](https://learn.microsoft.com/en-us/sql/relational-databases/system-tables/dbo-sysjobschedules-transact-sql)), and [sysschedules](https://learn.microsoft.com/en-us/sql/relational-databases/system-tables/dbo-sysschedules-transact-sql) decodes the `freq*\*`columns. MySQL events run only while`event_scheduler`is`ON`, the 8.4 default ([event scheduler](https://dev.mysql.com/doc/refman/8.4/en/events-configuration.html)).

SaaS automation tools such as Zapier can run work on a timer too; list those from each tool's own interface.

## What Each Inventory Record Needs

The collected rows say what runs. The inventory adds who owns it and what breaks without it, one row per job:

```text
job_id,scheduler,location,schedule,time_zone,run_as,owner,depends_on,definition,environment,monitor_key,runbook_url,last_seen
```

| Column                  | Example                                               | Why                                                                              |
| ----------------------- | ----------------------------------------------------- | -------------------------------------------------------------------------------- |
| `job_id`                | `invoice-export-nightly`                              | One stable name, reusable as the monitor key                                     |
| `scheduler`, `location` | `cronie`, `app03.prod`                                | Where to look when it stops; what a migration moves                              |
| `schedule`, `time_zone` | `30 1 * * *`, `Europe/Berlin`                         | The same expression is a different hour in another zone                          |
| `run_as`                | `invoice`                                             | Privileges and secrets; review root jobs first                                   |
| `owner`                 | `billing-team`                                        | Who gets the alert ([cron job alerting](https://cronradar.com/blog/cron-job-alerting))                |
| `depends_on`            | `finance close report, 09:00`                         | What breaks, and by when                                                         |
| `definition`            | `billing/deploy/cron.d/invoice-export`                | Where it's changed; a job defined only on a server is a finding                  |
| `environment`           | `production`                                          | Keeps staging out of production alerts                                           |
| `monitor_key`           | `invoice-export-nightly`                              | Empty means nothing watches it                                                   |
| `runbook_url`           | `https://git.example.com/billing/runbooks/invoice.md` | What the alert links to ([scheduled job runbooks](https://cronradar.com/blog/scheduled-job-runbooks)) |
| `last_seen`             | `2026-10-25`                                          | The last scan that found it; an old date means removed or moved                  |

To give each row a priority class and the rest of a per-job contract, use the [CronRadar Scheduled Job Methodology](https://cronradar.com/methodology/apply).

## Check the Inventory Against What Reports to CronRadar

In CronRadar, two .NET extensions build their part of the monitor list themselves (package versions checked September 20, 2026):

- **CronRadar.Hangfire 0.1.0**: `builder.Services.AddCronRadarHangfire();` after `AddHangfire()` reads the recurring-job registry at startup and reconciles it every 15 minutes, monitoring every recurring job whose method lacks `[SkipMonitor]`. When a job leaves the registry, CronRadar pauses its monitor and marks it as no longer discovered, keeping its history. This release can report a failure before Hangfire finishes retrying, including cancellations; reporting only after retries are exhausted needs the pending 0.2.1 ([Hangfire integration](https://docs.cronradar.com/integrations/hangfire)).
- **CronRadar.Quartz 0.1.0**: `await scheduler.MonitorAll();` after registering jobs and before `Start()` walks every registered job and trigger; `[SkipMonitor]` on an `IJob` class opts out. 0.1.0 sends a cron trigger's six-field expression unchanged, and CronRadar accepts only five-field cron, so until the pending 0.1.1 only jobs on repeating simple triggers are monitored, with approximate five-field schedules (`* * * * *` below a minute). It covers Quartz.NET 3.x (3.4+), not 4.x; on 4.x, call the base SDK's `CronRadar.CronRadar.WrapAsync` inside the job, fully qualified because the class shares its namespace's name ([Quartz.NET integration](https://docs.cronradar.com/integrations/quartz)).

A discovered monitor stays `pending` until the first run, and CronRadar sends a never-executed alert if its first expected run plus the grace period passes with no report. CronRadar evaluates schedules in UTC, so for a job on another zone, compare CronRadar's next expected run with the scheduler's next fire time.

Nothing else is discovered, and outside .NET there is no installable CronRadar SDK: crontab, systemd, Task Scheduler, Kubernetes, cloud, CI, database, and other framework jobs each report with their own HTTP check-in to `https://cron.life/api/v1/ping/{monitor_key}`, and .NET code can use the base `CronRadar` SDK. A check-in that carries `schedule` creates its monitor, so a job that never runs never appears; to catch that, create the monitor in the dashboard before the first run. Copy `owner` and `runbook_url` into the monitor's "Owner or team" and "Runbook URL" settings: the owner appears in email, Slack, Discord, PagerDuty, and webhook alerts, the runbook link in webhook payloads and PagerDuty custom details.

Then compare the lists. `GET /api/v1/sync/status` returns one application's monitors with their keys and pause state ([API reference](https://docs.cronradar.com/api-reference)). With one application per environment, run this with each environment's key:

```bash
#!/usr/bin/env bash
# inventory-vs-monitors.sh: compare inventory rows with one CronRadar application's monitors.
# Usage: CRONRADAR_API_KEY=... ./inventory-vs-monitors.sh inventory.csv production
set -euo pipefail
inventory=${1:?usage: inventory-vs-monitors.sh INVENTORY_CSV ENVIRONMENT}
environment=${2:?usage: inventory-vs-monitors.sh INVENTORY_CSV ENVIRONMENT}
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT

python3 - "$inventory" "$environment" > "$tmp/rows" <<'EOF'
import csv, sys
with open(sys.argv[1], newline="") as f:
    for row in csv.DictReader(f):
        if row["environment"] == sys.argv[2]:
            print(row["monitor_key"] or "-", row["job_id"])
EOF

curl --fail --silent --show-error --max-time 10 --user "$CRONRADAR_API_KEY:" \
  https://cron.life/api/v1/sync/status > "$tmp/status.json"

awk '$1 == "-" { print "UNMONITORED", $2 }' "$tmp/rows"
awk '$1 != "-" { print $1 }' "$tmp/rows" | sort -u > "$tmp/expected"
jq -r '.monitors[].monitorKey' "$tmp/status.json" | sort -u > "$tmp/monitors"
comm -23 "$tmp/expected" "$tmp/monitors" | sed 's/^/NO MONITOR /'
comm -13 "$tmp/expected" "$tmp/monitors" | sed 's/^/NOT IN INVENTORY /'
jq -r '.monitors[] | select(.isPaused) | "PAUSED \(.monitorKey)"' "$tmp/status.json"
```

`UNMONITORED` and `NO MONITOR` are jobs nothing watches yet. `NOT IN INVENTORY` is a job that reports but was never recorded. `PAUSED` covers monitors paused by hand, held by a plan limit, or paused because a discovered job left its registry.

## Keep the Inventory Current

- **Re-scan on a schedule.** Run the collectors weekly, update `last_seen`, and diff each scan against the last. Give the collector its own check-in, so one that stops doesn't leave an old inventory looking current.
- **Check in CI.** Fail a pull request that adds a schedule, such as a workflow `schedule:`, a `recurring.yml` key, an `@Scheduled` method, or a CronJob manifest, without an inventory row.
- **Diff against monitoring after each scan.** The Hangfire and Quartz.NET 3.x extensions keep their side current; every other row needs its check-in added by hand.

## Frequently Asked Questions

### How do I list all cron jobs on a Linux server?

As root, read every file in the cron spool (`/var/spool/cron/crontabs` on Debian and Ubuntu, `/var/spool/cron` on cronie systems, `/etc/crontabs` on Alpine), plus `/etc/crontab`, `/etc/cron.d`, the periodic directories, and `/etc/anacrontab`. Then add `systemctl list-timers --all` and `atq`; `crontab -l` alone shows only the current user.

### How do I find every scheduled task across servers?

Run the same read-only collection on every host over SSH and merge the output, as the fleet script above does for Linux; on Windows, `schtasks /query /s` or `Get-ScheduledTask -CimSession` reads remote machines. Then add what isn't on any host: Kubernetes, cloud schedulers in every region, CI schedules, framework registries, and database schedulers.

### How do I list systemd timers?

`systemctl list-timers --all` lists system timers, including loaded but inactive ones, with their next and last runs. `systemctl --user list-timers --all` lists your own, and as root, `systemctl --user --machine=alice@.host list-timers --all` lists alice's on systemd 248 or later while her user manager runs.

### How do I find scheduled jobs in Kubernetes?

Run `kubectl get cronjobs -A` against every context in your kubeconfig and record each CronJob's `timeZone` and `suspend` fields. `kubectl api-resources | grep -i cron` finds scheduling kinds added by operators, such as Argo Workflows' CronWorkflow.

### Can CronRadar find my scheduled jobs automatically?

Only through two .NET extensions: CronRadar.Hangfire 0.1.0 reconciles Hangfire's recurring-job registry, and CronRadar.Quartz 0.1.0 declares every job when the scheduler starts on Quartz.NET 3.x, though CronRadar accepts only jobs on repeating simple triggers until the pending 0.1.1. Crontab, systemd, Kubernetes, cloud schedulers, and other frameworks report through an HTTP check-in per job, so the monitor list is only as complete as what reports. Build the inventory first, then compare it with the monitor list.
