Skip to main content

Run a Script on Startup in Ubuntu (Systemd)

Published: July 25, 2023 Updated: May 25, 2026 Larry Qu 11 min read

Introduction

You have a script — a backup job, a fan speed tweak, a tunnel, a container restart — and you need it to run every time the machine boots. Ubuntu offers multiple ways to hook into the boot sequence, each with different trade-offs: systemd service files, cron’s @reboot macro, legacy rc.local, SysV init scripts, and the desktop’s Startup Applications menu.

This guide covers all five methods, focusing on systemd as the modern, recommended approach. You will learn how to set up each one, when to pick which, and how to debug them when something goes wrong.

Script Requirements

Before any method works, the script itself must meet a few conditions.

Shebang Line

The first line must be a valid shebang so the kernel knows which interpreter to use:

#!/bin/bash

Other common shebangs:

#!/usr/bin/env python3
#!/bin/bash -e
#!/usr/bin/env bash

Executable Permission

The file must have the execute bit set:

chmod +x /path/to/your-script.sh

If you forget this step, every method below will fail silently or with a permission-denied error.

Absolute Paths

Startup scripts run with a minimal environment — PATH is often /usr/bin:/bin and nothing else. Never rely on relative paths or ~. Use full paths for everything:

#!/bin/bash
LOG_FILE="/var/log/my-startup-script.log"
echo "Started at $(date)" >> "$LOG_FILE"
/usr/bin/rsync -a /data/ /backup/

Idempotency

The script may run on every boot, which can happen unexpectedly after a crash or power loss. Design it to be safe to run multiple times — overwrite temporary files, check for lock files, or skip work already done.

#!/bin/bash
LOCKFILE="/var/run/my-startup-script.lock"
if [ -f "$LOCKFILE" ]; then
    exit 0
fi
touch "$LOCKFILE"
# do work

Foreground Process Requirement

Your program must run in the foreground — it must not daemonize or fork to the background. systemd manages the process lifecycle and expects the main process to stay alive. If your program forks and exits, systemd sees the exit and (depending on Restart= policy) immediately restarts it, creating a loop.

Ensure your script or binary does not call fork(), double-fork, or otherwise background itself. Most web servers have a flag to stay in the foreground: caddy run, nginx -g "daemon off;", gunicorn --keep-alive.

Systemd is the default init system on Ubuntu (15.04+). Service files give you fine-grained control over dependencies, ordering, restart behaviour, environment, and logging.

Basic Oneshot Service

A oneshot service runs a script once and exits. This is the right type for most startup tasks.

Create /etc/systemd/system/set-fan-speed.service:

[Unit]
Description=Set fan speed on boot
After=multi-user.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/set-fan-speed.sh
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target

Enable and test:

sudo systemctl daemon-reload
sudo systemctl enable set-fan-speed.service
sudo systemctl start set-fan-speed.service
sudo systemctl status set-fan-speed.service

RemainAfterExit=yes keeps the service in an active state after the script finishes, which prevents systemd from restarting it and makes dependency chains cleaner.

Production Service File

For a real-world service, add user separation, resource limits, and security hardening:

[Unit]
Description=My Application Server
Documentation=https://github.com/myorg/myapp
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=myapp
Group=myapp
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/bin/server
Restart=on-failure
RestartSec=5s
Environment=APP_ENV=production
Environment=PORT=8080
LimitNOFILE=65536
StandardOutput=journal
StandardError=journal
NoNewPrivileges=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target

Key hardening options:

Setting Purpose
NoNewPrivileges=true Prevent privilege escalation via setuid binaries
PrivateTmp=true Isolate /tmp access from other processes
ProtectSystem=full Make /usr and /etc read-only for the service
LimitNOFILE=65536 Raise the file descriptor limit for high-traffic services

Simple Service (Long-Running Process)

For a script that stays running — a monitoring agent, a reverse tunnel, a file watcher — use Type=simple:

[Unit]
Description=Reverse SSH tunnel
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/bin/start-tunnel.sh
Restart=on-failure
RestartSec=10
User=tunnel

[Install]
WantedBy=multi-user.target

Restart=on-failure together with RestartSec=10 means systemd will restart the script if it crashes, waiting ten seconds between attempts.

User Service (No Sudo)

If the script only needs the current user’s privileges, place the service file in the user directory:

mkdir -p ~/.config/systemd/user

Create ~/.config/systemd/user/notify-me.service:

[Unit]
Description=Startup notification

[Service]
Type=oneshot
ExecStart=/home/user/bin/notify.sh

[Install]
WantedBy=default.target

Enable it with the --user flag:

systemctl --user daemon-reload
systemctl --user enable notify-me.service
systemctl --user start notify-me.service

User services start when the user logs in, not at system boot. This is useful for GUI tools, desktop notifications, or personal daemons.

To keep user services running after the user logs out, enable lingering:

sudo loginctl enable-linger $USER

Without lingering, user services stop when you close your session.

Handling Dependencies

Use After= and Requires= to order your service relative to other units:

