I like systemd more now than I did when I first had to live with it. My early frustration usually came from treating unit files like mysterious magic instead of what they really are: declarative service definitions with a few sharp edges. Once I started writing them carefully, validating paths, and reading logs instead of guessing, systemd became one of the more predictable parts of Linux service management.
This guide is the way I build and troubleshoot custom service units on real servers. I am not trying to document every directive. I am focusing on the handful that matter most when you need a service to start reliably at boot, restart correctly after failure, run with the right privileges, and leave enough logs behind to explain itself.
If you are migrating periodic tasks from crontab, systemd timers as a better cron replacement is the natural follow-up. If you still rely heavily on shell wrappers, my Bash scripting guide for sysadmins helps avoid a lot of service-startup pain too.
Where unit files live and how I think about them
Systemd unit files usually live in one of these places:
/usr/lib/systemd/system/or/lib/systemd/system/for package-provided units/etc/systemd/system/for local overrides and custom units
For my own services, I create files in /etc/systemd/system/.
Example:
sudo nano /etc/systemd/system/myapp.service
After creating or changing a unit file, I always run:
sudo systemctl daemon-reload
That tells systemd to reread unit definitions. Forgetting this is a classic mistake.
The three core sections: [Unit], [Service], [Install]
A basic service unit looks like this:
[Unit]
Description=My Application Service
After=network.target
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/myapp --config /etc/myapp/config.yml
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
That is enough to run a surprising number of services correctly.
[Unit]
This section describes metadata and dependencies.
Common directives I use:
Description=human-readable labelAfter=start orderingRequires=hard dependencyWants=softer dependency
[Service]
This is the heart of the unit: how to launch and manage the process.
[Install]
This defines how the unit is enabled into targets.
ExecStart is where mistakes get expensive
The most common systemd failure I see is a bad ExecStart line.
Always use the full path to the binary
Do this:
ExecStart=/usr/bin/python3 /opt/myapp/app.py
Not this:
ExecStart=python3 /opt/myapp/app.py
Systemd does not use your shell’s PATH the way you expect. I always use absolute paths for binaries and files.
Validate them before blaming systemd:
which python3
ls -l /opt/myapp/app.py
ExecStart is not a shell by default
If you need shell features like pipes, redirection, or variable expansion, wrap them explicitly:
ExecStart=/bin/bash -c '/usr/bin/mycmd | /usr/bin/tee -a /var/log/mycmd.log'
I avoid that unless necessary. Shell wrappers make quoting errors and hidden failures more likely.
ExecStartPre and ExecStartPost are useful when used sparingly
I use pre-start hooks for validation, not for building giant workflows.
Example:
[Service]
Type=simple
ExecStartPre=/usr/bin/test -f /etc/myapp/config.yml
ExecStart=/usr/local/bin/myapp --config /etc/myapp/config.yml
ExecStartPost=/usr/bin/logger -t myapp 'myapp started successfully'
ExecStartPre is good for things like:
- checking that config files exist
- creating required runtime directories in a controlled way
- validating permissions or mounted paths
I try not to bury complicated setup logic inside unit files. If it gets too large, I move it into a dedicated script and make that script safe and idempotent.
Choosing the correct Type= matters more than most people expect
Wrong Type= settings create confusing startup behavior.
Type=simple
This is the default mental model. Systemd starts the process and considers it the main service immediately.
Use it for:
- most long-running foreground daemons
- Python apps
- Go binaries
- Node apps started in the foreground
Type=simple
ExecStart=/usr/local/bin/myapp
Type=exec
This is similar to simple, but systemd waits until the execve() succeeds before treating startup as complete. I like it when I want slightly stricter behavior around launch failures.
Type=exec
ExecStart=/usr/local/bin/myapp
Type=forking
Use this for classic daemons that fork into the background themselves.
Type=forking
PIDFile=/run/legacyapp.pid
ExecStart=/usr/local/sbin/legacyapp -d
I avoid forking when I have a choice. Newer services should usually run in the foreground and let systemd manage them directly.
Type=oneshot
Use this for tasks that run once and exit.
[Service]
Type=oneshot
ExecStart=/usr/local/bin/refresh-data.sh
RemainAfterExit=yes
This is useful for setup actions, cache warm-ups, or administrative jobs that should be considered “done” after execution.
Type=notify
Use this when the daemon supports systemd readiness notifications via sd_notify.
Type=notify
ExecStart=/usr/local/bin/myapp
I use it when the software supports it properly, because it lets the service declare when it is actually ready rather than merely started.
Type=dbus
Relevant when a service acquires a D-Bus name as its readiness signal.
Type=dbus
BusName=org.example.MyService
This is less common for the kind of web infrastructure I manage, but it is worth recognizing.
Restart policy: pick one intentionally
A lot of unit files either restart too aggressively or not at all.
What I use most often
Restart=on-failure
RestartSec=5
This says: restart the service only when it exits abnormally or fails, and wait five seconds between attempts.
Other values worth knowing:
Restart=nodo not restartRestart=alwaysrestart regardless of exit statusRestart=on-abnormalrestart on signals, timeouts, and similar abnormal exits
When I do and do not use Restart=always
I use Restart=always for workers or daemons that should stay alive almost no matter what, but I avoid it for commands that exit successfully by design. Otherwise you create loops that look like persistence and feel like failure.
Check restart behavior with:
systemctl status myapp.service
journalctl -u myapp.service -b
WorkingDirectory, User, and Group: the minimum privilege set
I almost never run custom application services as root unless there is a real reason.
[Service]
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/myapp
This does three important things:
- drops privileges
- gives relative file access a known base directory
- makes ownership problems visible sooner
Create a service account if needed. I usually use a dedicated non-login account, similar to the pattern in Linux user and group management.
sudo useradd -r -s /usr/sbin/nologin -d /opt/myapp myapp
Common ownership mistake
The service runs as myapp, but config files or runtime directories are owned by root and not readable or writable. The unit looks fine; the app crashes anyway.
Verify with:
sudo -u myapp test -r /etc/myapp/config.yml && echo readable
sudo -u myapp test -w /var/lib/myapp && echo writable
Environment= and EnvironmentFile= for configuration and secrets
I avoid hardcoding environment values into unit files when they may change.
[Service]
EnvironmentFile=/etc/myapp/myapp.env
ExecStart=/usr/local/bin/myapp
Example environment file:
API_BIND=127.0.0.1:8080
LOG_LEVEL=info
DATABASE_URL=postgresql://myapp@localhost/myapp
Security note: EnvironmentFile= is convenient, but it is not a secrets vault. Protect the file with proper permissions.
sudo chown root:myapp /etc/myapp/myapp.env
sudo chmod 640 /etc/myapp/myapp.env
If the application supports reading sensitive data from a dedicated secrets manager, I prefer that. But for many internal services, a tightly permissioned environment file is the realistic compromise.
Standard output and error: journal first, file only when necessary
By default, I am happy to let systemd send output to the journal.
StandardOutput=journal
StandardError=journal
That works well with:
journalctl -u myapp.service
journalctl -u myapp.service -f
If I need a service to append to a file directly, newer systemd supports file targets:
StandardOutput=append:/var/log/myapp.log
StandardError=append:/var/log/myapp.log
I still prefer journald unless another logging pipeline requires flat files. File logging adds rotation responsibilities, which usually means you also need logrotate practices that actually work.
Dependency ordering: After=, Requires=, and WantedBy=
These are easy to misuse.
After= is ordering, not requirement
After=network.target
This means “start after the network target,” not “ensure my app has usable network connectivity.” For services that truly depend on a configured network stack, network-online.target may be more appropriate.
After=network-online.target
Wants=network-online.target
Requires= is a hard dependency
Requires=postgresql.service
After=postgresql.service
If PostgreSQL fails, systemd treats your service accordingly. I use Requires= when failure really should block the dependent service.
WantedBy= controls enablement target
Most normal server daemons use:
[Install]
WantedBy=multi-user.target
Then enable it:
sudo systemctl enable myapp.service
The systemctl commands I use constantly
sudo systemctl daemon-reload
sudo systemctl enable myapp.service
sudo systemctl disable myapp.service
sudo systemctl start myapp.service
sudo systemctl stop myapp.service
sudo systemctl restart myapp.service
sudo systemctl reload myapp.service
sudo systemctl status myapp.service
If the application supports reload semantics, reload is nicer than full restart for configuration changes.
To verify enablement:
systemctl is-enabled myapp.service
systemctl is-active myapp.service
Reading logs properly with journalctl
Most systemd troubleshooting ends in the journal.
Show recent service logs:
journalctl -u myapp.service -n 50 --no-pager
Follow logs live:
journalctl -u myapp.service -f
Show logs from the current boot only:
journalctl -u myapp.service -b
I use the journal before editing the unit again. Too many people change the file three times before reading the error that already explained the problem.
Verify the unit definition itself
Systemd can validate unit syntax before you get too deep into troubleshooting:
sudo systemd-analyze verify /etc/systemd/system/myapp.service
I use this more often than I used to. It catches surprisingly mundane issues like malformed directives, ordering mistakes, and typos that are easy to miss in a long file.
Override files: use systemctl edit instead of modifying packaged units directly
When a package installs a unit, I avoid editing it in place. Package upgrades will overwrite it.
Use:
sudo systemctl edit nginx.service
That creates a drop-in under:
/etc/systemd/system/nginx.service.d/override.conf
Example override:
[Service]
Environment=EXTRA_OPTS=--with-feature
Restart=on-failure
To inspect the effective unit:
systemctl cat nginx.service
This is a cleaner pattern than patching vendor files directly.
Security hardening directives worth using
Systemd can add real hardening around services if the application tolerates it.
A practical baseline I test first:
[Service]
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=full
ProtectHome=yes
What they do broadly:
NoNewPrivileges=yesprevents privilege escalation via execPrivateTmp=yesgives the service a private/tmpProtectSystem=fullmakes much of the system read-only to the serviceProtectHome=yesrestricts access to home directories
Other useful directives depending on the app:
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
ReadWritePaths=/var/lib/myapp /var/log/myapp
I add these incrementally and test, because aggressive hardening can break legitimate behavior.
There is also a good built-in review tool:
systemd-analyze security myapp.service
I do not treat its score as absolute truth, but it is a useful prompt. If it points out that the service still runs as root with broad filesystem access, that is usually a real improvement opportunity rather than theoretical noise.
Socket activation basics
Socket activation is one of those features I use selectively. Systemd can listen on a socket and start the service only when traffic arrives.
Example socket unit:
[Unit]
Description=MyApp Socket
[Socket]
ListenStream=127.0.0.1:9000
[Install]
WantedBy=sockets.target
Then the service unit can be started on demand. This is useful for:
- low-traffic internal services
- reducing always-on daemon count
- certain admin tools
I do not force socket activation everywhere. For high-traffic web apps, a normal always-running service is often simpler.
Common mistakes I keep seeing
Missing full path in ExecStart
This is probably number one.
Wrong Type= for a forking daemon
Systemd thinks the process died when it really daemonized correctly, or vice versa.
Forgetting daemon-reload
You edit the unit, restart the service, and wonder why nothing changed.
Running as a user without access to config or runtime directories
The unit looks secure, but the service fails immediately.
Using shell syntax directly in ExecStart
Redirection, pipes, and variable expansion do not behave like they do in an interactive shell unless you explicitly invoke one.
Putting secrets directly into the unit file
I still encounter tokens, passwords, and database URLs embedded in ExecStart= lines. That leaks sensitive values into unit definitions, process listings, and sometimes deployment tooling. A protected EnvironmentFile= is not perfect, but it is usually better than hardcoding secrets in the unit itself.
Forgetting that After=network.target is not a reachability guarantee
The service may start after the network stack initializes and still fail because DNS, routes, or remote dependencies are not ready. When startup order truly matters, I test whether network-online.target or explicit retry logic inside the application is the better fit.
A full example I would actually deploy
[Unit]
Description=Example API Service
After=network-online.target
Wants=network-online.target
[Service]
Type=exec
User=exampleapi
Group=exampleapi
WorkingDirectory=/opt/exampleapi
EnvironmentFile=/etc/exampleapi/exampleapi.env
ExecStartPre=/usr/bin/test -f /etc/exampleapi/exampleapi.env
ExecStart=/opt/exampleapi/bin/exampleapi --config /etc/exampleapi/config.yml
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=full
ProtectHome=yes
ReadWritePaths=/var/lib/exampleapi
[Install]
WantedBy=multi-user.target
Validation sequence:
sudo systemctl daemon-reload
sudo systemctl enable --now exampleapi.service
sudo systemctl status exampleapi.service
journalctl -u exampleapi.service -n 50 --no-pager
That sequence catches most problems quickly.
Conclusion
Writing good systemd units is mostly about being explicit. Use full paths. Choose the right Type=. Drop privileges with User= and Group=. Keep configuration in an EnvironmentFile= when appropriate. Read logs from journalctl before guessing. And when you need customizations, use drop-ins instead of editing package units directly.
Systemd is not as mysterious as it first looks. In day-to-day operations, it rewards clean unit files, minimal surprises, and consistent validation. Once you get those habits in place, custom services stop feeling fragile and start behaving like first-class citizens on the system.