Systemd Without Fear: Writing Your First Service Unit

Making Linux babysit your apps

You have written a small program. It runs beautifully in your terminal. Then you close the terminal, or the box reboots, or the process quietly dies at 3am, and your service is simply gone. There is a tool already installed on virtually every modern Linux machine that solves all of this, and yet a surprising number of people treat it like forbidden magic. That tool is systemd, and writing your first service unit is far less frightening than its reputation suggests.

systemd is the init system on most mainstream distributions: Debian, Ubuntu, Fedora, RHEL, Arch, openSUSE and friends. It is the very first process the kernel starts (PID 1), and its job is to bring the system up, start everything in the right order, and keep an eye on things while the machine is running.

The way you tell systemd about “things” is through units. A unit is a small text file describing something systemd should manage. There are several kinds: .service units for processes, .timer units for scheduled jobs, .socket units for socket activation, .mount units for filesystems, and a few others. For running your own application, the one you want is the humble .service.

You could just launch your app in a tmux session and hope for the best. People do. But a service unit gives you several things for nothing:

  • Restart on crash. If the process exits unexpectedly, systemd brings it back.
  • Start on boot. No more “did someone forget to start the app after the reboot?”
  • Centralised logging. Anything your app writes to stdout or stderr is captured by the journal automatically.
  • Resource and security controls. You can sandbox the process, limit its memory, and run it as an unprivileged user with a single line each.
  • Dependency ordering. You can say “start after the network is up” or “start after the database” and systemd sorts the sequencing out.

In short, you stop being your app’s babysitter and let the operating system do it instead.

A service unit is an INI-style file split into sections. Here is the skeleton, with the directives you will use most often:

[Unit]
Description=My small web app
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/myapp
Environment=NODE_ENV=production
Environment=PORT=8080
ExecStart=/usr/bin/node /opt/myapp/server.js
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Let us walk through the three sections.

[Unit] holds metadata and ordering. Description is the human-readable label you see in systemctl status. After= defines ordering (start this after the network target), while Wants= is a soft dependency that pulls the network target in without making your service fail if it is absent.

[Service] is the meat. ExecStart is the full command to run — always use absolute paths, because systemd does not inherit your shell’s PATH. Type=simple (the default) means “the process you start is the service; don’t wait for it to fork”. User and Group drop privileges so the app does not run as root. WorkingDirectory sets the directory the process starts in. Environment injects environment variables; you can repeat it, or point EnvironmentFile=/etc/myapp.env at a file of KEY=value lines. Restart=on-failure restarts the process if it exits non-zero, and RestartSec adds a short delay so you do not hammer a failing service in a tight loop.

[Install] tells systemd what should happen when you enable the unit. WantedBy=multi-user.target is the usual choice and roughly means “start this when the system reaches normal multi-user operation”, i.e. on boot.

Say you have a Python app living in /opt/widget. Create the unit at /etc/systemd/system/widget.service:

[Unit]
Description=Widget API server
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=widget
Group=widget
WorkingDirectory=/opt/widget
EnvironmentFile=/opt/widget/.env
ExecStart=/opt/widget/.venv/bin/python -m widget.main
Restart=on-failure
RestartSec=3

[Install]
WantedBy=multi-user.target

First, create the dedicated user so the app never runs as root:

sudo useradd --system --home /opt/widget --shell /usr/sbin/nologin widget
sudo chown -R widget:widget /opt/widget

The --system flag creates a service account with no login, which is exactly what a daemon wants.

Whenever you create or edit a unit, systemd needs to re-read its configuration:

sudo systemctl daemon-reload

Now start the service and have it come up on every boot in one command:

sudo systemctl enable --now widget.service

The --now flag means “enable for boot and start it immediately”. Check how it is doing:

systemctl status widget.service

You will see whether it is active (running), its PID, memory use, and the last few log lines. To restart after a code change, or stop it entirely:

sudo systemctl restart widget.service
sudo systemctl stop widget.service

Because systemd captures your app’s output, you never need to wire up your own log files just to see what happened. The journal is queryable:

# Everything this unit has logged
journalctl -u widget.service

# Follow live, like tail -f
journalctl -u widget.service -f

# Only since the last boot, only errors and worse
journalctl -u widget.service -b -p err

# A time window
journalctl -u widget.service --since "2026-04-18 08:00" --until "09:00"

The -p flag filters by priority (err, warning, info, and so on), -b limits to the current boot, and -f follows new entries. This alone is worth the price of admission.

If you reach for cron to run something on a schedule, systemd has a cleaner alternative: a .timer paired with a .service. The service does the work once and exits; the timer says when to run it. Create backup.service (a one-shot, Type=oneshot) and then backup.timer:

[Unit]
Description=Run nightly backup

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true

[Install]
WantedBy=timers.target

OnCalendar uses a readable syntax, Persistent=true runs a missed job if the machine was off at the scheduled time, and the logs land in the journal alongside everything else. Enable it with sudo systemctl enable --now backup.timer and inspect upcoming runs with systemctl list-timers.

systemd can sandbox your service with directives that would otherwise require considerable effort. Add these to [Service]:

NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/opt/widget/data

NoNewPrivileges=true stops the process gaining privileges via setuid binaries. ProtectSystem=strict makes the whole filesystem read-only except for a few permitted paths, and ReadWritePaths carves out the directories the app genuinely needs to write. ProtectHome=true hides /home, and PrivateTmp=true gives the service its own throwaway /tmp. Run systemd-analyze security widget.service to get a scored report of how locked-down your unit is.

The classics, so you can skip them: using a relative path or a shell builtin in ExecStart (always absolute, and remember there is no shell unless you invoke one). Forgetting daemon-reload after editing a unit and wondering why nothing changed. Setting Restart=always on a one-shot task that is supposed to exit. Putting secrets directly in Environment= lines, where they show up in systemctl show; use an EnvironmentFile with tight permissions instead. And editing files under /lib/systemd/system rather than /etc/systemd/system, where your overrides belong.

A service unit is just a short text file, and the handful of directives above cover the overwhelming majority of real-world needs. Once you have written one, the rest of systemd opens up: timers replace cron, the journal replaces log-file archaeology, and the sandboxing directives give you security hardening that used to demand a great deal more effort. Write that first unit, run systemctl enable --now, and let Linux do the babysitting it was built for.