Unit Files
Topic 41

Unit Files

Deep Dive

A unit file is the declarative config that tells systemd how to manage one resource. The resource can be a long-running service, a listening socket, a scheduled timer, a filesystem mount, a watched path, or a target that groups other units. The format is INI-style: sections in square brackets like [Unit] and [Service], each holding Key=Value directives. You describe the desired state and the dependencies; systemd decides the order and does the work.

Where the file lives decides whether your edit survives. Vendor units ship under /lib/systemd/system (a symlink to /usr/lib/systemd/system on Ubuntu 24.04 and Debian 12); your overrides belong under /etc/systemd/system. Edit the vendor copy directly and the next apt upgrade of that package silently replaces your changes. systemd merges the two trees at load time, so the layer you write into determines whether your configuration is durable or disposable.

Unit File Anatomy

A service unit has three sections, each with a distinct job. [Unit] carries metadata and relationships: Description, ordering with After, and dependencies with Wants or Requires. [Service] defines the process itself: ExecStart for the command, ExecReload for a reload signal, Restart and RestartSec for failure handling, and User and Group to drop privileges. [Install] holds only what systemctl enable acts on — usually WantedBy, which names the target that should pull this unit in at boot.

A complete unit for a simple web daemon looks like this:

# /etc/systemd/system/widget-api.service
[Unit]
Description=Widget API server
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=notify
User=widget
Group=widget
ExecStart=/usr/local/bin/widget-api --port 8080
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

The Override Hierarchy

systemd reads units from a search path with a fixed precedence. /etc/systemd/system wins over /run/systemd/system, which wins over /usr/lib/systemd/system. A file with the same name in a higher-precedence directory replaces the lower one wholesale. That is the heavy hammer: it works, but you now own the entire unit and lose any improvements the package ships later.

The precise tool is a drop-in. systemctl edit widget-api opens an empty override.conf under /etc/systemd/system/widget-api.service.d/, and systemd merges those directives on top of the vendor file. You override only the keys you care about — bump MemoryMax, change Restart — while the rest of the vendor unit stays live and keeps tracking upstream. Use systemctl cat widget-api to print the effective merged unit, vendor file plus every drop-in, in load order.

systemd does not watch unit files on disk. After any change — a new file, an edited drop-in, a deletion — you must run systemctl daemon-reload so the manager re-reads its configuration. Until you do, systemctl keeps acting on the version it parsed at last load. systemctl edit runs the reload for you; a manual edit with an editor does not.

Unit Types

The file extension picks the unit type, and each type has its own directive set. A .service manages a process; a .socket holds a listening socket that can start its service on first connection; a .timer replaces a cron entry with systemd's own scheduling. Targets are synchronization points, not processes — multi-user.target is the rough equivalent of the old SysV runlevel 3.

ExtensionManagesCommon directives
.serviceA process or daemonExecStart, Type, Restart
.socketA listening socket for activationListenStream, Accept
.timerScheduled activation of a unitOnCalendar, OnBootSec
.targetA grouping / sync pointWants, Requires
.mountA filesystem mount pointWhat, Where, Type
.pathA watched file or directoryPathExists, PathModified

Types compose by name. A widget-api.socket and a widget-api.service pair up automatically, so the socket can listen at boot and hand the connection to the service only when traffic arrives. A .timer activates the matching .service of the same stem unless you point it elsewhere with Unit=.

Dependencies and Ordering

Dependency and ordering are two separate axes, and conflating them is the most common unit-file error. Wants and Requires declare that another unit should also be running: Wants is a soft pull where failure of the dependency is tolerated, while Requires is hard — if the required unit fails to start, your unit is not started either. Requisite is stricter still: it refuses to start unless the other unit is already active, and never triggers it. BindsTo goes further than Requires by also stopping your unit if the bound unit later stops.

After and Before control only sequence, not whether a unit is pulled in. After=postgresql.service says "if postgresql is in this transaction, start it first," but it does not request postgresql at all. You almost always pair the two: Wants=network-online.target to request it and After=network-online.target to wait for it. Without an explicit ordering directive, systemd starts units in parallel.

Resource Limits and Sandboxing

