Skip to content
HN On Hacker News ↗

You Don't Love systemd Timers Enough

▲ 396 points 300 comments by yacin 3w ago HN discussion ↗

Pangram verdict · v3.3

We believe that this document is fully human-written

0 %

AI likelihood · overall

Human
100% human-written 0% AI-generated
SEGMENTS · HUMAN 5 of 5
SEGMENTS · AI 0 of 5
WORD COUNT 1,641
PEAK AI % 0% · §5
Analyzed
Jun 2
backend: pangram/v3.3
Segments scanned
5 windows
avg 328 words each
Distribution
100 / 0%
human / AI fraction
Verdict
Human
Pangram v3.3

Article text · 1,641 words · 5 segments analyzed

Human AI-generated
§1 Human · 0%

« You Don't Love systemd Timers Enough 5 May, 2026 2,139 words 9 minute read time

My favorite metonymic technology term is "cron job": even though cron may not literally be the daemon that executes actions on a schedule, we apply the term to anything that walks like a cron and quacks like a cron. As Patrick McKenzie likes to point out, cron jobs are one of the most eminently useful computing primitives. They offer utility that's almost immediately obvious for plenty of use cases that almost everybody has: do this every day; do that once a month.

And yet. You probably shouldn't use literal cron (or its more modern cousins) for scheduled tasks! In 2026 there are more modern options available, and my favorite is the humble systemd timer. I love systemd timers. If you don't love them yet, maybe I can show you the reasons why you should love them, too.

My cron? Cooked?

A systemd timer is a type of unit that schedules other units (usually a service) on a particular schedule. (How a systemd service unit works is another article, but you can logically consider the .service target of a systemd timer to be a script.) Timers are effectively a functional replacement for a traditional cron daemon (though you could conceivably run both), and timer calendar settings offer some similarities to help bridge the gap from traditional cron-like expressions.

At this point the systemd haters peer out of the woodwork in anticipation of torpedoing timers because they are part of the systemd project and because they replace mature (if clunky) technology. I'd rather not spend our time arguing about cron, so briefly consider why newer solutions like systemd timers that benefit from years of hindsight are better:

