Skip to content
C Codeloom
DevOps

Linux systemd Services: A Practical Guide

Write, enable, and debug systemd services on Linux. Learn unit files, dependencies, restart policies, and journalctl logs through a small hands-on example.

·5 min read · By Codeloom
Beginner 11 min read

What you'll learn

  • What a systemd unit file looks like and where to put it
  • How to start, stop, enable, and disable services
  • How restart policies keep services alive
  • How to read service logs with journalctl
  • How to set environment variables and resource limits

Prerequisites

  • Comfortable with the Linux command line

Almost every modern Linux distribution boots with systemd as PID 1. If you run anything long-lived on a Linux server — a web app, a worker, a background daemon — turning it into a systemd service is how you make it boot on startup, restart on crash, log cleanly, and behave like every other piece of the system. The good news is the basics fit in one short unit file.

Where unit files live

Two directories matter. Distribution packages drop their unit files into /lib/systemd/system/. Your own services belong in /etc/systemd/system/. If a unit with the same name exists in both, the one under /etc wins, which is exactly the override behavior you want.

A unit file is plain INI. The minimum useful service unit has three sections: [Unit], [Service], [Install].

A first service

Suppose you have a Node app at /opt/myapp/server.js that you want running as user app on boot. Create /etc/systemd/system/myapp.service:

[Unit]
Description=My Node API
After=network-online.target
Wants=network-online.target

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

[Install]
WantedBy=multi-user.target

A few lines deserve commentary. After=network-online.target tells systemd to wait for the network. Type=simple means the process started by ExecStart is the service — no forking, no PID files. Restart=on-failure makes systemd relaunch the process if it exits non-zero. WantedBy=multi-user.target is what systemctl enable hooks into so the service starts at boot.

Loading and starting

Whenever you add or edit a unit, tell systemd to reread its files.

sudo systemctl daemon-reload
sudo systemctl start myapp
sudo systemctl enable myapp
sudo systemctl status myapp

enable creates the symlink that makes the service start on boot. status prints the current state, recent log lines, and the cgroup tree of child processes.

To stop or disable:

sudo systemctl stop myapp
sudo systemctl disable myapp
sudo systemctl restart myapp

Reading logs

systemd captures stdout and stderr of every service and feeds them into the journal, queryable with journalctl.

journalctl -u myapp           # all logs for the unit
journalctl -u myapp -f        # follow live
journalctl -u myapp --since "1 hour ago"
journalctl -u myapp -p err    # only errors and worse

Because the journal is structured and indexed, you do not need a separate log file. If you also want plain files, your app can log to its own destination, but the journal alone is usually enough.

Environment variables

Two patterns work well. Inline with Environment= as shown above, or from a file with EnvironmentFile=.

[Service]
EnvironmentFile=/etc/myapp/env
ExecStart=/usr/bin/node server.js

Then /etc/myapp/env contains shell-style assignments:

NODE_ENV=production
PORT=3000
DATABASE_URL=postgres://user:pass@db/app

Keep that file mode 0640 and owned by the service user. It is the natural place for secrets that are not big enough to justify a vault.

Restart policies

The Restart= directive has several useful values:

  • no — never restart.
  • on-failure — restart only if the process exits non-zero or crashes.
  • on-abnormal — restart on signal, watchdog, or core dump.
  • always — restart no matter how it exited.

Pair it with RestartSec=5 to avoid hammering when something is genuinely broken, and with StartLimitBurst= and StartLimitIntervalSec= to give up after too many failures. Without limits, a tight crash loop will burn CPU forever.

Resource limits and sandboxing

systemd has a generous toolbox for keeping a service in its lane.

[Service]
MemoryMax=512M
CPUQuota=50%
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true

MemoryMax and CPUQuota push the cgroup limits. NoNewPrivileges prevents the process from gaining capabilities it did not start with. ProtectSystem=strict mounts /usr and friends read-only for this service. PrivateTmp gives the service its own /tmp. None of this requires changing your application — it is enforced from the outside.

Dependencies between services

If your app needs PostgreSQL up first and should fail to start when it cannot reach the database, use Requires= and After=:

[Unit]
Requires=postgresql.service
After=postgresql.service

Requires= is a hard dependency — if Postgres fails, your unit also stops. Wants= is a soft dependency — try to start it, but do not give up if it fails. Pick the weaker one when you can; tight coupling makes restarts brittle.

Debugging checklist

When a service refuses to start, work through it in order: systemctl status myapp for the headline, journalctl -u myapp -n 50 for the recent log, then systemd-analyze verify /etc/systemd/system/myapp.service to catch syntax errors. Nine times out of ten the answer is a wrong path in ExecStart or a permission problem on WorkingDirectory.

Once you have written one unit file, every future service follows the same shape. That consistency is the whole point of systemd.