Services with systemd

Read state, write a unit, debug a failure.

Beginner16 min · lesson 19 of 29

A service is a program that runs in the background without anyone logged in to start it: the SSH server that lets you log in, the time synchronisation daemon, a web server, your own application. On Ubuntu 26.04 and RHEL 10, as on nearly every current distribution, services are started and supervised by systemd. The kernel starts systemd first, as process ID 1, and systemd then starts services at boot in the right order, restarts them when they crash, collects their output in the journal, and stops them cleanly at shutdown. You give it instructions with one command, systemctl.

deploy@web01 · Ubuntu 26.04 LTS
$ ps -p 1 -o pid,user,comm
PID USER COMMAND 1 root systemd

PID 1 belongs to systemd and runs as root. Every other userspace process descends from it (kernel threads hang from PID 2, kthreadd, as the first lesson showed). systemd also puts each service's processes into its own control group, or cgroup, a kernel grouping of processes. That is how it can find and stop everything a service started, even a process that detached from its parent.

Reading a service's state

Start with a service that is certainly running: the SSH server you are probably logged in through. On Ubuntu it is called ssh.

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl status ssh
● ssh.service - OpenBSD Secure Shell server Loaded: loaded (/usr/lib/systemd/system/ssh.service; disabled; preset: enabled) Active: active (running) since Sun 2026-09-27 08:33:58 UTC; 16ms ago Invocation: e49d4ed683f8411280bbfe5baf16983e TriggeredBy: ● ssh.socket Docs: man:sshd(8) man:sshd_config(5) Process: 38975 ExecStartPre=/usr/sbin/sshd -t (code=exited, status=0/SUCCESS) Main PID: 38978 (sshd) Tasks: 1 (limit: 4107) Memory: 1.3M (peak: 1.9M) CPU: 13ms CGroup: /system.slice/ssh.service └─38978 "sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups" Sep 27 08:33:58 web01 systemd[1]: Starting ssh.service - OpenBSD Secure Shell server... Sep 27 08:33:58 web01 sshd[38978]: Server listening on 0.0.0.0 port 22. Sep 27 08:33:58 web01 sshd[38978]: Server listening on :: port 22. Sep 27 08:33:58 web01 systemd[1]: Started ssh.service - OpenBSD Secure Shell server.

Read it from the top. Loaded names the unit file systemd read, here /usr/lib/systemd/system/ssh.service, the directory where packages install their units. The same bracket says whether the unit is enabled to start at boot and what the distribution's preset would choose. Active is the state right now and how long it has been in it. Process shows a command that ran before the main one: sshd -t checks the configuration, so a broken sshd_config stops the start instead of taking the server down. Main PID is the process to look for in ps, and CGroup lists every process systemd counts as part of the service. The last lines come from the journal, systemd's log, so you see why the service did what it did without opening a log file.

One line needs explaining. The unit says disabled, yet it is running and TriggeredBy names ssh.socket. Since Ubuntu 22.10 the SSH server is socket-activated: ssh.socket is the unit that is enabled and listens on port 22, and systemd starts ssh.service when the first connection arrives. The two units answer the two questions differently.

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl list-unit-files ssh.service ssh.socket
UNIT FILE STATE PRESET ssh.service disabled enabled ssh.socket enabled enabled 2 unit files listed.
$ systemctl is-active ssh.socket ssh.service systemctl is-enabled ssh.socket ssh.service
active active enabled disabled

To see every service running right now, list the service units in the running state. The list below leaves some lines out, among them the agent of the virtual machine tool the lab runs in.

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl list-units --type=service --state=running
UNIT LOAD ACTIVE SUB DESCRIPTION … chrony.service loaded active running chrony, an NTP client/server cron.service loaded active running Regular background program processing daemon dbus.service loaded active running D-Bus System Message Bus … serial-getty@hvc0.service loaded active running Serial Getty on hvc0 … sshd@3-4-3:22-2:683324215.service loaded active running OpenBSD Secure Shell server per-connection daemon … systemd-logind.service loaded active running User Login Management systemd-networkd.service loaded active running Network Management systemd-resolved.service loaded active running Network Name Resolution systemd-timedated.service loaded active running Time & Date Service systemd-udevd.service loaded active running Rule-based Manager for Device Events and Files … 24 loaded units listed.

Each line is one unit: its name, whether its file loaded, and its high-level and low-level state. This is the quickest way to learn what a server you have just been handed actually runs.

