A systemd unit is the description file for anything systemd manages: services, timers, mount points, devices and more. If you want your own service to run permanently on a modern Linux system, a unit file is the way to do it.

Unit types

  • .service — services and one-shot commands.
  • .timer — scheduled execution (the modern replacement for cron).
  • .socket — listen for connections and start the service on demand.
  • .target — grouping (roughly comparable to a runlevel).
  • .mount / .automount — mount points.
  • .path — watch files or directories.

Where unit files live

# Search order and precedence (later wins)
/usr/lib/systemd/system/     packages - do NOT edit, gets overwritten
/etc/systemd/system/         your units and overrides       <- work here
/run/systemd/system/         generated at runtime

# Create your own unit
/etc/systemd/system/my-service.service

# Modify an existing unit WITHOUT touching the original file:
systemctl edit my-service.service
#   creates /etc/systemd/system/my-service.service.d/override.conf
#   put only the lines you change in there
#   -> survives package updates

# Alternative: copy the unit and adjust its paths
cp /usr/lib/systemd/system/nginx.service /etc/systemd/system/This email address is being protected from spambots. You need JavaScript enabled to view it.
#   change the Name= line and paths inside - never run both at once!

A service unit by hand

[Unit]
Description=Example API server
Documentation=https://example.org/api
# Only start once these units are ready
After=network-online.target postgresql.service
Wants=network-online.target
# Do not start if these are running
Conflicts=apache2.service

[Service]
Type=simple
User=api
Group=api
WorkingDirectory=/srv/api
Environment=NODE_ENV=production
Environment="PORT=8080"
# Secrets must NOT live in the unit - use a separate file with chmod 600
EnvironmentFile=-/etc/api/api.env

ExecStart=/usr/bin/node /srv/api/server.js
# Graceful stop first, then hard kill - important for data integrity
ExecReload=/bin/kill -HUP $MAINPID
KillSignal=SIGTERM
TimeoutStopSec=30
Restart=on-failure
RestartSec=5

# Logging
StandardOutput=journal
StandardError=journal
SyslogIdentifier=api

# Hardening - the most valuable options
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/srv/api/data /var/log/api
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
LockPersonality=true
MemoryDenyWriteExecute=true

# Resource limits
LimitNOFILE=65536
MemoryMax=1G
CPUQuota=200%

[Install]
WantedBy=multi-user.target

The four Type values that matter

  • simple (default): the command starts and keeps running. systemd considers it active immediately.
  • exec: like simple, but activation counts as successful only after the actual program call succeeded. Safer against failures inside wrapper scripts.
  • forking: for classic daemons that detach into the background. Usually needs PIDFile=.
  • oneshot: a task that is expected to finish. Correct for scripts, often combined with RemainAfterExit=yes and Before=.
  • notify: the program signals readiness itself (sd_notify). Best for services with a slow startup.

Controlling and diagnosing

# MANDATORY after changing any unit file
systemctl daemon-reload

systemctl enable --now my-service
systemctl disable --now my-service
systemctl start | stop | restart | reload my-service
systemctl status my-service

systemctl is-active my-service
systemctl is-enabled my-service
systemctl is-failed my-service

# Why is it not running? - the key question
systemctl status my-service
journalctl -u my-service -n 100 --no-pager
journalctl -u my-service -f
journalctl -u my-service --since '10 min ago'
journalctl -u my-service -p err

# Dependencies and ordering
systemctl list-dependencies my-service
systemctl list-dependencies --reverse my-service
systemctl show my-service | grep -E 'After|Wants|Requires'

# What went wrong?
systemctl show my-service -p Restart -p RestartUSec -p NRestarts -p Result
journalctl -u my-service | grep -iE 'fail|error|denied|not found'

systemctl --failed
systemd-analyze blame | head -20
systemd-analyze critical-chain

Timers instead of cron

# /etc/systemd/system/backup.timer
[Unit]
Description=Daily backup

[Timer]
OnCalendar=*-*-* 03:30:00
# If the machine was off at the scheduled time:
Persistent=true
# Random delay - relieves pressure from simultaneous jobs
RandomizedDelaySec=15m
Unit=backup.service

[Install]
WantedBy=timers.target

# /etc/systemd/system/backup.service
[Unit]
Description=Run daily backup

[Service]
Type=oneshot
User=backup
ExecStart=/usr/local/bin/backup.sh

systemctl daemon-reload
systemctl enable --now backup.timer

# Check - the most important command for scheduled work
systemctl list-timers --all
journalctl -u backup.service -n 50

# Validate the calendar expression BEFORE relying on it
systemd-analyze calendar '*-*-* 03:30:00'
systemd-analyze calendar 'Mon..Fri 08:00'
#   The output shows the next firing times - typos become visible.

Advantages over cron

  • Logging: output goes to the journal with timestamps, exit codes and runtime. No more mail silently discarded.
  • Dependencies: a job can require that a service is running or a mount point is ready.
  • Resource control: memory and CPU limits per job.
  • Missed runs: Persistent=true catches up after downtime.
  • Status: systemctl list-timers shows last and next run for every job.

Common mistakes

  1. Forgetting daemon-reload. Unit file changes do nothing. This is the single most common error.
  2. Relative paths in ExecStart. systemd has no meaningful PATH — always use the full path.
  3. Missing environment. Shell variables are not inherited. Use Environment= or EnvironmentFile=.
  4. Wrong Type. A forking daemon declared as Type=simple is considered exited and restarted endlessly.
  5. Confusing After= with a dependency. After only orders; it does not require. Use Requires= or Wants= for "only start if".
  6. Over-tightening hardening. ProtectSystem=strict makes the whole filesystem read-only — write paths must be listed in ReadWritePaths= or the service fails to start.
  7. Secrets inside the unit file. Unit files are world-readable. Use EnvironmentFile= with chmod 600 or LoadCredential=.
  8. Bad calendar expressions. OnCalendar is not cron syntax. Validate with systemd-analyze calendar.
  9. Missing WantedBy=. The service can be started but not enabled persistently.

Checklist for a new service

  1. Does it run as a dedicated user with no login shell?
  2. Are secrets out of the unit file and in a protected file?
  3. Is Restart= sensible, with a start limit preventing a restart loop?
  4. Does a signal stop the process cleanly before the hard kill?
  5. Is hardening active (ProtectSystem, NoNewPrivileges, PrivateTmp)?
  6. Are logs in the journal, discoverable with journalctl -u?
  7. Does it start automatically after a reboot (enable)?
  8. Are resources limited so the service cannot take over the whole host?

Related: systemd explained, cron and crontab, Shell, systemd-Units (DE).