Skip to content

Creating a Custom Systemd Service

Your application needs to start on boot, restart on crash, and log output. Shell scripts in /etc/rc.local give you none of that. Systemd solves all three with a single declarative file.

Why Write a Custom Unit File

Supervisord and init scripts are overkill for most cases. Systemd provides a unified interface for service management: socket-based activation, dependency tracking, resource limits, and built-in logging via journald. You get all of it without additional tooling.

Unit File Structure

A unit file is an ini-style text file placed in /etc/systemd/system/ for persistent configuration or /run/systemd/system/ for runtime-only changes. Naming convention is name.service.

[Unit]
Description=My Application
After=network.target

[Service]
Type=simple
ExecStart=/opt/myapp/bin/start.sh
Restart=on-failure
User=myapp

[Install]
WantedBy=multi-user.target

Required Sections and Directives

SectionKeyPurpose
[Unit]DescriptionHuman-readable name
[Unit]AfterStartup ordering relative to other units
[Service]TypeHow the service demonizes
[Service]ExecStartCommand to execute on start
[Install]WantedByTarget that enables this unit

Type

  • simple — process stays in foreground, systemd monitors it directly
  • forking — process forks and parent exits (classic daemon pattern)
  • oneshot — runs once and exits, useful for one-off tasks
  • exec — like simple, but waits for ExecStartPre to finish first

For modern applications written in Go, Node.js, or similar, simple is almost always correct.

Example: Application Service

[Unit]
Description=Backend API Service
Documentation=https://internal.example.com/docs
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=simple
User=appuser
Group=appgroup
WorkingDirectory=/opt/api
ExecStart=/opt/api/start.sh
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s
TimeoutStartSec=30s
TimeoutStopSec=60s
Environment=NODE_ENV=production
EnvironmentFile=/etc/default/api
StandardOutput=journal
StandardError=journal
SyslogIdentifier=api-backend

# Hardening
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/opt/api /var/log/api
ProtectKernelTunables=true
ProtectControlGroups=true

[Install]
WantedBy=multi-user.target
Note

The --user flag creates a user-level service that lives with the user’s session instead of the system. Useful for personal tooling without root access.

Python: venv and gunicorn

Point ExecStart at the venv interpreter, not system python. That keeps service dependencies off the host packages:

[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/venv/bin/python -m myapp
EnvironmentFile=/etc/myapp/env
Restart=on-failure
RestartSec=5

For a WSGI app:

ExecStart=/opt/myapp/venv/bin/gunicorn --workers 3 --bind 127.0.0.1:8000 myapp.wsgi:application

Keep secrets in EnvironmentFile (KEY=VALUE), mode 600, owner root:appuser. If the unit fails to start, journalctl -u myapp usually shows a traceback, a missing WorkingDirectory, or a missing env file.

Production-Ready Directives

Restart Behavior

Restart=on-failure      # on non-zero exit code
Restart=on-abnormal     # on signal or timeout
Restart=always          # unconditionally, even after clean stop
RestartSec=5

Dependencies

After=network.target         # after network is up
After=postgresql.service     # after specific service
Wants=network-online.target  # weak dependency, continue if unavailable
Requires=postgresql.service   # hard dependency, fail if unavailable

Resource Limits

LimitNOFILE=65536
LimitNPROC=4096
MemoryMax=512M
CPUQuota=50%

Logging

StandardOutput=journal
StandardError=journal
SyslogIdentifier=myapp

Reading logs:

journalctl -u myapp -f
journalctl -u myapp --since "1 hour ago"
journalctl -u myapp -p err

Activation and Management

# Reload unit files from disk
sudo systemctl daemon-reload

# Start the service
sudo systemctl start myapp

# Check current status
sudo systemctl status myapp

# Enable on boot
sudo systemctl enable myapp

# Reload config without restarting
sudo systemctl reload myapp

# Full restart
sudo systemctl restart myapp

# Stop
sudo systemctl stop myapp

# Disable from boot
sudo systemctl disable myapp

Syntax check without applying changes:

systemd-analyze verify /etc/systemd/system/myapp.service

Common Mistakes

Missing ExecStart. Service fails immediately with Unit entered failed state.

Type not specified. Defaults to simple, but if your process self-daemonizes, you need forking.

Wrong path to script. Verify the file exists and is executable. Systemd does not validate paths at parse time — it simply fails to start.

Runtime edit without reload. Changed the file, forgot daemon-reload. Systemd continues using the old version.

WorkingDirectory does not exist. If you specify it, the directory must be present.

Restart=always without ExecStop. If the process exits cleanly, always restarts it anyway. This means systemctl stop may not behave as expected for long-running services.

Warning

Never edit package-provided unit files directly in /usr/lib/systemd/system/. Updates overwrite them. Use drop-in files in /etc/systemd/system/<name>.service.d/ instead.

Drop-in example:

mkdir -p /etc/systemd/system/myapp.service.d
# /etc/systemd/system/myapp.service.d/override.conf
[Service]
Environment=DEBUG=1
RestartSec=10s
sudo systemctl daemon-reload
sudo systemctl restart myapp