Run a Python Script at Boot on Pi Zero with a systemd Service

Most Pi Zero projects are meant to run unattended: a sensor logger in a corner, a camera on a window, a button box on a desk. That means the Python program must start by itself when power is applied, restart if it crashes, and keep a log you can read later. Running the script from an SSH session is not enough, because it stops the moment you disconnect.

Raspberry Pi OS uses systemd to start everything at boot, from networking to the SSH server. In this tutorial you will write a small Python program that shuts down cleanly, create a service unit for it, manage it with systemctl, read its output with journalctl, and use a systemd timer as a modern replacement for cron.

Why systemd Instead of rc.local or cron @reboot

MethodRestarts on crashWaits for networkLogging
rc.localNoNot reliablyNone unless you redirect output
cron @rebootNoNoMailed or lost
~/.bashrcNoNoRuns on every login, not at boot
systemd serviceYes, with Restart=Yes, with After= and Wants=Automatic, through journalctl

A systemd service is described by a short text file called a unit. systemd reads it, starts the program as the user you choose, captures everything it prints to standard output and standard error, and can restart it after a failure with a delay you set.

A Service-Friendly Python Program

When systemd stops a service it sends the SIGTERM signal and, if the program does not exit within a timeout, kills it. A well-behaved program catches SIGTERM, finishes its current work and exits. The example below logs the CPU temperature of the Zero every 30 seconds and handles SIGTERM properly. Save it as /home/student/temp_logger.py, using your own username.

temp_logger.py: periodic CPU temperature logger with clean shutdown
#!/usr/bin/env python3
"""Log the Pi Zero's CPU temperature; designed to run under systemd."""
import signal
import sys
import time

INTERVAL_S = 30
running = True

def handle_sigterm(signum, frame):
    """systemd sends SIGTERM on 'systemctl stop'; ask the loop to finish."""
    global running
    running = False

signal.signal(signal.SIGTERM, handle_sigterm)

def cpu_temp_c():
    # The kernel reports millidegrees Celsius in this file
    with open('/sys/class/thermal/thermal_zone0/temp') as f:
        return int(f.read().strip()) / 1000.0

def main():
    print('temp_logger started', flush=True)
    while running:
        print(f'CPU temperature: {cpu_temp_c():.1f} C', flush=True)
        # Sleep in short steps so SIGTERM is handled within a second
        for _ in range(INTERVAL_S):
            if not running:
                break
            time.sleep(1)
    print('temp_logger stopping cleanly', flush=True)
    return 0

if __name__ == '__main__':
    sys.exit(main())

Note the flush=True on every print. When output goes to a pipe instead of a terminal, Python buffers it, and without flushing your log messages may appear late or be lost when the service stops.

Creating the Service Unit

System-wide units you write yourself belong in /etc/systemd/system/. Create the file with sudo nano /etc/systemd/system/temp-logger.service and paste the following, replacing student with your username.

/etc/systemd/system/temp-logger.service
[Unit]
Description=CPU temperature logger
# Start after the network is up (useful if your script sends data)
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
User=student
WorkingDirectory=/home/student
# Use absolute paths; for a virtual environment use its python, e.g.
# ExecStart=/home/student/envs/sensors/bin/python /home/student/temp_logger.py
ExecStart=/usr/bin/python3 /home/student/temp_logger.py
# Restart after crashes, waiting 5 s between attempts
Restart=on-failure
RestartSec=5
# Belt and braces: unbuffered Python output
Environment=PYTHONUNBUFFERED=1

[Install]
# Start in normal multi-user boot
WantedBy=multi-user.target
Load, enable, start and inspect the service
# Tell systemd to re-read unit files after any edit
sudo systemctl daemon-reload

# Enable at boot and start now in one step
sudo systemctl enable --now temp-logger.service

# Is it running? Shows the last few log lines too
systemctl status temp-logger.service

# Follow the log live (Ctrl+C to stop following)
journalctl -u temp-logger.service -f

# Logs since the last boot only
journalctl -u temp-logger.service -b

# Stop, restart, or remove from boot
sudo systemctl stop temp-logger.service
sudo systemctl restart temp-logger.service
sudo systemctl disable temp-logger.service

What Each Directive Does

DirectiveSectionPurpose
Description=[Unit]Human-readable name shown by systemctl status and in the journal
Wants= / After=[Unit]Pull in network-online.target and start only after it is reached; After= alone only sets order
Type=simple[Service]The process started by ExecStart is the service itself and keeps running
User=[Service]Run as an ordinary user instead of root, limiting the damage a bug can do
WorkingDirectory=[Service]Current directory for the program, so relative file paths behave as expected
ExecStart=[Service]The command to run; must use absolute paths
Restart=on-failure[Service]Restart only after a non-zero exit code or a crash, not after a clean stop
RestartSec=[Service]Delay before restarting, which avoids hammering a sensor that is temporarily missing
WantedBy=[Install]Which boot target pulls the service in when you run systemctl enable

systemctl enable does not start anything by itself. It creates a symbolic link under /etc/systemd/system/multi-user.target.wants/, so the service is pulled in at the next boot. Adding --now also starts it immediately. Use Restart=always instead of on-failure if you want the program brought back even after it exits normally, for example a kiosk loop that should never end.

Periodic Jobs with a systemd Timer

Some tasks should run briefly and exit, for example uploading a CSV file every 15 minutes. Instead of a cron entry, pair a oneshot service with a timer. Timers log through the journal and can catch up on a run missed while the Pi was off.

upload.service and upload.timer in /etc/systemd/system/
# ---- /etc/systemd/system/upload.service ----
[Unit]
Description=Upload sensor CSV

[Service]
Type=oneshot
User=student
ExecStart=/usr/bin/python3 /home/student/upload.py

# ---- /etc/systemd/system/upload.timer ----
[Unit]
Description=Run upload.service every 15 minutes

[Timer]
OnCalendar=*:0/15
# Run once at boot if a scheduled run was missed while powered off
Persistent=true

[Install]
WantedBy=timers.target

Enable the timer, not the service: sudo systemctl enable --now upload.timer. Then systemctl list-timers shows when it last ran and when it runs next.

Troubleshooting

  • status=203/EXEC: the path in ExecStart is wrong or not absolute. systemd does not use your shell's PATH or ~.
  • ModuleNotFoundError under systemd but not in SSH: the unit uses system Python while you installed packages in a virtual environment. Point ExecStart at the venv's bin/python.
  • Permission denied on GPIO or I2C: the user lacks group membership. Default users are already in gpio and i2c; a new user must be added with sudo usermod -aG gpio,i2c username.
  • Service restarts endlessly: read the error with journalctl -u name -b and fix it; systemd eventually stops retrying after repeated quick failures.
  • Edits seem ignored: run sudo systemctl daemon-reload and restart the service after changing a unit file.

Summary

A systemd unit with absolute paths, a dedicated user, Restart=on-failure and WantedBy=multi-user.target makes any Python program start at boot and recover from crashes, while journalctl keeps its output. Catching SIGTERM and flushing output make the program a good citizen under systemd, and timers replace cron for periodic jobs. Every long-running project in this series, from the Flask dashboard to the MQTT node, is deployed this way.