Plenty of servers still start a vendor daemon or an in-house service from a shell script in /etc/init.d. It works because systemd quietly wraps it: systemd-sysv-generator turns each script into a generated unit at boot. That compatibility layer was deprecated years ago and upstream systemd removed it in v258, so on distributions that ship newer systemd those scripts simply stop running. The migration has to happen eventually.
Translating an init script is a job an LLM does quickly, and mostly well. The trouble is the "mostly": a model will happily keep the daemon forking, wrap everything in bash -c, and pick network.target when the service needs a working network. This guide covers what to give the model, how each init idiom should map to a directive, and how to prove the new unit behaves like the old script before you delete it. For debugging units that already fail, see our systemd debugging guide.
Inventory: What systemd Is Already Generating
Start by finding every service that still comes from a script. Generated units show up with the state generated, and systemctl cat shows the wrapper systemd built from the LSB header:
$ systemctl list-unit-files --type=service --state=generated
legacy-app.service generated -
vendor-agent.service generated -
$ systemctl cat legacy-app.service
# /run/systemd/generator.late/legacy-app.service
# Automatically generated by systemd-sysv-generator
[Unit]
Documentation=man:systemd-sysv-generator(8)
SourcePath=/etc/init.d/legacy-app
Description=LSB: Legacy app daemon
After=network-online.target remote-fs.target
Wants=network-online.target
[Service]
Type=forking
Restart=no
TimeoutSec=5min
KillMode=process
GuessMainPID=no
RemainAfterExit=yes
SuccessExitStatus=5 6
ExecStart=/etc/init.d/legacy-app start
ExecStop=/etc/init.d/legacy-app stop
# ...remaining lines trimmed
Read the generated unit β note Restart=no, RemainAfterExit=yes and KillMode=process. The wrapper never restarts a crashed daemon and can report active (exited) while nothing is running. A native unit fixes all three.
Check what is actually running β ps -o user,group,ni,pid,ppid,cmd -C legacy-app records the real user, nice value and arguments today. That is the behaviour the new unit has to reproduce, whatever the script claims.
Catch rc.local too β commands in /etc/rc.local run via rc-local.service on many distributions. They deserve the same treatment, one unit per job.
Also check the cron side β some init-era services were kept alive by an @reboot crontab entry or a watchdog cron job. crontab -l -u app for each service account finds them.
Give the Model the Whole Picture
The script alone is not enough. Most of the real behaviour lives in the defaults file it sources, the daemon's own flags, and the process as it runs now. Paste all of it, and tell the model which systemd version it is writing for:
$ systemctl --version | head -1
systemd 252 (252.33-1~deb12u1)
$ ollama run qwen2.5-coder "Convert this SysV init script into a native systemd
service unit for Debian 12 running systemd 252.
INIT SCRIPT:
$(cat /etc/init.d/legacy-app)
DEFAULTS FILE IT SOURCES:
$(cat /etc/default/legacy-app)
DAEMON HELP TEXT:
$(/opt/legacy-app/bin/legacy-app --help 2>&1 | head -40)
THE PROCESS AS IT RUNS TODAY:
$(ps -o user,group,ni,pid,ppid,cmd -C legacy-app)
Rules:
- Prefer Type=exec with a foreground flag from the help text. Use
Type=forking with PIDFile= only if the daemon has no foreground mode.
- No bash -c or sh -c wrappers. Translate ulimit, cd, mkdir, chown, umask
and nice into unit directives.
- Map each LSB Required-Start facility to explicit After= and Wants=.
- After the unit, output a table: every behaviour of the init script, the
directive that replaces it, or DROPPED with a reason.
- List anything you could not translate as TODO. Do not invent flags."
The help text decides Type= β the single most important choice is whether the daemon can stay in the foreground. Without its flags the model guesses, and it usually guesses forking because that is what the script did.
The behaviour table is the review β it forces the model to account for every line of the script. A missing row is how you find the chown or umask it silently dropped.
Name the systemd version β directives keep arriving. Type=exec needs systemd 240, and RHEL 7 still ships 219. A model that does not know the target will use whatever it saw most often.
Scrub before pasting β defaults files often hold tokens and passwords. Use a local model or redact them first.
Mapping Init Idioms to Directives
Whatever the model produces, check it against this table. Each row is something init scripts do by hand that systemd does natively, and usually more reliably:
| Init script idiom | systemd replacement | Watch out for |
|---|---|---|
--chuid app:app | User=app / Group=app | ExecStartPre= also runs as User= unless prefixed with + |
. /etc/default/app | EnvironmentFile=-/etc/default/app | Plain KEY=value only: no $(...), no if/then |
ulimit -n 65536 | LimitNOFILE=65536 | Visible in systemctl show; a ulimit inside bash -c is not |
mkdir /var/run/app; chown | RuntimeDirectory=app | Created as /run/app owned by User=, removed on stop |
cd /opt/app | WorkingDirectory=/opt/app | Relative paths in config files depend on it |
umask 027 | UMask=0027 | Default for services is 0022 |
nice -n 10 | Nice=10 | Also consider CPUWeight= for cgroup-level priority |
--pidfile + --daemon | Type=exec + foreground flag | Fall back to Type=forking + PIDFile= only if needed |
reload) kill -HUP | ExecReload=/bin/kill -HUP $MAINPID | Only if the daemon actually rereads config on HUP |
--retry TERM/30/KILL/5 | TimeoutStopSec=30 | systemd sends SIGTERM, then SIGKILL after the timeout |
>> /var/log/app.log 2>&1 | Drop it: stdout goes to the journal | To keep a file: StandardOutput=append:/var/log/app.log |
| respawn wrapper / cron watchdog | Restart=on-failure | Add RestartSec= so a crash loop does not spin |
The LSB header maps the same way. systemd-sysv-generator uses these translations, and the native unit should spell them out:
| Required-Start facility | Native equivalent |
|---|---|
$network | After=network-online.target + Wants=network-online.target |
$remote_fs | After=remote-fs.target |
$named | After=nss-lookup.target |
$time | After=time-sync.target |
$syslog | Nothing: the journal is available before any service starts |
$local_fs | Nothing: default dependencies already order services after local mounts |
Default-Start: 2 3 4 5 | WantedBy=multi-user.target |
Correcting the First Draft
Here is a typical first answer for a script that used start-stop-daemon, followed by the corrected unit. Every problem in the draft is one models produce regularly:
# First draft from the model
[Unit]
Description=Legacy app daemon
After=network.target
[Service]
Type=forking
User=app
EnvironmentFile=/etc/default/legacy-app
ExecStartPre=/bin/mkdir -p /var/run/legacy-app
ExecStart=/bin/bash -c "ulimit -n 65536 && cd /opt/legacy-app && ./bin/legacy-app --daemon $DAEMON_OPTS"
Restart=always
[Install]
WantedBy=multi-user.target
ExecStartPre runs as app β so mkdir under /run fails with permission denied. RuntimeDirectory= replaces it entirely.
Type=forking without PIDFile= β systemd has to guess which process is the main one. Combined with bash -c, the guess can land on the wrong PID, and then stop, reload and restart act on the wrong process.
bash -c hides everything β the file limit and working directory no longer appear in systemctl show, and a typo in the chain only shows up at runtime.
network.target is not "network up" β it only orders against the network management stack starting. A daemon that binds to a specific address or connects out at startup needs network-online.target.
Missing dash on EnvironmentFile β the script tolerated a missing defaults file with [ -r ... ]. Without the leading - the unit refuses to start when the file is absent.
# /etc/systemd/system/legacy-app.service (corrected)
[Unit]
Description=Legacy app daemon
After=network-online.target remote-fs.target
Wants=network-online.target
[Service]
Type=exec
User=app
Group=app
EnvironmentFile=-/etc/default/legacy-app
WorkingDirectory=/opt/legacy-app
RuntimeDirectory=legacy-app
LimitNOFILE=65536
UMask=0027
ExecStart=/opt/legacy-app/bin/legacy-app --foreground $DAEMON_OPTS
ExecReload=/bin/kill -HUP $MAINPID
TimeoutStopSec=30
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.target
--foreground stands in for whatever the daemon calls it: -f, -D, --no-daemon. Two details about the environment variable matter. Written as a separate word, $DAEMON_OPTS is split on whitespace into several arguments, which matches the unquoted use in the old script. Written as ${DAEMON_OPTS}, it becomes exactly one argument, spaces and all. Neither form runs a shell, so quotes inside the defaults file are not interpreted the way the script interpreted them.
Verify Before Cutover
A unit that starts is not the same as a unit that behaves like the old script. Lint it, switch over, then compare the running process against what you recorded during the inventory:
$ sudo systemd-analyze verify /etc/systemd/system/legacy-app.service
$ sudo systemctl stop legacy-app # still the generated unit
$ sudo systemctl daemon-reload # native unit now shadows the script
$ systemctl cat legacy-app | head -1
# /etc/systemd/system/legacy-app.service
$ sudo systemctl enable --now legacy-app
$ systemctl show legacy-app -p Type -p MainPID -p NRestarts -p LimitNOFILE
Type=exec
MainPID=48211
NRestarts=0
LimitNOFILE=65536
$ ps -o user,group,ni,cmd -p "$(systemctl show -p MainPID --value legacy-app)"
# Does Restart= work? Kill it hard and check it came back.
$ sudo kill -9 "$(systemctl show -p MainPID --value legacy-app)"
$ systemctl show legacy-app -p NRestarts
NRestarts=1
Same name shadows the script β when /etc/systemd/system/legacy-app.service exists, the generator skips /etc/init.d/legacy-app. Keeping the name means dependants and monitoring keep working.
Retire the script properly β on Debian and Ubuntu, update-rc.d -f legacy-app remove; on RHEL-family systems, chkconfig --del legacy-app. Then move the script out of /etc/init.d rather than deleting it, until the unit has survived a reboot.
KillMode changed β the generated unit used KillMode=process. The native default, control-group, also kills child processes on stop. That is usually what you want, but check for workers that were meant to outlive a restart.
Reboot once β ordering problems only show up at boot, when the network and mounts are not ready yet. A restart on a running system proves nothing about After=.
Hardening the Unit Afterwards
Once behaviour matches, the model is useful again for the step init scripts never had: sandboxing. Ask it for hardening directives, then add them one at a time and restart after each, because every one of them can break a daemon that writes somewhere unexpected:
$ systemd-analyze security legacy-app.service | tail -1
# Add to [Service], one at a time:
NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
StateDirectory=legacy-app
LogsDirectory=legacy-app
# Find what broke
$ journalctl -u legacy-app -b --no-pager | grep -iE 'read-only|permission denied'
ProtectSystem=strict β makes the whole filesystem read-only for the service except /dev, /proc, /sys, the paths you list in ReadWritePaths=, and directories created with StateDirectory= or LogsDirectory=. It is the directive that finds every undocumented write.
PrivateTmp=yes β gives the service its own /tmp. It breaks daemons that exchange files with other processes through /tmp, which init-era software does more often than you would hope.
Treat the exposure score as a guide β systemd-analyze security rates exposure from 0 to 10. Getting a legacy daemon from about 9 to the 4-6 range is realistic; chasing lower usually means fighting the software.
Generate the skeleton β for new services and timers, our systemd generator produces a clean starting unit, and the server hardening checklist covers the rest of the host.
Frequently Asked Questions
Do SysV init scripts still work with systemd?
On most current distributions, yes, through systemd-sysv-generator, which wraps each /etc/init.d script in a generated unit at boot. That support was deprecated and has been removed upstream in systemd v258, so distributions moving to newer systemd will stop running init scripts. Migrating to native units now avoids a surprise during an upgrade.
Should I use Type=forking or Type=simple for a converted daemon?
Run the daemon in the foreground if it supports it, and use Type=exec (systemd 240 and later) or Type=simple. systemd then tracks the real main process without a PID file. Use Type=forking with PIDFile= only for daemons that cannot stay in the foreground.
What is the difference between network.target and network-online.target?
network.target only means the network management stack has been started; interfaces may not have addresses yet. network-online.target waits until the network is configured. A service that binds to a specific IP or connects to a remote host at startup needs After= and Wants= on network-online.target.
Can I trust an LLM to write the systemd unit for me?
Treat the output as a draft. Models are good at mapping init script lines to directives but often keep the daemon forking, wrap commands in bash -c, or use the wrong network target. Ask for a table that accounts for every behaviour of the script, lint with systemd-analyze verify, and compare the running process before and after.
How do I stop the old init script from also starting?
Create the native unit with the same name, for example legacy-app.service, and run systemctl daemon-reload. systemd then ignores the init script of that name. Remove its runlevel links with update-rc.d -f name remove on Debian or Ubuntu, or chkconfig --del name on RHEL-family systems, and move the script out of /etc/init.d.