Skip to content
← Blog

systemd 260 removed SysV. Now what?

systemd 260 deleted the sysv-generator, rc-local.service and systemd-sysv-install. Every /etc/init.d script and every /etc/rc.local on your fleet is now on a clock. Here is how to find them and convert them properly.

·18 min read
  • systemd
  • Linux
  • SysAdmin
  • Migration

For roughly fifteen years, systemd quietly translated your /etc/init.d scripts into services on every single boot, and never asked you to notice. In version 260, released in March 2026, it stopped. The systemd-sysv-generator, systemd-rc-local-generator together with rc-local.service, and the systemd-sysv-install hook behind systemctl enable were all deleted outright. Nothing about your scripts changed. What changed is that nobody reads them any more.

Two-panel cover image. On the left, a panel headed "DELETED IN systemd 260" listing systemd-sysv-generator, systemd-rc-local-generator, rc-local.service and systemd-sysv-install. On the right, a panel headed "WHAT REPLACES WHAT" with four before-and-after pairs: start-stop-daemon --background becomes Type=exec, --make-pidfile becomes no PID file at all, --chuid acme becomes User=acme, and /etc/rc[2-5].d/S20name becomes WantedBy=multi-user.target.
Three components removed in one release. The migration itself is not hard — the hard part is knowing which scripts you still have, on which machines, before an unattended upgrade tells you.

This is the migration, written for someone who has to do it on real servers rather than admire it from a distance: how to inventory every wrapped script across a fleet using a journal field almost nobody knows exists, exactly what the generator was writing so you can copy it instead of guessing, a field-by-field conversion of a realistic init script, the Type= mistake that makes a unit report success while the daemon is dead, and the commands that replaced init 3. Every version number and every directive here was checked against the systemd NEWS file and against the source at the tags where the removed code still existed, not against memory.

The unit is not failed. It does not exist

The symptom is unusually confusing, because it is an absence rather than an error. Your service does not appear as failed. It does not appear at all. systemctl status reports that the unit could not be found while the script sits on disk, executable, unmodified since 2015. That is correct behaviour: the script was never a unit. A generator manufactured one in /run/systemd/generator.late on every boot, and that directory is now empty.[v260]

What you seeWhat it meansWhere it is covered
Unit x.service could not be found, while /etc/init.d/x exists and is executableThe generator that used to invent that unit has been removed. Nothing is broken; nothing is translating any more.What the generator was writing
A service that started at every boot for years simply does not start, and nothing appears in the journalSame cause, seen from the boot rather than from the command line. There is no failed unit because there is no unit.Converting one script
/etc/rc.local is still executable and no longer runssystemd-rc-local-generator and rc-local.service were removed together with the SysV generator.Replacing /etc/rc.local
systemctl enable on an old service now does nothing usefulsystemd-sysv-install, the hook that forwarded enable/disable to chkconfig or update-rc.d, is gone.What a package must ship
telinit: command not found, or init 3 does nothingRemoved in 258, two releases earlier and for a different reason: the concept of runlevels was dropped, not just the scripts.init 3, telinit and runlevel
A converted unit reports active, but the process is gone — or reports failed while the daemon runsThe Type= is wrong for a daemon that forks. This is the most common broken conversion.Type=forking and PID files
# After the upgrade, the service that has started at boot for eleven years
# is simply not there. Not failed - not there.

systemctl status acme-collector
# Unit acme-collector.service could not be found.

ls -l /etc/init.d/acme-collector
# -rwxr-xr-x 1 root root 1284 Mar 14  2015 /etc/init.d/acme-collector
#            ^ the script is still on disk, still executable, still correct.

# The script was never a service. A generator turned it into one on every
# boot, and the generator is gone. Confirm which systemd you are on:
systemctl --version | head -1
# systemd 260 (260.2-1)

# And confirm the generator really is absent rather than just failing:
ls /usr/lib/systemd/system-generators/ | grep -E 'sysv|rc-local'
# (no output on 260 and later; on 259 and earlier you get one or two lines)

# The same disappearance, seen from the other end - nothing was generated:
ls /run/systemd/generator.late/
# (empty, or missing your unit)

Generators run before the manager loads any units, on every boot and on every daemon-reload, and their output lives in tmpfs. That is why nothing on disk looks different after the upgrade, and why there is nothing to repair: there is no broken file anywhere, only a translation step that no longer happens. Everything below assumes you have shell on the machine and can reboot it once at the end. Nothing in this article changes system state until you decide it does; the inventory commands only read.[generator]

What was removed, and exactly when

Four separate releases are involved, and confusing them is why so much advice on this topic is wrong. The service scripts were declared deprecated a long way back, in version 255 — which is also the release whose generator started logging a warning about every script it wrapped, so most affected systems have been complaining in the journal for years. Version 258, in September 2025, did something different: it removed the System V system state interfaces — /dev/initctl, the initctl, runlevel and telinit commands, state control via init 3, the runlevel[0-6].target units, and the recording of runlevel transitions in utmp and wtmp. Version 259 partially walked one of those back. Version 260 removed the service scripts themselves.[news][v255][v258]

ReleaseDateWhat happened
258September 2025The notes restate the deprecation of SysV service scripts — declared back in 255 — and at that point still schedule removal for 259; it was later postponed to 260. What 258 removes immediately is different: /dev/initctl, the initctl, runlevel and telinit commands, init 3 style state changes and the runlevel[0-6].target units are all gone, and runlevel transitions stop being recorded in utmp/wtmp. cgroup v1 support is dropped in the same release.
259December 2025runlevel[0-6].target restored, but only when the distribution builds with -Dcompat-sysv-interfaces=yes. The removed commands do not return. This is the version Ubuntu 26.04 LTS ships.
260March 2026Support for System V service scripts removed. Deleted: systemd-sysv-generator; systemd-rc-local-generator and rc-local.service; systemd-sysv-install. The build options -Drc-local=, -Dsysvinit-path= and -Dsysvrcnd-path= become deprecated. Minimum kernel rises to 5.10, glibc to 2.34, OpenSSL to 3.0.
261 and later2026No further SysV-related changes; the compatibility layer is simply absent. The deprecated build options are expected to disappear in a future release.

