TT Lab
Get started
Learn Learning paths Courses

RHEL-Family Administration

Writing a systemd Unit File Precisely

Continue in TT Lab

In one line

The most important line in a unit file is Type=. That is because it is the criterion by which systemd judges "when has this service finished starting".

Why this exists

systemctl start myapp hangs for 30 seconds and then fails. Yet the process is running fine. The cause in such a situation is usually Type.

How it works

Unit file locations and precedence

Path Precedence Purpose
/etc/systemd/system/ High Administrator's custom units (overrides)
/run/systemd/system/ Medium Created at runtime
/usr/lib/systemd/system/ Low Default units installed by packages

Do not edit a unit provided by a package directly. It gets overwritten and disappears in an update. The standard way is to create /etc/systemd/system/<유닛>.d/override.conf with systemctl edit <유닛> (the placeholder is the unit name).

Three sections

[Unit]
Description=My Web Application
Documentation=https://wiki.internal/myapp
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service
ConditionPathExists=/etc/myapp/config.yaml

[Service]
Type=notify
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
EnvironmentFile=-/etc/myapp/env
ExecStartPre=/opt/myapp/bin/check-config --validate
ExecStart=/opt/myapp/bin/server --config /etc/myapp/config.yaml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=300
StartLimitBurst=5
TimeoutStartSec=30
StandardOutput=journal
SyslogIdentifier=myapp

[Install]
WantedBy=multi-user.target

After and Requires are different. After decides only order, and Requires creates a dependency. If you set a dependency without an order, the two services can start at the same time. Usually you use the two together.

The hyphen in EnvironmentFile=- means "do not fail even if the file does not exist".

Comparing Type

Type How start completion is judged Suitable services
simple (default) As soon as the ExecStart process starts Foreground daemons
exec When the binary's exec() succeeds More accurate than simple
forking When ExecStart exits and the child remains Traditional fork daemons. Needs PIDFile=
oneshot When ExecStart has fully exited Initialization scripts
notify When the service sends sd_notify(READY=1) Services that announce readiness themselves
dbus When the D-Bus name is registered Needs BusName=

The most common mistake is Type=simple when the process daemonizes (forks and the parent exits). systemd sees the parent dying as the service ending and treats it as a failure. Conversely, if it is Type=forking but the program runs in the foreground, the parent never dies, so systemd waits forever — and then gets caught by TimeoutStartSec and kills it. This is the typical cause of "the start hangs for 30 seconds and then fails".

The production recommendation is Type=notify. You can tell exactly when the service is really ready to handle requests, so starting dependent services becomes safe.

Five frequent mistakes

  1. Using shell features in ExecStart. Pipes, redirection, variable expansion, and wildcards do not work. That is because systemd execs without a shell. If you need them, wrap it like ExecStart=/bin/bash -c '...'.
  2. A relative path. ExecStart=myapp fails. It must be an absolute path.
  3. No [Install]. Then systemctl enable does nothing. Automatic start at boot does not happen.
  4. Restart=always + RestartSec=0. A crash loop paralyzes the system. Set an upper limit with StartLimitIntervalSec/StartLimitBurst.
  5. Type=forking with no PIDFile=. systemd cannot find the main process and tracking goes off.

Security hardening

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/myapp /var/log/myapp

ProtectSystem=strict makes /usr, /boot, and /etc read-only. For paths that need writing, you give an exception with ReadWritePaths. If the service does not start after you turn this on, it is usually a missing write path.

Timers

You can use them instead of cron. A .timer and a .service are a pair.

# labhub-backup.timer
[Unit]
Description=Nightly backup

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true
Unit=labhub-backup.service

[Install]
WantedBy=timers.target

Persistent=true performs, right after boot, runs that were missed because the system was off. It is a feature cron does not have.

What it looks like in the field

systemd-analyze verify <유닛> can check the syntax and references (the placeholder is the unit). Running this before deployment is a good habit.

What you will do in the next lab

You write a unit file from scratch, make a timer pair, and build a unit verification script yourself. The grader runs it against both a good unit and a bad unit.