Ambiguous $PATH settings make cron script execution difficult to predict. stdout and stderr output often ends up in a black hole (and, often, sent to the host's mail system, which is usually not what you want to happen.) Execution history is difficult to follow and interrogate.

§2 Human · 0%

You might feel cool knowing the scheduling grammar by heart, but 01,31 04,05 1-15 1,6 * isn't easy or intuitive for humans to read.

Incidentally, timers solve all these problems (and more.)

Prime Time for a Timer Primer

We can cover the basics without a lot of ceremony. First you need a target for a timer to execute. On a Linux host with systemd operational, placing the following unit contents at /etc/systemd/system/roulette.service installs a service with a 1 in 10 chance to be free (i.e., shut down your computer):

SystemdFont used to highlight strings. Font used to highlight keywords. Font used to highlight type and class names. [Unit] Description=1 in 10 chance to break your chains

[Service] ExecStart=/usr/bin/env bash -c '[[ $(($RANDOM % 10)) == 0 ]] && systemctl poweroff || echo LIVE ANOTHER DAY'

Update: [2026-05-05 Tue]

Twitter mutual HSVSphere points out that the service option ExecCondition= offers a native way to handle conditional execution. This is a more tightly-integrated way to express "should I continue to execute?" and I agree that it offers a clearer way to express intent at the unit level (I'm using absolute paths here for a NixOS system):

SystemdFont used to highlight strings. Font used to highlight keywords. Font used to highlight type and class names. [Unit] Description=1 in 10 chance to break your chains

[Service] ExecCondition=/run/current-system/sw/bin/bash -c '[[ $(($RANDOM % 10)) == 0 ]]' ExecStart=/run/current-system/sw/bin/systemctl poweroff

This has the same effect as the prior bash conditional, and you end up with different wording in the journal that (in my opinion) expresses the situation more clearly for you when the condition is met:

May 05 11:05:32 diesel systemd[3117]: Condition check resulted in 1 in 10 chance to break your chains being skipped.

In general, leaning into the options that systemd presents is a better experience than scripting your own. (

§3 Human · 0%

Another example would be to use OnFailure= to react when your service scripts fail or Restart= to attempt recovery in the case of ephemeral failures.)

Associate that service with a timer by placing a file with the same file stem (roulette) at /etc/systemd/system/roulette.timer:

SystemdFont used to highlight keywords. Font used to highlight type and class names. [Unit] Description=impending destruction

[Timer] OnCalendar=10:00

[Install] WantedBy=timers.target

What I mean by associate is that, by default, a timer's Unit= setting will choose a service unit with a matching stem suffixed by .service. In this case, roulette.service. You can always change this if you want to execute a service with a different unit name.

I want to call out a few things right away:

Per normal service unit semantics, the ExecStart= target does not run as a shell command by default. You should treat the absolute path target like a script or, in our case, an interpreter that expects a script as a string argument. For example, ExecStart=/usr/bin/echo Hello | /usr/bin/awk straight-up won't work; the pipe makes no sense in context here. The ExecStart= argument does not inherit any environment variables by default (outside of some system manager defaults), so we begin with a pretty bare $PATH by default. Executing /usr/bin/env is a shortcut to ensure things like systemctl are available, but out of the box, you get a clean state to begin with. If we had used a bare ExecStart=/usr/bin/bash, we'd have the basics in $PATH, but using env here is an extra safeguard.

You can roll the dice without the aid of the timer at all:

shell systemctl start roulette

Although note that you cannot enable this service without any usable [Install] section: our timer is the canonical way to make the service run in a consistent way. Also useful to highlight that systemctl operates on roulette.service by default without any explicit suffix.

When applied to a .timer unit, the systemctl start subcommand puts it on the clock, per se, but does not actually execute the Unit= target.

shell systemctl start roulette.timer

The timer is now active, but not the service.

§4 Human · 0%

Depending on the moment in time, status will tell you when to next expect the timer to decide your fate:

shell systemctl status roulette.timer

You'll see plenty of information about the timer on its status page, including the next time it'll fire:

Trigger: Sat 2026-04-18 10:00:00 MDT; 35min left

That's the simplest timer onboarding: create a target, place the target service file alongside a timer with a schedule, and start the timer (not the target) to get the schedule started. Because the .timer defines an WantedBy= within [Install], we can ensure the timer comes up at boot time too, not just when we start it:

shell systemctl enable roulette.timer

Let's move on past the basics.

Time Lord

Arguably the most important bit of information about timers is how to express a schedule, whether a repeating period of time (which the manual usually refers to as a time span) versus a calendar event (or a timestamp). Fortunately, I think the man page for this under systemd.time(7) is actually very good with plenty of examples. You should use it as the first resource when writing timers; it's good (or better) than, uh, casual blog posts by casual writers.

systemd also ships with a command-line tool called systemd-analyze which includes the ability to validate and explain time expressions from the command line directly in an imperative way to help understand them. You can even disambiguate the classic wildcard cron expression which systemd-analyzer can parse and then explain to you, complete with the expected execution times:

shell systemd-analyze calendar '*-*-* *:*:*'

Normalized form: *-*-* *:*:* Next elapse: Sat 2026-04-18 16:44:26 MDT (in UTC): Sat 2026-04-18 22:44:26 UTC From now: 431ms left

This blog post is not the place to reproduce the entirety of systemd.time(7) verbatim, so I encourage you to Read The Helpful Manual (RTHM).

§5 Human · 0%

Writ small, you can pretty simply define either a recurring wallclock period or, in contrast to plain old cron, a recurring period of time against some previous event.

The first category of time expressions is easy to envision. For example, in fully-qualified form, daily means:

*-*-* 00:00:00 │ │ │ │ │ ╰── at second 00 │ │ │ │ ╰───── at minute 00 │ │ │ ╰──────── at hour 00 │ │ ╰────────── every day │ ╰──────────── every month ╰────────────── every year

You can use shorthand terms like daily, write out the complete form, or use any other supported value listed out in systemd.time(7) and subsequently validate your assumptions against systemd-analyze.

The second category of time expressions apply to "run this relative to some other event." This distinction from "run at the same time very day" is very often what you actually want. Consider a job that clears out a temporary directory, for example: if a cron expression lapsed right after boot, there probably isn't much to clean out of /tmp at all. But if you encode "execute an hour after my computer has started and then every hour after that", the schedule logic is meaningful for what the related service is actually doing.

This is easy to do in a timer:

SystemdFont used to highlight keywords. Font used to highlight type and class names. [Timer] OnBootSec=1h OnUnitActiveSec=1h

That is: "run an hour after the machine starts" (which will execute once) and also "run one hour after my Unit= runs" (which implicitly makes the timer repeat indefinitely.)

Periodic time spans like this fit the "every once in a while" use case surprisingly more often than "run at this minute every hour" and similar expressions. Another good example is a timer I use every December to poll the Advent of Code API for a Slack bot I wrote for some friends. The */15 cron expression honors the "every 15 minutes" policy that their API requests, but since that's the easiest way to express it in cron language, I'm sure it makes spiky traffic alongside everyone else polling the API!