Skip to content

How to run a script as a service

You have a shell script that should run all the time: started at boot, running as an unprivileged user, and restarted automatically if it dies. This guide turns a script into a service managed by the init system: a unit file for systemd (Debian, Ubuntu, RHEL, AlmaLinux, Rocky Linux, Arch, and Gentoo with a systemd profile), or an openrc-run script supervised by supervise-daemon on Gentoo with OpenRC.

The example script writes a timestamp to /var/lib/heartbeat/beat every 10 seconds. Replace it with your own; the parts that matter are that the script runs in the foreground (it doesn't put itself in the background with & or nohup) and runs until it's stopped.

Before you begin

  • You need root access through sudo.
  • Know which init system runs your machine. ps -p 1 -o comm= prints systemd on a systemd system and init on Gentoo with OpenRC (where sysvinit is PID 1).

Steps

  1. Create the script as /usr/local/bin/heartbeat.sh:

    #!/bin/sh
    # heartbeat.sh: write the current time to a file every INTERVAL seconds.
    # Usage: heartbeat.sh [INTERVAL]
    set -eu
    interval="${1:-10}"
    state_dir=/var/lib/heartbeat
    
    while true; do
        date -Iseconds > "$state_dir/beat.tmp"
        mv "$state_dir/beat.tmp" "$state_dir/beat"
        sleep "$interval"
    done
    

    Make it executable:

    sudo chmod 0755 /usr/local/bin/heartbeat.sh
    
  2. Create a system user for the service, and a state directory it owns:

    sudo useradd --system --user-group --no-create-home \
        --home-dir /var/lib/heartbeat --shell /usr/sbin/nologin heartbeat
    sudo install -d -o heartbeat -g heartbeat -m 0755 /var/lib/heartbeat
    
    sudo useradd --system --user-group --no-create-home \
        --home-dir /var/lib/heartbeat --shell /usr/sbin/nologin heartbeat
    sudo install -d -o heartbeat -g heartbeat -m 0755 /var/lib/heartbeat
    
    sudo useradd --system --user-group --no-create-home \
        --home-dir /var/lib/heartbeat --shell /usr/bin/nologin heartbeat
    sudo install -d -o heartbeat -g heartbeat -m 0755 /var/lib/heartbeat
    
    sudo useradd --system --user-group --no-create-home \
        --home-dir /var/lib/heartbeat --shell /sbin/nologin heartbeat
    sudo install -d -o heartbeat -g heartbeat -m 0755 /var/lib/heartbeat
    

    /sbin/nologin exists with both split and merged /usr profiles.

    Running the script as its own user limits what a bug in it can damage. If your script genuinely needs root, skip this step and leave out the user settings in the next one.

  3. Define the service, start it at boot, and test that it restarts. systemd uses a unit file; OpenRC on Gentoo uses an openrc-run service script supervised by supervise-daemon.

    Create /etc/systemd/system/heartbeat.service:

    [Unit]
    Description=Example heartbeat writer
    StartLimitIntervalSec=60
    StartLimitBurst=5
    
    [Service]
    Type=exec
    User=heartbeat
    Group=heartbeat
    WorkingDirectory=/var/lib/heartbeat
    ExecStart=/usr/local/bin/heartbeat.sh 10
    Restart=on-failure
    RestartSec=5
    
    [Install]
    WantedBy=multi-user.target
    

    Make systemd read the new unit, check it, then enable and start it:

    sudo systemctl daemon-reload
    sudo systemd-analyze verify /etc/systemd/system/heartbeat.service
    sudo systemctl enable --now heartbeat.service
    

    Check that it's running and writing its file:

    $ systemctl status heartbeat.service --no-pager
    ● heartbeat.service - Example heartbeat writer
         Loaded: loaded (/etc/systemd/system/heartbeat.service; enabled; preset: enabled)
         Active: active (running) since Sat 2026-11-07 10:15:02 UTC; 3s ago
       Main PID: 1234 (heartbeat.sh)
    $ cat /var/lib/heartbeat/beat
    2026-11-07T10:15:02+00:00
    

    Prove that it restarts after a failure: kill it, wait longer than RestartSec=, and check the restart counter:

    $ sudo systemctl kill --signal=SIGKILL heartbeat.service
    $ sleep 7
    $ systemctl show heartbeat.service --property=ActiveState,NRestarts
    ActiveState=active
    NRestarts=1
    

    Create /etc/systemd/system/heartbeat.service:

    [Unit]
    Description=Example heartbeat writer
    StartLimitIntervalSec=60
    StartLimitBurst=5
    
    [Service]
    Type=exec
    User=heartbeat
    Group=heartbeat
    WorkingDirectory=/var/lib/heartbeat
    ExecStart=/usr/local/bin/heartbeat.sh 10
    Restart=on-failure
    RestartSec=5
    
    [Install]
    WantedBy=multi-user.target
    

    Make systemd read the new unit, check it, then enable and start it:

    sudo systemctl daemon-reload
    sudo systemd-analyze verify /etc/systemd/system/heartbeat.service
    sudo systemctl enable --now heartbeat.service
    

    Check that it's running and writing its file:

    $ systemctl status heartbeat.service --no-pager
    ● heartbeat.service - Example heartbeat writer
         Loaded: loaded (/etc/systemd/system/heartbeat.service; enabled; preset: enabled)
         Active: active (running) since Sat 2026-11-07 10:15:02 UTC; 3s ago
       Main PID: 1234 (heartbeat.sh)
    $ cat /var/lib/heartbeat/beat
    2026-11-07T10:15:02+00:00
    

    Prove that it restarts after a failure: kill it, wait longer than RestartSec=, and check the restart counter:

    $ sudo systemctl kill --signal=SIGKILL heartbeat.service
    $ sleep 7
    $ systemctl show heartbeat.service --property=ActiveState,NRestarts
    ActiveState=active
    NRestarts=1
    

    Keep the script in /usr/local/bin/ (or /usr/bin/): SELinux lets systemd run files labelled as binaries there, but not, for example, scripts in a home directory.

    Create /etc/systemd/system/heartbeat.service:

    [Unit]
    Description=Example heartbeat writer
    StartLimitIntervalSec=60
    StartLimitBurst=5
    
    [Service]
    Type=exec
    User=heartbeat
    Group=heartbeat
    WorkingDirectory=/var/lib/heartbeat
    ExecStart=/usr/local/bin/heartbeat.sh 10
    Restart=on-failure
    RestartSec=5
    
    [Install]
    WantedBy=multi-user.target
    

    Make systemd read the new unit, check it, then enable and start it:

    sudo systemctl daemon-reload
    sudo systemd-analyze verify /etc/systemd/system/heartbeat.service
    sudo systemctl enable --now heartbeat.service
    

    Check that it's running and writing its file:

    $ systemctl status heartbeat.service --no-pager
    ● heartbeat.service - Example heartbeat writer
         Loaded: loaded (/etc/systemd/system/heartbeat.service; enabled; preset: enabled)
         Active: active (running) since Sat 2026-11-07 10:15:02 UTC; 3s ago
       Main PID: 1234 (heartbeat.sh)
    $ cat /var/lib/heartbeat/beat
    2026-11-07T10:15:02+00:00
    

    Prove that it restarts after a failure: kill it, wait longer than RestartSec=, and check the restart counter:

    $ sudo systemctl kill --signal=SIGKILL heartbeat.service
    $ sleep 7
    $ systemctl show heartbeat.service --property=ActiveState,NRestarts
    ActiveState=active
    NRestarts=1
    

    With OpenRC, create /etc/init.d/heartbeat:

    #!/sbin/openrc-run
    # /etc/init.d/heartbeat
    
    description="Example heartbeat writer"
    
    supervisor=supervise-daemon
    command=/usr/local/bin/heartbeat.sh
    command_args="10"
    command_user="heartbeat:heartbeat"
    directory=/var/lib/heartbeat
    
    # Restart after 5 seconds; give up after 10 restarts within 5 minutes.
    respawn_delay=5
    respawn_max=10
    respawn_period=300
    
    depend() {
        need localmount
    }
    
    • supervisor=supervise-daemon keeps the script as a child of OpenRC's supervisor, which restarts it when it exits. Without it, OpenRC starts the script and stops watching it.
    • Don't set command_background or pidfile: those are for daemons started by start-stop-daemon. supervise-daemon needs the program in the foreground and tracks it itself.

    Make it executable, add it to the default runlevel so it starts at boot, and start it:

    sudo chmod 0755 /etc/init.d/heartbeat
    sudo rc-update add heartbeat default
    sudo rc-service heartbeat start
    

    Check that it's running and writing its file:

    $ rc-service heartbeat status
     * status: started
    $ cat /var/lib/heartbeat/beat
    2026-11-07T10:15:02+00:00
    

    Prove that it restarts after a failure: kill the script (not the supervisor, which runs as root), wait, and check that a new process is running:

    $ pgrep -u heartbeat -x heartbeat.sh
    2345
    $ sudo pkill -KILL -u heartbeat -x heartbeat.sh
    $ sleep 7
    $ pgrep -u heartbeat -x heartbeat.sh
    2391
    

    A different process ID shows that supervise-daemon started it again.

    With a systemd profile, use the unit file and commands from the other tabs (systemd profile, not verified).

    In the systemd unit:

    • Type=exec makes systemctl start report an error if the script can't be executed.
    • Restart=on-failure restarts the script, after RestartSec= seconds, if it exits with a non-zero status or is killed by an unclean signal such as SIGKILL. Exits caused by SIGTERM, SIGHUP, SIGINT or SIGPIPE count as clean and aren't restarted; use Restart=always to restart after any exit.
    • StartLimitIntervalSec=60 and StartLimitBurst=5 stop the restarts if the service has to be started more than 5 times within 60 seconds, so a script that fails immediately doesn't loop forever. Without them, the default limit (5 starts in 10 seconds) is never reached with RestartSec=5, and systemd keeps restarting the script indefinitely.
    • WantedBy=multi-user.target is what makes systemctl enable start it at boot.
    • systemd-analyze verify prints nothing if the unit has no problems. On some systems it also prints warnings about other, unrelated units (on Ubuntu 26.04, for example, about CPUAccounting= in xfs_scrub units); you can ignore those.

    The directives are described in systemd unit directives.

Verify

Reboot, log in again, and check that the service started by itself and the heartbeat file is current.

$ systemctl is-enabled heartbeat.service
enabled
$ systemctl is-active heartbeat.service
active
$ cat /var/lib/heartbeat/beat; date -Iseconds
2026-11-07T10:21:40+00:00
2026-11-07T10:21:44+00:00
$ systemctl is-enabled heartbeat.service
enabled
$ systemctl is-active heartbeat.service
active
$ cat /var/lib/heartbeat/beat; date -Iseconds
2026-11-07T10:21:40+00:00
2026-11-07T10:21:44+00:00
$ systemctl is-enabled heartbeat.service
enabled
$ systemctl is-active heartbeat.service
active
$ cat /var/lib/heartbeat/beat; date -Iseconds
2026-11-07T10:21:40+00:00
2026-11-07T10:21:44+00:00
$ rc-update show default | grep heartbeat
             heartbeat | default
$ rc-service heartbeat status
 * status: started
$ cat /var/lib/heartbeat/beat; date -Iseconds
2026-11-07T10:21:40+00:00
2026-11-07T10:21:44+00:00

The two timestamps should be no more than about 10 seconds apart.

Troubleshooting

The service stops restarting with "Start request repeated too quickly". The script exits straight after it starts, and systemd has reached the start limit set by StartLimitIntervalSec= and StartLimitBurst=. Read the script's own error messages with journalctl -u heartbeat.service -n 50, fix the cause, then clear the failed state with sudo systemctl reset-failed heartbeat.service and start it again. If you removed the start limit, the service restarts forever instead: watch systemctl show heartbeat.service -p NRestarts grow, and read the journal the same way. On OpenRC, supervise-daemon gives up after respawn_max restarts within respawn_period; its messages go to syslog, usually /var/log/messages.

status=203/EXEC or "Permission denied". The script isn't executable, has no #! line, or has Windows line endings (/bin/sh^M: bad interpreter). Check with ls -l /usr/local/bin/heartbeat.sh and head -1 /usr/local/bin/heartbeat.sh | od -c. On RHEL, AlmaLinux and Rocky Linux, also check the SELinux label with ls -Z: a script copied from a home directory may keep a user_home_t label; fix it with sudo restorecon -v /usr/local/bin/heartbeat.sh.

The script works when you run it but fails as a service. Services start with a minimal environment: a fixed PATH, no variables from your shell profile, a different user and working directory. Use absolute paths in the script, and set the variables it needs with Environment= in the unit, or in /etc/conf.d/heartbeat (exported with export) on OpenRC.

The service starts and is immediately "inactive (dead)" without restarting. The script runs a background command and exits. Make sure the long-running command is the last thing the script does and runs in the foreground; use exec for it if the script only prepares arguments.