On this page

The systemd timers guide covered jobs that run on a clock. This is the other half of that pair: the service unit, the small text file that turns a script into something systemd starts, supervises, and logs.

Everything here was checked on Ubuntu 26.04, Fedora 44, and Arch in October 2026, across systemd 259 and 261. A few journal lines change wording between releases, and those spots are flagged as they come up.

The examples create unit files under /etc/systemd/system/, scripts under /usr/local/bin/, and a throwaway user, so run them on a machine where that is welcome.

What a unit actually is

A unit is a small text file describing one thing systemd manages. Service units end in .service and use three sections: [Unit] carries the description, [Service] says what to run, and [Install] is the boot wiring that systemctl enable acts on. A unit without [Install] is legal.

Unit files live in two places:

  • /etc/systemd/system/ — yours. Every unit in this guide goes there.
  • /usr/lib/systemd/system/ — the distro’s, hundreds of them. Read them for examples. Never edit anything there: package upgrades overwrite your edits.

The suffix is optional on the command line: systemctl start hello and systemctl start hello.service are the same command, because systemd assumes .service for a name with no dot. On disk, keep the full name.

The smallest service that can run

A script first:

#!/bin/bash
# /usr/local/bin/run.sh
echo "hello at $(date +%T)"

Make it executable with sudo chmod +x /usr/local/bin/run.sh, then give it a unit in /etc/systemd/system/hello.service:

[Unit]
Description=Say hello

[Service]
Type=oneshot
ExecStart=/usr/local/bin/run.sh

Type=oneshot means the service runs once and exits. Reload, start, and read the proof in the journal:

$ sudo systemctl daemon-reload
$ sudo systemctl start hello.service
$ journalctl -u hello.service
Oct 05 10:02:11 linuz-ubuntu run.sh[3512]: hello at 10:02:11

(The PID is from the capture; yours will differ.)

Whatever the script writes to stdout or stderr lands in the journal, and journalctl -u NAME filters by unit. That is the proof technique for this whole guide, and the reason every example script ends in an echo.

That first daemon-reload was ritual, not necessity. A brand-new unit starts fine with no reload; systemd has never seen the file. Editing a unit it already has in memory, active or failed, is different: the old version keeps running and your changes sit idle until the next reload. Say you edit hello’s file after that first start and skip the reload. The next command on the unit warns you:

$ sudo systemctl start hello.service
Warning: The unit file, source configuration file or drop-ins of hello.service changed on disk. Run 'systemctl daemon-reload' to reload units.

The warning means the file on disk and the unit in memory disagree, and it names the cure:

$ sudo systemctl daemon-reload

Make daemon-reload the after-every-edit step, and that family of “why did my fix do nothing” mysteries never comes up.

How systemd decides you’re “running”

For Type=oneshot the answer is: only while the script runs. A finished oneshot reports Active: inactive (dead), which startles people the first time. The service worked. It is just not running anymore. RemainAfterExit=yes in the [Service] section changes that to Active: active (exited) after a successful run, a better fit for setup scripts whose effect should read as a state instead of a blink.

Long-running services are the other kind. A loop shows them, and this script stays with the guide to the end:

#!/bin/bash
# /usr/local/bin/watcher.sh
while true; do
    echo "tick at $(date +%T)"
    sleep 10
done
# /etc/systemd/system/watcher.service
[Unit]
Description=Heartbeat ticker

[Service]
ExecStart=/usr/local/bin/watcher.sh

Make it executable and start it:

$ sudo chmod +x /usr/local/bin/watcher.sh
$ sudo systemctl start watcher.service

Run journalctl -f -u watcher.service in a second terminal (or a tmux pane) and the ticks arrive live.

No Type= line, so systemd defaults to Type=simple: the service counts as started as soon as the ExecStart process exists. An explicit Type=simple behaves identically. systemctl status watcher.service shows, among other lines:

Loaded: loaded (/etc/systemd/system/watcher.service; static)
   Active: active (running)
   Main PID: 831 (watcher.sh)

Exact formatting varies between systemd versions. The substance does not: Main PID is the PID of the process systemd started, the number the capstone kills on purpose. The Loaded: line shows the unit’s file path, and static there means no [Install] section yet, picked up again when this guide wires units into boot.

When it dies

Restart=on-failure restarts the service when the process dies unexpectedly. Add it to the [Service] section of /etc/systemd/system/watcher.service, then reload and restart so the running process picks up the new policy:

$ sudo systemctl daemon-reload
$ sudo systemctl restart watcher.service

The test is to kill the Main PID by hand (take the PID from your own systemctl status; 831 was the capture):

$ sudo kill -9 831

The journal prints this sequence, identical on systemd 259 and 261:

watcher.service: Main process exited, code=killed, status=9/KILL
watcher.service: Failed with result 'signal'.
watcher.service: Scheduled restart job, restart counter is at 1.

Then systemd starts the unit again, under a fresh Main PID that status shows. The “Started” line that follows is worded differently across releases, so it is not quoted here. The restart counter climbs with every death, a quick health check for a service that keeps dying.

