Systemd replaced init scripts on most Linux distributions. Instead of shell scripts with start/stop logic, you write declarative unit files that tell systemd what to run and how.

Basic Service File

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# /etc/systemd/system/myapp.service
[Unit]
Description=My Application
After=network.target

[Service]
Type=simple
User=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/server
Restart=always

[Install]
WantedBy=multi-user.target

Enable and start:

1
2
3
sudo systemctl daemon-reload
sudo systemctl enable myapp
sudo systemctl start myapp

Service Types

simple (default)

Process started by ExecStart is the main process:

1
2
3
[Service]
Type=simple
ExecStart=/usr/bin/myapp

forking

Traditional daemon that forks and exits:

1
2
3
4
[Service]
Type=forking
PIDFile=/run/myapp.pid
ExecStart=/usr/bin/myapp --daemon

oneshot

Runs once and exits (like a script):

1
2
3
4
[Service]
Type=oneshot
ExecStart=/opt/scripts/cleanup.sh
RemainAfterExit=yes  # Consider service "active" after exit

notify

Process signals when ready:

1
2
3
[Service]
Type=notify
ExecStart=/usr/bin/myapp

App must call sd_notify(0, "READY=1") when ready.

Restart Policies

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
[Service]
# Always restart (except on clean exit or stop)
Restart=always

# Restart only on failure (non-zero exit)
Restart=on-failure

# Restart only on unclean signal
Restart=on-abnormal

# Never restart
Restart=no

# Time to wait before restart
RestartSec=5

# Limit restart attempts
StartLimitIntervalSec=300
StartLimitBurst=5

Environment Variables

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[Service]
# Single variable
Environment=NODE_ENV=production

# Multiple variables
Environment="DB_HOST=localhost" "DB_PORT=5432"

# From file
EnvironmentFile=/etc/myapp/env
EnvironmentFile=-/etc/myapp/env.local  # - means optional

/etc/myapp/env:

1
2
DATABASE_URL=postgres://localhost/mydb
SECRET_KEY=abc123

User and Permissions

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[Service]
# Run as specific user/group
User=myapp
Group=myapp

# Dynamic user (sandboxed, no persistent files)
DynamicUser=yes

# Supplementary groups
SupplementaryGroups=docker ssl-cert

Working Directory and Paths

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
[Service]
WorkingDirectory=/opt/myapp
RootDirectory=/opt/myapp-chroot  # chroot

# Temporary directory
PrivateTmp=yes  # Private /tmp

# Read-only paths
ReadOnlyPaths=/etc
ReadWritePaths=/var/lib/myapp

Pre/Post Commands

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
[Service]
# Run before main process
ExecStartPre=/opt/myapp/scripts/check-deps.sh
ExecStartPre=!/opt/myapp/scripts/optional-check.sh  # ! = ok to fail

# Main process
ExecStart=/opt/myapp/bin/server

# Run after main process starts
ExecStartPost=/opt/myapp/scripts/notify-ready.sh

# Run when stopping
ExecStop=/opt/myapp/scripts/graceful-shutdown.sh

# Run after stop
ExecStopPost=/opt/myapp/scripts/cleanup.sh

# Run on reload (systemctl reload)
ExecReload=/bin/kill -HUP $MAINPID

Resource Limits

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
[Service]
# Memory limit
MemoryMax=512M
MemoryHigh=400M  # Soft limit, starts throttling

# CPU limit
CPUQuota=50%  # Max 50% of one CPU

# File descriptors
LimitNOFILE=65535

# Process limit
LimitNPROC=100

# Core dump size
LimitCORE=0  # Disable core dumps

Logging

Systemd captures stdout/stderr automatically:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
[Service]
# Send to journal (default)
StandardOutput=journal
StandardError=journal

# Or to file
StandardOutput=append:/var/log/myapp/stdout.log
StandardError=append:/var/log/myapp/stderr.log

# Identify in journal
SyslogIdentifier=myapp

