Baseline systemd Service and Timer Setup for a Scheduled Job

A systemd timer is two small files and a great deal of capability almost nobody uses. The crontab line most teams reach for gives you scheduling and nothing else: no logging, no catch-up after downtime, no isolation, no record of whether the job succeeded.

This is the baseline I reach for on Ubuntu 24.04, which ships systemd 255. It is a oneshot service that does the work, plus a timer that decides when. The service runs as a dedicated user, in a sandbox, and sends its output straight to the journal. You verify it with systemctl list-timers and read the results with journalctl. The whole thing is auditable, which matters more than the scheduling itself when a supervisor or an auditor asks what ran, when, and whether it worked.

Two units, one job

systemd splits the concern in two. The .service unit describes what to run; the .timer unit describes when. They share a name — report.service is triggered by report.timer by convention — so you never wire them together explicitly. Here is the service, at /etc/systemd/system/report.service:

Free · 4 minutes

Is your engineering team shipping safely, or quietly accumulating risk?

Fourteen questions on how work gets from idea to production — cadence, testing, rollback, and the key-person risk in your delivery. Banded finding on screen, full sheet by email.

[Unit]
Description=Generate the nightly reconciliation report
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/opt/report/bin/run-report --output /var/lib/report

# Run as a throwaway user systemd creates and destroys per invocation
DynamicUser=true
StateDirectory=report

# Sandboxing: read-only filesystem except the paths we grant
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ProtectKernelTunables=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6

# Tag journal lines so they are easy to filter
SyslogIdentifier=report

Type=oneshot is the key. It tells systemd the process is expected to run to completion and exit, rather than staying resident like a daemon. systemd treats the unit as active only while it runs, and records the exit status. There is no [Install] section on the service, because you never enable the service directly — the timer starts it.

The timer, and what Persistent actually buys you

The companion file lives at /etc/systemd/system/report.timer:

[Unit]
Description=Schedule the nightly reconciliation report

[Timer]
# Every day at 02:15
OnCalendar=*-*-* 02:15:00

# If the machine was off at 02:15, run on next boot
Persistent=true

# Spread the load: fire up to 5 minutes after the scheduled time
RandomizedDelaySec=300

# Widen the accuracy window so systemd can batch wake-ups
AccuracySec=1min

[Install]
WantedBy=timers.target

OnCalendar uses systemd’s calendar syntax. *-*-* 02:15:00 means every day at 02:15; you can validate any expression with systemd-analyze calendar "*-*-* 02:15:00", which prints the next elapse times before you commit.

Persistent=true is the directive cron cannot match. systemd records the last time the timer fired on disk, under /var/lib/systemd/timers. If the machine is off or asleep when 02:15 comes round, the job runs once, immediately, on the next boot — instead of being silently skipped until the following night. For a reconciliation or backup job, a missed run is exactly the failure you least want to be invisible.

RandomizedDelaySec delays each trigger by a random interval, evenly distributed between zero and the value you set. If you have fifty hosts all scheduled at 02:15, that stops them stampeding a shared database or object store at the same instant — the thundering-herd problem, solved with one line. AccuracySec lets systemd batch timer wake-ups to save power; tightening it to a few seconds only matters if you genuinely need sub-minute precision.

Why run it as a dedicated user in a sandbox

A cron job typically runs as root or as whoever owns the crontab, with full access to the filesystem. That is a large blast radius for a job whose only task is to write one report. DynamicUser=true tells systemd to allocate a transient, unprivileged user for the duration of the run and remove it afterwards — no useradd, no dangling account, no shared credentials to leak.

The sandbox directives narrow what that user can touch. ProtectSystem=strict mounts the entire filesystem read-only, so the job cannot modify anything it was not explicitly granted. StateDirectory=report then carves out one writable, correctly-owned directory at /var/lib/report — created for you, owned by the dynamic user, surviving between runs. PrivateTmp=true gives the process its own /tmp, invisible to and from everything else on the box. NoNewPrivileges=true stops the process ever escalating, even via a setuid binary. This is the same discipline that belongs in any baseline that actually runs in CI rather than sitting in a wiki: a default that is safe on the day someone forgets to think about it.

When you have the units in place, ask systemd to grade them: systemd-analyze security report.service scores the unit and lists every hardening directive you have not yet applied. Treat it as a checklist, not a target — some directives will break a job that genuinely needs the network or a specific syscall.

Enable it, then verify it exists

Reload the unit files, enable the timer so it survives reboots, and start it now:

sudo systemctl daemon-reload
sudo systemctl enable --now report.timer

# When will it next fire? When did it last?
systemctl list-timers report.timer

# Force a run now, to prove the service works end to end
sudo systemctl start report.service

# Read the output — no log file to hunt for
journalctl -u report.service -n 50 --no-pager

systemctl list-timers is the single most useful command here. It shows the next elapse time, the last trigger, and the unit each timer activates — the answer to “is this thing actually scheduled?” without reading a crontab that may or may not reflect reality. Because the service writes to stdout and stderr, everything it prints lands in the journal automatically, tagged with the SyslogIdentifier you set, timestamped, and rotated. There is no logfile to create, chmod, or forget to rotate.

The part that matters for regulated work

The scheduling was never the hard part — cron did that in 1975. What this baseline gives you that a crontab line does not is evidence: a recorded exit status for every run, journal output you can query and ship to a central store, a catch-up guarantee for missed runs, and a failure signal you can alert on with OnFailure= pointing at a notification unit. That is the difference between a job that runs and a job you can prove ran, which is the same distinction that separates a real deployment metric from a vanity one when you come to instrument your delivery pipeline.

One caveat worth stating plainly: a timer guarantees the job is started, not that it is safe to start twice. If Persistent=true fires a catch-up run while a delayed one is still queued, the job needs to tolerate that — which is why anything that mutates shared state should be built to be idempotent and safe to retry. Get that right, and two small files replace a fragile crontab line with something you can hand an auditor.

Free interactive tool

Website compliance checklist

What your site has to do, based on what it actually does

Answer as much or as little as you like — the list builds as you go. Nothing is stored against your name and no email is required.

Most technology problems are not technology problems. They are control problems.

The systems exist. The investment has been made. The question is whether leadership can understand, direct, evidence, and sustain what those systems produce. Find out where control exists — and where it only appears to.