The partial reversal in 259 matters if you administer a mixed fleet: the runlevel[0-6].target units came back, but only as an option a distribution has to enable at build time, and the commands did not come back at all. So systemctl isolate runlevel3.target may work on one of your machines and not on the next one, depending on how each distribution compiled its package. Do not build habits on it. Where your servers actually stand is a matter of which systemd they ship:[v259][v260]

Where you aresystemdSysV scripts and rc.local
Debian 12 bookworm252Both work, and the journal says nothing: the per-script warning arrived in 255.
Debian 13 trixie257Both work, and the journal already warns for every wrapped script.
Ubuntu 24.04 LTS255Both work, and the journal already warns for every wrapped script — this is the release the warning arrived in.
Ubuntu 26.04 LTS259Both work, and the journal warns for every wrapped script, as it has since 24.04. Canonical states this is the last Ubuntu with SysV compatibility.
Ubuntu 26.10260 or laterRemoved. Scripts stay on disk and are never read.
RHEL 9 / RHEL 10252 / 257Both work for the life of the release. RHEL 9 is silent; RHEL 10 warns per script, and its release notes already carry the deprecation. The change arrives with the next major version.
Rolling distributions260 or laterAlready removed. Check with systemctl --version rather than assuming.

The dates make this concrete rather than theoretical. Canonical's own release notes state that Ubuntu 26.04 LTS — which ships systemd 259 — is the last Ubuntu release with System V compatibility, and that the change takes effect in 26.10. That release is scheduled for October 2026. If you run the interim series, or if you have anything that follows a rolling distribution, the deadline is weeks away rather than years. If you are on an LTS or on an enterprise distribution, you have until your next major upgrade — which is exactly the moment when you least want to be discovering unowned shell scripts.[ubuntu2604][ubuntu2610][rhel10]

Find every script before the upgrade finds them

Do the inventory first, and do it while the generator is still running, because the generator itself will tell you the answer. From version 255 onward, every time systemd wraps a script it emits a structured warning carrying a stable message ID, plus two custom journal fields: SYSVSCRIPT= with the path and UNIT= with the name it invented. That turns a fleet-wide audit into a single exact query instead of a guess assembled from ls and hope.[messages][v255][journalctl]

#!/usr/bin/env bash
# Inventory every SysV script this host still depends on. Run it BEFORE the
# upgrade, on every machine, and keep the output.

echo '== 1. scripts systemd is currently wrapping =========================='
# systemd 255 and later log a structured warning for every script they wrap.
# The message ID is stable, so this is an exact list rather than a guess.
journalctl -b -o json --output-fields=SYSVSCRIPT,UNIT \
  MESSAGE_ID=a8fa8dacdb1d443e9503b8be367a6adb 2>/dev/null \
  | python3 -c 'import sys,json
for l in sys.stdin:
    d = json.loads(l)
    print("%-28s %s" % (d.get("UNIT","?"), d.get("SYSVSCRIPT","?")))'

echo '== 2. fallback for systemd 254 and older ============================='
# Older systemd wraps silently. Ask the manager which units came from a
# generator instead: generated units live under /run/systemd/generator.late
# and carry a SourcePath= pointing back at the script. This also catches
# scripts that were wrapped before the current boot's journal starts.
systemctl list-units --type=service --all --no-legend --plain \
  | awk '{print $1}' \
  | while read -r u; do
      frag=$(systemctl show -p FragmentPath --value "$u" 2>/dev/null)
      case "$frag" in
        /run/systemd/generator*)
          src=$(systemctl show -p SourcePath --value "$u" 2>/dev/null)
          [ -n "$src" ] && printf '%-28s %s\n' "$u" "$src" ;;
      esac
    done