RHEL and Rocky Linux name it sshd
On RHEL 10 and Rocky Linux 10 the unit is sshd.service, it is enabled directly, and sshd.socket exists but is disabled, so the daemon starts at boot and listens itself. Scripts that restart SSH on both families need the right name: systemctl restart ssh on Ubuntu, systemctl restart sshd on RHEL.
deploy@rocky10 · Rocky Linux 10.2
$ systemctl status sshd
● sshd.service - OpenSSH server daemon Loaded: loaded (/usr/lib/systemd/system/sshd.service; enabled; preset: enabled) Active: active (running) since Sun 2026-09-27 08:17:50 UTC; 37ms ago …
$ systemctl list-unit-files sshd.service sshd.socket ssh.service
UNIT FILE STATE PRESET sshd.service enabled enabled sshd.socket disabled disabled 2 unit files listed.

Running now versus starting at boot

systemd keeps two questions apart. Is the service running this second? start, stop and restart change that, reload asks a running service to re-read its configuration, and systemctl is-active reports it. Will it start at the next boot? enable and disable change that, and systemctl is-enabled reports it. Neither set of commands touches the other question. sudo systemctl enable --now NAME does both in one step, which is what you want when you deploy something.

What survives a reboot
You acted on a service. Is it running after the next boot?
start only
Running now, gone after reboot
start changes the running system only
enable only
Stopped now, starts at next boot
enable adds a link in a boot target
enable --now
Running now and after every boot
the normal choice for a deployed service
socket enabled
Starts on the first connection
as ssh.service does on Ubuntu

Writing a unit for your own program

Unit files live in two main places. /usr/lib/systemd/system belongs to packages: a package upgrade can replace any file there, so you never edit them. /etc/systemd/system belongs to you, and a unit there with the same name as a packaged one takes its place. Between the two sits /run/systemd/system, for units created at runtime that disappear at reboot, so the order of precedence is /etc, then /run, then /usr/lib. Your own services go in /etc/systemd/system.

As a stand-in for an application, the example runs Python's built-in web server, serving one page from /srv/myapp as a system account called myapp that cannot log in. Create the account and the page first:

deploy@web01 · Ubuntu 26.04 LTS
$ sudo useradd --system --no-create-home --shell /usr/sbin/nologin myapp sudo mkdir -p /srv/myapp echo "hello from myapp" | sudo tee /srv/myapp/index.html
hello from myapp

Then write the unit file as root, for example with sudoedit /etc/systemd/system/myapp.service:

/etc/systemd/system/myapp.service
[Unit]
Description=Demo web app (Python http.server)
[Service]
Type=exec
User=myapp
ExecStart=/usr/bin/python3 -m http.server 8080 --bind 127.0.0.1 --directory /srv/myapp
Restart=on-failure
[Install]
WantedBy=multi-user.target

[Unit] describes the unit; ordering lines such as After= would go here too, and this app needs none because it only listens on the local address. [Service] says how to run it. ExecStart is the command, with an absolute path. User=myapp runs it without root privileges. Restart=on-failure restarts it if it exits with an error. Type=exec makes systemctl start wait until the program has actually been executed and report an error if it could not be; the default, Type=simple, reports success as soon as systemd has forked, even when the program does not exist. [Install] is read only by enable: WantedBy=multi-user.target attaches the service to the normal boot.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl start myapp
$ systemctl status myapp
● myapp.service - Demo web app (Python http.server) Loaded: loaded (/etc/systemd/system/myapp.service; disabled; preset: enabled) Active: active (running) since Sun 2026-09-27 08:33:58 UTC; 139ms ago Invocation: ede956057a0a42f1afcc552a09dc9fe1 …
$ curl -s http://127.0.0.1:8080/
hello from myapp
$ systemctl is-active myapp systemctl is-enabled myapp
active disabled

The service answers, and the status line reads disabled: it is running, but nothing will start it after a reboot. That combination is the usual cause of "it worked until we patched and rebooted". systemd found the new file without being told because it loads a unit it has never seen the first time you name it. Changing a unit it has already loaded is different, as the next section shows.

Waiting for the network
Services that make outgoing connections at startup are often given After=network.target. That only orders the service after the network management service has started; it does not wait for an address or a route. A service that genuinely needs a working network at startup uses Wants=network-online.target and After=network-online.target. Most services, including anything that only listens, need neither.

When a service fails

Now break it the way people really do: a typo in the program path. After editing the file, a plain restart looks like it worked.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl restart myapp
Warning: The unit file, source configuration file or drop-ins of myapp.service changed on disk. Run 'systemctl daemon-reload' to reload units.

systemd warns and restarts the old definition it still holds in memory: the service is running the command from before the edit. After you change a unit file by hand, run sudo systemctl daemon-reload so systemd reads it again. This time the restart fails.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl daemon-reload sudo systemctl restart myapp
Job for myapp.service failed because the control process exited with error code. See "systemctl status myapp.service" and "journalctl -xeu myapp.service" for details.

