Writing a systemd Unit File Precisely
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
- 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 '...'. - A relative path.
ExecStart=myappfails. It must be an absolute path. - No
[Install]. Thensystemctl enabledoes nothing. Automatic start at boot does not happen. Restart=always+RestartSec=0. A crash loop paralyzes the system. Set an upper limit withStartLimitIntervalSec/StartLimitBurst.Type=forkingwith noPIDFile=. 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.