[Unit]
Description=Mount NFS share
After=network-online.target
Wants=network-online.target
After=remote-fs.target
  • Wants= starts the target if possible but does not fail if it cannot.
  • Requires= fails the whole unit if the dependency fails.
  • After= controls ordering only, not activation.

Service Types Reference

systemd supports several service types that change how it tracks process state:

Type Behaviour Use Case
simple Process stays in foreground, systemd considers it started immediately Default for most daemons
oneshot Process runs once and exits, systemd waits for it to complete Setup scripts, one-time migrations
forking Process forks to background, systemd tracks the original PID until the child signals readiness Legacy daemons (Apache, Nginx)
notify Process sends READY=1 via sd_notify() when fully started Apps that support systemd notification protocol
idle Like simple but delays start until other jobs finish Late-boot services with no urgency

Environment Variables

Systemd strips most environment variables. Set them explicitly in the service file:

[Service]
Type=oneshot
Environment="MY_VAR=hello"
Environment="PATH=/usr/local/bin:/usr/bin:/bin"
WorkingDirectory=/opt/myapp
ExecStart=/opt/myapp/start.sh

Or load them from a file:

[Service]
EnvironmentFile=/etc/default/myapp
ExecStart=/opt/myapp/start.sh

Environment variables can be specified inline with multiple values on one line:

Environment="KEY1=value1" "KEY2=value2"

Prefix the file path with - to ignore it when missing:

EnvironmentFile=-/etc/myapp/env.local  # the - means "ignore if absent"

Common mistake: using export KEY=value inside the EnvironmentFile — systemd does not understand export. Each line must be KEY=value without the keyword:

# /etc/myapp/env — correct format
DATABASE_URL=postgres://localhost/mydb
SECRET_KEY=abc123
PORT=8080

Capturing Output

Systemd captures stdout and stderr from the script automatically. View it with journalctl:

sudo journalctl -u set-fan-speed.service

To follow the log in real time:

sudo journalctl -u set-fan-speed.service -f

If you also want the output written to a plain file, redirect inside the script:

#!/bin/bash
exec &>> /var/log/my-startup.log
echo "Script started at $(date)"

Managing the Service

Once the service is defined, use systemctl for all lifecycle operations:

# Start and stop
sudo systemctl start myapp.service
sudo systemctl stop myapp.service

# Restart (stop then start)
sudo systemctl restart myapp.service

# Reload configuration without dropping connections
sudo systemctl reload myapp.service

# Enable/disable automatic start at boot
sudo systemctl enable myapp.service
sudo systemctl disable myapp.service

# Check status
sudo systemctl status myapp.service
sudo systemctl is-enabled myapp.service
sudo systemctl is-active myapp.service

# List all services
sudo systemctl list-units --type=service
sudo systemctl list-units --type=service --state=failed

The reload command sends SIGHUP to the process. Your application must handle this signal to reload its configuration. If the app does not support reloading, restart is the only option.

Method 2: Cron @reboot

The cron daemon supports the @reboot macro, which runs a command once when the system boots. This approach is simple and requires no service files.

Open the root crontab:

sudo crontab -e

Add a line:

@reboot /usr/local/bin/backup-data.sh

For a user-specific job (runs at user login, not system boot):

crontab -e
@reboot /home/user/bin/personal-script.sh

Key behaviour:

  • @reboot runs after cron starts, which is early in the boot sequence — before network mounts, before most services.
  • Environment is minimal. Wrap your script in a helper that sets PATH and other variables.
  • Logging is non-existent by default. Redirect output manually:
@reboot /usr/local/bin/backup-data.sh >> /var/log/backup-cron.log 2>&1

Pros: dead simple, no systemd knowledge needed. Cons: no dependency control, no restart-on-failure, no status checking, runs as a one-shot only.

Method 3: rc.local (Legacy)

Before systemd, Ubuntu used /etc/rc.local — a script that runs at the end of the boot sequence. It still works on most Ubuntu releases if you enable it.

Create or edit /etc/rc.local:

#!/bin/sh -e
#
# rc.local
#
/usr/local/bin/set-fan-speed.sh
exit 0

Make it executable:

sudo chmod +x /etc/rc.local

Enable the rc-local service:

sudo systemctl enable rc-local
sudo systemctl start rc-local
sudo systemctl status rc-local

rc.local runs as root, after most services, with a minimal environment. It is a single script — if one command fails, the whole file stops (because of sh -e). Use || true for commands that may fail:

/usr/local/bin/set-fan-speed.sh || true
/usr/local/bin/backup-data.sh || true

This method is deprecated on newer Ubuntu releases (20.04+) and may be removed. Prefer systemd for new setups.

Method 4: Init.d Scripts (SysV Legacy)

Very old Ubuntu installations used SysV init scripts in /etc/init.d/. These are still supported through systemd’s compatibility layer but are not recommended for new work.

A minimal init.d script:

#!/bin/bash
### BEGIN INIT INFO
# Provides:          my-startup
# Required-Start:    $remote_fs $syslog
# Required-Stop:     $remote_fs $syslog
# Default-Start:     2 3 4 5
# Default-Stop:      0 1 6
# Description:       Run script on startup
### END INIT INFO