Follow the advice in the message and start with systemctl status.

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl status myapp
× myapp.service - Demo web app (Python http.server) Loaded: loaded (/etc/systemd/system/myapp.service; disabled; preset: enabled) Active: failed (Result: exit-code) since Sun 2026-09-27 08:33:59 UTC; 2s ago Duration: 292ms Invocation: 2dc9883fe93d4a5aaa95c8a04dfc8c04 Process: 39399 ExecStart=/usr/bin/pyhton3 -m http.server 8080 --bind 127.0.0.1 --directory /srv/myapp (code=exited, status=203/EXEC) Main PID: 39399 (code=exited, status=203/EXEC) Mem peak: 1.7M CPU: 3ms Sep 27 08:33:59 web01 systemd[1]: myapp.service: Scheduled restart job, restart counter is at 3. Sep 27 08:33:59 web01 systemd[1]: myapp.service: Start request repeated too quickly. Sep 27 08:33:59 web01 systemd[1]: myapp.service: Failed with result 'exit-code'. Sep 27 08:33:59 web01 systemd[1]: Failed to start myapp.service - Demo web app (Python http.server).

The cross and failed say the service is down. The Process line shows the exact command systemd tried and status=203/EXEC. Codes from 200 upwards are systemd's own and mean it failed while setting the process up, before your program ran: 203 means the program could not be executed at all (systemd.exec(5) lists them). The journal lines show that Restart=on-failure tried again, then gave up: systemd refuses to start a unit more than five times in ten seconds by default, so a broken service cannot loop forever.

deploy@web01 · Ubuntu 26.04 LTS
$ journalctl -u myapp -n 8 --no-hostname
Sep 27 08:33:59 (pyhton3)[39399]: myapp.service: Failed at step EXEC spawning /usr/bin/pyhton3: No such file or directory Sep 27 08:33:59 systemd[1]: myapp.service: Main process exited, code=exited, status=203/EXEC … Sep 27 08:33:59 systemd[1]: myapp.service: Start request repeated too quickly. …

journalctl -u myapp shows only this unit's messages, and the first line spells out the reason: Failed at step EXEC spawning /usr/bin/pyhton3: No such file or directory, so the executable does not exist. The next line is the 203/EXEC status again, and after the restart attempts (left out) comes Start request repeated too quickly, the rate limit giving up. When you do not know which service is in trouble, systemctl --failed lists every unit in the failed state. It is a good first command on a machine that misbehaves after a reboot.

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl --failed
UNIT LOAD ACTIVE SUB DESCRIPTION ● myapp.service loaded failed failed Demo web app (Python http.server) …

The lines left out are per-user service managers (user@UID.service) of throwaway accounts that other lab exercises deleted while those accounts were still logged in; on your own machine the list should hold only what is really broken.

Fix the path, reload, restart, and check the result instead of assuming it.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl daemon-reload sudo systemctl restart myapp systemctl is-active myapp
active
$ curl -s http://127.0.0.1:8080/
hello from myapp

If you retry within ten seconds of the failures you may instead see "start of the service was attempted too often". sudo systemctl reset-failed myapp clears the counter.

Enabling it for boot

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl enable myapp
Created symlink '/etc/systemd/system/multi-user.target.wants/myapp.service' → '/etc/systemd/system/myapp.service'.
$ ls -l /etc/systemd/system/multi-user.target.wants/myapp.service systemctl is-enabled myapp
lrwxrwxrwx 1 root root 33 Sep 27 08:34 /etc/systemd/system/multi-user.target.wants/myapp.service -> /etc/systemd/system/myapp.service enabled

Enabling is nothing more than that symbolic link: multi-user.target pulls in every unit linked from its .wants directory when the machine boots. disable removes the link and leaves the running service alone.

A disabled unit can still be started by hand or pulled in by another unit. When a service must not run at all, mask it: systemd puts a link to /dev/null in /etc/systemd/system under the unit's name, which hides the packaged file, and every attempt to start it fails until you unmask it. Here it is on the rsync daemon, a packaged unit that is installed but not running.

deploy@web01 · Ubuntu 26.04 LTS
$ systemctl is-active rsync sudo systemctl mask rsync
inactive Created symlink '/etc/systemd/system/rsync.service' → '/dev/null'.
$ sudo systemctl start rsync
Failed to start rsync.service: Unit rsync.service is masked.
$ sudo systemctl unmask rsync systemctl is-enabled rsync
Removed '/etc/systemd/system/rsync.service'. disabled

Because masking works by occupying the unit's name in /etc/systemd/system, it cannot mask a unit whose own file already lives there: sudo systemctl mask myapp answers Failed to mask unit: File '/etc/systemd/system/myapp.service' already exists. For your own units, disable them or remove the file instead.

