Writing your first systemd unit — the practical 101
Every modern Linux distro uses systemd as PID 1; sooner or later you need to run your own service under it (a custom Python app, a Go daemon, a backup script). The Internet's systemd tutorials run from one-paragraph (won't help when something breaks) to multi-thousand-word (you'll never read them). This is the in-between: enough to write a working unit, enough to debug it, no aspirational completeness.
Where unit files live
/lib/systemd/system/— units shipped by packages. Don't edit these./etc/systemd/system/— your custom units, or overrides for package-shipped ones. This is where your file goes.~/.config/systemd/user/— user-mode units (run when a specific user is logged in). Useful for desktops; rare on a VPS.
After dropping or editing a file, run systemctl daemon-reload so systemd re-reads its on-disk state. Forgetting this is the most common "why isn't my change taking effect" cause.
The minimal service unit
Drop this at /etc/systemd/system/myapp.service:
[Unit]
Description=My app
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/myapp --config /etc/myapp.yaml
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
What each line does:
After=network-online.target+Wants=...— start after the network is up. Use this for anything that binds to a port or makes outbound connections.Type=simple— your binary runs in the foreground and stays running. The default and the right choice for ~90% of services. If your app daemonizes/forks, useType=forkinginstead.User=/Group=— run as a non-root account. Create the account first withuseradd -r -s /usr/sbin/nologin myapp.ExecStart=— absolute path required. Systemd doesn't search$PATH.Restart=on-failure+RestartSec=5s— auto-restart if the process exits non-zero, with a 5-second pause to avoid hammering on persistent failures.WantedBy=multi-user.target— start at boot oncesystemctl enableis run.
Enable and start:
systemctl daemon-reload
systemctl enable --now myapp
systemctl status myapp
Reading status output
The status command shows what's going on at a glance:
● myapp.service - My app
Loaded: loaded (/etc/systemd/system/myapp.service; enabled)
Active: active (running) since Tue 2026-06-22 14:30:01 UTC; 5min ago
Main PID: 12345 (myapp)
Tasks: 8
Memory: 42.0M
CPU: 1.234s
Key signals:
- Loaded: systemd found and parsed the file. If "not-found", check the filename and run
daemon-reload. - Active: running. "failed" means it crashed; "inactive" means it stopped (could be normal, could be a crash if you expected it running).
- Main PID — the actual process. Use this in
ps,strace,lsoffor further debugging.
Logs
# Live tail (Ctrl-C to exit)
journalctl -u myapp -f
# Last 100 lines, newest first
journalctl -u myapp -n 100 --no-pager
# Since boot
journalctl -u myapp -b
# Since timestamp
journalctl -u myapp --since "2026-06-22 14:00"
If your service writes to stderr/stdout, journald captures it automatically. You don't need to wire up any logging library.
Hardening (worth adding even for tiny services)
Most of these are one-line additions under [Service] that reduce blast radius if the service is compromised:
NoNewPrivileges=true # service can't escalate via setuid binaries
PrivateTmp=true # gets its own /tmp, /var/tmp; can't see other processes' tmpfiles
ProtectSystem=strict # /usr, /boot, /etc read-only to the service
ProtectHome=true # /home, /root, /run/user not visible
ReadWritePaths=/var/lib/myapp /var/log/myapp # carve out the paths it DOES need to write
These get more aggressive (and more potentially-breaking) up the stack — start with the four above, add more once the service is running well.
Editing a package-shipped unit safely
Don't modify /lib/systemd/system/*.service — package upgrades will overwrite you. Use override drops:
# Opens an editor at /etc/systemd/system/postgresql.service.d/override.conf
systemctl edit postgresql
# After editing, systemd auto-reloads
systemctl restart postgresql
The override file gets MERGED with the package's original. To replace a setting entirely (e.g. clear an inherited ExecStart before setting your own), assign the empty string first:
[Service]
ExecStart=
ExecStart=/usr/bin/my-replacement-command
Common failure modes
- "status=203/EXEC" —
ExecStartpath doesn't exist or isn't executable. Checkls -lon the binary. - "status=1/FAILURE" — the process exited with code 1. Check the journal for what it said.
- Service starts then immediately stops, no error in journal —
Type=is wrong. If your binary forks/daemonizes, change toType=forking; if it stays foreground, ensure it's actually staying foreground (some apps need a--foregroundflag). - Permissions issues at startup — the user you specified can't read the config or write to the working dir.
journalctlwill tell you which path.
Also Read
Powered by WHMCompleteSolution