View logs:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# All logs for service
journalctl -u myapp

# Follow logs
journalctl -u myapp -f

# Since last boot
journalctl -u myapp -b

# Last 100 lines
journalctl -u myapp -n 100

# With timestamps
journalctl -u myapp --since "1 hour ago"

Dependencies

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
[Unit]
Description=My Web App

# Start after these
After=network.target postgresql.service redis.service

# Require these (fail if they fail)
Requires=postgresql.service

# Want these (don't fail if they fail)
Wants=redis.service

# Conflict with
Conflicts=nginx.service

Socket Activation

Let systemd manage the socket, start service on demand:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
# /etc/systemd/system/myapp.socket
[Unit]
Description=My App Socket

[Socket]
ListenStream=8080
Accept=no

[Install]
WantedBy=sockets.target
1
2
3
4
5
6
7
8
# /etc/systemd/system/myapp.service
[Unit]
Description=My App
Requires=myapp.socket

[Service]
Type=simple
ExecStart=/opt/myapp/bin/server
1
2
3
sudo systemctl enable myapp.socket
sudo systemctl start myapp.socket
# Service starts automatically on first connection

Timers (Cron Replacement)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# /etc/systemd/system/backup.timer
[Unit]
Description=Daily Backup Timer

[Timer]
OnCalendar=daily
# Or: OnCalendar=*-*-* 02:00:00
Persistent=true  # Run if missed

[Install]
WantedBy=timers.target
1
2
3
4
5
6
7
# /etc/systemd/system/backup.service
[Unit]
Description=Backup Job

[Service]
Type=oneshot
ExecStart=/opt/scripts/backup.sh
1
2
3
sudo systemctl enable backup.timer
sudo systemctl start backup.timer
systemctl list-timers

Common Patterns

Node.js App

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
[Unit]
Description=Node.js App
After=network.target

[Service]
Type=simple
User=nodeapp
WorkingDirectory=/opt/nodeapp
Environment=NODE_ENV=production
Environment=PORT=3000
ExecStart=/usr/bin/node server.js
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

Python with Gunicorn

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
[Unit]
Description=Gunicorn Django App
After=network.target

[Service]
Type=notify
User=django
Group=www-data
WorkingDirectory=/opt/django
Environment="PATH=/opt/django/venv/bin"
ExecStart=/opt/django/venv/bin/gunicorn \
    --workers 4 \
    --bind unix:/run/django.sock \
    --notify \
    myproject.wsgi:application
ExecReload=/bin/kill -s HUP $MAINPID
Restart=on-failure

[Install]
WantedBy=multi-user.target

Java App

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
[Unit]
Description=Java Application
After=network.target

[Service]
Type=simple
User=javaapp
WorkingDirectory=/opt/javaapp
ExecStart=/usr/bin/java -Xmx512m -jar app.jar
SuccessExitStatus=143  # SIGTERM exit code for Java
Restart=always
RestartSec=10

[Install]
WantedBy=multi-user.target

Commands Reference

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# Reload unit files after changes
sudo systemctl daemon-reload

# Enable (start at boot)
sudo systemctl enable myapp

# Disable
sudo systemctl disable myapp

# Start/Stop/Restart
sudo systemctl start myapp
sudo systemctl stop myapp
sudo systemctl restart myapp

# Reload config (SIGHUP)
sudo systemctl reload myapp

# Status
systemctl status myapp

# Show full properties
systemctl show myapp

# List all services
systemctl list-units --type=service

# List failed
systemctl --failed

Debugging

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# Check syntax
systemd-analyze verify myapp.service

# See what's wrong
journalctl -u myapp -e --no-pager

# Check why it's not starting
systemctl status myapp

# See full service config
systemctl cat myapp

# Environment variables
systemctl show myapp --property=Environment

Systemd service files are verbose but predictable. Once you understand the patterns, they’re easier to maintain than shell scripts. Start with the basic template, add what you need, and let systemd handle the rest.