Signal9Signal9 — home
online
all lessons

Task Scheduling: Cron, At & Timers

Beginner40 min6 steps
What you'll learn
  • Choose the right scheduler — cron, at, or a systemd timer — by the shape of the job
  • Read and write the five-field cron format without guessing
  • Manage user and system cron, and defuse cron's environment trap before it bites
  • Debug the classic "my cron job didn't run" the systematic way
  • Build a systemd timer + service pair and schedule a one-off job with at
  1. Why schedulers exist — and which of the three to reach for

    Pick the right tool by the shape of the job, before writing any schedule

    Half of system administration is things that must happen when you're not there: backups at 2 a.m., log rotation nightly, certificate renewal monthly, a health check every five minutes. A machine that needs a human to remember these isn't automated — it's just waiting to fail on a weekend. Schedulers are how the machine remembers for you.

    Linux gives you three, and they answer different questions. The fastest way to hold them apart is by analogy to your own calendar:

    • cron — a repeating calendar appointment: "every day at 02:30," forever, on a clock.
    • at — a one-off reminder that deletes itself after firing: "once, at 4 p.m. today."
    • systemd timers — the appointment that also keeps records and reschedules itself: recurring or one-shot, but with logging you can query (journalctl), dependencies ("only after the network is up"), and catch-up if the machine was off when the moment passed.

    So the choice is a two-question flowchart. Does it repeat? No → at. Yes → does it need logs, dependencies, or catch-up after downtime? No → cron (simple, universal, on every Unix since the 1970s). Yes → a systemd timer.

    On the exams: cron is the bread-and-butter you must write fluently; timers are the "modern alternative" you should be able to build; at is the quick one-off. In real life the split is the same — plus a rule of thumb: on a laptop or VM that isn't always on, prefer timers, because cron silently skips anything scheduled while the machine slept.

    Check yourself

    A database migration script must run once, at 03:00 next Saturday, and never again. Which scheduler?

  2. The cron five-field format

    Read and write a cron schedule with confidence

    A cron line is five time fields, then a command — and the entire skill is knowing the field order cold. Read the diagram top to bottom, then never guess again:

    `

    ┌ minute (0–59)

    │ ┌ hour (0–23)

    │ │ ┌ day of month (1–31)

    │ │ │ ┌ month (1–12)

    │ │ │ │ ┌ day of week (0–6, 0 = Sunday)

    │ │ │ │ │

    * command

    `

    Four operators work in any field: * = every value, , = a list (1,15,30), - = a range (1-5), / = a step (*/15 = every 15). That's the whole grammar — everything else is combining them.

    echo '30 2 * * 1-5 /opt/scripts/backup.sh'
    30 2 * * 1-5 /opt/scripts/backup.sh
    Read it left to right, field by field: minute 30, hour 2, any day-of-month, any month, days 1–5 (Mon–Fri) → "02:30 every weekday." Always decode a cron line this way — out loud at first. A few more to read the same way: /15 is "every 15 minutes"; 0 /6 is "every 6 hours, on the hour"; 0 9 1 * is "09:00 on the 1st of each month."

    Check yourself

    Decode this schedule: 0 22 0 /opt/report.sh

  3. User cron, system cron, and the environment trap

    Manage crontabs — and defuse the #1 reason cron jobs silently fail

    Cron entries live in two distinct places, and the difference shows up as one extra field.

    User crontabs belong to an account — yours runs as you. You never edit the file directly; crontab -e opens your crontab in an editor and installs it safely on save (the actual files live under /var/spool/cron/).

    System cron is for machine-wide jobs: /etc/crontab and drop-in files in /etc/cron.d/ use a sixth field — the user to run as — between the schedule and the command: 30 2 * * * root /opt/backup.sh. And for jobs that just need to happen "about daily," drop an executable script into /etc/cron.daily/ (or .hourly, .weekly, .monthly) and cron runs it — no schedule line at all.

    Example — not runnable on your lab box

    crontab -l
    sudo crontab -u alice -l
    0 2 * * 1-5 /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
    no crontab for alice
    -e
    edit your crontab in $EDITOR; installed atomically on save
    -l
    list your crontab to stdout
    -r
    remove your whole crontab — no confirmation, no undo
    -u alice
    (root only) act on another user's crontab
    -l lists without opening an editor — the safe first look. Root can inspect (or edit) any user's crontab with -u; auditing who has scheduled what is a standard security sweep. The dangerous sibling is -r: it removes your ENTIRE crontab, immediately, with no confirmation — and it sits right next to -e on the keyboard.
    ( crontab -l 2>/dev/null; echo '*/5 * * * * /usr/local/bin/health-check.sh >> /var/log/hc.log 2>&1' ) | crontab -
    The scriptable append: list the current crontab (2>/dev/null hides the 'no crontab' complaint if it's empty), add a line, pipe the whole thing back in. Note the redirect on the job itself: cron emails a job's output by default, which in practice means output vanishes. Send it to a log (>> file 2>&1) — the 2>&1 captures stderr too, so FAILURES are visible. A cron job without redirected output is a job you can't debug.

    Check yourself

    A backup script runs fine when you execute it manually, but its cron job produces nothing — no backup, no error. Name the two most likely causes.

  4. at: a one-time job

    Schedule something to run once, then verify and cancel from the queue

    For a job that should happen exactly once — tonight's migration, a reminder to re-enable monitoring in two hours — a recurring scheduler is the wrong shape. at takes a time and commands on stdin, runs them once as you, and forgets them. It leans on a small daemon, atd, which must be running (systemctl enable --now atd — your systemctl knowledge already covers it).

    Its party trick is friendly time syntax: at 14:30, at 10:00 AM Dec 25, at now + 30 minutes, at now + 2 hours, even at teatime (16:00 — really).

    Example — not runnable on your lab box

    echo '/opt/scripts/migrate.sh' | at 02:00 tomorrow
    warning: commands will be executed using /bin/sh
    job 7 at Wed Jul 11 02:00:00 2026
    at reads the commands from stdin (or a file with -f) and answers with a job id and the resolved fire time — read that time back; it's your confirmation the phrase parsed the way you meant. The /bin/sh warning matters: like cron, at runs with a plain shell and minimal environment, so the absolute-path habit applies here too.

    Example — not runnable on your lab box

    atq
    atrm 7
    atq
    7	Wed Jul 11 02:00:00 2026 a root
    
    atq
    list pending one-time jobs (id + scheduled time)
    atrm N
    remove pending job N (alias: at -r N)
    -c N
    (at) print job N's full contents, environment included
    atq lists the queue (id, fire time, queue, user); atrm cancels by id; the second atq confirms it's gone — schedule, verify, cancel, verify, the full lifecycle. To see exactly what a pending job will run (environment and all), at -c 7 prints its full contents. Access is controlled by /etc/at.allow and /etc/at.deny, the same model cron uses.

    Check yourself

    Which tool schedules a command to run exactly ONCE at a future time — and which service must be running for it to work?

  5. systemd timers: the modern way

    Build a timer + service pair and verify when it fires

    A systemd timer splits the job in two, and the split is the point: a `.service` unit says what to do, a `.timer` unit says when. The names must match — backup.timer triggers backup.service. Why two files? Because each half gets systemd's full machinery: the service gets logging (journalctl -u backup.service), dependencies (After=network-online.target), and resource limits; the timer gets calendar syntax, catch-up behaviour, and a queryable next-fire time. Cron gives you none of those without duct tape.

    Example — not runnable on your lab box

    cat /etc/systemd/system/backup.timer
    [Unit]
    Description=Run backup daily
    
    [Timer]
    OnCalendar=*-*-* 02:30:00
    Persistent=true
    
    [Install]
    WantedBy=timers.target
    OnCalendar is the schedule. The format is year-month-day hour:minute:second with as wildcards: -- 02:30:00 = daily at 02:30; Mon -- 09:00:00 = Mondays at 9; :0/15 = every 15 minutes; and the shortcuts hourly/daily/weekly work too. The matching backup.service is a normal unit with Type=oneshot and ExecStart=/opt/scripts/backup.sh. Alternative triggers exist for relative timing: OnBootSec=5min (after boot), OnUnitActiveSec=1h (after the last run — a self-spacing job).

    Example — not runnable on your lab box

    sudo systemctl daemon-reload
    sudo systemctl enable --now backup.timer
    systemctl list-timers backup.timer --no-pager
    NEXT                        LEFT     LAST PASSED UNIT         ACTIVATES
    Fri 2026-07-11 02:30:00 UTC 11h left -    -      backup.timer backup.service
    The activation ritual: daemon-reload so systemd reads the new unit files (you know this from systemctl), then enable --now the TIMER — not the service; the timer starts the service on schedule. list-timers is the verification: NEXT shows exactly when it will fire, LAST shows the previous run. If NEXT is empty or wrong, your OnCalendar expression didn't mean what you thought — which is why you validate it first with systemd-analyze calendar (next step).

    Check yourself

    Which timer setting makes a job run at next boot if the system was off when it was scheduled — and what does plain cron do in the same situation?

  6. Now do it — schedule, verify, and clean up in your live terminal

    Run the schedule-verify-cancel loop a "schedule this job" lab grades

    Scheduling has a special failure mode: you don't find out you got it wrong until the moment passes. That makes the verify step — reading back what the scheduler actually understood — the professional habit this practice drills. Schedule things, read them back, clean them up.

    On this lab box the cron and calendar reads below are the real thing: crontab writes and reads back, and systemd-analyze calendar decodes an expression for you. The box runs no init daemon, though, so nothing scheduled here ever fires — the at block is shown rather than run, and systemctl list-timers has no system bus to ask. Drill the schedule-then-read-back loop; take the firing on trust until you are on a real host.

    crontab -l 2>/dev/null; systemctl list-timers --all --no-pager | head
    Survey what's already scheduled: your crontab (likely empty) and every timer with its next-fire time. On an unfamiliar machine this pair of commands is how you discover its automated life.
    systemd-analyze calendar 'Mon *-*-* 09:00:00' --iterations=3
    Validate BEFORE trusting: this decodes a calendar expression and previews the next three real fire times. If the preview doesn't match your intent, you just saved yourself a silent mis-schedule. Try changing it to '*:0/15' and watch the preview shift to every-15-minutes.
    ( crontab -l 2>/dev/null; echo '0 2 * * 1-5 /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1' ) | crontab -
    crontab -l
    Append a weekdays-at-02:00 job carrying both reliability habits — absolute path, output redirected with stderr — then immediately read it back. Decode the listed line aloud, field by field: minute 0, hour 2, any date, any month, Mon–Fri.

    Example — not runnable on your lab box

    echo 'touch /tmp/at-proof' | at now + 1 minute
    atq
    sleep 70 && ls -l /tmp/at-proof
    The full one-shot lifecycle in ~70 seconds: schedule a tiny job, see it in the queue, wait past its moment, and find the file it created. Run atq again — the queue is empty; the job ran once and forgot itself. That's at's whole contract, witnessed end to end.
Recap
  • Pick by the job's shape: at = once then forgotten; cron = recurring on a clock, simple and universal; systemd timer = recurring/one-shot with logs, dependencies, and Persistent=true catch-up after downtime.
  • cron is five fields — minute hour day-of-month month day-of-week — then the command. Decode field-by-field, left to right. Setting both day-of-month AND day-of-week is an OR, not an AND.
  • System cron (/etc/crontab, /etc/cron.d/) adds a sixth field: the user to run as. /etc/cron.daily and friends run any script you drop in.
  • Cron runs with a minimal environment and mails output to nobody: absolute paths (or set PATH), and redirect every job's output with >> log 2>&1 — or failures are invisible.
  • A timer is a .timer + matching .service; enable the TIMER, verify with list-timers, and validate OnCalendar expressions with systemd-analyze calendar before trusting them.
Scheduler → when to use it
ToolRecurring?Manage withLogsMissed-run catch-up
cronYescrontab -e / /etc/cron.demail → redirect to a fileNo — skipped
atNo (one-shot)at TIME · atq · atrmemailn/a
systemd timerYes / one-shot.timer+.service · list-timersjournalctl -u svcYes — Persistent=true