๐ŸŽ New User? Get 20% off your first purchase with code NEWUSER20 ยท โšก Instant download ยท ๐Ÿ”’ Secure checkout Register Now โ†’
Menu

Categories

Your First systemd Service Unit: A Step-by-Step Tutorial for Beginners

Your First systemd Service Unit: A Step-by-Step Tutorial for Beginners

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.

systemd service unit step by step beginner tutorial 2026

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 as systemd.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

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.

Share this article:
Dargslan Editorial Team (Dargslan)
About the Author

Dargslan Editorial Team (Dargslan)

Collective of Software Developers, System Administrators, DevOps Engineers, and IT Authors

Dargslan is an independent technology publishing collective formed by experienced software developers, system administrators, and IT specialists.

The Dargslan editorial team works collaboratively to create practical, hands-on technology books focused on real-world use cases. Each publication is developed, reviewed, and...

Programming Languages Linux Administration Web Development Cybersecurity Networking

Stay Updated

Subscribe to our newsletter for the latest tutorials, tips, and exclusive offers.