echo '== 3. scripts on disk with no NATIVE unit behind them ================'
# Note the test: `systemctl cat` succeeds for a generated unit too, so asking
# whether the unit exists tells you nothing while the generator is running.
# Ask where the definition lives instead.
for f in /etc/init.d/*; do
  [ -f "$f" ] && [ -x "$f" ] || continue
  n=$(basename "$f"); n=${n%.sh}
  frag=$(systemctl show -p FragmentPath --value "$n.service" 2>/dev/null)
  case "$frag" in
    /etc/systemd/system/*|/usr/lib/systemd/system/*|/lib/systemd/system/*) ;;
    *) echo "no native unit: $f" ;;
  esac
done

echo '== 4. runlevel wiring (this is what set the boot order) ============='
ls -l /etc/rc[1-5].d/S* 2>/dev/null | awk '{print $9, $10, $11}'

echo '== 5. rc.local ======================================================'
ls -l /etc/rc.local 2>/dev/null && \
  { [ -x /etc/rc.local ] && echo 'executable: it runs at boot today'; }

Run the inventory on every host, not on a representative one. In my experience the scripts that survive into 2026 are never the ones a configuration-management repository knows about — they are the one-off started by a contractor, the vendor appliance agent, the backup wrapper that predates the current team. Those are exactly the hosts nobody thinks to check, and exactly the services whose absence is noticed a week later.

One limit and two details are worth internalising. The limit: step one reads only the current boot's journal, so a host whose warning has already rotated away — or that has not rebooted since you started looking — will answer with silence and still needs step two. The details: systemd only ever wrapped executable regular files in the init directory, so a script left at mode 644 was already dead and you should not resurrect it. And a native unit always beat a script of the same name: the generator explicitly skipped any script for which a real unit already existed. That second rule is what makes the whole migration safe to do incrementally — you can install the new unit while the script is still present and still enabled, and the script simply stops being consulted.[gensrc]

What the generator was quietly writing for you

Before you write a single unit, take the one the generator produces and read it. It is not a rough approximation; it is a deterministic translation, and it is the closest thing you will get to a specification of what your script's boot behaviour actually was. Capture it with systemctl cat while you still can — after the upgrade this information is gone, and reconstructing it from the LSB header is where people introduce ordering bugs.[gensrc][genman]

# Capture the generated unit BEFORE you upgrade. It is the specification for
# the unit you are about to write, produced by the only thing that ever read
# your init script correctly.
systemctl cat acme-collector.service > ~/acme-collector.generated.service

# What comes out looks like this - and every line of it is decided by the
# generator source, not by convention:

# /run/systemd/generator.late/acme-collector.service
# Automatically generated by systemd-sysv-generator

[Unit]
Documentation=man:systemd-sysv-generator(8)
SourcePath=/etc/init.d/acme-collector
Description=LSB: ACME metrics collector
Before=multi-user.target
Before=multi-user.target
Before=multi-user.target
Before=graphical.target
After=remote-fs.target
After=network-online.target
After=time-sync.target
After=postgresql.service
Wants=network-online.target

[Service]
Type=forking
Restart=no
TimeoutSec=5min
IgnoreSIGPIPE=no
KillMode=process
GuessMainPID=no
RemainAfterExit=no
PIDFile=/var/run/acme-collector.pid
SuccessExitStatus=5 6
ExecStart=/etc/init.d/acme-collector start
ExecStop=/etc/init.d/acme-collector stop
ExecReload=/etc/init.d/acme-collector reload

# Yes, Before=multi-user.target really is repeated. The rc2.d, rc3.d and rc4.d
# symlinks each append it and nothing deduplicates the list. Harmless, but it
# tells you the file was machine-written and how.
#
# Note what is NOT there: no [Install] section. The generator wired the unit in
# by dropping symlinks next to it - into multi-user.target.wants for rc2-rc4,
# and into graphical.target.wants for rc5 - which is why `systemctl is-enabled`
# on one of these was never a straight answer.

Now the part that surprises most people, and that you can verify in the generator's own source. Default-Start: and Default-Stop: in the LSB header were never read. The generator parses only Provides:, Required-Start:, Should-Start:, X-Start-Before:, X-Start-After:, the two description fields, and the Red Hat style # pidfile: and # description: comments. The runlevel wiring came entirely from the S??name symlinks in /etc/rc[1-5].d. If you have been maintaining Default-Start for a decade, you have been maintaining a comment.[lsb][exitstatus]

In the init scriptWhat the generator did with itWrite this in your unit
Provides: nameIf it names a service, an alias symlink. If it names a $facility, Before= plus Wants= on that target — one-way. If it repeats the file name, ignored.Alias= in [Install], or nothing
Required-Start: $networkAfter=network-online.target and Wants=network-online.target, because that target is inert unless pulled inWants= + After=network-online.target
Required-Start: $remote_fsAfter=remote-fs.targetAfter=remote-fs.target
Required-Start: $namedAfter=nss-lookup.targetAfter=nss-lookup.target
Required-Start: $portmapAfter=rpcbind.targetAfter=rpcbind.target
Required-Start: $timeAfter=time-sync.targetAfter=time-sync.target
Required-Start: $local_fs or $syslogNothing. Both were dropped: under systemd they are already satisfied before any ordinary service runs.Nothing
Should-Start: fooAfter=foo.service — ordering only, never a requirementAfter=foo.service, without Requires=
X-Start-Before: fooBefore=foo.serviceBefore=foo.service
Default-Start: / Default-Stop:Nothing at all. Never parsed. The runlevel wiring came from the S?? symlinks in /etc/rc[1-5].d.WantedBy=multi-user.target in [Install]
/etc/rc2.d, rc3.d, rc4.d symlinkBefore=multi-user.target and a wants symlink into itWantedBy=multi-user.target
/etc/rc5.d symlinkBefore=graphical.target and a wants symlink into itWantedBy=graphical.target
/etc/rc1.d symlinkBefore=rescue.target and a wants symlink into it — rc1.d was treated exactly like rc2 to rc5Almost never what you want; leave it out
# pidfile: /path (Red Hat style)PIDFile=/path, and RemainAfterExit=no. With no pidfile line at all, RemainAfterExit=yesDelete both. Use Type=exec instead
### BEGIN INIT INFO presentSuccessExitStatus=5 6 — the LSB codes for "not installed" and "not configured"Only if you still exit with those codes
A usage line containing |reload} or similarExecReload=/etc/init.d/x reload. No usage line, no reload verb.ExecReload=/bin/kill -HUP $MAINPID

Two facility mappings in that table deserve a second look, because they are the usual source of "it worked under SysV and races now". $network did not become network.target; it became network-online.target, and the generator added both After= and Wants= because that target does nothing unless something actively pulls it in. Meanwhile $local_fs and $syslog mapped to nothing at all — they were dropped, on the reasonable grounds that under systemd both are already guaranteed by the time any normal service starts. Copy those decisions into your unit; do not improvise new ones.[special]

Converting one script, field by field

Here is a realistic script — not a toy. It has an LSB header, a Red Hat style PID file comment, a start-stop-daemon invocation that backgrounds the process and writes the PID file on the daemon's behalf, a reload verb, and the usage line that is the only reason the generator emitted an ExecReload= at all. Most real scripts in the wild are worse than this one.

#!/bin/sh
### BEGIN INIT INFO
# Provides:          acme-collector
# Required-Start:    $remote_fs $network $time
# Required-Stop:     $remote_fs $network
# Should-Start:      postgresql
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Short-Description: ACME metrics collector
### END INIT INFO
# pidfile: /var/run/acme-collector.pid

DAEMON=/opt/acme/bin/collector
PIDFILE=/var/run/acme-collector.pid
RUNAS=acme
OPTS="--config /etc/acme/collector.conf"

case "$1" in
  start)
    start-stop-daemon --start --quiet --background --make-pidfile \
        --pidfile "$PIDFILE" --chuid "$RUNAS" --exec "$DAEMON" -- $OPTS
    ;;
  stop)
    start-stop-daemon --stop --quiet --pidfile "$PIDFILE" --retry 30
    rm -f "$PIDFILE"
    ;;
  reload)
    kill -HUP "$(cat "$PIDFILE")"
    ;;
  restart)
    "$0" stop; sleep 1; "$0" start
    ;;
  *)
    echo "Usage: $0 {start|stop|restart|reload}" >&2
    exit 2
    ;;
esac
exit 0

And here is what replaces it. Read the comments rather than the directives: the interesting decisions are the ones about what not to carry over. The forking goes. The manual PID file goes. The start-stop-daemon wrapper goes, because everything it was doing — dropping privileges, backgrounding, tracking the process — is a directive now.[service][exec]

# /etc/systemd/system/acme-collector.service
#
# Everything above the blank line inside [Service] is the translation of the
# init script. Everything below it is what the wrapper could never give you.

[Unit]
Description=ACME metrics collector
Documentation=https://example.internal/acme/collector
# $network in the old header became network-online.target - and that target
# only does something if a unit actively pulls it in, hence the Wants=.
Wants=network-online.target
After=network-online.target
# "Should-Start: postgresql" was ordering only, never a requirement. Keep it
# that way: After= without Requires= means a database outage does not cascade.
After=postgresql.service

[Service]
Type=exec
User=acme
Group=acme
# The single most important line of the migration: stop the daemon forking.
# Almost every daemon has a flag for this - --foreground, -f, -D, --no-detach,
# --nodaemon. Find it and the PID file problem disappears with it.
ExecStart=/opt/acme/bin/collector --config /etc/acme/collector.conf --foreground
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s

# /var/run/acme-collector.pid was in /run all along; let systemd own it.
RuntimeDirectory=acme-collector
StateDirectory=acme-collector
ConfigurationDirectory=acme

NoNewPrivileges=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=yes
RestrictSUIDSGID=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
SystemCallArchitectures=native
SystemCallFilter=@system-service
# Empty means "no capabilities at all". If the daemon binds a port below 1024,
# use CapabilityBoundingSet=CAP_NET_BIND_SERVICE plus the matching
# AmbientCapabilities= instead of dropping this line.
CapabilityBoundingSet=

[Install]
WantedBy=multi-user.target
In the scriptIn the unitNote
start-stop-daemon --background or &Type=exec and the daemon's own foreground flagThe whole point. Do not background anything.
--make-pidfile, echo $! > …delete itsystemd knows the PID because it forked the process.
--chuid, su - user -c, runuserUser= and Group=Applies to every descendant, not just the first.
mkdir -p /run/x; chown …RuntimeDirectory=xCreated before start, removed after stop, with the right owner.
mkdir -p /var/lib/xStateDirectory=xSurvives restarts; use CacheDirectory=/LogsDirectory= for the others.
ulimit -n 65535LimitNOFILE=65535A shell ulimit in the script applied to the shell, sometimes not to the daemon.
export FOO=barEnvironment= or EnvironmentFile=EnvironmentFile=-/etc/default/x keeps existing config files working.
nice -n 10, ioniceNice=10, IOSchedulingClass=Or go further with CPUWeight= and IOWeight=.
cd /opt/xWorkingDirectory=/opt/x
>> /var/log/x.log 2>&1delete itOutput goes to the journal, tagged with the unit. No rotation to write.
sleep 5; check_if_upType=notify in the daemon, or a health check in a separate unitA sleep in a start verb is a race with a longer fuse.
restart) stop; sleep 1; startdelete itsystemctl restart exists and waits properly.
status) blockdelete itsystemctl status and is-active report real state.
trap / cleanup on stopExecStop=, or better, handle SIGTERM in the daemonTimeoutStopSec= bounds how long you wait before SIGKILL.

The mapping in general terms, for the idioms you will actually meet. Where a row says "delete it", that is not a simplification: the behaviour is provided by the manager, and reimplementing it in shell is how you end up with two things fighting over the same process.[unit]

# Cut over on a system that still has the generator, so you can roll back by
# doing nothing. Order matters here.

# 1. Check the file before systemd ever loads it.
sudo systemd-analyze verify /etc/systemd/system/acme-collector.service

# 2. Install it and reload. The native unit now WINS over the script: the
#    generator skips any script that already has a unit of the same name.
sudo systemctl daemon-reload

# 3. Prove which one is live before you touch the running process.
systemctl show -p FragmentPath -p SourcePath --value acme-collector.service
# /etc/systemd/system/acme-collector.service
#                       <- empty SourcePath = no longer generated. Good.

# 4. Restart through systemd, not through the script.
sudo systemctl restart acme-collector.service
systemctl status acme-collector.service --no-pager

# 5. Enable it explicitly. The generator's implicit wiring is not inherited.
sudo systemctl enable acme-collector.service
systemctl is-enabled acme-collector.service   # -> enabled

# 6. Retire the old wiring. Do NOT delete the script yet - move it aside, so
#    a rollback is one mv away for the length of the change window.
sudo rm -f /etc/rc[0-6].d/[SK]??acme-collector
sudo mv /etc/init.d/acme-collector /root/retired-init.d-acme-collector

# 7. The only real test: reboot, then read the boot rather than the status.
sudo systemctl reboot
journalctl -b -u acme-collector.service
systemd-analyze blame | head -20

Cut over in this order and a rollback costs one mv. The single most important step is the third one: systemctl show -p FragmentPath -p SourcePath tells you unambiguously which of the two definitions is live. A non-empty SourcePath means you are still looking at the generated wrapper and your new file has a name collision, a syntax error, or is sitting in the wrong directory.[systemctl]

Replacing /etc/rc.local without keeping its worst habit

/etc/rc.local deserves its own section because it is the one people put off, and because the compatibility unit it is losing had genuinely strange semantics that are worth knowing before you copy them forward. The real rc-local.service used Type=forking with GuessMainPID=no, RemainAfterExit=yes and TimeoutSec=infinity, ordered only after network.target, and pulled itself in only if the file was executable. Upstream's own manual page went out of its way to warn that this ordering does not mean the network is usable, and that the whole thing existed for compatibility with specific System V systems rather than as a design anyone endorsed.[rclocalunit][rclocalman]

# /etc/rc.local was never one thing. Read yours first and split it: a mount,
# a sysctl, a firewall rule and a background daemon are four different units,
# and only the last one belongs in a service.

# --- 1. the direct replacement, when it really is one script ---------------
# /etc/systemd/system/local-startup.service
[Unit]
Description=Local startup commands (former /etc/rc.local)
ConditionFileIsExecutable=/usr/local/sbin/local-startup
# The old rc-local.service used After=network.target, which upstream's own
# manual page warns does not mean the network is usable. If your script talks
# to the network, use network-online.target and pull it in.
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/local-startup
# The original had TimeoutSec=infinity. Keeping that means a hung script hangs
# the boot forever with no message. Pick a number you would accept waiting.
TimeoutStartSec=90s

[Install]
WantedBy=multi-user.target

# --- 2. the pieces that should not be a service at all ---------------------
# sysctl:      /etc/sysctl.d/90-local.conf
# modules:     /etc/modules-load.d/local.conf
# files/dirs:  /etc/tmpfiles.d/local.conf
# mounts:      /etc/fstab or a .mount unit
# periodic:    a .timer, not a sleep loop

# --- 3. install it --------------------------------------------------------
sudo install -m 0755 /etc/rc.local /usr/local/sbin/local-startup
sudo systemctl daemon-reload
sudo systemctl enable --now local-startup.service
systemctl status local-startup.service --no-pager
# On SELinux systems relabel the script, exactly as the old generator's manual
# page told you to do for /etc/rc.local:
command -v restorecon >/dev/null && sudo restorecon -v /usr/local/sbin/local-startup

Two habits are worth breaking while you are in there. Do not keep TimeoutSec=infinity: it converts a hung command into a boot that never finishes, silently, with no failed unit to point at. And do not keep everything in one script. An rc.local is usually four unrelated things that were put together because there was only one hook available — a sysctl, a mount, a firewall rule and something that should have been a daemon. As separate units they can be ordered, retried and disabled independently, and when one of them breaks you find out which one.[special]

Type=forking, PID files, and daemons that fight you

This is the mistake that costs a night, and it does not announce itself. If the daemon forks and the unit says Type=simple, systemd tracks the parent, watches it exit within milliseconds, and then either kills the surviving children as strays or declares the start a success while nothing is running. If the daemon forks and the unit says Type=forking but the PID file arrives late, systemd loses the main process and reports MainPID=0: the unit looks active, Restart= never fires, and shutdown leaves processes behind.[daemon][service]

# The failure mode nobody warns you about: the unit reports "active" and the
# daemon is dead, or reports "failed" while the daemon is happily running.
# Both come from getting Type= wrong.

# --- diagnosis -------------------------------------------------------------
systemctl show -p Type -p MainPID -p PIDFile -p ControlGroup --value myd.service
# forking
# 0                <- systemd has no main process: it lost the daemon
# /var/run/myd.pid
# /system.slice/myd.service

# If MainPID is 0 while the process exists, systemd is not supervising it.
# Restart=, watchdogs, and clean shutdown are all silently not working.
systemd-cgls -u myd.service      # who is actually inside the unit's cgroup

# --- fix 1 (preferred): stop forking --------------------------------------
# Type=exec: systemd considers the unit started once the binary has been
# executed. No PID file, no race, no double-fork, no GuessMainPID guesswork.
#   ExecStart=/usr/sbin/myd --foreground
#   Type=exec

# --- fix 2 (best, if the daemon supports it): tell systemd when you are up --
# Type=notify with sd_notify(READY=1) from the daemon, or Type=notify-reload
# if it can also signal that a reload finished. These - together with Type=dbus
# for D-Bus services - are the only readiness protocols precise enough that
# After= on a dependent unit means "the port is actually accepting".
#   Type=notify
#   NotifyAccess=main

# --- fix 3 (last resort): keep forking, but do it correctly ---------------
# PIDFile= must be an absolute path under /run, and the daemon must write it
# BEFORE the parent exits. Anything else is a race you will lose under load.
#   Type=forking
#   PIDFile=/run/myd/myd.pid
#   RuntimeDirectory=myd

# --- do not do this -------------------------------------------------------
# Type=simple with a daemon that forks. systemd will track the parent, see it
# exit immediately, and either kill the children or declare success while the
# service never started. This is the single most common broken conversion.

The durable fix is not a cleverer PID file, it is to stop forking. Almost every daemon written in the last twenty years has a flag to stay in the foreground, and upstream's own guidance for new-style daemons is to use it. Type=exec then removes the entire class of problem, and Type=notify goes further: it — together with notify-reload, or Type=dbus for D-Bus services — is the only reliable way to make an After= on a dependent unit mean "the socket is genuinely accepting connections" rather than "the binary has been executed". Type=forking is nominally a readiness protocol too, but it depends on the daemon's parent exiting at exactly the right moment, which is the assumption that keeps failing. Everything else is a hopeful ordering.[incompat]

init 3, telinit and runlevel are gone too

This is the part of the change that reaches beyond servers into muscle memory and into runbooks. Version 258 removed the /dev/initctl device node and the initctl, runlevel and telinit commands, removed support for state changes via init 3, removed the runlevel[0-6].target units, and stopped recording runlevel transitions in utmp and wtmp — because the concept of a runlevel no longer exists. Version 259 restored the targets behind the -Dcompat-sysv-interfaces=yes build option; it did not restore the commands.[v258][v259]

SysVsystemdNote
init 3, telinit 3systemctl isolate multi-user.targettelinit was removed in 258. init still exists — it is PID 1 — but state control through it is gone.
init 5systemctl isolate graphical.target
init 1, telinit Ssystemctl isolate rescue.targetemergency.target goes further down.
init 0 / init 6systemctl poweroff / systemctl reboot
runlevelsystemctl get-default, systemctl list-units --type=targetThe command was removed; a target is not a runlevel.
Default runlevel in /etc/inittabsystemctl set-default multi-user.target/etc/inittab has not been read for years.
chkconfig x on, update-rc.d x defaultssystemctl enable x.serviceNeeds an [Install] section in the unit.
chkconfig x off, update-rc.d -f x removesystemctl disable x.service
(no equivalent)systemctl mask x.serviceMakes the unit unstartable even as a dependency.
chkconfig --list, service --status-allsystemctl list-unit-files --type=service
service x startsystemctl start x.serviceservice often still exists as a shim; do not rely on it.
# v258 removed /dev/initctl and the initctl, runlevel and telinit commands,
# and with them `init 3`. v259 brought the runlevel[0-6].target ALIASES back,
# but only if the distribution builds with -Dcompat-sysv-interfaces=yes, and
# the commands did not come back at all. Do not build habits on them.

# What runlevel am I in?  ->  what is the system aiming at?
systemctl get-default                      # the boot target
systemctl list-units --type=target --state=active

# init 3 / telinit 3     ->
sudo systemctl isolate multi-user.target
# init 5                 ->
sudo systemctl isolate graphical.target
# init 1 / telinit 1 / S ->
sudo systemctl isolate rescue.target
# init 0                 ->
sudo systemctl poweroff
# init 6                 ->
sudo systemctl reboot

# Change the default "runlevel" permanently (this replaces /etc/inittab):
sudo systemctl set-default multi-user.target

# chkconfig foo on / update-rc.d foo defaults ->
sudo systemctl enable foo.service
# chkconfig foo off / update-rc.d -f foo remove ->
sudo systemctl disable foo.service
# ...and the one with no SysV equivalent, for a unit that must never start:
sudo systemctl mask foo.service

# chkconfig --list / service --status-all ->
systemctl list-unit-files --type=service
systemctl list-units --type=service --all

One of those replacements has no SysV equivalent at all and is worth learning on its own merits. systemctl mask makes a unit unstartable, by anything, including as a dependency of something else — the closest SysV ever came was deleting the script and hoping the package manager did not put it back. It is the correct tool for retiring a service you are not ready to uninstall, and for making sure a decommissioned script cannot be revived by a well-meaning colleague.[systemctl]

What you gain that the wrapper never gave you

It is worth being honest that this migration is imposed work with no feature at the end of it. So take the compensation, because it is real and it is free: a wrapped init script could use none of it. The wrapper ran your shell script, and your shell script ran the daemon; systemd had no idea what the process was, could not restart it reliably, and could not constrain it at all. A native unit gets all of the following by adding lines:[exec][resctl]

  • A real supervision loop. Restart=on-failure with RestartSec=, backed by StartLimitIntervalSec= so a crash loop stops rather than pinning a core. The generated wrapper hardcoded Restart=no for every script it ever produced.
  • Filesystem isolation. ProtectSystem=strict makes the whole filesystem read-only except what you name, PrivateTmp=yes gives the service its own /tmp, and StateDirectory=, RuntimeDirectory= and ConfigurationDirectory= create, own and clean up the directories your script used to mkdir -p by hand.
  • Privilege reduction that actually holds. User=, NoNewPrivileges=yes and an explicit CapabilityBoundingSet= replace su and start-stop-daemon --chuid, and unlike them they apply to every process the service spawns, forever, including the ones a compromised daemon tries to spawn.
  • Syscall and namespace restriction. SystemCallFilter=@system-service, RestrictAddressFamilies=, RestrictNamespaces= and MemoryDenyWriteExecute=yes are a seccomp policy you can write in four lines. There was no shell equivalent at any price.
  • Resource accounting per service. MemoryMax=, CPUQuota=, TasksMax= and IOWeight= apply to the unit's cgroup, which means to the daemon and everything it forks — the exact thing a PID-file-based script could never contain.
  • Logs that arrive somewhere. Standard output and standard error go to the journal with the unit name attached, so journalctl -u works without the daemon knowing what syslog is, and without a logfile silently filling a partition because nobody wrote a rotation rule.

Do not add all of that blind, though. Run systemd-analyze security on the unit and treat the score as a to-do list rather than a grade: it names each directive you have not set and what it would buy. Tighten in small steps, restart, and read the journal — the failure mode of over-hardening is a service that starts perfectly and then cannot open a file three hours later, which is a much worse Tuesday than a service that refuses to start.[analyze]

Verifying the migration, not hoping

Do not verify by looking at systemctl status and seeing green. A wrapped script showed green too. Verify the four things that were actually different: that the definition is a file you control rather than a generated one, that no SourcePath points back at an init script, that the unit is enabled explicitly rather than by leftover wiring, and that systemd knows the main process ID.[analyze]

#!/usr/bin/env bash
# Run after the cut-over and again after the first reboot. Non-zero exit means
# something in the migration is not finished.
set -u
rc=0
units=("$@")   # e.g. ./verify.sh acme-collector.service local-startup.service

for u in "${units[@]}"; do
  echo "--- $u"

  # 1. Is it a real file, not a generated one?
  frag=$(systemctl show -p FragmentPath --value "$u")
  case "$frag" in
    /etc/systemd/system/*|/usr/lib/systemd/system/*|/lib/systemd/system/*) ;;
    *) echo "  FAIL still generated or missing: '$frag'"; rc=1 ;;
  esac

  # 2. No SourcePath = no init script behind it.
  src=$(systemctl show -p SourcePath --value "$u")
  [ -z "$src" ] || { echo "  FAIL SourcePath=$src"; rc=1; }

  # 3. Syntactically valid, with no warnings.
  systemd-analyze verify "$frag" || { echo "  FAIL verify"; rc=1; }

  # 4. Enabled explicitly, not by leftover generator wiring.
  [ "$(systemctl is-enabled "$u")" = enabled ] \
    || { echo "  FAIL not enabled"; rc=1; }

  # 5. Actually supervised: a Type= that forks and loses its child shows
  #    MainPID=0 while looking perfectly healthy.
  mp=$(systemctl show -p MainPID --value "$u")
  ty=$(systemctl show -p Type --value "$u")
  [ "$ty" = oneshot ] || [ "$mp" != 0 ] \
    || { echo "  FAIL Type=$ty but MainPID=0"; rc=1; }

  # 6. Report the sandbox score. Not pass/fail - a number to improve.
  systemd-analyze security "$u" | tail -1
done

# 7. Nothing anywhere is still being generated from an init script.
if ls /run/systemd/generator.late/*.service >/dev/null 2>&1; then
  for g in /run/systemd/generator.late/*.service; do
    grep -q '^SourcePath=/etc/init.d/' "$g" && { echo "STILL WRAPPED: $g"; rc=1; }
  done
fi

# 8. Nothing failed at boot for an ordering reason you introduced.
systemctl --failed --no-legend --no-pager
exit $rc

Then reboot, because the entire class of bug this migration introduces is an ordering bug, and ordering bugs are invisible on a running system where every dependency is already up. Read journalctl -b -u for each converted unit and systemd-analyze blame for the boot as a whole. A service that used to start under a numbered symlink at position 20 and now starts too early will not fail — it will retry, or it will start with an empty configuration, or it will bind before an interface has an address.

If you ship software to other people's servers

If you distribute software that other people install, this stopped being optional some time ago. The generator had been logging a warning for every wrapped script since 255, addressed directly at you: please update package to include a native systemd unit file. On systemd 260 that warning is not there any more, because there is nothing left to warn about — the script installs, and then nothing runs it.[gensrc]

What a package must now ship is a unit file in /usr/lib/systemd/system/ with a proper [Install] section, because the third removed component, systemd-sysv-install, was the hook that let systemctl enable fall through to chkconfig or update-rc.d for script-based services. Without it, enabling a service is purely a matter of the symlinks that [Install] describes. Keep the init script in the package if you still support older distributions — a native unit and a script can coexist, and the unit wins wherever systemd is new enough to matter.[unit]

The order to do this in

There is no version of this where doing it later is cheaper. The work is proportional to the number of scripts, which does not shrink, and the window is defined by somebody else's release schedule. What varies is how much notice you get, so the first decision is which of these situations you are in:[ubuntu2604]

SituationHow much time you haveDo this
Rolling distribution, or systemd 260 already installedNone — it is already goneInventory from backups or configuration management, then convert. Scripts on disk are inert but still tell you what existed.
Ubuntu interim series (26.04 → 26.10)WeeksInventory and capture generated units now, while 259 is still running. Convert before the October release.
Ubuntu 26.04 LTS, staying on LTSUntil the next LTSNo emergency, but the journal is already warning. Convert opportunistically, starting with anything unowned.
Debian stable, RHEL 9 or 10Until the next major versionThe generator is present and doing its job; on Debian 13 and RHEL 10 it is already warning per script. Do the inventory anyway — the cost is one command and it ages well.
You package software for othersNoneShip a unit file with an [Install] section now. Your users' upgrades are not under your control.
An appliance or vendor agent you cannot modifySame as the hostWrite the unit yourself and mask the vendor's script, or raise it with the vendor before their next release strands you.
  1. Inventory the whole fleet this week, using the journal query above, and store the output. Two columns — hostname and script path — is enough to size the work and to catch the machines nobody remembers.
  2. Capture the generated unit for every script with systemctl cat into a directory you keep. This is a read-only operation, it takes minutes, and after the upgrade the information cannot be recovered.
  3. Sort into three piles: delete, replace, rewrite. A surprising share of what you find is a service for something that was decommissioned years ago. Deleting it is a complete migration and takes no time at all.
  4. Convert the easy ones first — anything already packaged upstream almost certainly has an official unit, and installing the current package is faster and more correct than writing one yourself.
  5. Do the cut-over while the generator still exists, one service at a time, on a system where the script is still present. Rolling back is then removing one file and reloading, not restoring a backup under pressure.
  6. Reboot and read the boot, per host, before you call any of it done. Then mask the retired units so nothing brings them back.

The same maintenance window usually contains the rest of it: the Ubuntu 24.04 to 26.04 server upgrade covers the distribution upgrade that will remove the generator from under you, systemd timers versus cron is the other half of the same clean-up, since a machine with SysV scripts almost always has crontabs that should be units too, and what breaks when you move to Docker Engine 29 deals with the container runtime, which changed its own defaults in the same period and for related reasons.

Frequently asked questions

Can I just reinstall the sysv-generator on systemd 260?

No, and the workarounds that look like they might work are worse than doing the migration. The generator was deleted from the source tree, not disabled behind a flag, so there is no package to install and no option to set — the three deprecated meson options that remain (-Drc-local=, -Dsysvinit-path=, -Dsysvrcnd-path=) only affect paths, not the presence of the code. You could in principle copy the generator binary from a 259 build into /usr/lib/systemd/system-generators/, but you would be running an unmaintained generator against a manager it was never tested with, and it would be silently overwritten by the next systemd update. Writing the unit file takes less time than debugging that once.

Does my init script still work if I run it by hand?

Yes. Nothing about the script changed; it is an ordinary shell script and /etc/init.d/x start will do exactly what it always did. What is gone is the automatic translation into a service, which means: it does not start at boot, systemctl does not know about it, nothing supervises or restarts the process, and stopping the machine will not stop it cleanly. That combination is worse than it sounds, because a script you can still run by hand does not feel broken — so people work around it with a crontab @reboot entry and the problem goes quiet for a year.

How do I know which of my servers are affected, without logging into all of them?

Query the journal for the structured warning. From systemd 255 onward every wrapped script produces a log entry with MESSAGE_ID=a8fa8dacdb1d443e9503b8be367a6adb and two custom fields, SYSVSCRIPT= and UNIT=. If you ship journals centrally, that is one query across the fleet. If you do not, run journalctl -b MESSAGE_ID=a8fa8dacdb1d443e9503b8be367a6adb through whatever runs commands on many hosts. Two caveats: on systemd 254 and older the wrapping is silent, and -b only covers the current boot. In both cases fall back to asking each unit for its FragmentPath and SourcePath, as the inventory script above does.

What is the difference between Type=simple, Type=exec, Type=forking and Type=notify?

Type=simple considers the service started as soon as systemd has forked, before the binary has necessarily been executed successfully. Type=exec waits until the binary has actually been executed, which catches a missing file or a bad User= as a start failure instead of a mysterious immediate exit — it is the better default for a foreground daemon. Type=forking is for daemons that background themselves and expects a PIDFile=; it is what the sysv-generator used, because it had no choice. Type=notify waits for the daemon to call sd_notify(READY=1), which is the only one of the four where an After= dependency means the service is genuinely ready. Convert to Type=exec; use Type=notify if the daemon supports it.

Is /etc/rc.local really gone, or just deprecated?

Gone, on systemd 260 and later. systemd-rc-local-generator and the rc-local.service unit it pulled in were both removed. The file will still be sitting in /etc, still executable, and nothing will ever call it. Note that the path was a compile-time setting and varied between distributions — some used /etc/rc.d/rc.local — so check what your generator was actually configured with before you assume you have found all of them.

My unit says active (running) but the process does not exist. What did I do wrong?

Almost certainly Type=forking with a PID file that systemd could not read at the right moment, or Type=simple on a daemon that forks. Run systemctl show -p Type -p MainPID -p PIDFile --value yourunit.service: if MainPID is 0 while the daemon is running, systemd has lost track of it and none of the supervision applies. The fix is to stop the daemon forking — find its foreground flag and use Type=exec. If you truly cannot, make sure PIDFile= is an absolute path under /run and that the daemon writes it before the parent process exits.

Should I keep the init script after writing the unit?

Keep it during the change window, then remove it. While both exist, the native unit wins — the generator explicitly skipped any script that already had a unit of the same name — so having both is safe and makes rollback trivial. But leave it there permanently and you have two definitions of the same service, one of which is a trap for whoever debugs this at three in the morning. Move it to a retired directory once the host has rebooted cleanly, and remove the /etc/rc*.d symlinks at the same time.

Do I need to worry about this on RHEL or Debian stable?

Not urgently, but do the inventory anyway. RHEL 10 and Debian 13 both ship systemd 257, so both still have the generator — and both are already printing the per-script deprecation warning that arrived in 255, which means the inventory query works on them today. Red Hat's own release notes also state that System V service script support is deprecated and will be removed. The change arrives with the next major version of each. The reason to act now is that the inventory step is one read-only command, it is far easier while the generator is running and telling you what it wraps, and the alternative is discovering an unowned script during a major upgrade, which is the worst possible time to be reverse-engineering a colleague's shell.

What replaces systemd-sysv-install?

Nothing, because there is nothing left for it to do. It was the hook that let systemctl enable, disable and is-enabled fall through to the distribution's own tool — chkconfig on Red Hat systems, update-rc.d on Debian ones — when the named service was a script rather than a unit. With script support removed, enabling a service is entirely a matter of the symlinks described by the unit's [Install] section. If your package relied on that fall-through, it now needs a real unit file with a real [Install] section.

Can I convert a script automatically instead of by hand?

Partly, and the best converter is the one that is about to be removed: systemctl cat yourservice.service on a system that still has the generator gives you a mechanically correct translation of the dependencies, which is the part that is easy to get wrong. What no tool can do for you is the part that matters — deciding that the daemon should stop forking, that the PID file should disappear, that start-stop-daemon --chuid becomes User=, and which sandboxing directives are safe for this particular workload. Treat the generated unit as the specification and write the real one from it.

The resource-control layer moved in the same releases: systemd 258 removed cgroup v1 outright, so every host now boots the unified hierarchy whether or not anything asked it to. migrating from cgroup v1 to cgroup v2 covers the audit, the file-by-file conversion, and the two translations that change behaviour rather than spelling.

Sources

Every version number, directive and default in this article was read from the source below rather than reproduced from other coverage. Where the code has since been deleted, the link points at the last tag where it still existed, so you can check it yourself.

  1. systemd — NEWS: the upstream changelog, and the only authoritative statement of what was removed in which release. Everything dated in this article was checked against it
  2. systemd v260 release notes — "Support for System V service scripts has been removed", with the three components that went with it: systemd-sysv-generator, systemd-rc-local-generator plus rc-local.service, and systemd-sysv-install
  3. systemd v259 release notes — the release that restored runlevel[0-6].target behind the new -Dcompat-sysv-interfaces=yes build option, and the version shipped by Ubuntu 26.04 LTS
  4. systemd v258 release notes — the removal of /dev/initctl and of the initctl, runlevel and telinit commands, the removal of the runlevel[0-6].target units, and the removal of cgroup v1
  5. systemd v255 release notes — where support for System V service scripts was first declared deprecated, and the release whose sysv-generator already logs the structured per-script warning this article uses for the inventory
  6. systemd-sysv-generator source at v259 — the last tag where the code still exists. It is the definitive answer to what the generator read, what it ignored, and exactly which directives it wrote
  7. systemd-sysv-generator(8) — the manual page, including the statement that the wrapper units are always ordered after basic.target and that compatibility was never 100%
  8. systemd-rc-local-generator(8) — the rc.local compatibility generator, its warning that rc-local.service is ordered after network.target and that this does not mean the network works, and the SELinux note about restorecon
  9. rc-local.service at v259 — the actual unit that ran /etc/rc.local, so you can see what semantics you are replacing rather than guessing them
  10. systemd exit-status.h — the LSB start-verb exit codes, and the origin of the SuccessExitStatus=5 6 line the generator emitted for scripts with an LSB header
  11. systemd sd-messages.h — the catalogue of structured log message IDs, including SD_MESSAGE_SYSV_GENERATOR_DEPRECATED, which is what makes the journal-based inventory in this article possible
  12. systemd — Incompatibilities: the upstream list of the places where SysV behaviour and systemd behaviour genuinely differ, worth reading before you assume a script will behave the same way
  13. Linux Standard Base — Init Script Actions: the specification that defines the LSB header fields and the exit codes an init script is supposed to return
  14. systemd.service(5) — Type=, Restart=, PIDFile=, ExecReload=, RemainAfterExit= and the rest of the service options used in the unit files below
  15. systemd.unit(5) — Wants=, After=, Before=, the Condition family and the [Install] section that replaces update-rc.d and chkconfig
  16. systemd.exec(5) — User=, RuntimeDirectory=, StateDirectory= and the whole sandboxing vocabulary, none of which a wrapped init script could ever use
  17. systemd.special(7) — what multi-user.target, graphical.target, network.target and network-online.target actually mean, and why After=network.target does not mean the network is up
  18. systemd.generator(7) — how generators work and where their output lands, which is why the units you are about to lose live under /run/systemd/generator.late
  19. systemctl(1) — enable, mask, cat, show, list-unit-files and isolate: the commands that replace chkconfig, update-rc.d, service and telinit
  20. systemd-analyze(1) — verify, security and blame: the three subcommands that turn this migration from a guess into something you can check
  21. journalctl(1) — matching on MESSAGE_ID= and on arbitrary structured fields, which is how the inventory command in this article finds every wrapped script
  22. daemon(7) — the difference between a SysV-style forking daemon and a new-style daemon, and upstream's own recommendation to stop forking
  23. systemd.resource-control(5) — the per-unit cgroup accounting and limits that come for free once a service is a real unit
  24. Ubuntu 26.04 LTS release notes — the section stating that 26.04 LTS is the last release with System V compatibility in systemd, that the change takes effect in 26.10, and that the release ships systemd 259
  25. Ubuntu 26.10 release schedule — the release date that turns "eventually" into a deadline for anyone tracking the interim series
  26. Red Hat Enterprise Linux 10 release notes — the systemd rebase to 257, the statement that System V service script support is deprecated and will be removed, and the move of default configuration files under /usr/lib/systemd
  27. Debian trixie systemd package — the version currently in Debian stable, for readers deciding how much runway they actually have

Was this useful?