Restart=always goes one further: it restarts even after a clean exit. Clean exits log Deactivated successfully. in the journal, easy to tell from a crash. RestartSec=2 waits about two seconds between exit and restart, the gap visible in the journal timestamps. Read such values as about N seconds, never as a guarantee.

One exemption covers all of it: systemctl stop wins. Stopping a Restart=always service keeps it stopped. The policy governs deaths, not deliberate shutdowns.

One way to die without ever living: a script with no execute bit. To reproduce it, strip the bit again: sudo chmod -x /usr/local/bin/run.sh. The start attempt prints a first line that is stable across versions:

$ sudo systemctl start hello.service
Job for hello.service failed because the control process exited with error code.

The journal names the crime, and the status is 203/EXEC:

Failed at step EXEC spawning /usr/local/bin/run.sh: Permission denied

sudo chmod +x on the script fixes it, then start again.

With no Restart= set, death is permanent. A failed service stays failed and is never retried. systemctl --failed lists the unit, marked failed, and ends its output with:

1 loaded units listed.

sudo systemctl reset-failed hello.service clears the unit from that list once dealt with, and the count drops to 0 loaded units listed.

Who it runs as

User= decides the account: set it, and the process runs as that user, with that user’s permissions. A probe script makes the proof visible, one answer per line:

#!/bin/bash
# /usr/local/bin/probe.sh
echo "user: $(whoami)"
echo "cwd: $PWD"
echo "greeting: $GREETING"
echo "extra: $SECOND_GREETING"

Three setup commands come first. The -m in useradd -m makes the home directory that WorkingDirectory= points at:

$ sudo useradd -m probe
$ echo 'SECOND_GREETING=evening' | sudo tee /etc/probe.env
$ sudo chmod +x /usr/local/bin/probe.sh
# /etc/systemd/system/probe.service
[Unit]
Description=Report identity and environment

[Service]
Type=oneshot
User=probe
WorkingDirectory=/home/probe
Environment=GREETING=morning
EnvironmentFile=/etc/probe.env
ExecStart=/usr/local/bin/probe.sh

/etc/probe.env is a plain file holding one line, SECOND_GREETING=evening. Start the unit and the journal answers all four questions: the account from User=, the directory from WorkingDirectory=, the variable from Environment=, and the one only the file provides.

WorkingDirectory= sets the process’s current directory, and when the line is absent that directory is /, which has ruined many a script that wrote its output to a relative path. Environment= takes KEY=value pairs right in the unit. EnvironmentFile= reads them from a file instead, one pair per line, handy for settings you tweak without touching the unit.

A User= naming an account that does not exist fails with status 217/USER, and systemd-analyze verify does not catch it. The name is syntactically fine but matches nobody. The journal is explicit:

Failed to determine credentials for user 'nosuchuser': Unknown user
Failed at step USER spawning /usr/local/bin/probe.sh: Invalid argument

Surviving the reboot

Everything so far dies at reboot. systemctl start runs a unit for the current boot only, and after a power cycle systemd has forgotten it. The [Install] section is the fix: WantedBy=multi-user.target names the target every normal boot reaches, and enable wires the unit into it. Add it to watcher.service, reload, and enable:

# add to the end of /etc/systemd/system/watcher.service
[Install]
WantedBy=multi-user.target
$ sudo systemctl daemon-reload
$ sudo systemctl enable watcher.service
Created symlink '/etc/systemd/system/multi-user.target.wants/watcher.service' → '/etc/systemd/system/watcher.service'.

A single symlink in multi-user.target.wants/, which is how systemd knows to start the unit on boot. That is the entire mechanism. systemctl is-enabled watcher.service answers enabled now, and disable removes the symlink again:

$ sudo systemctl disable watcher.service
Removed '/etc/systemd/system/multi-user.target.wants/watcher.service'.

No arrow in the disable output, since nothing is being pointed at, only deleted. Worth memorizing: disable does not stop a running unit. It only touches boot wiring, so a service that is up stays up until you stop it.

For a brand-new service one command does both halves: systemctl enable --now watcher.service enables and starts together.

Static units close the loop on static. A unit with no [Install] section cannot be enabled, but current systemd does not error when you try. It prints a short block explaining that there is nothing to enable, changes nothing on disk, and systemctl is-enabled keeps answering static. The unit is not broken; it just never offered itself for boot.

Changing a unit safely

The edit cycle never changes: touch the file, sudo systemctl daemon-reload, then restart. A warning earlier in the guide built that ritual, and removing a unit shows it from another side. hello plays the victim, and it has to be running when its file disappears: an inactive unit can re-read the disk silently and skip the bad phase entirely, an active one cannot. Start it, delete the file, and ask status before reloading:

$ sudo systemctl start hello
$ sudo rm /etc/systemd/system/hello.service
$ systemctl status hello.service

status still knows the unit: its Loaded: line contains bad, with the changed-on-disk warning from earlier tagging along:

Warning: The unit file, source configuration file or drop-ins of hello.service changed on disk. Run 'systemctl daemon-reload' to reload units.

The reload makes systemd forget:

$ sudo systemctl daemon-reload
$ systemctl status hello.service
Loaded: not-found (Reason: Unit hello.service not found.)

