Cron to systemd timers: my cron job never ran because cron wasn't installed
A file in /etc/cron.d that nothing ever read: cron was missing on Debian 13. How to convert a crontab line to a systemd timer, step by step, and the traps.
On September 20 I set up a weekly security scan on my VPS: Trivy against the Docker images, a few host drift checks, and a Telegram alert when something new shows up. I tested the whole chain by hand and got the alert. To schedule it, I dropped a line into /etc/cron.d/scan-securite: Mondays, 07:00 UTC. As boring as it gets.
On the 23rd, while checking things, I found out the cron package was not installed on this Debian 13 box. systemctl is-active cron returned inactive. The file in /etc/cron.d/ was there, correctly formatted, and nothing was reading it. Monday the 21st at 07:00 had come and gone without a scan. No error, no log line, nothing: a config file with no program to read it doesn’t complain.
I replaced the cron line with a systemd timer. This post shows how to turn a crontab line into a .timer + .service pair, what you gain, and the four traps I hit or nearly hit.
The short version
- The problem: a job declared in a cron file only runs if the cron program is installed and running. Otherwise nothing happens, and nothing tells you.
- The fix: use a systemd timer, already there on any distribution that boots with systemd, which gives you a log, a state and catch-up of missed runs.
- The check:
systemctl list-timersmust show the next run. A file on disk proves nothing.
Three things to know first
- Daemon. A program that runs permanently in the background. Cron is one: it reads its files and starts jobs on time.
- systemd. The program that boots Debian, Ubuntu and most distributions, and manages every service. It can also run jobs on a schedule.
- Unit. A systemd configuration file. A
.servicedescribes what runs, a.timerdescribes when.
A picture: a letterbox. Dropping a file into /etc/cron.d/ is posting a letter. If no postman ever comes, the letter stays there, correctly addressed, and nobody tells you it never left.
Why a cron job can silently never run
Cron is a daemon. Files in /etc/cron.d/, /etc/crontab or /var/spool/cron/ are just data it reads. No daemon, no execution, and nobody around to report it.
Had I used crontab -e, I’d have had a clue: crontab: command not found. But writing straight into /etc/cron.d/ with sudo tee never calls any cron tool. The file is created like any other text file.
The Debian 13 image my hosting provider ships didn’t include cron. A Debian installed from the ISO with “standard system utilities” usually does, so don’t assume: check.
# Is the package installed?
dpkg-query -W -f='${Status}\n' cron
# Is the daemon running?
systemctl is-active cron
On my VPS:
$ dpkg-query -W -f='${Status}\n' cron
unknown ok not-installed
$ systemctl is-active cron
inactive
Watch out with the first one: it exits 0 even when the package is missing. The text is what counts (install ok installed when it’s there), not the exit code. dpkg -s cron is the one that errors out.
I could have run apt install cron and moved on. But systemd is already there as PID 1, and my nightly backups already ran from a timer. One scheduler to watch is better than two.
What systemd timers give you over cron
- A log per job. Everything the script writes to stdout and stderr lands in the journal:
journalctl -u scan-securite. No more>> /var/log/whatever.log 2>&1orMAILTO. - A state.
systemctl status scan-securite.servicetells you when the job ran, how long it took, and its exit code. A failure puts the unit infailed, visible insystemctl --failed. - Catch-up. With
Persistent=true, if the machine was off at the scheduled time, the job runs at next boot. Cron just skips it. - Jitter.
RandomizedDelaySec=adds a random delay so every job doesn’t fire at the same second. - Dependencies.
After=docker.serviceandRequires=docker.service: the job won’t start before Docker. - Manual runs identical to scheduled runs.
systemctl start scan-securite.serviceruns the job with exactly the environment the timer uses. No more “works in my shell, fails in cron” because of a differentPATH. - An overview.
systemctl list-timersshows every schedule, with last and next run.
The cost: two files instead of one line, and a calendar syntax to learn. Fair trade.
Tutorial: convert a crontab line to a systemd timer
My starting point, the line in /etc/cron.d/scan-securite (cron.d format, with a user field):
# minute hour day-of-month month day-of-week user command
0 7 * * 1 root /srv/outils/scan-securite.sh
In plain words: every Monday at 07:00 server local time (UTC on my box), as root.
1. The service: what runs
The actual file on my VPS (comments in French: the script exits 1 as soon as it finds something to report; that’s not a service failure, the alert has already gone to Telegram):
# /etc/systemd/system/scan-securite.service
[Unit]
Description=Controle de securite hebdomadaire
[Service]
Type=oneshot
User=root
ExecStart=/srv/outils/scan-securite.sh
# Le script sort en code 1 des qu'il a trouve quelque chose a signaler.
# Ce n'est pas un echec du service : l'alerte est deja partie sur Telegram.
SuccessExitStatus=1
Nice=10
IOSchedulingClass=idle
TimeoutStartSec=30min
A few notes:
Type=oneshot: the service is a task that starts, works and exits. systemd waits forExecStartto finish before considering it done.- No
[Install]section: this service is never enabled directly, the timer triggers it. User=root: the scan needs root (it reads the SSH config, talks to Docker and ufw). That’s the default, but writing it makes the choice visible. For a job that doesn’t need root, useUser=deploy: my backups run that way.Nice=10andIOSchedulingClass=idle: the scan yields to the rest of the server, CPU and disk.TimeoutStartSec=30min: withType=oneshotthere is no default timeout, and a stuck script stays “activating” forever.- No dependency on Docker (
After=docker.service): the scan queries Docker, but if Docker isn’t up, the scan should report it, not wait quietly.
SuccessExitStatus=1 gets its own section below.
2. The timer: when it runs
# /etc/systemd/system/scan-securite.timer
[Unit]
Description=Declenche le controle de securite hebdomadaire
[Timer]
OnCalendar=Mon *-*-* 07:00:00 UTC
Persistent=true
RandomizedDelaySec=5min
[Install]
WantedBy=timers.target
By default a timer activates the service with the same name (scan-securite.timer → scan-securite.service). Use Unit= to point elsewhere.
Crontab to OnCalendar cheat sheet (format DayOfWeek Year-Month-Day Hour:Minute:Second):
| cron | OnCalendar |
|---|---|
0 7 * * 1 |
Mon *-*-* 07:00:00 |
30 2 * * * |
*-*-* 02:30:00 |
0 5 2,16 * * |
*-*-02,16 05:00:00 |
*/15 * * * * |
*:0/15 |
0 0 * * 0 |
Sun *-*-* 00:00:00 |
There are shorthands too: daily, weekly, monthly. Careful: weekly means Monday at midnight, local time.
3. Check the expression before installing it
systemd-analyze calendar normalizes the expression and computes the next runs. Think of it as a crontab simulator that ships with your OS:
systemd-analyze calendar --iterations=2 'Mon *-*-* 07:00:00 UTC'
Normalized form: Mon *-*-* 07:00:00 UTC
Next elapse: Mon 2026-09-28 07:00:00 UTC
From now: 1 day 9h left
Iteration #2: Mon 2026-10-05 07:00:00 UTC
From now: 1 week 1 day left
An invalid expression is rejected right away, instead of you finding out on a Monday.
4. Install and enable
sudo systemctl daemon-reload
# Test the service by hand FIRST: same environment as the timer
sudo systemctl start scan-securite.service
systemctl status scan-securite.service --no-pager
journalctl -u scan-securite.service -n 20 --no-pager
# Then enable the TIMER (not the service)
sudo systemctl enable --now scan-securite.timer
On my server the manual run exited successfully and the journal showed the script’s last line: scan-securite: rien de nouveau a signaler (“nothing new to report”). The script writes it to stderr; systemd files it in the journal with zero configuration.
5. Confirm the timer is actually armed
systemctl list-timers scan-securite.timer
On my server on September 29:
NEXT LEFT LAST PASSED UNIT ACTIVATES
Mon 2026-10-05 07:04:38 UTC 5 days Mon 2026-09-28 07:00:23 UTC 1 day 2h ago scan-securite.timer scan-securite.service
LAST: Monday the 28th’s run did happen. NEXT: the next one is at 07:04:38, not 07:00:00, because of RandomizedDelaySec=5min, drawn at random each time.
You should get one row with the NEXT column filled in. If the timer isn’t listed, it isn’t active, whatever file sits on disk. That is the whole lesson of the cron episode: a schedule file proves nothing, only the scheduler’s state does.
6. Remove the old schedule
sudo rm /etc/cron.d/scan-securite
Inert or not, I deleted it: the day someone installs cron, the scan would run twice.
SuccessExitStatus: don’t confuse an alert with a crash
My script has three outcomes:
exit 0: nothing new, no alert;exit 1: it found something and sent the alert;exit 2: it found something but the notification URL is missing from its config, so nobody gets told.
Without SuccessExitStatus=1, every alert would flip the service to failed. Then failed would mean either “the scan did its job and found a problem” or “the scan crashed”. A state that mixes both is unreadable, and you end up ignoring it.
With that line, failed means one thing only: the script couldn’t do its job (crash, exit 2, killed). The September 28 run shows it: the scan found something to report, sent its alert and exited 1. systemctl status the next day (excerpt):
○ scan-securite.service - Controle de securite hebdomadaire
Loaded: loaded (/etc/systemd/system/scan-securite.service; static)
Drop-In: /etc/systemd/system/scan-securite.service.d
└─heartbeat.conf
Active: inactive (dead) since Mon 2026-09-28 07:00:42 UTC; 1 day 2h ago
TriggeredBy: ● scan-securite.timer
Main PID: 1607249 (code=exited, status=1/FAILURE)
status=1/FAILURE reports the exit code, but the state is inactive (dead), not failed: as far as systemd is concerned, the job ended normally. static means “no [Install] section”, and TriggeredBy shows the timer that fires it. It’s also what lets you hook monitoring on cleanly: ExecStartPost= only runs if ExecStart succeeded, SuccessExitStatus codes included.
Details in the systemd.service documentation.
systemd timer gotchas
OnCalendar and time zones
Without an explicit zone, OnCalendar uses the machine’s local time zone. So does cron, but it’s easy to forget when you change a server’s zone or copy a timer from one box to another.
So I always put the zone in the expression: UTC for a fixed hour, or an IANA name to follow daylight saving time:
systemd-analyze calendar 'Mon 07:00 Europe/Paris'
Original form: Mon 07:00 Europe/Paris
Normalized form: Mon *-*-* 07:00:00 Europe/Paris
Next elapse: Mon 2026-09-28 05:00:00 UTC
From now: 1 day 7h left
The syntax is documented in systemd.time(7).
Persistent=true: catch-up, nothing more
Persistent=true stores the last trigger time on disk. At boot, if a run was missed while the machine was down, the service starts right away. For a backup or a scan, that’s what you want.
Two caveats:
- Catch-up only applies to
OnCalendar=timers. Relative timers (OnBootSec=,OnUnitActiveSec=) don’t get it. - It catches up once. A machine that was off for three weeks runs the scan once at boot, not three times.
And obviously it catches up nothing if the timer isn’t active. Which leads to the next trap.
Enable the .timer, not the .service
Muscle memory from every other service: systemctl enable --now scan-securite.service. Two problems:
-
enabledoes nothing but print a warning, because the service has no[Install]section. Here it is, and the command still exits 0 (excerpt):The unit files have no installation config (WantedBy=, RequiredBy=, UpheldBy=, Also=, or Alias= settings in the [Install] section, and DefaultInstance= for template units). This means they are not meant to be enabled or disabled using systemctl. -
--nowruns the scan once, immediately, which makes it look like everything works.
The timer stays inactive and nothing else ever happens. Same silence as the orphaned cron file. What you enable is the .timer. Checking with systemctl list-timers is not optional.
Another classic: editing the .timer and forgetting systemctl daemon-reload. systemd keeps using the old version it has in memory.
Secrets: EnvironmentFile with mode 600, not Environment=
My scan needs a Telegram webhook URL, which embeds the bot token. Tempting: Environment=NOTIFICATION_WEBHOOK_URL=... in the .service. Bad idea: files in /etc/systemd/system/ are world-readable, and systemctl show prints the variables.
The right way: a separate file, readable by root only.
sudo install -m 600 -o root -g root /dev/null /etc/scan-securite.env
sudoedit /etc/scan-securite.env
# In the .service, [Service] section
EnvironmentFile=/etc/scan-securite.env
The result on my server:
$ ls -l /etc/scan-securite.env
-rw------- 1 root root 200 Sep 23 23:29 /etc/scan-securite.env
systemd itself (so root) reads that file before starting the process: it can stay 600 root:root even when the service runs with User=deploy. With a leading dash (EnvironmentFile=-/etc/...), a missing file isn’t an error.
In my case the script also reads that file itself (handy for manual runs with --tout), and I later added the heartbeat URL to it, loaded by a scan-securite.service.d/heartbeat.conf drop-in that uses EnvironmentFile= for exactly this reason.
What if the timer itself stops running?
Everything above improves visibility on the machine. But the original failure, a job that doesn’t run, by definition produces no log and no local alert. The only reliable detection comes from outside: the job reports each successful run, and a missing report triggers the alert. I wired the scan up that way the next day; I cover it in a separate post on monitoring cron jobs with heartbeats.
Key takeaways
- A file in
/etc/cron.d/proves nothing: with no cron daemon, nothing happens and nothing tells you. Check withsystemctl is-active cron. - A systemd timer is a
.service(Type=oneshot, what runs) plus a.timer(OnCalendar=, when). Test the expression withsystemd-analyze calendar. - Enable the
.timerwithenable --now, never the.service, then confirm withsystemctl list-timers. - Put the time zone in
OnCalendar=, and usePersistent=trueto catch up a missed run. SuccessExitStatus=separates “the job found a problem” from “the job crashed”.- Secrets go in a mode-600
EnvironmentFile=, never in the unit file. - A job that stopped running can only be caught from outside, with a heartbeat.