case "$1" in
    start)
        /usr/local/bin/my-script.sh
        ;;
    stop)
        echo "Nothing to stop"
        ;;
    *)
        echo "Usage: $0 {start|stop}"
        exit 1
        ;;
esac

Place it and register:

sudo cp my-startup /etc/init.d/
sudo chmod +x /etc/init.d/my-startup
sudo update-rc.d my-startup defaults

Only use this method if you are maintaining a legacy system that predates systemd.

Method 5: Startup Applications (GUI)

On Ubuntu Desktop, users can add startup programs through the graphical Startup Applications tool.

Open the app (search “Startup Applications” in the menu), click Add, and fill in:

  • Name: My Startup Script
  • Command: /usr/local/bin/my-script.sh
  • Comment: (optional)

The underlying mechanism stores .desktop files in ~/.config/autostart/. You can create them manually:

[Desktop Entry]
Type=Application
Name=My Startup Script
Exec=/usr/local/bin/my-script.sh
X-GNOME-Autostart-enabled=true

GUI startup entries run after the desktop environment loads — useful for GUI tools, but too late for system-level tasks like mounting drives or setting hardware parameters.

Comparison Table

Method Scope Deps/Ordering Restart Logging Ubuntu Version
systemd service System / User Full support Built-in journalctl 15.04+
cron @reboot System / User None None Manual redirect All
rc.local System only Minimal None stdout to console Pre-20.04 (legacy)
init.d script System only LSB headers Per-script impl syslog / file Pre-15.04 (legacy)
GUI autostart User only None None stderr to journal Desktop only

If you need reliability, logging, and dependency ordering, use systemd. If you need quick-and-dirty for a personal machine, cron @reboot or GUI autostart may be enough.

Debugging Startup Scripts

When a script does not run at boot, work through this checklist:

Check if the script works when run manually:

sudo /usr/local/bin/your-script.sh

For systemd services, check the unit status and logs:

sudo systemctl status your-service.service
sudo journalctl -u your-service.service -b

The -b flag limits output to the current boot. Omit it to see all logs.

For cron jobs, check the cron log:

grep CRON /var/log/syslog

For rc.local, check the rc-local service status:

sudo systemctl status rc-local

Validate the service file syntax:

sudo systemd-analyze verify /etc/systemd/system/myapp.service

If the file has syntax errors, systemd-analyze verify reports them with line numbers and the specific directive that caused the problem.

Common failure modes:

  • Script lacks execute permission.
  • Script uses a relative path that does not exist at boot time.
  • PATH is missing a needed directory (e.g., /usr/local/bin).
  • The service depends on a target that has not started yet — add After=.
  • A non-zero exit code stops rc.local — add || true.
  • A cron @reboot line misses the trailing newline — ensure the file ends with a blank line.
  • The port the service needs is already in use — check with ss -tlnp | grep :PORT.

Common Issues and Fixes

Script runs manually but not at boot. The environment differs — environment variables, PATH, and working directory are all different at boot. Wrap your script to set everything it needs at the top.

Service shows “failed” status. Run journalctl -xe for a detailed error. Common causes: missing executable, bad shebang, syntax error in the script, or a missing dependency unit.

Cron @reboot does not run. The cron daemon may start after your expected time, or the script may exit too quickly. Add logging at the top of the script: exec &> /tmp/cron-debug.log.

rc.local does not run. The script file must be executable (chmod +x /etc/rc.local) and must start with #!/bin/sh -e. On Ubuntu 20.04+, the rc-local service may be masked — run sudo systemctl unmask rc-local.

User service does not start. User services require loginctl enable-linger $USER to start at boot (not just login). Alternatively, they start when the user logs in via the display manager or SSH.

Wrong path in ExecStart. Use which to find the full path of your binary before writing the service file: which myapp.

Port already in use. Check with ss -tlnp | grep :PORT and stop the conflicting process.

Complete Production Example: Go API Server

A realistic systemd service file for a Go-based API server with PostgreSQL dependency and security hardening:

# /etc/systemd/system/api-server.service
[Unit]
Description=API Server
Documentation=https://github.com/example/api-server
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service

[Service]
Type=simple
User=api
Group=api
WorkingDirectory=/opt/api-server
ExecStart=/opt/api-server/api-server
ExecStopPost=/opt/api-server/cleanup.sh
Restart=on-failure
RestartSec=5s

EnvironmentFile=/opt/api-server/.env
LimitNOFILE=65536

StandardOutput=journal
StandardError=journal

NoNewPrivileges=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target

This example includes:

  • Dependency ordering: API starts after both network and PostgreSQL.
  • Hard failure on missing DB: Requires=postgresql.service fails the unit if PostgreSQL is not running.
  • Cleanup hook: ExecStopPost runs even on crash, ensuring temporary files are cleaned up.
  • Environment isolation: Environment file keeps secrets out of the unit file.

Deploy it:

sudo systemctl daemon-reload
sudo systemctl enable api-server.service
sudo systemctl start api-server.service
sudo systemctl status api-server.service

If the server supports SIGHUP or an admin endpoint, test a config reload:

sudo systemctl reload api-server.service

Resources

Comments

👍 Was this article helpful?