A last stop finds nothing left to stop:

$ sudo systemctl stop hello
Unit hello.service could not be found.

A drop-in changes a unit without editing it. Create a directory named after the unit plus .d, and put a file with only the changed keys inside. Here watcher.service gets Restart=always while the original stays untouched:

# /etc/systemd/system/watcher.service.d/override.conf
[Service]
Restart=always

systemctl cat watcher.service shows what systemd actually reads: the original fragment first, then each drop-in with its own # /path header. Where a key appears in both, the drop-in wins, so this service now restarts even after clean exits.

Undoing it is deleting the drop-in directory plus a reload. Mostly. One verified gotcha: a deleted drop-in can stay in effect for a running unit until daemon-reload, because the process keeps living under the old merged configuration. Another entry for the after-every-edit ritual.

There is an interactive shortcut, systemctl edit watcher.service, which opens an editor and saves exactly such an override.conf. The manual path gets the nod here: a file you have seen is a file you can trust.

A watcher that refuses to stay dead

All of it goes into one unit now. watcher.sh keeps its shape from its first appearance, with the echo line extended to print the user and the configured variable:

#!/bin/bash
# /usr/local/bin/watcher.sh
while true; do
    echo "tick at $(date +%T) as $(whoami), greeting=$GREETING"
    sleep 10
done
# /etc/systemd/system/watcher.service
[Unit]
Description=Heartbeat watcher

[Service]
Type=simple
User=probe
Environment=GREETING=morning
EnvironmentFile=/etc/probe.env
WorkingDirectory=/home/probe
ExecStart=/usr/local/bin/watcher.sh
Restart=on-failure
RestartSec=2

[Install]
WantedBy=multi-user.target

The user and the env file come straight from the probe in the previous section. Check the unit before systemd ever sees it:

$ systemd-analyze verify /etc/systemd/system/watcher.service

Silence is the healthy output: a clean unit prints nothing and exits 0. Then the bring-up, and the order matters. The drop-in from the previous section still sets Restart=always, which would quietly outrank the Restart=on-failure declared above, and watcher has been running ever since it was first started: starting an already-running unit never applies the new file. So the drop-in goes first, then the enable, then one restart moves the running unit onto the new configuration. The enable half gets its own command this time, because the disable demo in Surviving the reboot took the boot symlink away:

$ sudo rm -r /etc/systemd/system/watcher.service.d
$ sudo systemctl daemon-reload
$ sudo systemctl enable watcher.service
Created symlink '/etc/systemd/system/multi-user.target.wants/watcher.service' → '/etc/systemd/system/watcher.service'.
$ sudo systemctl restart watcher.service

The Created symlink line is the expected one. The journal proves the identity and environment took: heartbeat lines reading as probe and greeting=morning.

Take the Main PID from systemctl status watcher.service, here 1247 (yours will differ), and kill it:

$ sudo kill -9 1247
watcher.service: Main process exited, code=killed, status=9/KILL
watcher.service: Failed with result 'signal'.
watcher.service: Scheduled restart job, restart counter is at 1.

About two seconds later, RestartSec=2 at work, a Started line announces a new run and heartbeats resume under a fresh Main PID. Two seconds on purpose, so the proof does not keep you waiting. Those three journal lines are the same stable sequence as in When it dies, wording identical on systemd 259 and 261.

One more word on systemd-analyze verify, because its exit codes are not what people expect. A directive it cannot parse produces a path:line diagnostic, but the command still exits 0. It is a warning:

/etc/systemd/system/hello.service:5: Failed to parse Type=nonsense, ignoring: Invalid argument

Exit code 1 is saved for fatal problems, a nonexistent binary, the class of mistake that surfaces at start time as the 203/EXEC failure from When it dies. Zero means nothing fatal, not that every directive made sense.

Tearing it down, the bring-up in reverse plus every other path this guide created:

$ sudo systemctl stop watcher.service
$ sudo systemctl disable watcher.service
$ sudo rm /etc/systemd/system/watcher.service /usr/local/bin/watcher.sh
$ sudo rm /etc/systemd/system/hello.service /usr/local/bin/run.sh
$ sudo rm /etc/systemd/system/probe.service /usr/local/bin/probe.sh
$ sudo rm /etc/probe.env
$ sudo userdel -r probe
$ sudo systemctl daemon-reload
$ sudo systemctl reset-failed

All paths this guide created, nothing else. If anything of your own already lives at one of these paths, or a probe user exists from earlier experiments, skip the matching lines. The -r in userdel -r takes the probe home directory along. systemctl --failed answers with 0 loaded units listed., and the machine is back where it started.

Where you are now

The full loop: write a unit, run it once or forever, restart it on failure, run it as a specific user with a controlled environment, wire it into boot, and change it safely through drop-ins. The journal settles what actually happened, and daemon-reload is the reflex between edit and expectation.

Where to go from here:

One corner left alone on purpose: per-user units under systemctl --user, the same idea rooted in your own login session instead of the system boot. They deserve a guide of their own.

For the complete option reference, the official pages are the ones to keep open: