Run /etc/rc.local Safely with systemd's Compatibility Service

An old /etc/rc.local script inherited from a migrated server still runs under systemd, and rc-local.service is what makes it happen. This guide creates a small executable script, makes systemd discover it at boot, and verifies the resulting service. The installed systemd here is version 255.4-1ubuntu8.17. Allow about fifteen minutes, including a reboot only if you need to test the complete boot path.

This is a compatibility procedure for an existing legacy script. For new boot tasks, a dedicated systemd unit is usually easier to order, monitor and remove. You need root access, a shell, and a clear understanding of every command the script will run. Editing a boot script is a privileged and service-disrupting change, so keep a recovery shell or console available before you begin.

1. Check what systemd is looking for

The generator does not enable a permanently installed service in the usual sense. During system startup, it checks whether /etc/rc.local exists and is executable. Only then does it pull rc-local.service into the boot transaction. The path is compiled into systemd and can differ on another distribution.

Run these read-only checks as an ordinary user first:

$ test -e /etc/rc.local && echo exists || echo absent
absent
$ systemctl is-enabled rc-local.service
static

static is not an error here. The unit is conditional and is intended to be pulled in by the generator when its script exists and has execute permission. On this machine, /etc/rc.local is absent, so the compatibility service is inactive.

Checkpoint: Do not create the file until you have identified the exact commands that belong in it. A typo in a boot script can delay or break startup.

2. Write a minimal script with a harmless test

Use an editor as root to create the file. The following example records a boot marker in a dedicated log, then exits successfully. The marker is useful because the service itself normally has no interactive terminal.

$ sudo install -o root -g root -m 0755 /dev/null /etc/rc.local
$ sudoedit /etc/rc.local

Put this content in the editor:

#!/bin/sh

printf '%s\n' "rc.local reached at $(date -Is)" >> /var/log/rc.local-test.log
exit 0

The shebang is required because systemd executes the file directly. The file must remain executable, and the commands must return promptly. A command that waits forever can leave the service running indefinitely because the packaged unit has an infinite start timeout.

Check the saved file and its mode:

$ sudo head -n 5 /etc/rc.local
#!/bin/sh

printf '%s\n' "rc.local reached at $(date -Is)" >> /var/log/rc.local-test.log
exit 0
$ stat -c '%A %a %U:%G %n' /etc/rc.local
-rwxr-xr-x 755 root:root /etc/rc.local

Your timestamp will differ. The important checks are the interpreter line, root ownership, and execute bits for the file.

3. Ask systemd to rerun its generators

Creating the file changes the generator's input. Ask the system manager to reload its configuration as root:

$ sudo systemctl daemon-reload
$ systemctl status rc-local.service --no-pager
○ rc-local.service - /etc/rc.local Compatibility
     Loaded: loaded (/usr/lib/systemd/system/rc-local.service; static)
     Active: inactive (dead)

The exact status layout varies. Seeing the unit loaded but inactive is expected until you start it or reboot. daemon-reload reruns generators and rebuilds the dependency tree; it does not run the script by itself.

Inspect the unit before starting it:

$ systemctl cat rc-local.service
[Unit]
Description=/etc/rc.local Compatibility
ConditionFileIsExecutable=/etc/rc.local
After=network.target

[Service]
Type=forking
ExecStart=/etc/rc.local start
TimeoutSec=infinity
RemainAfterExit=yes

Distribution drop-ins may add settings. On this Ubuntu installation, a drop-in also orders the unit after network-online.target and sends output to the journal and console. Inspect the complete output on your own host rather than assuming another distribution has the same additions.

4. Run the compatibility service once

Starting the service now is a privileged, real execution of every command in /etc/rc.local. Review the file first, then run:

$ sudo systemctl start rc-local.service
$ systemctl is-active rc-local.service
active
$ sudo tail -n 1 /var/log/rc.local-test.log
rc.local reached at 2026-09-27T07:30:00+01:00

The timestamp is only an example. The useful result is an active service and one new log line. Because the unit uses RemainAfterExit=yes, an active state means the start operation completed and the unit is being kept active. It does not mean a long-running process from the script is being supervised.

If the start fails, read the service log before changing anything:

$ sudo journalctl -u rc-local.service -b --no-pager
$ systemctl status rc-local.service --no-pager

Common causes are a missing execute bit, a bad shebang, a command that is not on the boot-time PATH, or a script that assumes a terminal. Use absolute paths and explicit environment setup where needed.

5. Understand boot ordering before adding real work

The manual describes the compatibility service as late boot, after network.target, but in parallel with most regular services. That is not the same as "last". Another service can still be starting, and network.target does not prove that an address, route or remote service is ready.

If the script genuinely needs a configured network connection, add a drop-in rather than editing the vendor unit:

$ sudo install -d -m 0755 /etc/systemd/system/rc-local.service.d
$ sudoedit /etc/systemd/system/rc-local.service.d/network.conf
[Unit]
Wants=network-online.target
After=network-online.target

Then reload and restart the service during a maintenance window:

$ sudo systemctl daemon-reload
$ sudo systemctl restart rc-local.service
$ systemctl show -p After,Wants rc-local.service

network-online.target is still only as useful as the network manager's wait-online implementation. If the task has more precise dependencies, a dedicated unit can express them more clearly.

6. Replace the test and keep a rollback

Do not turn the compatibility script into an unreviewed collection of unrelated boot commands. Replace the harmless marker with the smallest tested action, use absolute paths, log failures, and make repeated runs safe. The service runs with root privileges, so a command injection bug or an unsafe writable input can become a full system compromise.

Before changing the test, keep a recoverable copy:

$ sudo cp --preserve=all /etc/rc.local /etc/rc.local.working
$ sudoedit /etc/rc.local

If the new version causes trouble, restore the copy and reload the manager:

$ sudo cp --preserve=all /etc/rc.local.working /etc/rc.local
$ sudo systemctl daemon-reload
$ sudo systemctl restart rc-local.service

Once you no longer need the compatibility path, remove the file and any drop-in you created, then reload systemd. Do this only after moving the work to a tested replacement unit or confirming that it is no longer required:

$ sudo rm /etc/systemd/system/rc-local.service.d/network.conf
$ sudo rmdir /etc/systemd/system/rc-local.service.d
$ sudo rm /etc/rc.local
$ sudo systemctl daemon-reload

Those removals are destructive. Keep the backup until the replacement has survived a complete boot.

Done means