open atlas
↑ Back to track
Linux, the operating system LIN · 03 · 02

Writing a service unit file

A systemd service unit has three sections: [Unit] for metadata and deps, [Service] for process config, [Install] for boot-time wiring. ExecStart, Type, Restart, User, and WorkingDirectory are the five knobs you will set on every service you write.

LIN Middle ◷ 22 min
Level
FoundationsJuniorMiddleSenior

You have a Python API server that works perfectly when you run it manually. But when you leave, it stops. When the server reboots, it does not come back. When it crashes, nobody notices for an hour. All three problems have the same fix: a service unit file. Twelve lines of INI syntax and your process becomes a first-class systemd citizen — supervised, restartable, logged, and boot-persistent. The unit file is not boilerplate you copy from Stack Overflow and forget. Every key is a deliberate choice that determines how your process behaves when it fails at 3am.

Goal

After this lesson you can write a minimal but production-grade unit file, explain what Type=simple, Type=forking, and Type=notify mean and when to use each, configure automatic restarts and run-as-non-root, place the file in the right location, and reload systemd after making changes.

1

A unit file has three sections. Knowing which key goes in which section removes most of the confusion. Place the file in /etc/systemd/system/myapp.service:

[Unit]
Description=My Python API
After=network.target

[Service]
ExecStart=/usr/bin/python3 /opt/myapp/server.py
Restart=on-failure
User=myapp
WorkingDirectory=/opt/myapp

[Install]
WantedBy=multi-user.target

[Unit] contains metadata and dependency declarations — everything that is true regardless of what type of unit this is. [Service] contains the process-specific configuration — how to start it, what user to run as, restart behaviour. [Install] contains the wiring for systemctl enable — which target should pull this service in when enabled.

2

Type= tells systemd how your process signals that it is ready. Getting this wrong causes silent bugs — systemd may declare the service “active” before it is actually serving requests, or may time out waiting for readiness.

TypeWhen systemd considers the service “up”Use when
simple (default)Immediately after fork(), before the process does anythingProcess stays in foreground; most modern daemons
forkingAfter the parent process exitsProcess double-forks to background (old-school daemons, e.g. legacy nginx)
notifyWhen the process sends sd_notify(READY=1)Process explicitly signals readiness (systemd-native daemons)
oneshotAfter ExecStart exits (for scripts that run and finish)Migration jobs, one-time setup
# A modern foreground daemon (Node.js, Python uvicorn, Go http.ListenAndServe)
[Service]
Type=simple
ExecStart=/usr/bin/node /opt/app/index.js

# Legacy nginx that double-forks
[Service]
Type=forking
PIDFile=/run/nginx.pid
ExecStart=/usr/sbin/nginx

If you set Type=forking for a process that does not fork, systemd will wait for the parent to exit — which it never will — and timeout after 90 seconds, marking the service failed.

3

Restart= controls what happens when your process exits unexpectedly. This is one of the most important directives — it is the difference between “paged at 3am” and “service recovered automatically”:

ValueRestarts onUse when
no (default)NeverOneshot tasks; manual control
on-failureNon-zero exit or signalProduction services — restart on crash but not on clean stop
alwaysAny exit including exit 0Persistent services that should never be down
on-abnormalSignal or watchdog timeout (not clean exit)Similar to on-failure but excludes non-zero exit
[Service]
Restart=on-failure
RestartSec=5s          # Wait 5 seconds before restarting (avoids tight loops)
StartLimitIntervalSec=60s  # Window for counting restart attempts
StartLimitBurst=3      # If it restarts 3 times in 60s, give up and enter failed state

Without RestartSec, a crashing service will restart immediately and may spam logs or cause CPU load from a tight restart loop. The StartLimitBurst / StartLimitIntervalSec pair prevents the service from restarting forever if there is a hard configuration error.

4

User=, Group=, and WorkingDirectory= are non-negotiable for production services. Running as root is a security risk — a compromised process gets the keys to the kingdom:

