systemd and journalctl¶
Level 4 · Chapter 1 · ⏱️ ~45 min read · Prerequisites: The boot process, Processes and signals
systemd starts, stops, supervises, and logs almost everything on a modern Linux system. This chapter teaches you to control services with systemctl, write your own unit files, and read logs with journalctl.
Why it matters¶
Alex runs a small Python API on a cloud server. It works fine while Alex is logged in and running python3 app.py in a terminal. Then Alex closes the laptop, the SSH session ends, and the API dies with it. Alex tries nohup python3 app.py &. That survives the logout, but the API crashes at 3 a.m. on a bad request and stays dead until someone notices on Monday. After a kernel update and reboot, it does not come back at all.
Then Alex writes a 15-line systemd unit file. Now the API starts at boot, restarts two seconds after any crash, runs as an unprivileged user instead of Alex's account, and every line it prints lands in a searchable log with timestamps. When it does fail, systemctl status webapp shows the exit code and the last ten log lines in one screen.
That is the job of systemd: turn "a program I ran once" into "a service the system keeps alive".
Concepts¶
What systemd is¶
systemd is the init system on Mint, Ubuntu, Debian, Fedora, Arch, and most other distributions. The init system is the first user-space process the kernel starts. It always has process ID (PID) 1. You met it in The boot process: the kernel starts /sbin/init, which is a symlink to systemd.
PID 1 has three jobs:
- Bring the system up. Mount filesystems, start networking, start the login screen, and so on, in the right order.
- Supervise. Track every process it started, restart the ones that die, and collect their exit status.
- Bring the system down. Stop everything cleanly at shutdown.
systemd is also a family of helpers: journald (logging), resolved (DNS), logind (logins and sessions), timesyncd (clock sync), and more. You will meet several of them in this level.
Units: the things systemd manages¶
systemd manages units. A unit is any resource systemd knows how to start, stop, or watch. Each unit is described by a plain-text unit file, and the file's extension tells you the unit type.
| Type | Extension | What it represents | Example |
|---|---|---|---|
| Service | .service |
A process or group of processes | cron.service, ssh.service |
| Socket | .socket |
A listening socket; starts a service on the first connection | ssh.socket |
| Timer | .timer |
A schedule that starts another unit | logrotate.timer |
| Target | .target |
A named group of units, used as a sync point | multi-user.target |
| Mount | .mount |
A mounted filesystem | home.mount for /home |
| Path | .path |
Watches a file or directory and starts a unit when it changes | cups.path |
There are a few more (.device, .swap, .slice, .scope, .automount), but these six cover almost everything you will touch.
A target deserves a closer look. It runs nothing itself. It is a label that means "everything that should be running at this stage". multi-user.target means "a fully booted system with networking and services, but no graphical login". graphical.target pulls in multi-user.target plus the display manager. Targets replace the old numbered runlevels from SysV init.
flowchart LR
G["graphical.target"] --> M["multi-user.target"]
M --> B["basic.target"]
B --> S["sysinit.target"]
M --> cron["cron.service"]
M --> ssh["ssh.service"]
M --> web["webapp.service"]
T["timers.target"] --> lr["logrotate.timer"]
B --> T
When you "enable" a service, you add it to one of these groups, usually multi-user.target. At boot, systemd starts the default target and pulls in everything that hangs off it.
Where unit files live, and who wins¶
The same unit name can exist in several directories. systemd searches them in a fixed order, and the first match wins. Here are the three you need to know, from highest priority to lowest:
| Directory | Who writes here | Survives package upgrades? |
|---|---|---|
/etc/systemd/system/ |
You, the administrator | Yes |
/run/systemd/system/ |
Runtime tools; wiped at reboot | No (temporary) |
/usr/lib/systemd/system/ (also reachable as /lib/systemd/system/) |
Packages installed with apt |
Overwritten on upgrade |
On Mint 22 and Ubuntu 24.04, /lib is a symlink to /usr/lib, so /lib/systemd/system and /usr/lib/systemd/system are the same directory. Older tutorials use the /lib spelling.
The rule that follows from this table is the most important habit in this chapter:
Common mistake
Never edit files in /usr/lib/systemd/system/. The next apt upgrade of that package silently overwrites your change. Put overrides in /etc/systemd/system/ instead, ideally with systemctl edit (shown below).
User units live in a separate tree and are run by a per-user copy of systemd (systemd --user) that starts when you log in:
| Directory | Purpose |
|---|---|
~/.config/systemd/user/ |
Your personal units |
/etc/systemd/user/ |
Admin-provided units for every user |
/usr/lib/systemd/user/ |
Package-provided user units |
You can print the full search path with a read-only command:
/etc/systemd/system.control
/run/systemd/system.control
/run/systemd/transient
/run/systemd/generator.early
/etc/systemd/system
/etc/systemd/system.attached
/run/systemd/system
/run/systemd/system.attached
/run/systemd/generator
/usr/local/lib/systemd/system
/usr/lib/systemd/system
/run/systemd/generator.late
The generator directories hold units created at boot by small programs called generators. For example, systemd-fstab-generator turns each line in /etc/fstab into a .mount unit. That is why /home shows up as home.mount.
Anatomy of a unit file¶
Unit files use an INI-like format: [Section] headers followed by Key=Value lines. Here is the real cron.service that ships with Mint:
# /usr/lib/systemd/system/cron.service
[Unit]
Description=Regular background program processing daemon
Documentation=man:cron(8)
After=remote-fs.target nss-user-lookup.target
[Service]
EnvironmentFile=-/etc/default/cron
ExecStart=/usr/sbin/cron -f -P $EXTRA_OPTS
IgnoreSIGPIPE=false
KillMode=process
Restart=on-failure
SyslogFacility=cron
[Install]
WantedBy=multi-user.target
Every unit file has up to three sections:
[Unit]is generic metadata and ordering. Every unit type has it.Descriptionis the human name you see in logs.After=controls start order.[Service](or[Timer],[Socket],[Mount],[Path]) holds type-specific settings. For a service, this is mainly "what command to run and how to supervise it".[Install]is read only bysystemctl enableanddisable. It says which target should pull this unit in at boot. It has no effect at runtime.
Notice cron -f. The -f flag means "stay in the foreground". Old daemons forked themselves into the background. systemd prefers programs that stay in the foreground, because then the process systemd started is the service and is easy to track.
Service types: how systemd knows a service is "started"¶
When you start a service, systemd needs to know when startup is finished. That matters because other units may be waiting for it. The Type= setting tells systemd how to decide.
Type= |
systemd considers it started when... | Use it for |
|---|---|---|
simple (default) |
Immediately after fork(), before the program even runs |
Simple foreground programs |
exec |
After the program binary was successfully executed | Foreground programs (better error reporting than simple) |
forking |
The first process exits, leaving a background child | Old-style daemons that fork themselves |
oneshot |
The process exits successfully | Scripts that do a job and finish (backups, setup) |
notify |
The program sends a "READY=1" message to systemd | Daemons that support sd_notify, such as sshd on Ubuntu |
The difference between simple and exec is subtle but useful. With simple, if you mistype the path in ExecStart=, systemctl start still reports success, and the failure shows up a moment later. With exec, systemctl start itself fails, so you see the error right away. Prefer exec for new services.
forking needs a PIDFile= so systemd knows which child is the real daemon. You will rarely write a forking unit yourself. If a program has a "don't daemonize" flag (like cron -f or sshd -D), use it with Type=exec instead.
oneshot services are usually not "running" after they finish. Their state goes to inactive (dead) with a successful result. Add RemainAfterExit=yes if you want the unit to count as active after the command exits, for example for a "set up the firewall" unit.
Restart policies¶
The Restart= setting tells systemd when to restart a service that stopped on its own (not one you stopped with systemctl stop).
Restart= |
Restarts after... |
|---|---|
no (default) |
Never |
on-failure |
Non-zero exit code, a crash signal, a timeout, or a watchdog failure |
on-abnormal |
A crash signal, timeout, or watchdog, but not a non-zero exit |
always |
Any exit, even a clean exit code 0 |
RestartSec= sets the delay before the restart (default 100 ms). Two seconds is a polite value.
systemd also has a safety net called start rate limiting. By default, if a unit starts more than 5 times in 10 seconds, systemd gives up and marks it failed with the message Start request repeated too quickly. This stops a broken service from restarting in a tight loop forever. The knobs are StartLimitBurst= and StartLimitIntervalSec= in the [Unit] section. After you fix the problem, systemctl reset-failed webapp clears the counter.
Dependencies: Wants, Requires, After, Before¶
systemd separates two questions that people often mix up:
- Requirement: if A starts, must B also start?
- Ordering: if both start, which goes first?
| Directive | Kind | Meaning for A.service |
|---|---|---|
Wants=B |
Requirement (weak) | Starting A also starts B. If B fails, A keeps going. |
Requires=B |
Requirement (strong) | Starting A also starts B. If B fails to start, or is stopped later, A is stopped too. |
After=B |
Ordering | If both are being started, start A only after B has finished starting. |
Before=B |
Ordering | The mirror image: start A before B. |
The key insight is that Requires= without After= starts both units at the same time. Requirement and ordering are independent. If your app needs a database to be up first, you need both lines:
Prefer Wants= over Requires= unless you really want A to die when B dies. Wants= is more robust: a temporary problem in B does not cascade.
Ask yourself two separate questions when writing [Unit]:
flowchart TD
Q1{"Should starting A also start B?"} -->|"Yes, A survives if B fails"| W["Wants=B"]
Q1 -->|"Yes, A must stop if B stops"| R["Requires=B"]
Q1 -->|No| N1["no requirement line"]
Q2{"Must B be ready before A starts?"} -->|Yes| AF["After=B"]
Q2 -->|No| N2["no ordering line"]
A common case is networking. After=network.target only means "after the network stack is configured", not "after we have an IP address". If your service must reach the network at startup, use:
Services that only listen on a port do not need this. They can bind to 0.0.0.0 before any address exists.
WantedBy=multi-user.target in [Install] is the reverse of Wants=. It says "when I am enabled, make multi-user.target want me".
What enable really does: symlinks¶
Starting a unit runs it now. Enabling a unit makes it start automatically at boot. These are separate operations.
When you run systemctl enable webapp, systemd reads the [Install] section, sees WantedBy=multi-user.target, and creates a symlink:
At boot, systemd starts multi-user.target, looks in its .wants/ directory, and starts everything linked there. disable simply deletes that symlink. You can see every enabled service on your machine with:
Masking is stronger than disabling. systemctl mask foo creates a symlink from /etc/systemd/system/foo.service to /dev/null. Because /etc has higher priority than /usr/lib, systemd finds the /dev/null "file" first and refuses to start the unit at all, even manually or as a dependency. Use it when you really want something to never run. unmask removes the link.
stateDiagram-v2
direction LR
[*] --> disabled
disabled --> enabled: systemctl enable
enabled --> disabled: systemctl disable
disabled --> masked: systemctl mask
enabled --> masked: systemctl mask
masked --> disabled: systemctl unmask
Enabled or disabled is about boot. Active or inactive is about right now. A unit can be enabled but stopped, or disabled but running. systemctl enable --now webapp does both in one command.
journald: one log for everything¶
journald (systemd-journald.service) is systemd's logging daemon. It collects:
- everything services write to stdout and stderr (systemd connects them to journald automatically),
- messages sent through the classic syslog API (
/dev/log), - kernel messages (what
dmesgshows), - structured messages sent with the native journal API.
It stores them in binary, indexed journal files. Each entry is a set of fields, not just a line of text: the message, the PID, the unit name, the priority, the boot ID, and more. That is why journalctl -u cron can instantly filter one service out of millions of lines.
flowchart LR
svc["Service stdout/stderr"] --> J["systemd-journald"]
sys["syslog() calls via /dev/log"] --> J
k["Kernel ring buffer"] --> J
J --> F["/var/log/journal/ (binary files)"]
J -->|"ForwardToSyslog=yes"| R["rsyslog"]
R --> T["/var/log/syslog, auth.log, kern.log"]
F --> JC["journalctl"]
Persistent vs volatile storage. journald can keep logs in memory under /run/log/journal/ (volatile: lost at reboot) or on disk under /var/log/journal/ (persistent). The Storage= setting in /etc/systemd/journald.conf picks the mode:
Storage= |
Behavior |
|---|---|
volatile |
Memory only |
persistent |
Disk; creates /var/log/journal/ if needed |
auto (default) |
Disk if /var/log/journal/ exists, otherwise memory |
none |
Keep nothing (still forwards to syslog) |
Ubuntu and Mint ship /var/log/journal/, so the default auto means persistent. That is why you can read logs from previous boots.
By default, journald caps persistent logs at 10% of the filesystem size, up to 4 GiB (SystemMaxUse=), and leaves at least 15% free (SystemKeepFree=). Old files are rotated away automatically.
rsyslog and /var/log/syslog¶
Before journald, Linux logged through a syslog daemon that wrote plain-text files under /var/log. Mint and Ubuntu still run rsyslog next to journald, for compatibility with tools and habits that expect text files. The rsyslog package ships a small journald drop-in, /usr/lib/systemd/journald.conf.d/syslog.conf, that sets ForwardToSyslog=yes, so every journal message is also handed to rsyslog. rsyslog then sorts them into files according to /etc/rsyslog.d/50-default.conf:
| File | Contents |
|---|---|
/var/log/syslog |
Almost everything |
/var/log/auth.log |
Logins, sudo, SSH authentication |
/var/log/kern.log |
Kernel messages |
These files are readable by the adm group (the first user created on Mint is in it) and are rotated by logrotate. Both views have their uses: grep over /var/log/syslog is quick and familiar, while journalctl gives you structured filtering. Some minimal distributions (Fedora, Arch, and newer Debian) no longer install rsyslog, so journalctl is the skill that travels.
Commands and examples¶
Reading systemctl status line by line¶
systemctl status is the first command to run whenever something about a service seems off. It needs no sudo to read.
● cron.service - Regular background program processing daemon
Loaded: loaded (/usr/lib/systemd/system/cron.service; enabled; preset: enabled)
Active: active (running) since Fri 2026-10-02 09:35:50 UTC; 1h 1min ago
Docs: man:cron(8)
Main PID: 911 (cron)
Tasks: 1 (limit: 18382)
Memory: 644.0K (peak: 2.8M)
CPU: 48ms
CGroup: /system.slice/cron.service
└─911 /usr/sbin/cron -f -P
Oct 02 10:17:01 mint CRON[44695]: (root) CMD (cd / && run-parts --report /etc/cron.hourly)
Oct 02 10:17:01 mint CRON[44694]: pam_unix(cron:session): session closed for user root
Line by line:
●is the state dot. It is green for active, white for inactive, and a red×for failed. The rest of the line is the unit name and itsDescription=.Loaded:tells you systemd found and parsed the unit file, which file it used, whether it isenabledat boot, and the preset (the distribution's default for this unit). If you seeLoaded: not-found, you mistyped the name. If you see aDrop-In:line under it, an override file is in effect.Active:is the current state. The format isACTIVE (SUB):active (running)for a live daemon,active (exited)for a finished oneshot withRemainAfterExit=yes,inactive (dead)for stopped,failed (Result: exit-code)for crashed, andactivating (auto-restart)while waiting to restart.Docs:points to the manual.Main PID:is the process systemd treats as "the service". If it dies, the service is considered stopped.Tasks:counts processes and threads in the service. The limit protects against fork bombs.Memory:andCPU:are accounting values from the service's cgroup.CGroup:shows the control group, a kernel feature that groups processes so systemd can track them all, including children that fork away. That is howsystemctl stopcan reliably kill everything a service started. You will build cgroups by hand in Level 6.- The last lines are the 10 newest journal entries for this unit. That is often all you need to diagnose a failure.
For a service that is socket-activated (started on the first connection), you will also see a TriggeredBy: line. On Mint 22 or Ubuntu 24.04 with the SSH server (openssh-server) installed, systemctl status ssh shows TriggeredBy: ● ssh.socket.
Tip
systemctl status pipes into a pager when the output is long. Add --no-pager in scripts, and -l (or --full) if lines are cut off with ….
Start, stop, restart, reload¶
These commands change system state, so they need sudo for system services:
sudo systemctl start nginx # start now
sudo systemctl stop nginx # stop now (sends SIGTERM, then SIGKILL after a timeout)
sudo systemctl restart nginx # stop, then start: new process, new PID
sudo systemctl reload nginx # ask the running process to re-read its config
They print nothing on success. That is normal Unix style: silence means it worked. Always follow up with systemctl status to confirm.
restart vs reload matters on servers. restart kills the process and starts a new one, which drops open connections. reload runs the unit's ExecReload= command. For most daemons that sends SIGHUP, which they interpret as "re-read your config without stopping". Not every service supports reload. sudo systemctl reload-or-restart nginx picks the gentler one when it can.
Ubuntu's ssh.service is a good real example. Its reload first tests the config, then signals the daemon:
If the config test fails, the reload stops there and the running daemon keeps its old, working config.
Enable, disable, mask¶
Synchronizing state of nginx.service with SysV service script with /usr/lib/systemd/systemd-sysv-install.
Executing: /usr/lib/systemd/systemd-sysv-install enable nginx
Created symlink /etc/systemd/system/multi-user.target.wants/nginx.service → /usr/lib/systemd/system/nginx.service.
The Created symlink line is exactly the .wants/ mechanism from the Concepts section. The first two lines appear only for packages that also ship an old SysV init script in /etc/init.d/; systemd keeps both in sync.
After masking, even a manual start is refused:
Quick state checks for scripts¶
These subcommands print one word and set an exit code, which makes them ideal in if statements:
is-failed prints the state (active) and exits with 1, meaning "no, it is not failed". A script can say:
--quiet suppresses the output so only the exit code matters.
Listing units¶
UNIT LOAD ACTIVE SUB DESCRIPTION
accounts-daemon.service loaded active running Accounts Service
alsa-restore.service loaded active exited Save/Restore Sound Card State
apparmor.service loaded active exited Load AppArmor profiles
avahi-daemon.service loaded active running Avahi mDNS/DNS-SD Stack
...
Legend: LOAD → Reflects whether the unit definition was properly loaded.
ACTIVE → The high-level unit activation state, i.e. generalization of SUB.
SUB → The low-level unit activation state, values depend on unit type.
74 loaded units listed. Pass --all to see loaded but inactive units, too.
list-units shows units that are loaded in memory right now. Useful filters:
systemctl list-units --type=service --state=running # only running services
systemctl --failed # only failed units
systemctl list-units --all # include inactive ones
list-unit-files answers a different question: which unit files are installed, and are they enabled?
UNIT FILE STATE PRESET
anacron.timer enabled enabled
apt-daily-upgrade.timer enabled enabled
apt-daily.timer enabled enabled
dpkg-db-backup.timer enabled enabled
e2scrub_all.timer enabled enabled
fstrim.timer enabled enabled
...
The STATE column can also be disabled, static (no [Install] section; the unit is only ever started as a dependency of something else), masked, or alias.
Reading a unit with systemctl cat¶
Never go hunting for a unit file by hand. systemctl cat prints the file systemd is actually using, plus every drop-in, with a comment showing each path:
# /usr/lib/systemd/system/logrotate.timer
[Unit]
Description=Daily rotation of log files
Documentation=man:logrotate(8) man:logrotate.conf(5)
[Timer]
OnCalendar=daily
AccuracySec=1h
Persistent=true
[Install]
WantedBy=timers.target
To see the effective value of one setting after all defaults and overrides are merged, use systemctl show:
Overriding a packaged unit with drop-ins¶
Suppose you want cron to restart even after a clean exit. You must not edit /usr/lib/systemd/system/cron.service. Instead, you create a drop-in: a small .conf file in a directory named after the unit with .d appended. systemd reads the main file first, then applies each drop-in on top.
⚠️ VM only
Run this in your throwaway VM, never on your main machine. It changes how a system service behaves.
This opens your editor on a new file, /etc/systemd/system/cron.service.d/override.conf, with the original unit shown as comments for reference. Type only the lines you want to change, in their sections:
Save and quit. systemctl edit runs daemon-reload for you. Check the result:
# /usr/lib/systemd/system/cron.service
[Unit]
Description=Regular background program processing daemon
...
# /etc/systemd/system/cron.service.d/override.conf
[Service]
Restart=always
RestartSec=5
And systemctl status cron now has a Drop-In: line under Loaded:.
Two rules for drop-ins:
- Most settings replace the earlier value.
Restart=alwaysabove replacesRestart=on-failure. - List settings append.
ExecStart=,Environment=,After=, and similar can appear several times. To replaceExecStart=, first clear it with an empty assignment:
Without the empty ExecStart= line, systemd would see two ExecStart= lines and refuse to load a non-oneshot service.
To undo everything: sudo systemctl revert cron deletes the drop-ins and reloads.
systemctl edit --full cron copies the whole unit into /etc/systemd/system/ for editing. It works, but you then own a full copy that no longer receives fixes from package updates. Prefer drop-ins.
daemon-reload¶
systemd reads unit files into memory. If you create or change a unit file by hand (with nano, cp, or a config tool), systemd does not notice until you tell it:
If you forget, systemctl status warns you:
Warning: The unit file, source configuration file or drop-ins of webapp.service changed on disk. Run 'systemctl daemon-reload' to reload units.
daemon-reload does not restart any services. It only reloads definitions. To make a running service use its new definition, follow it with systemctl restart.
Writing your own service¶
Now put it all together. You will run a small Python web server as a service under a dedicated, unprivileged user.
⚠️ VM only
Run this in your throwaway VM, never on your main machine. You will create a system user and install a system service.
Step 1: create a system user. A system user has no password, no login shell, and a low UID. If the app is ever compromised, the attacker gets this powerless account, not yours.
Step 2: create a tiny app. Python's standard library includes a static file server, so you only need some content to serve:
echo '<h1>Hello from webapp</h1>' | sudo tee /srv/webapp/index.html
sudo chown -R webapp:webapp /srv/webapp
Step 3: write the unit file at /etc/systemd/system/webapp.service:
[Unit]
Description=Tiny demo web app
Documentation=https://docs.python.org/3/library/http.server.html
After=network.target
[Service]
Type=exec
User=webapp
Group=webapp
WorkingDirectory=/srv/webapp
Environment=PYTHONUNBUFFERED=1
Environment=PORT=8080
EnvironmentFile=-/etc/default/webapp
ExecStart=/usr/bin/python3 -m http.server ${PORT} --bind 0.0.0.0
Restart=on-failure
RestartSec=2
[Install]
WantedBy=multi-user.target
You could also create an environment file, /etc/default/webapp, containing a line such as PORT=8081 to override the default port. It is optional, and the rest of this example skips it, so the app listens on 8080.
What each line does, and why:
Type=exec:http.serverstays in the foreground, soexecfits. A typo in the path fails loudly atstart.User=/Group=: systemd drops privileges before running the command. The program never runs as root.WorkingDirectory=: the current directory for the process.http.serverserves files from here.Environment=: sets an environment variable.PYTHONUNBUFFERED=1makes Python flush output immediately, so log lines appear in the journal in real time instead of in bursts.PORT=8080is a default.EnvironmentFile=: readsKEY=valuelines from a file. The leading-means "ignore this if the file does not exist". Values from the file overrideEnvironment=, so the default port applies only when the file does not set one. Keeping settings and secrets in a separate file (withchmod 600) keeps them out of the unit, which anyone can read withsystemctl cat.ExecStart=: the command, with an absolute path to the program. systemd does not run it through a shell, so pipes,&&, and>do not work here. It does substitute${PORT}from the environment.Restart=on-failureandRestartSec=2: come back two seconds after a crash.WantedBy=multi-user.target: whatenablehooks into.
Common mistake
Writing ExecStart=python3 app.py > /var/log/app.log. The redirection is not shell syntax to systemd; it is passed to Python as two extra arguments. Leave output on stdout and stderr and let journald collect it. If you really need a shell, use ExecStart=/bin/bash -c '...'.
Step 4: load, start, and enable it.
Created symlink /etc/systemd/system/multi-user.target.wants/webapp.service → /etc/systemd/system/webapp.service.
● webapp.service - Tiny demo web app
Loaded: loaded (/etc/systemd/system/webapp.service; enabled; preset: enabled)
Active: active (running) since Fri 2026-10-02 11:02:13 UTC; 1s ago
Docs: https://docs.python.org/3/library/http.server.html
Main PID: 2741 (python3)
Tasks: 1 (limit: 4558)
Memory: 9.4M (peak: 9.6M)
CPU: 61ms
CGroup: /system.slice/webapp.service
└─2741 /usr/bin/python3 -m http.server 8080 --bind 0.0.0.0
Oct 02 11:02:13 mint systemd[1]: Starting webapp.service - Tiny demo web app...
Oct 02 11:02:13 mint systemd[1]: Started webapp.service - Tiny demo web app.
Step 5: test it, then test the restart policy.
curl -s http://localhost:8080/
sudo kill -9 "$(systemctl show -p MainPID --value webapp)"
sleep 3
systemctl status webapp --no-pager | head -3
<h1>Hello from webapp</h1>
● webapp.service - Tiny demo web app
Loaded: loaded (/etc/systemd/system/webapp.service; enabled; preset: enabled)
Active: active (running) since Fri 2026-10-02 11:03:40 UTC; 1s ago
The "since" time moved: systemd noticed the SIGKILL, waited 2 seconds, and started a fresh process. The journal records why:
Oct 02 11:03:38 mint systemd[1]: webapp.service: Main process exited, code=killed, status=9/KILL
Oct 02 11:03:38 mint systemd[1]: webapp.service: Failed with result 'signal'.
Oct 02 11:03:40 mint systemd[1]: webapp.service: Scheduled restart job, restart counter is at 1.
Oct 02 11:03:40 mint systemd[1]: Started webapp.service - Tiny demo web app.
Hardening in one block
systemd can sandbox a service with a few extra lines. These are safe for most web apps and are worth adding by habit:
ProtectSystem=strict makes the whole filesystem read-only for the service, so add ReadWritePaths=/var/lib/webapp (or StateDirectory=webapp) for anything it must write. Run systemd-analyze security webapp to get a score and a list of further options. Level 6 covers these mechanisms in depth.
You can also check a unit file for mistakes before installing it:
It prints nothing when the file is fine, and a message like Command /usr/local/sbin/backup.sh is not executable: No such file or directory when it is not.
A oneshot service¶
A oneshot unit runs a command to completion. It is the building block for scheduled jobs, which you will pair with timers in the next chapter:
[Unit]
Description=Write disk usage report
[Service]
Type=oneshot
ExecStart=/usr/bin/df -h --output=target,pcent /
○ disk-report.service - Write disk usage report
Loaded: loaded (/etc/systemd/system/disk-report.service; static)
Active: inactive (dead)
Oct 02 11:10:02 mint systemd[1]: Starting disk-report.service - Write disk usage report...
Oct 02 11:10:02 mint df[3012]: Mounted on Use%
Oct 02 11:10:02 mint df[3012]: / 41%
Oct 02 11:10:02 mint systemd[1]: disk-report.service: Deactivated successfully.
Oct 02 11:10:02 mint systemd[1]: Finished disk-report.service - Write disk usage report.
Notice static: there is no [Install] section, so it cannot be enabled. It is meant to be started by something else. Note also that systemctl start waited until the command finished before returning. That is the oneshot contract.
User services and lingering¶
You do not need root to use systemd. Every logged-in user has their own service manager. Put unit files in ~/.config/systemd/user/ and add --user to every command:
[Unit]
Description=Personal notes server
[Service]
ExecStart=/usr/bin/python3 -m http.server 8000 --bind 127.0.0.1 --directory %h/notes
Restart=on-failure
[Install]
WantedBy=default.target
%h is a specifier that systemd expands to your home directory. User units use default.target instead of multi-user.target.
systemctl --user daemon-reload
systemctl --user enable --now notes-server
systemctl --user status notes-server
journalctl --user -u notes-server
By default, your user manager starts when you log in and stops when your last session ends, killing your user services with it. On a server, you usually want them to keep running. That is what lingering is for:
With lingering on, alex's user manager starts at boot and keeps running without any login. Behind the scenes, enable-linger just creates an empty file, /var/lib/systemd/linger/alex.
System or user service?
Use a system service for anything that is part of the machine's job (a web app, a database, a backup). Use a user service for personal tools that belong to one account and need no root access to set up.
Reading logs with journalctl¶
journalctl with no arguments shows the whole journal, oldest first, in a pager. That is rarely what you want. The power is in filters. Your account can read the system journal on Mint because the first user is in the adm group; otherwise use sudo.
By unit:
Oct 02 10:17:01 mint CRON[44694]: pam_unix(cron:session): session opened for user root(uid=0) by root(uid=0)
Oct 02 10:17:01 mint CRON[44695]: (root) CMD (cd / && run-parts --report /etc/cron.hourly)
Oct 02 10:17:01 mint CRON[44694]: pam_unix(cron:session): session closed for user root
Oct 02 10:30:01 mint CRON[63752]: pam_unix(cron:session): session opened for user root(uid=0) by root(uid=0)
Oct 02 10:30:01 mint CRON[63752]: pam_unix(cron:session): session closed for user root
Each line is: timestamp, hostname, identifier[PID], message. -n 5 means "the last 5 entries". You can repeat -u to merge several units into one timeline.
Follow live (like tail -f):
Leave this running in one terminal while you curl the app in another. Press Ctrl+C to stop.
By boot:
journalctl --list-boots --no-pager | tail -3
journalctl -b # this boot
journalctl -b -1 # the previous boot
-2 3e1f7a92c4d84b06a1f25c9d8e0b7a41 Sun 2026-09-27 13:11:44 UTC Sun 2026-09-27 23:27:46 UTC
-1 b7d20c5e9a1f4c3d8e6b2a0f19c47d58 Mon 2026-09-28 05:26:59 UTC Mon 2026-09-28 08:16:36 UTC
0 5a8c3e1d7b2f49e0a6d4c9f01e3b8a72 Fri 2026-10-02 09:35:48 UTC Fri 2026-10-02 10:37:37 UTC
-b -1 is a lifesaver after an unexpected reboot: the last lines of the previous boot often show why it went down. This only works with persistent storage.
By priority. Every entry has a syslog priority from 0 to 7:
| Number | Name | Meaning |
|---|---|---|
| 0 | emerg |
System is unusable |
| 1 | alert |
Act immediately |
| 2 | crit |
Critical condition |
| 3 | err |
Error |
| 4 | warning |
Warning |
| 5 | notice |
Normal but significant |
| 6 | info |
Informational |
| 7 | debug |
Debug detail |
-p err shows priority err and everything more severe (0 to 3):
In a terminal, journalctl colors errors red and warnings yellow.
Common mistake
Expecting -p err to catch a service's crash output. systemd logs a service's stdout and stderr at priority info (6) unless the program marks a line with a level prefix such as <3>. A Python traceback from your service is therefore info, and -p err hides it. Filter by unit (-u) instead of by priority when debugging your own services.
By time:
journalctl --since "2026-10-02 09:00" --until "2026-10-02 10:00"
journalctl --since "1 hour ago"
journalctl --since today -u ssh
journalctl --since yesterday --until today
By field. Because entries are structured, you can match any field:
journalctl _PID=911
journalctl _UID=1000 --since today
journalctl -k # kernel messages only (like dmesg)
journalctl -t CRON # by syslog identifier
Explain and jump to the end. -e jumps to the end of the pager, and -x adds explanatory help text to some messages. Together with -u, they are the classic "why did my service fail" command:
Output formats. -o changes how entries are printed:
journalctl -u cron -n 1 -o short-iso # ISO timestamps, good for sorting
journalctl -u cron -n 1 -o cat # message only, no prefix
journalctl -u cron -n 1 -o json-pretty # every field
{
"_SYSTEMD_UNIT" : "cron.service",
"MESSAGE" : "pam_unix(cron:session): session closed for user root",
"PRIORITY" : "6",
"SYSLOG_IDENTIFIER" : "CRON",
"_PID" : "44694",
"_UID" : "0",
"_COMM" : "cron",
"_EXE" : "/usr/sbin/cron",
"_BOOT_ID" : "5a8c3e1d7b2f49e0a6d4c9f01e3b8a72",
"__REALTIME_TIMESTAMP" : "1790916421680256",
"_HOSTNAME" : "mint",
"_TRANSPORT" : "syslog",
...
}
Fields that start with _ are trusted fields added by journald itself, so a program cannot fake its PID or UID. -o json prints one JSON object per line, which is perfect for piping into jq or a Python script:
Managing journal disk usage¶
To shrink it right now, vacuum old archived files:
sudo journalctl --vacuum-size=200M # delete oldest until under 200 MB
sudo journalctl --vacuum-time=2weeks # delete entries older than two weeks
Vacuuming done, freed 241.2M of archived journals from /var/log/journal/7c1e4b0a9d2f4e6b8a3c5d7e9f1a2b3c.
Vacuuming only removes archived files, not the one being written. Run sudo journalctl --rotate first if you need to include current data.
To set a permanent cap, use a drop-in for journald instead of editing the main file:
⚠️ VM only
Practice changing system daemon configuration in your VM first.
sudo mkdir -p /etc/systemd/journald.conf.d
printf '[Journal]\nSystemMaxUse=500M\n' | sudo tee /etc/systemd/journald.conf.d/size.conf
sudo systemctl restart systemd-journald
Reading the classic log files¶
On Mint, the old-school text logs are still there:
2026-10-02T10:30:01.412093+00:00 mint CRON[63752]: pam_unix(cron:session): session closed for user root
2026-10-02T10:37:12.118422+00:00 mint systemd[1]: Starting anacron.service - Run anacron jobs...
2026-10-02T10:37:12.140176+00:00 mint systemd[1]: Started anacron.service - Run anacron jobs.
Ubuntu 24.04's rsyslog writes high-precision ISO 8601 timestamps with the time zone offset. Rotated copies are named syslog.1, syslog.2.gz, and so on. Use zgrep to search the compressed ones.
Exercises¶
Exercise 1: Explore your services (easy)¶
Without sudo, answer these about your own machine: How many services are running right now? Which timers are enabled? What file does ssh.service (or cron.service, if SSH is not installed) load from, and is it enabled? Which services, if any, have failed?
Solution
systemctl list-units --type=service --state=running --no-legend | wc -l
systemctl list-unit-files --type=timer --state=enabled
systemctl status cron --no-pager | head -3
systemctl --failed
--no-legend drops the header and footer so wc -l counts only units. The Loaded: line of status gives the file path and the enabled state. systemctl --failed prints 0 loaded units listed. on a healthy machine.
Exercise 2: Journal detective (easy)¶
Using only journalctl, find: (a) all messages of priority warning or worse from the current boot, (b) how many times cron ran a job today, and (c) the kernel's first line from this boot.
Solution
journalctl -b -p warning --no-pager | less
journalctl -u cron --since today --no-pager | grep -c 'CMD'
journalctl -k -b --no-pager | head -n 1
(a) -p warning includes everything from warning up to emerg. (b) Each job cron starts is logged with CMD (...). (c) -k restricts to kernel messages; the first line is the Linux version ... banner, the same as dmesg | head -1.
Exercise 3: A user service with no root (medium)¶
Create a user service named clock-logger that logs the current time every 10 seconds, using /bin/bash -c 'while true; do date; sleep 10; done'. Start it, follow its log for 30 seconds, then stop and disable it. No sudo needed.
Solution
mkdir -p ~/.config/systemd/user
cat > ~/.config/systemd/user/clock-logger.service <<'EOF'
[Unit]
Description=Log the time every 10 seconds
[Service]
ExecStart=/bin/bash -c 'while true; do date; sleep 10; done'
Restart=on-failure
[Install]
WantedBy=default.target
EOF
systemctl --user daemon-reload
systemctl --user start clock-logger
journalctl --user -u clock-logger -f
After Ctrl+C:
systemctl --user stop clock-logger
rm ~/.config/systemd/user/clock-logger.service
systemctl --user daemon-reload
Each date output appears as a journal line with the identifier bash. This works because systemd connects the service's stdout to journald. Here a shell is needed, so the unit runs /bin/bash -c explicitly.
Exercise 4: Break and fix a service (medium)¶
⚠️ VM only
This exercise installs and breaks a system service. Do it in your VM.
Install the webapp service from this chapter. Then break it on purpose by changing ExecStart= to point at /usr/bin/python4. Restart it. Use systemctl status and journalctl to find the error, then fix it. Finally, make it crash-loop by changing the port to 80 (which an unprivileged user cannot bind) and observe the start rate limit.
Solution
With the wrong path and Type=exec:
Job for webapp.service failed because the control process exited with error code.
See "systemctl status webapp.service" and "journalctl -xeu webapp.service" for details.
Oct 02 11:20:41 mint (python4)[3301]: webapp.service: Unable to locate executable '/usr/bin/python4': No such file or directory
Oct 02 11:20:41 mint (python4)[3301]: webapp.service: Failed at step EXEC spawning /usr/bin/python4: No such file or directory
Oct 02 11:20:41 mint systemd[1]: webapp.service: Main process exited, code=exited, status=203/EXEC
Exit status 203/EXEC is systemd's own code for "could not execute the program". It never comes from your app.
Fix the path, daemon-reload, restart. With PORT=80, Python exits with PermissionError: [Errno 13] Permission denied. Restart=on-failure restarts it every 2 seconds. Because 2 s × 5 restarts is exactly 10 s, you may or may not hit the default limit; set RestartSec=1 to see it clearly:
webapp.service: Start request repeated too quickly.
webapp.service: Failed with result 'exit-code'.
Failed to start webapp.service - Tiny demo web app.
Fix the port, then sudo systemctl reset-failed webapp && sudo systemctl restart webapp.
Exercise 5: Override without touching the package (hard)¶
⚠️ VM only
This changes a packaged system service. Do it in your VM.
In your VM, use a drop-in so that cron logs at a higher detail level by adding -L 15 to its command line (see man 8 cron for -L). Do it without editing anything in /usr/lib. Prove that the running process has the new flag, then revert.
Solution
Enter:
The empty ExecStart= clears the original list. Without it, systemd would see two commands and refuse to start a Type=simple service (Service has more than one ExecStart= setting). Revert with:
Check yourself¶
-
What is the difference between
systemctl enableandsystemctl start?Answer
startruns the unit now and has no effect on the next boot.enablereads the[Install]section and creates a symlink in a target's.wants/directory (for example/etc/systemd/system/multi-user.target.wants/), so the unit starts at boot. It does not start it now unless you add--now. -
You edited
/etc/systemd/system/webapp.servicewithnano, then ransystemctl restart webapp, but the change had no effect. Why?Answer
systemd had the old definition cached in memory. After changing unit files by hand, run
sudo systemctl daemon-reload, then restart. (systemctl editdoes the reload for you.) -
Why should you never edit files in
/usr/lib/systemd/system/, and what should you do instead?Answer
That directory belongs to packages, and
aptoverwrites the files on upgrade. Usesudo systemctl edit unitto create a drop-in at/etc/systemd/system/unit.d/override.conf./etchas higher priority, and drop-ins survive upgrades. -
A unit has
Requires=db.servicebut noAfter=. What happens at boot?Answer
Both units are started in parallel, because requirement and ordering are independent. The app may start before the database is ready. Add
After=db.serviceto order them. -
What is the difference between
Type=simple,Type=exec, andType=oneshot?Answer
simplecounts the service as started right after forking, even before the program runs.execwaits until the program binary has been executed, so a bad path makessystemctl startfail immediately.oneshotwaits for the process to exit and is meant for jobs that run to completion, such as backups. -
Which
journalctlcommand shows only errors and worse from the previous boot?Answer
journalctl -b -1 -p err.-b -1selects the previous boot (needs persistent storage) and-p errincludes priorities 0 to 3. -
Your user service dies every time you log out of SSH. What is the fix?
Answer
Enable lingering for the user:
sudo loginctl enable-linger alex. Then the user's systemd instance starts at boot and keeps running without a login session. -
Where do
/var/log/syslogentries come from on Mint, if services log to journald?Answer
journald forwards every message to rsyslog (
ForwardToSyslog=yes, set by a drop-in the rsyslog package installs). rsyslog writes them to text files according to the rules in/etc/rsyslog.d/50-default.conf.
Key takeaways¶
- systemd is PID 1. It manages units (services, sockets, timers, targets, mounts, paths) described by INI-style unit files.
- Your own units and overrides go in
/etc/systemd/system/; package units live in/usr/lib/systemd/system/and must not be edited. Usesystemctl editfor drop-ins anddaemon-reloadafter manual changes. start/stopact now;enable/disablecreate or remove boot-time symlinks;masklinks a unit to/dev/nullso nothing can start it.- A good service unit runs a foreground program with an absolute path, as a dedicated
User=, withRestart=on-failure, and logs to stdout. Wants=/Requires=say what starts together;After=/Before=say in what order. You usually need both.journalctl -u UNIT,-f,-b,-p, and--sinceanswer most "what happened?" questions.-o jsonmakes logs machine-readable.
Next¶
Services run all the time. Many jobs, such as backups and reports, should run on a schedule instead. Continue with Scheduling tasks, where you will pair the oneshot services from this chapter with timers, and compare them with cron.