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:
- The systemd timers guide runs jobs on a schedule instead of restarting them.
- The troubleshooting guide picks up when a unit misbehaves and the journal is not enough.
- The bash scripting guide is the other half of every service, the script
ExecStartruns. - The services and logs chapter covers
systemctlandjournalctlbasics for the units your distro ships.
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:
- systemd.service(5) — every service option, from Type= to Restart=
- systemd.service(5) on man.archlinux.org — the same manual, plain HTML
- ArchWiki: Systemd — worked patterns and the wider ecosystem