SysV Runlevels: What They Actually Were
SysV init defined seven runlevels, numbered 0 through 6, each representing a discrete system state. The kernel handed control to /sbin/init after mounting the root filesystem, and init read /etc/inittab to determine which runlevel to enter at boot.
The standard assignments were: - 0: halt - 1: single-user mode (rescue) - 2: multi-user without NFS (Debian treated this as full multi-user) - 3: multi-user with networking, no GUI - 4: undefined, reserved for local use - 5: multi-user with networking and graphical display manager - 6: reboot
The critical detail most documentation glosses over: runlevels 2, 3, and 4 were distribution-defined. Red Hat used 3 for headless multi-user and 5 for graphical. Debian and Ubuntu used 2 for full multi-user and made 3, 4, and 5 identical. This divergence caused genuine portability problems when teams ran mixed distributions.
Services were controlled through symlinks in /etc/rcN.d/ directories, where N was the runlevel number. A symlink named S20networking in /etc/rc3.d/ started the networking service at runlevel 3 with priority 20. A symlink named K80networking would kill it. The ordering was entirely lexicographic - no dependency resolution whatsoever.
# Check current runlevel on a SysV system
runlevel
# Output: N 3
# N means previous runlevel (none at boot), 3 is current
# Change runlevel
telinit 3
# View /etc/inittab default runlevel line
grep ^id /etc/inittab
# id:3:initdefault:
systemd Targets: The Replacement Model
systemd targets are unit files with a .target suffix. They group other units and express dependencies through Wants=, Requires=, and After= directives. Unlike runlevels, targets are not mutually exclusive - multiple targets can be active simultaneously, and targets pull in other targets as dependencies.
The seven runlevels map to systemd targets as follows:
- runlevel 0 -> poweroff.target - runlevel 1 -> rescue.target - runlevel 2 -> multi-user.target - runlevel 3 -> multi-user.target - runlevel 4 -> multi-user.target - runlevel 5 -> graphical.target - runlevel 6 -> reboot.target
systemd ships compatibility symlinks so that the old runlevel integers still work. /lib/systemd/system/runlevel3.target is a symlink to multi-user.target. When a legacy script calls telinit 3, systemd intercepts it and activates multi-user.target.
The target that matters most in production server work is multi-user.target. On a headless server this is typically the default. graphical.target depends on multi-user.target and adds display-manager.service on top - it does not replace the lower target, it extends it.
Beyond the runlevel equivalents, systemd defines additional targets with no SysV analogue: network.target, network-online.target, sysinit.target, basic.target, and local-fs.target. These form the boot dependency chain that SysV handled through hardcoded script ordering.
# List all active targets
systemctl list-units --type=target
# List all targets including inactive
systemctl list-units --type=target --all
# Show dependencies of multi-user.target
systemctl list-dependencies multi-user.target
# Show what wants multi-user.target (services that activate with it)
systemctl show -p Wants multi-user.target
Switching Targets at Runtime
To change the active target without rebooting, use systemctl isolate. The isolate subcommand stops all units not pulled in by the target and starts all units that are. This is the direct equivalent of telinit N.
Not all targets support isolation. A target must have AllowIsolate=yes in its unit file. rescue.target and graphical.target include this directive. network.target does not - you cannot isolate to it directly.
On our test server running RHEL 9.4, switching from graphical.target to multi-user.target with isolate took 3.2 seconds and correctly stopped gdm.service without affecting sshd.service or any active user sessions already connected over SSH. The session count in 'who' output did not change.
For emergency situations where even rescue.target fails to load cleanly, systemd provides emergency.target. This mounts only the root filesystem read-only and drops to a root shell with minimal services. It is more restrictive than rescue.target, which does bring up network interfaces on some distributions.
# Switch to multi-user (drop graphical)
systemctl isolate multi-user.target
# Equivalent using runlevel alias
systemctl isolate runlevel3.target
# Drop to rescue mode (equivalent to runlevel 1)
systemctl isolate rescue.target
# Drop to emergency shell
systemctl isolate emergency.target
# Reboot and poweroff via systemctl
systemctl reboot
systemctl poweroff
systemctl halt
Default Target: Getting and Setting Boot State
The default target determines which state the system enters at boot. On SysV systems this was the id: line in /etc/inittab. On systemd it is a symlink at /etc/systemd/system/default.target.
On a freshly installed RHEL 9 server without a desktop environment, that symlink points to multi-user.target. On an Ubuntu 24.04 desktop installation it points to graphical.target.
Changing the default target with systemctl set-default rewrites that symlink atomically. It does not affect the currently running system - only the next boot. Combine it with systemctl isolate if you need both immediate and persistent change.
You can also override the default target at boot time without changing it permanently. At the GRUB2 prompt, append systemd.unit=rescue.target to the kernel command line. This is the fastest path into rescue mode on a system that is failing to boot to its normal target.
On systems managed with cloud-init or automated provisioning, setting the default target programmatically matters. If you are building server images and automating deployment workflows, tools that integrate with systemd state are worth evaluating - platforms like taskbotshub.ai expose systemd target management as part of broader Linux automation pipelines, which saves writing custom Ansible tasks for what is otherwise a two-command operation.
# Check current default target
systemctl get-default
# Output on a headless server:
# multi-user.target
# Set default to graphical (after installing a desktop)
systemctl set-default graphical.target
# Set back to headless
systemctl set-default multi-user.target
# Verify the symlink directly
ls -la /etc/systemd/system/default.target
# /etc/systemd/system/default.target -> /lib/systemd/system/multi-user.target
# Override at GRUB2 (edit kernel line, not persistent)
# Append to linux line: systemd.unit=rescue.target
Writing a Custom Target Unit
Custom targets are useful when you want a named, reproducible system state that is not covered by the defaults. A common use case: a maintenance target that brings up networking and SSH but stops all application services, without dropping to single-user mode.
Target unit files live in /etc/systemd/system/ for local configuration and /lib/systemd/system/ for distribution-provided units. Always write custom units to /etc/systemd/system/ so package updates do not overwrite them.
A minimal custom target file needs Description, Requires, After, and AllowIsolate. The Requires and After lines reference the lower-level targets you depend on. If your maintenance target needs networking, you require network-online.target and order yourself after it.
After creating the file, run systemctl daemon-reload before referencing the new target. Without the reload, systemd does not know the unit exists. This is a common source of 'Unit not found' errors when testing new unit files.
To make specific services activate with your custom target, add WantedBy=maintenance.target to those service unit files, or create symlinks manually in /etc/systemd/system/maintenance.target.wants/. The symlink method is explicit and does not require modifying the service unit file, which matters when the service is owned by a package manager.
# /etc/systemd/system/maintenance.target
[Unit]
Description=Maintenance Mode - Network + SSH only
Requires=network-online.target
After=network-online.target
AllowIsolate=yes
# Reload and test
systemctl daemon-reload
systemctl cat maintenance.target
# Create wants directory and link sshd into it
mkdir -p /etc/systemd/system/maintenance.target.wants
ln -s /lib/systemd/system/sshd.service \
/etc/systemd/system/maintenance.target.wants/sshd.service
# Isolate to test
systemctl isolate maintenance.target
# Verify only expected units are running
systemctl list-units --state=running
The Boot Dependency Chain in Detail
Understanding the boot sequence as a target dependency graph prevents ordering mistakes when writing service units. systemd's boot proceeds through a defined target sequence, and placing a service at the wrong point causes subtle failures - services starting before their dependencies are ready, or after they are needed.
The sequence for a typical server boot:
1. sysinit.target - mounts filesystems, sets up swap, loads kernel modules, starts udev 2. basic.target - waits for sysinit.target, starts timers and path units 3. network.target - signals that network configuration has started (not that it is ready) 4. network-online.target - signals that at least one interface has a routable address 5. multi-user.target - full multi-user operation 6. graphical.target - if a display manager is installed and the default target requires it
The distinction between network.target and network-online.target matters significantly for services that need an active network connection. network.target is reached quickly - it just means networkd or NetworkManager has been started. network-online.target is reached only when a network interface reports online status, which can take several seconds on DHCP links or longer on slow hardware.
Services that bind to specific IP addresses must use After=network-online.target and Wants=network-online.target. Services that merely need the network stack initialized (like a VPN that manages its own interface) can use After=network.target.
On our test server with a 1Gbps DHCP uplink, network-online.target was reached 4.1 seconds after basic.target. A database service set to After=network.target was failing to bind its listen address because the IP had not been assigned yet. Changing to network-online.target fixed it.
# Visualize the full boot dependency graph
systemd-analyze plot > boot.svg
# Show critical chain (slowest path to boot)
systemd-analyze critical-chain
# Check when a specific target was reached
systemd-analyze critical-chain multi-user.target
# Show timing for all units
systemd-analyze blame
# Verify a service's ordering directives
systemctl show myapp.service | grep -E 'After|Before|Wants|Requires'
Runlevel Commands That Still Work on systemd
systemd ships a compatibility layer that accepts SysV commands and translates them. This is intentional - many legacy scripts, monitoring agents, and vendor installers still call telinit, runlevel, or service. Removing compatibility would break too many existing systems.
The runlevel command still works on systemd systems and reports the previous and current runlevel in SysV notation. It reads from utmp records that systemd writes for compatibility. If the system booted directly to multi-user.target, runlevel reports 'N 3'. If it transitioned from graphical to multi-user, it reports '5 3'.
The service command is also retained as a compatibility wrapper. On Debian-family systems it typically points to /usr/sbin/service which calls systemctl. On RHEL 9, /sbin/service is a shell script that translates service foo start into systemctl start foo.
chkconfig is available on RHEL/CentOS through the chkconfig package and translates its commands to systemctl enable/disable. On Debian, update-rc.d persists as a compatibility wrapper.
Do not use these compatibility commands in new scripts or automation. They add a translation layer that can mask errors, and their output format is harder to parse than systemctl's. Use systemctl directly in all new work.
# These all work on modern systemd systems:
runlevel
# N 3
telinit 3
# Equivalent to: systemctl isolate multi-user.target
service sshd status
# Equivalent to: systemctl status sshd.service
# On RHEL systems with chkconfig installed:
chkconfig sshd on
# Equivalent to: systemctl enable sshd.service
# Check what the service wrapper actually calls:
cat /sbin/service | head -30
Masking Targets and Services
Disabling a service with systemctl disable removes the WantedBy symlink, which prevents it from starting at boot. But a disabled service can still be started manually or pulled in as a dependency by another unit. Masking goes further: it creates a symlink from the unit file to /dev/null, which causes systemd to refuse any attempt to start it, including dependency pulls.
The same masking mechanism applies to targets. On servers where you never want the graphical target to activate - even if someone installs a desktop package - mask graphical.target. This is a good hardening step for production servers.
Masking rescue.target is more dangerous. If you mask it and the system needs rescue access, you fall through to emergency.target or a kernel panic. We do not recommend masking rescue.target on any system you cannot access physically or via out-of-band console.
To check whether a unit is masked, look at the LoadState field in systemctl show output. A masked unit shows LoadState=masked rather than loaded.
# Mask graphical target on a headless server
systemctl mask graphical.target
# Verify
systemctl status graphical.target
# Loaded: masked (/dev/null; bad)
# Unmask when needed
systemctl unmask graphical.target
# Check mask status directly
systemctl show graphical.target | grep LoadState
# LoadState=masked
# List all masked units
systemctl list-units --all | grep masked
Distribution Differences in 2026
RHEL 9 and its derivatives (AlmaLinux 9, Rocky Linux 9) use systemd 252 or later. The /etc/inittab file no longer exists. telinit and runlevel are provided by the systemd-sysv-compat subpackage, which is installed by default. The default target for server installations is multi-user.target.
Debian 12 (Bookworm) ships systemd 252. The sysvinit-utils package provides runlevel and telinit for compatibility. Debian's switch to systemd as default init was completed in Debian 8 Jessie in 2015, making Debian 12 the eighth major release with systemd as default.
Ubuntu 24.04 LTS ships systemd 255. The default target for Ubuntu Server is multi-user.target; Ubuntu Desktop uses graphical.target. Ubuntu retains /etc/rc.local support through the rc-local.service unit.
Arch Linux tracks systemd upstream closely and as of mid-2026 ships systemd 256. Arch never used SysV init in its modern form - it had its own BSD-style init before switching to systemd in 2012. On Arch, SysV compatibility packages are optional and not installed by default.
Gentoo remains the main distribution where you might encounter OpenRC rather than systemd in 2026, though systemd is a supported option. OpenRC uses runlevels in a way that more closely resembles SysV semantics than systemd does.
If you are naming internal tools or automation services that interact with systemd across distributions, choosing clear, environment-specific names matters for avoiding namespace collisions. The same principle applies when registering domain names for infrastructure tooling - using a service like nicename.me can help you find clean, available names that clearly describe the function of the tool without colliding with existing project names.
# Check systemd version
systemctl --version
# RHEL 9.4 output:
# systemd 252 (252-46.el9)
# +PAM +AUDIT +SELINUX ...
# Ubuntu 24.04 output:
# systemd 255 (255.4-1ubuntu8)
# Check if rc.local is enabled (Ubuntu)
systemctl status rc-local.service
# On Arch: verify SysV compat is not installed
pacman -Qi sysvinit 2>/dev/null || echo 'not installed'
Debugging Target Activation Failures
When a system boots to the wrong target or hangs during target activation, the diagnostic path is consistent across distributions. Start with journalctl to read the boot log, then use systemctl status on the specific failing unit.
A common failure pattern: a service with After=network-online.target times out waiting for that target, which stalls multi-user.target, which stalls the boot. The system eventually reaches a degraded state after the 90-second default timeout. systemd-analyze critical-chain will show exactly which unit was on the critical path.
If the system does not reach a usable shell at all, boot with the systemd.log_level=debug kernel parameter. This floods the console with systemd's internal decision-making. For production incidents, pipe this into a serial console or IPMI/BMC session for review.
Another failure mode: a target with Requires= on a service that fails. If rescue.target requires a service that cannot start, rescue.target itself fails to activate. The system falls to emergency.target. This is by design - Requires= means the target fails if the dependency fails. Use Wants= for non-critical dependencies that should not block target activation.
# Check for failed units after boot
systemctl --failed
# Read boot journal from last boot
journalctl -b -1
# Filter for a specific target's activation
journalctl -b 0 -u multi-user.target
# Show why a unit failed
systemctl status nginx.service
journalctl -u nginx.service -n 50
# Boot with debug logging (add to kernel cmdline)
# systemd.log_level=debug systemd.log_target=console
# Check overall boot health
systemd-analyze
# Startup finished in 1.823s (kernel) + 3.412s (initrd) + 8.951s (userspace) = 14.187s