Quick summary: systemd is the init system on every major Linux distribution in 2026. Writing your own unit files turns "I have a script that should run as a service" into a properly-managed daemon with logging, restart policies, sandboxing, and dependency management. This tutorial walks through building a complete service unit from scratch โ every directive explained, every sandboxing option you should add for security, how to inspect status and logs with journalctl, and how to companion the service with a timer unit for scheduled execution.
Why Write Unit Files Yourself?
Almost every long-running process on a modern Linux box runs as a systemd service. When you install nginx, the package manager drops a unit file into /lib/systemd/system/nginx.service. When you write your own application, you should drop your own unit file into /etc/systemd/system/myapp.service.
The alternatives โ running your script in nohup, in a screen session, in cron โ are all worse. systemd gives you:
- Automatic restart on crash
- Centralized logging via journalctl
- Dependency management (start after the database, stop before the firewall)
- Resource limits (CPU, memory, file descriptors)
- Sandboxing (read-only filesystem, no network, no privilege escalation)
- Standardized lifecycle commands (start, stop, restart, reload, status)
Once you write one unit file, you will never go back to nohup.
The Anatomy of a Unit File
A unit file is plain text in INI-style format. Three sections matter for service units:
[Unit]
# Metadata, dependencies, ordering
[Service]
# What to run, how to run it, restart policy, sandboxing
[Install]
# How the unit gets enabled (which target it attaches to)
Save unit files in /etc/systemd/system/ for system-wide custom services. Always end the filename in .service for service units, .timer for timers, .socket for socket activation, .target for groupings.
Building Your First Unit, Step by Step
Let us build a unit for a hypothetical Python web service that runs python3 /opt/myapp/server.py --port 8080. We will start minimal and add features.
Step 1: The minimal viable unit
# /etc/systemd/system/myapp.service
[Unit]
Description=My Python Application
[Service]
ExecStart=/usr/bin/python3 /opt/myapp/server.py --port 8080
[Install]
WantedBy=multi-user.target
Three directives, four lines of meaningful content, and you have a working service. Try it:
sudo systemctl daemon-reload
sudo systemctl start myapp
sudo systemctl status myapp
This works but is missing important pieces. Let us add them.
Step 2: Add a dedicated user
Running as root is the wrong default. Create a dedicated user for the service:
sudo useradd --system --no-create-home --shell /sbin/nologin myapp
Then add to the unit:
[Service]
User=myapp
Group=myapp
ExecStart=/usr/bin/python3 /opt/myapp/server.py --port 8080
Why it matters: If your service is ever exploited, the attacker gets the privileges of the user running it. Don't let that be root.
Step 3: Working directory and environment
[Service]
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
Environment="PYTHONUNBUFFERED=1"
EnvironmentFile=-/etc/myapp/myapp.env
ExecStart=/usr/bin/python3 /opt/myapp/server.py --port 8080
WorkingDirectory sets cwd. Environment sets specific env vars. EnvironmentFile loads them from a file โ the leading dash means "skip silently if the file doesn't exist," which is handy for secrets that may not be present in dev.
Step 4: Restart policy
[Service]
Restart=on-failure
RestartSec=5s
StartLimitBurst=5
StartLimitIntervalSec=60s
Restart=on-failureโ restart if the process exits with a non-zero code or is killed by a signal (excluding clean shutdown signals). Other options:always,on-success,on-abnormal,on-watchdog,on-abort.RestartSec=5sโ wait 5 seconds before restarting. Prevents tight crash loops.StartLimitBurst+StartLimitIntervalSecโ give up if the unit fails 5 times in 60 seconds. Otherwise systemd marks it as failed and stops trying.
Step 5: Sandboxing โ the directives every modern unit should have
This is where systemd shines. A handful of directives turn your service into a properly-isolated daemon.
[Service]
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
ProtectClock=true
ProtectKernelLogs=true
ProtectHostname=true
RestrictNamespaces=true
RestrictRealtime=true
RestrictSUIDSGID=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
ReadWritePaths=/var/lib/myapp /var/log/myapp
That looks like a lot, but each line is one specific isolation:
NoNewPrivilegesโ process cannot gain new privileges via setuid binaries.PrivateTmpโ its own /tmp, isolated from other processes.PrivateDevicesโ only essential /dev devices.ProtectSystem=strictโ entire root filesystem read-only.ProtectHomeโ /home, /root, /run/user inaccessible.Protect*series โ various kernel surfaces hidden.ReadWritePathsโ explicit allowlist for the few paths that need write access.SystemCallFilterโ seccomp filter restricting available syscalls.
The systemd-analyze security myapp.service command scores your unit on these isolations from 0 (perfect) to 10 (no isolation). Aim for under 3 in production.
Step 6: The full production-ready unit
# /etc/systemd/system/myapp.service
[Unit]
Description=My Python Application
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service
[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
Environment="PYTHONUNBUFFERED=1"
EnvironmentFile=-/etc/myapp/myapp.env
ExecStart=/usr/bin/python3 /opt/myapp/server.py --port 8080
ExecReload=/bin/kill -HUP $MAINPID
# Restart policy
Restart=on-failure
RestartSec=5s
StartLimitBurst=5
StartLimitIntervalSec=60s
# Sandboxing
NoNewPrivileges=true
PrivateTmp=true
PrivateDevices=true
ProtectSystem=strict
ProtectHome=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true
SystemCallArchitectures=native
SystemCallFilter=@system-service
ReadWritePaths=/var/lib/myapp /var/log/myapp
# Resource limits
LimitNOFILE=65536
MemoryMax=2G
CPUQuota=200%
[Install]
WantedBy=multi-user.target
That is a production-ready service unit. Reload, enable, start:
sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp
sudo systemctl status myapp
Service Types Explained
Type= tells systemd how to determine that the service has started successfully:
- simple (default) โ the process started by ExecStart is the main process; systemd considers the unit started immediately.
- exec โ like simple but waits for exec() to complete before considering started.
- forking โ the ExecStart process forks and exits; the child is the daemon. Common for traditional Unix daemons.
- oneshot โ runs once and exits; the unit is "active" only while it runs. Useful for setup tasks.
- notify โ service explicitly tells systemd via
sd_notify()when it is ready. Cleanest for modern services. - idle โ like simple but waits for other startup jobs to complete.
For most modern applications, simple is fine. For services where startup involves loading large data or making remote connections, notify is better โ it lets dependent services wait until the dependency is actually ready, not just started.
Reading Logs With journalctl
systemd captures stdout and stderr of every service automatically. Read them with:
# All logs for the unit
journalctl -u myapp.service
# Follow live (like tail -f)
journalctl -u myapp.service -f
# Since 1 hour ago
journalctl -u myapp.service --since "1 hour ago"
# Only errors and worse
journalctl -u myapp.service -p err
# JSON output for programmatic processing
journalctl -u myapp.service -o json
# Last 100 lines
journalctl -u myapp.service -n 100
# Logs from previous boot
journalctl -u myapp.service -b -1
The journal is structured โ each log entry has fields (priority, timestamp, PID, etc.) accessible via -o verbose. Filter by any field:
journalctl _SYSTEMD_UNIT=myapp.service _PID=1234
Adding a Timer Companion
For scheduled execution, systemd timers replace cron. Create two files:
The service (oneshot)
# /etc/systemd/system/myapp-cleanup.service
[Unit]
Description=Daily cleanup for myapp
After=network-online.target
[Service]
Type=oneshot
User=myapp
ExecStart=/usr/bin/python3 /opt/myapp/cleanup.py
The timer
# /etc/systemd/system/myapp-cleanup.timer
[Unit]
Description=Run myapp cleanup daily
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=15min
[Install]
WantedBy=timers.target
Enable the timer (not the service):
sudo systemctl daemon-reload
sudo systemctl enable --now myapp-cleanup.timer
systemctl list-timers
Key directives:
OnCalendar=*-*-* 03:00:00โ daily at 3am. Same syntax assystemd.time(7)manpage.Persistent=trueโ if the system was down at the scheduled time, run on next boot.RandomizedDelaySec=15minโ add up to 15 minutes random delay (avoids the "thundering herd" problem when many machines run the same job).
Why timers instead of cron? You get journalctl logs, restart policies if the job fails, sandboxing, dependency management, and standardized lifecycle commands. Cron is fine for simple cases; timers scale better.
Common Beginner Mistakes
1. Forgetting daemon-reload
After editing a unit file, you must run systemctl daemon-reload for systemd to see the changes. The error you get otherwise is "Warning: The unit file changed on disk."
2. Using shell features in ExecStart
ExecStart does not run in a shell. ExecStart=/usr/bin/echo $HOME > /tmp/log does not work โ there is no shell to expand the variable or do the redirect. To use shell features, run a shell explicitly: ExecStart=/bin/bash -c "echo $HOME > /tmp/log".
3. Wrong path in ExecStart
ExecStart requires absolute paths. ExecStart=python3 myapp.py fails; ExecStart=/usr/bin/python3 /opt/myapp/myapp.py works.
4. Not handling SIGTERM gracefully
systemd sends SIGTERM on stop, then SIGKILL after a timeout (default 90s). If your application does not handle SIGTERM, you will lose in-flight work on every restart. Always implement a graceful-shutdown signal handler.
5. Forgetting to enable
systemctl start myapp runs it now; systemctl enable myapp makes it run on boot. systemctl enable --now myapp does both.
Debugging When Things Go Wrong
The standard debug sequence when a service won't start:
# 1. Check status โ usually tells you the immediate failure reason
sudo systemctl status myapp
# 2. Check the journal for full error context
sudo journalctl -u myapp -n 50
# 3. Validate the unit file syntax
systemd-analyze verify myapp.service
# 4. Check security score and isolation
systemd-analyze security myapp.service
# 5. Try running ExecStart manually as the service user
sudo -u myapp /usr/bin/python3 /opt/myapp/server.py --port 8080
The fifth step catches the highest percentage of "works for me, fails for systemd" issues โ usually permission problems on log files, missing environment variables, or sandboxing too aggressive for the actual code paths.
Templating Units for Multiple Instances
If you need to run the same service multiple times with different parameters (one per tenant, one per port, one per worker), use template units. The filename ends with @ and an instance string is passed at activation time:
# /etc/systemd/system/myapp@.service
[Unit]
Description=My Python Application instance %i
[Service]
User=myapp
ExecStart=/usr/bin/python3 /opt/myapp/server.py --port %i
Restart=on-failure
Then start named instances:
sudo systemctl start myapp@8080.service
sudo systemctl start myapp@8081.service
sudo systemctl enable myapp@8082.service
The %i specifier is replaced with the instance string. There is also %I (unescaped), %n (full unit name), %p (prefix before @), %H (hostname), and many more โ see the systemd.unit(5) manpage for the complete list. Template units are the right pattern for any "one service per shard / port / tenant" architecture.
Frequently Asked Questions
Where should I put my unit file?
/etc/systemd/system/ for hand-written custom services. /usr/lib/systemd/system/ is for distribution-packaged units. Never edit /usr/lib files directly; copy to /etc/systemd/system/ if you need to override.
How do I override a packaged unit?
sudo systemctl edit nginx.service opens an override file. Anything you put there merges with (or overrides) the original. Cleaner than copy-and-edit.
What's the difference between Wants and Requires?
Requires is hard โ if the dependency fails, this unit also fails. Wants is soft โ start the dependency if possible, but proceed regardless. Use Wants in most cases.
How do I run a service as a regular user?
User services live in ~/.config/systemd/user/ and are managed with systemctl --user. Useful for per-user daemons (sync clients, dev tools).
What about resource limits beyond what I showed?
systemd exposes most cgroup controls: MemoryHigh, MemoryMax, CPUQuota, TasksMax, IOWeight, BlockIODeviceWeight. The systemd.resource-control(5) manpage is the authoritative reference.
Should I use Type=notify?
If your application can call sd_notify() (Python: sdnotify package, Go: github.com/coreos/go-systemd, etc.), yes โ it gives systemd accurate "ready" timing. If not, simple is fine.
One Real Production Unit We Maintain
The unit file pattern in this article is what we actually use for our internal Go services in 2026. Adding the sandboxing block to every service was the single highest-ROI security improvement we made in the past two years โ it caught two would-be incidents (an exploited Python dep that tried to write to /etc, a misconfigured deploy script that tried to read /home/admin/.ssh) where the systemd sandboxing prevented the actual harm. The directives are boilerplate, but the boilerplate is your defense in depth.
Further Reading from the Dargslan Library
- Linux Tutorials category โ systemd, init systems, process management, and service architecture.
- Security & Hardening category โ sandboxing, seccomp, capabilities, and least-privilege patterns.
- Free cheat sheet library โ printable references for systemctl, journalctl, and unit file syntax.
- Dargslan eBook library โ comprehensive Linux administration courses.
The Bottom Line
Writing your own systemd unit file is a 30-minute investment with permanent payoff. Start with the minimal pattern, add sandboxing as your default boilerplate, use journalctl for logs, and pair services with timers for scheduled work. Once you have written one production-grade unit, every future service you deploy starts from that template โ and you stop reinventing process supervision in shell scripts.