systemd Units: How Services, Sockets and Timers Actually Work
This article walks through what a unit is, where its configuration lives, how the priority order between directories works, and how to read and control units with `systemctl`. It assumes you have root or `sudo` access to a Linux box running systemd 250 or newer — that covers RHEL 9, Rocky 9, AlmaLinux 9, Debian 12, Ubuntu 22.04 and 24.04. Older releases (RHEL 8, Ubuntu 20.04) ship systemd 239 and 245; almost everything here works there too, but a few flags differ and I'll note them where relevant.
What a unit actually is
A unit is not a config file. It is a resource systemd tracks — a service, a mount point, a timer, a socket, a device — and the file on disk is just the description of that resource.That distinction matters. Systemd keeps state for every unit: whether it is active, what its dependencies are, which process belongs to it. The file tells systemd how to build that state. Change the file and you have not changed the running unit until you tell systemd to reload.
Every unit file ends in an extension that declares its type. `nginx.service` is a service unit. `sshd.socket` is a socket unit. `logrotate.timer` is a timer. Systemd reads the extension first and picks the matching parser. Get the extension wrong and systemd will refuse the file outright.
There are eleven unit types in current systemd: `service`, `socket`, `target`, `device`, `mount`, `automount`, `swap`, `timer`, `path`, `slice` and `scope`. You will spend most of your time with `.service`, `.socket`, `.timer` and `.target`. The rest exist for cases where you need them and are invisible when you don't.
Unit file format
Unit files use an INI-style format: sections in square brackets, key-value pairs underneath.```ini [Unit] Description=Example web service After=network-online.target Wants=network-online.target
[Service] Type=simple ExecStart=/usr/local/bin/example --port 8080 Restart=on-failure User=example
[Install] WantedBy=multi-user.target ```
Three sections do most of the work. `[Unit]` holds metadata and dependency ordering. `[Service]` (or `[Socket]`, `[Timer]`, etc.) holds the type-specific behavior. `[Install]` tells systemd what to do when you run `systemctl enable`.
The `[Install]` section is the one people forget. Without `WantedBy=multi-user.target`, `systemctl enable` has nothing to hook into and the unit will not start on boot even though the command appears to succeed.
Where unit files live
Three directories matter, and they are read in a fixed priority order.`/usr/lib/systemd/system/` holds the units shipped by your distribution and by packages. On Debian and Ubuntu you may also see `/lib/systemd/system/` — it is a symlink to the same place. Never edit files here. A package upgrade will overwrite your changes without warning.
`/run/systemd/system/` holds units generated at runtime, usually by unit generators. It is a tmpfs, so anything in it disappears on reboot. You rarely touch this directory directly.
`/etc/systemd/system/` is yours. Files here override files with the same name in `/usr/lib/systemd/system/`. This is where you put custom units and where you put drop-in overrides.
The priority is `/etc/` > `/run/` > `/usr/lib/`. If `nginx.service` exists in both `/usr/lib/systemd/system/` and `/etc/systemd/system/`, systemd uses the one in `/etc/` and does not merge them — it picks the higher-priority file wholesale.
That is a trap. Copying a packaged unit into `/etc/` to change one line means you freeze the whole file. When the package updates the unit upstream, your copy stays stale. Use a drop-in instead:
```bash sudo mkdir -p /etc/systemd/system/nginx.service.d sudo tee /etc/systemd/system/nginx.service.d/override.conf > /dev/null <<'EOF' [Service] LimitNOFILE=65535 EOF sudo systemctl daemon-reload ```
A drop-in merges into the unit instead of replacing it. Only the keys you set are overridden. This is almost always what you want.
Managing units with systemctl
`systemctl` is the interface. The commands you will type most often:```bash sudo systemctl start nginx.service sudo systemctl stop nginx.service sudo systemctl restart nginx.service sudo systemctl reload nginx.service sudo systemctl enable nginx.service sudo systemctl disable nginx.service sudo systemctl status nginx.service ```
`restart` kills and starts the process. `reload` sends `SIGHUP` and asks the service to re-read its config without dropping connections. Not every service implements reload — check `systemctl show nginx.service -p CanReload` before relying on it in a deploy script.
`enable` creates the symlinks described in the `[Install]` section. It does not start the unit. `enable --now` does both, which is what you usually mean.
To see what is running:
```bash systemctl list-units --type=service --state=running systemctl list-units --failed systemctl list-dependencies nginx.service ```
`list-units --failed` is the first command to run when something is wrong on a server you did not build.
Dependencies and ordering
Systemd starts services in parallel. That is the whole point — a SysV-style boot that starts things one at a time wastes seconds on every reboot. Parallel startup only works if services declare their relationships, and that is what the `[Unit]` section is for.Two separate things get conflated here, and getting them confused is the most common source of "why did my service start before the network" bugs.
Ordering directives say when. `After=network-online.target` means start this unit after that one. `Before=` is the reverse. Ordering alone does not pull anything in — if the target is not scheduled to start, `After=` does nothing.
Requirement directives say whether. `Requires=foo.service` means if `foo` fails, this unit fails too. `Wants=foo.service` means try to start `foo`, but do not fail if it does not come up. `BindsTo=` is stricter than `Requires` — the unit stops if the dependency stops.
The practical combination for a service that needs the network is:
```ini [Unit] After=network-online.target Wants=network-online.target ```
`Wants` pulls the target in; `After` orders against it. Use `Requires` only when the dependency failing genuinely makes your service useless. Overusing `Requires` turns one broken unit into five.
Sockets, timers and targets
A socket unit listens on a port or Unix socket and starts the matching service on first connection. This is lazy loading — the service consumes no memory until someone connects. For services that get hit rarely, this saves real RAM.```ini
/etc/systemd/system/example.socket
[Unit] Description=Example socket[Socket] ListenStream=8080 Accept=no
[Install] WantedBy=sockets.target ```
The matching `example.service` must not also bind port 8080 — systemd passes the listening file descriptor to the service on start. With `Accept=no`, one service instance handles all connections; with `Accept=yes`, systemd spawns one process per connection. The second mode is for `inetd`-style services and is rarely what you want for a web app.
Timers replace `cron` for anything you want logged, ordered against other units, or run with a specific user. A timer unit needs a matching service unit of the same name.
```ini
/etc/systemd/system/backup.timer
[Unit] Description=Run backup daily[Timer] OnCalendar=daily Persistent=true
[Install] WantedBy=timers.target ```
`Persistent=true` catches up a missed run after downtime. Test your calendar expression before trusting it:
```bash systemd-analyze calendar "daily" ```
Targets group units. `multi-user.target` is the equivalent of SysV runlevel 3 — the normal boot state for a server without a GUI. `graphical.target` adds the display manager. When you write `WantedBy=multi-user.target`, you are saying "this unit belongs to the normal boot set."
Verifying it worked
After creating or editing a unit:```bash sudo systemctl daemon-reload sudo systemctl enable --now example.service systemctl status example.service ```
`daemon-reload` is not optional. Systemd caches unit files; without a reload it will keep using the old version even after you save the new one. If you skip it and wonder why your change had no effect, that is why.
A healthy `status` output shows `Active: active (running)` and a process tree. A failing one shows `Active: failed` with the exit code. For the full log:
```bash journalctl -u example.service -n 50 --no-pager journalctl -u example.service -f ```
The second command follows the log live. Leave it running in one terminal while you restart the service in another.
To check whether a unit will start at boot:
```bash systemctl is-enabled example.service ```
`enabled` means yes. `disabled` means no. `static` means the unit has no `[Install]` section and cannot be enabled — it starts because something else pulls it in.
Common problems
Unit starts but exits immediately. Read `journalctl -u```ini ExecStart=/bin/bash -c 'exec /usr/local/bin/app --flag >> /var/log/app.log 2>&1' ```
Changes to the unit file have no effect. You forgot `systemctl daemon-reload`. Run it after every edit, then `systemctl restart
Service works when started manually but fails on boot. It is starting before something it needs. Add `After=` and `Wants=` for the missing dependency, or `After=network-online.target` if the service makes outbound connections. `systemd-analyze critical-chain What this costs you
Systemd is not the only init system. Alpine ships OpenRC, Void ships runit, and both are simpler to reason about. A runit service is a shell script and a symlink. OpenRC uses plain text files with no dependency graph to speak of.
What you give up with those is exactly what systemd does well: parallel startup with declared ordering, per-service resource limits via cgroups, socket activation, timer units that survive reboots, and a single log interface through the journal. On a single-purpose VPS, runit is plenty. On a fleet of servers where you need to know which unit failed and why, the dependency graph earns its complexity.
The unit file format is the price of admission. Learn to read `[Unit]`, `[Service]` and `[Install]`, learn the difference between ordering and requiring, and the rest is lookup. Skip that and every incident becomes a `journalctl` session with no map.