Because every service runs inside its own cgroup, the [Service] section can cap what it consumes. MemoryMax=512M sets a hard ceiling the kernel enforces by killing the service if it exceeds it; CPUQuota=50% limits it to half of one core's time. These contain a runaway process before it starves the rest of the host, and they cost nothing when the service stays within budget.

The same section hardens the service against the rest of the system. ProtectSystem=strict mounts the entire filesystem read-only except the API trees /dev, /proc, and /sys; PrivateTmp=true gives the unit its own /tmp that no other process can see; NoNewPrivileges=true blocks setuid binaries from escalating privilege. The trade-off is real: tighten ProtectSystem too far and a daemon that writes to a state directory fails until you add a ReadWritePaths= exception, so verify each change against the service's actual filesystem needs.

Common Mistakes
  • Editing the vendor unit under /lib/systemd/system directly. The next package upgrade overwrites it with no warning; use systemctl edit to write a drop-in under /etc instead.
  • Forgetting systemctl daemon-reload after editing a unit by hand. systemd keeps running the version it cached at last load, so your change appears to do nothing.
  • Confusing After= with Requires=. After sets order only and pulls nothing in; a unit that needs a database both Requires it and lists it in After.
  • Writing WantedBy= in [Install] but never running systemctl enable. The enable step is what creates the symlink, so the unit never starts at boot until you do.
  • Setting Restart=always on a unit that legitimately exits with status 0. systemd restarts it immediately, producing a tight loop; use Restart=on-failure or a oneshot type.
  • Putting shell syntax — pipes, &&, redirections, globs — directly in ExecStart. systemd runs the binary itself, not a shell, so wrap it in /bin/sh -c '...' when you need the shell.
Best Practices
  • Create overrides with systemctl edit <unit> so changes land as drop-ins under /etc/systemd/system and survive package upgrades.
  • Run systemctl daemon-reload after any on-disk unit change, then systemctl restart <unit> to apply it to the running process.
  • Inspect the effective configuration with systemctl cat <unit> before debugging, so you see the vendor file and every drop-in merged in load order.
  • Prefer Type=notify with real readiness signaling over Type=simple when the daemon supports sd_notify, so dependent units start only once the service is actually ready.
  • Add sandboxing — ProtectSystem=strict, PrivateTmp=true, NoNewPrivileges=true — and grant write access back with ReadWritePaths= only where the service needs it.
  • Set MemoryMax and CPUQuota on services that can spike, so one bad release cannot exhaust the host.
  • Validate units with systemd-analyze verify <unit> before deploying to catch typos and bad directives that a reload would otherwise accept silently.
Comparable toolsWindows Services / SCMlaunchd (macOS)SysV init scripts

Knowledge Check

You need to raise MemoryMax on a packaged service without losing the change on the next upgrade. What do you do?

  • Run systemctl edit to add a drop-in under /etc/systemd/system/<unit>.d/
  • Edit the packaged vendor unit file under /lib/systemd/system directly in place
  • Copy the vendor file to your home directory and edit it there
  • Pass MemoryMax as an argument to systemctl start

A unit must not start until PostgreSQL is running, and PostgreSQL should be pulled in if it isn't. Which directives express that?

  • Both Requires=postgresql.service and After=postgresql.service
  • After=postgresql.service alone, since ordering implies the dependency
  • Before=postgresql.service to guarantee sequence
  • Requisite=postgresql.service, which both starts it and orders it

You edited a unit file with vim and restarted the service, but the old behavior persists. What's the likely cause?

  • You skipped systemctl daemon-reload, so systemd is still running the cached unit
  • systemd watches unit files on disk and silently reloaded the wrong stale copy of it
  • A restart never re-reads the unit; only a full reboot does
  • The [Install] section was missing a WantedBy key

Why does ExecStart=/usr/bin/cat /var/log/app.log | grep ERROR fail to do what you expect?

  • systemd runs the binary directly without a shell, so the pipe is passed as arguments, not interpreted
  • Shell pipes are only ever allowed inside an ExecReload line, never inside ExecStart
  • systemd requires a separate absolute path for every single command appearing anywhere in the pipeline before it will run them
  • grep cannot run under a service unit's cgroup

You got correct