[Service]
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
RuntimeDirectory=myapp        # Creates /run/myapp owned by User= at start
StateDirectory=myapp          # Creates /var/lib/myapp owned by User= at start
LogsDirectory=myapp           # Creates /var/log/myapp owned by User= at start

RuntimeDirectory, StateDirectory, and LogsDirectory are the clean systemd way to create and own directories at service start — no ExecStartPre=mkdir hacks needed, and the directories are created with the right ownership automatically.

The myapp user should be a system user created at deployment time:

sudo useradd --system --no-create-home --shell /usr/sbin/nologin myapp
5

After writing or editing a unit file, you must tell systemd to re-read it with daemon-reload. systemd caches the unit files it reads. If you skip daemon-reload, it will continue running the old definition even if you restart the service:

# After creating or editing /etc/systemd/system/myapp.service
sudo systemctl daemon-reload

# Now the new definition is active — start or restart
sudo systemctl enable --now myapp.service

# Verify it is running as expected
systemctl status myapp.service

# Watch live logs for this service
journalctl -u myapp.service -f

The most common support question: “I updated the unit file but nothing changed.” Nine times out of ten, daemon-reload was skipped.

Worked example

Turn a hand-run script into a supervised service.

A team has a background job worker.py that they have been running in a tmux session. It needs to survive reboots and restart on crash.

# /etc/systemd/system/worker.service
[Unit]
Description=Background task worker
After=network.target postgresql.service
Wants=postgresql.service

[Service]
Type=simple
User=worker
Group=worker
WorkingDirectory=/opt/worker
ExecStart=/opt/worker/venv/bin/python worker.py
Environment=DATABASE_URL=postgresql://localhost/mydb
Restart=on-failure
RestartSec=10s
StartLimitIntervalSec=120s
StartLimitBurst=5
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

Deploy and start:

sudo systemctl daemon-reload
sudo systemctl enable --now worker.service
systemctl status worker.service
journalctl -u worker.service --since "5 minutes ago"

Key choices explained: After=postgresql.service ensures postgres is up before worker tries to connect. Environment= injects config without a .env file. StandardOutput=journal routes stdout into the journal so journalctl -u worker shows everything.

Common mistake

Do not put secrets in the Environment= line of a unit file — those values are visible in systemctl show output and the journal, and the file itself is world-readable on most distros. Instead use EnvironmentFile=/etc/worker.env with chmod 600 on the file and ownership by the service user. Even better on modern Ubuntu/Debian: use LoadCredential= (systemd v250+) to inject secrets through the credential store.

Why this works

The /etc/systemd/system/ vs /lib/systemd/system/ split exists because package managers own /lib/. When apt upgrade nginx runs, it overwrites /lib/systemd/system/nginx.service with the package’s version. Files in /etc/systemd/system/ are never touched by the package manager. You can also use drop-in overrides — create /etc/systemd/system/nginx.service.d/override.conf and put only the keys you want to change there. Drop-ins are merged at runtime; you override the specific directive without copying the entire unit file.

Check yourself
Quiz

You edit /etc/systemd/system/myapp.service to add Restart=on-failure, then run `sudo systemctl restart myapp`. Later you observe that the service still does not restart when it crashes. What did you most likely forget?

Recap

A systemd service unit file has three mandatory sections: [Unit] for metadata and dependency declarations (Description, After, Requires), [Service] for process configuration (ExecStart, Type, Restart, User, WorkingDirectory), and [Install] for boot-time wiring (WantedBy). Type=simple is correct for foreground daemons; Type=forking for processes that double-fork; Type=notify for systemd-native daemons. Restart=on-failure with a RestartSec prevents crash loops from becoming outages. Always run daemon-reload after editing a unit file — skipping it is the number-one cause of “my change did nothing.” Put local unit files in /etc/systemd/system/, never in /lib/systemd/system/.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 4 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources1
expand
  1. 01

Trademarks belong to their respective owners. Editorial reference only.