Changing a unit without editing it

To change a packaged unit, or one a colleague owns, add a drop-in instead of editing the file. sudo systemctl edit myapp opens an editor on /etc/systemd/system/myapp.service.d/override.conf and reloads systemd when you save; in a script, --stdin reads the new content from a pipe. Here the drop-in moves the app to port 9090. The empty ExecStart= line first clears the command inherited from the main file; without it systemd would refuse the unit for having two commands.

deploy@web01 · Ubuntu 26.04 LTS
$ printf "[Service]\nExecStart=\nExecStart=/usr/bin/python3 -m http.server 9090 --bind 127.0.0.1 --directory /srv/myapp\n" | sudo systemctl edit --stdin myapp
Successfully installed edited file '/etc/systemd/system/myapp.service.d/override.conf'.
$ systemctl cat myapp
# /etc/systemd/system/myapp.service [Unit] Description=Demo web app (Python http.server) [Service] Type=exec User=myapp ExecStart=/usr/bin/python3 -m http.server 8080 --bind 127.0.0.1 --directory /srv/myapp Restart=on-failure [Install] WantedBy=multi-user.target # /etc/systemd/system/myapp.service.d/override.conf [Service] ExecStart= ExecStart=/usr/bin/python3 -m http.server 9090 --bind 127.0.0.1 --directory /srv/myapp

systemctl cat shows every file that makes up the unit, in the order they apply, which is the quickest way to find out why a service is not behaving the way its main file says it should.

deploy@web01 · Ubuntu 26.04 LTS
$ sudo systemctl restart myapp
$ ss -tln sport = :9090 curl -s http://127.0.0.1:9090/
State Recv-Q Send-Q Local Address:Port Peer Address:Port LISTEN 0 5 127.0.0.1:9090 0.0.0.0:* hello from myapp

Service settings go much further than this: the hardening course uses the same drop-ins to take privileges and filesystem access away from a service. The journal that journalctl -u reads is the subject of the next lesson.

Try this

Build myapp from this lesson on a lab machine, enable it, and confirm it answers with curl. Then add a second drop-in, separate from override.conf: sudo systemctl edit --drop-in=zz-user myapp opens /etc/systemd/system/myapp.service.d/zz-user.conf; put [Service] and User=nosuchuser in it, save, and restart. Find the reason in systemctl status myapp and journalctl -u myapp: the status line reads status=217/USER, systemd's code for a user it could not set. Remove that file with sudo rm, run sudo systemctl daemon-reload and sudo systemctl reset-failed myapp, and start it again. Finish with sudo systemctl disable --now myapp, so is-active and is-enabled both report it off, then remove everything: sudo rm -r /etc/systemd/system/myapp.service /etc/systemd/system/myapp.service.d /srv/myapp, sudo systemctl daemon-reload and sudo userdel myapp.

Takeaway

systemctl status NAME and journalctl -u NAME answer most questions about a service. Keep your own units in /etc/systemd/system, change packaged ones with drop-ins, and run daemon-reload after any edit you make by hand.

Quick check
01You fix a path in /etc/systemd/system/api.service and run sudo systemctl restart api. systemctl prints "The unit file ... changed on disk. Run 'systemctl daemon-reload'", and the service keeps running the old path. Why?
Incorrect — restart re-reads nothing from disk. It restarts whatever definition systemd already holds, whichever section your change was in.
Correct — systemd keeps loaded units in memory. daemon-reload makes it read the files again; the warning is systemd telling you that.
Incorrect — The opposite: a unit in /etc/systemd/system replaces a packaged unit of the same name.
Incorrect — restart does stop and start the service. The problem is which definition it starts, not how it starts it.
02systemctl status web shows "Active: failed (Result: exit-code)" and "status=203/EXEC". What do you check first?
Incorrect — Codes of 200 and above come from systemd itself. The application never ran, so it has written nothing to its log.
Incorrect — Port and firewall problems happen after the program runs. 203 means it was never executed.
Correct — 203/EXEC is systemd reporting that it could not execute the program: a wrong path, a missing file or a missing execute bit.
Incorrect — A kill for memory shows as a signal (status=9/KILL) or an oom-kill result, not 203.
03On an Ubuntu 26.04 server, systemctl is-enabled ssh.service prints "disabled", yet you are connected over SSH right now. What explains it?
Incorrect — Enablement is read from the links on disk, so it is not stale. The service really is disabled.
Incorrect — The kernel starts only PID 1. The status output shows ssh.service inside systemd's own cgroup.
Incorrect — Disabled means the unit has no boot link of its own. It does not schedule a stop.
Correct — Ubuntu socket-activates SSH: the socket unit is enabled and triggers the service when the first connection arrives.

Related