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.
Method 1: Systemd Service (Recommended)
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:
@rebootruns 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
PATHand 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.
PATHis 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
@rebootline 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.servicefails the unit if PostgreSQL is not running. - Cleanup hook:
ExecStopPostruns 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
- systemd.service Documentation
- systemd.exec Documentation (Environment, WorkingDirectory)
- Ubuntu systemd Guide
- systemd Unit Files
- crontab(5) — @reboot
- Arch Wiki: Autostarting
- DigitalOcean: systemd Essentials
Comments