Converting SysV Init Scripts to systemd Service Units

Converting SysV Init Scripts to systemd Service Units

For more than a decade, systemd quietly ran old System V init scripts, the shell scripts in /etc/init.d/, through a compatibility layer. That layer has now been removed from recent systemd releases; Ubuntu 26.10, with systemd 261, no longer runs them. Any service you still start from an init script needs a proper unit file.

Most packaged software switched long ago. The scripts that remain are usually local: an in-house application, an old vendor package, a script someone wrote years ago. Here is how to convert them.

Find what still depends on init scripts

Before upgrading, while the compatibility layer still works:

ls /etc/init.d/
systemctl list-units --all --type=service | grep -iE 'lsb|sysv'

For each script, check whether a native unit already exists:

systemctl cat myapp.service 2>&1 | head -3

If the first line points to a file under /run/systemd/generator.late/, that unit was generated from the init script and will disappear.

Anatomy of an init script

A typical SysV script looks like this:

#!/bin/sh
### BEGIN INIT INFO
# Provides:          myapp
# Required-Start:    $network $remote_fs
# Required-Stop:     $network $remote_fs
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Description:       My application server
### END INIT INFO

DAEMON=/opt/myapp/bin/myapp
PIDFILE=/var/run/myapp.pid
USER=myapp

case "$1" in
  start)
    start-stop-daemon --start --background --make-pidfile --pidfile $PIDFILE \
      --chuid $USER --exec $DAEMON -- --config /etc/myapp.conf
    ;;
  stop)
    start-stop-daemon --stop --pidfile $PIDFILE
    ;;
  restart) $0 stop; $0 start ;;
esac

Most of it is boilerplate that systemd handles for you: backgrounding, PID files, user switching, and dependency ordering.

The translation

Init scriptsystemd unit
Description:Description= in [Unit]
Required-Start: $networkAfter=network-online.target and Wants=network-online.target
Required-Start: $remote_fsAfter=remote-fs.target
Default-Start: 2 3 4 5WantedBy=multi-user.target in [Install]
start commandExecStart=
stop commandUsually nothing: systemd sends SIGTERM
--chuid myappUser=myapp
--background, PID filesNot needed if the program runs in the foreground
reload commandExecReload=

The converted unit:

# /etc/systemd/system/myapp.service
[Unit]
Description=My application server
Wants=network-online.target
After=network-online.target remote-fs.target

[Service]
Type=exec
User=myapp
Group=myapp
ExecStart=/opt/myapp/bin/myapp --config /etc/myapp.conf
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Note what disappeared: no PID file, no backgrounding, no stop logic. systemd keeps the process in a cgroup, knows exactly which processes belong to it, and stops all of them cleanly. And Restart=on-failure gives you something the init script never had.

Our systemd unit builder generates units like this from a form.

Daemons that insist on forking

Some programs always detach into the background. Prefer a flag that keeps them in the foreground, often -f, --foreground, --nodaemon, or -D; check the man page. If there is none:

[Service]
Type=forking
PIDFile=/run/myapp/myapp.pid
RuntimeDirectory=myapp
ExecStart=/opt/myapp/bin/myapp --daemon

RuntimeDirectory= creates /run/myapp owned by the service user, a common need for PID files.

Scripts that do more than start a program

Init scripts sometimes create directories, set limits, or run setup before starting. systemd has directives for most of that:

Script doesUse instead
mkdir /var/run/myappRuntimeDirectory=myapp
mkdir /var/lib/myappStateDirectory=myapp
ulimit -n 65536LimitNOFILE=65536
export VAR=valueEnvironment=VAR=value or EnvironmentFile=
cd /opt/myappWorkingDirectory=/opt/myapp
A setup command before startExecStartPre=

Test and enable

sudo systemd-analyze verify /etc/systemd/system/myapp.service
sudo systemctl daemon-reload
sudo systemctl start myapp
systemctl status myapp
journalctl -u myapp -f
sudo systemctl enable myapp

Once the unit works, remove or rename the old script so nothing calls it by accident:

sudo mv /etc/init.d/myapp /root/myapp.initd.bak

Reboot once to confirm it starts on its own.

Go further

A converted unit is a good moment to add hardening that init scripts could never offer: NoNewPrivileges=yes, ProtectSystem=strict, PrivateTmp=yes, and more. Our systemd service hardening guide explains each, and writing systemd service files covers the rest of the unit format.

Frequently Asked Questions

Does systemd still run /etc/init.d scripts?

For many years systemd ran them through a compatibility generator that turned each script into a temporary unit. Recent systemd releases have removed that support; Ubuntu 26.10, for example, ships a systemd that no longer runs legacy System V scripts. Services that relied on them need a real unit file.

How do I find services that still use init scripts?

List files in /etc/init.d and check which have no matching unit with systemctl cat. Before upgrading, systemctl list-units —all can show units with descriptions mentioning LSB or SYSV, which were generated from scripts.

What Type should I use for a converted service?

If the program can run in the foreground, use Type=simple or Type=exec and start it without its daemonise option. If it can only fork into the background, use Type=forking and set PIDFile= to the file it writes so systemd can track the main process.

Where do runlevels go?

Runlevels become targets. Runlevel 3 corresponds to multi-user.target and runlevel 5 to graphical.target. A service that started in runlevels 2 to 5 normally gets WantedBy=multi-user.target in its Install section.

What replaces the start-stop-daemon user switching?

The User= and Group= directives in the Service section. systemd starts the process as that user directly, and you can add hardening options such as NoNewPrivileges=yes and ProtectSystem=strict.

How do I test the new unit?

Run systemd-analyze verify on the file to catch errors, then systemctl daemon-reload, systemctl start, and systemctl status. Check journalctl -u for output, and reboot once to confirm it starts at boot.