The most reliable way to run a Python script on boot on a Raspberry Pi 5 is by creating a systemd service file. While older tutorials often suggest cron @reboot, rc.local, or .bashrc, these legacy methods lack automatic crash-recovery, proper environment variable mapping, and centralized logging in Raspberry Pi OS (Bookworm).

This guide targets the Raspberry Pi 5 (8GB variant) running the 64-bit Bookworm release. We will build a headless GPIO relay controller that survives reboots, restarts automatically if the code crashes, and logs errors directly to the system journal.

Project Difficulty: Intermediate (Requires basic Linux CLI and GPIO wiring knowledge)
Estimated Time: 45 minutes
Target Board: Raspberry Pi 5 (4GB/8GB) • Raspberry Pi OS Bookworm 64-bit

The Verdict: Boot Method Comparison Matrix

Before writing any code, it is critical to understand why systemd is the industry standard for embedded Linux deployments. The table below breaks down the four most common methods used to auto-start scripts on a Raspberry Pi, evaluated against the requirements of a production or remote deployment.

Method Execution Context Restarts on Crash? Stdout/Stderr Logging Recommendation
systemd System service (configurable user) Yes (via Restart=always) Native (journalctl) Best Practice. Use for all headless/production scripts.
cron (@reboot) User crontab (limited PATH) No None (must pipe to file) Acceptable for simple, non-critical daily tasks.
rc.local Root (runs as root by default) No None Deprecated. Avoid; poses severe security risks.
.bashrc / autostart Desktop user login session No Tied to terminal/X11 session Only for GUI apps requiring a desktop environment.

As shown above, systemd is the only method that provides a watchdog-like restart capability and native logging. For a comprehensive breakdown of service unit configurations, refer to the official freedesktop.org systemd.service documentation.

Hardware Build and Pin Mapping

To ground this in a real-world scenario, we are building a greenhouse vent controller. The Pi 5 will monitor a physical momentary push button and toggle a 5V optocoupler relay module, which in turn switches a 120V AC exhaust fan.

Parts List

  • Microcontroller: Raspberry Pi 5 (8GB)
  • Actuator: 5V Optocoupler Relay Module (Active Low)
  • Input: Momentary Push Button (Normally Open)
  • Wiring: Female-to-Female and Male-to-Female Dupont jumpers

GPIO Pin Mapping Table

The Raspberry Pi 5 uses the same 40-pin header layout as the Pi 4, but its underlying RP1 chip handles the GPIO routing. We are using gpiozero, which abstracts the pin numbering to standard BCM GPIO numbers.

Pi 5 Physical Pin BCM GPIO / Function Component Component Pin
Pin 25V PowerRelay ModuleVCC
Pin 6GroundRelay ModuleGND
Pin 11GPIO 17Relay ModuleIN (Signal)
Pin 13.3V PowerPush ButtonTerminal 1 (VCC)
Pin 13GPIO 27Push ButtonTerminal 2 (Signal)
Wiring Warning: Never connect 5V or mains voltage directly to the Pi 5's GPIO pins. The RP1 chip operates strictly at 3.3V logic levels. The optocoupler on the relay module provides the necessary galvanic isolation between the Pi's 3.3V logic and the relay's 5V coil drive.

The Python Control Script

Bookworm ships with gpiozero pre-installed. The script below uses OutputDevice for the relay (defaulting to active-low logic common in cheap relay modules) and Button with the internal pull-up resistor enabled. Notice the explicit error handling and the use of the logging module, which is mandatory for capturing faults in a headless service.

import time
import logging
import signal
import sys
from gpiozero import Button, OutputDevice

# --- Pin Definitions ---
RELAY_GPIO = 17
BUTTON_GPIO = 27

# --- Logging Configuration ---
# Output to stderr so systemd journalctl can capture it natively
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
    handlers=[logging.StreamHandler(sys.stderr)]
)
logger = logging.getLogger('VentController')

def main():
    logger.info('Initializing GPIO pins...')
    
    # Relay is active-low: active_high=False means GPIO LOW turns the relay ON
    relay = OutputDevice(RELAY_GPIO, active_high=False, initial_value=False)
    
    # Button uses internal pull-up; bounce_time prevents contact chatter
    button = Button(BUTTON_GPIO, pull_up=True, bounce_time=0.05)
    
    state = False
    logger.info('System ready. Awaiting button press.')

    def toggle_relay():
        nonlocal state
        state = not state
        relay.value = state
        logger.info(f'Relay toggled. New state: {"ON" if state else "OFF"}')

    button.when_pressed = toggle_relay

    # Graceful shutdown handler to release GPIO resources
    def graceful_exit(signum, frame):
        logger.info('Received shutdown signal. Cleaning up GPIO...')
        relay.off()
        relay.close()
        button.close()
        sys.exit(0)

    signal.signal(signal.SIGTERM, graceful_exit)
    signal.signal(signal.SIGINT, graceful_exit)

    # Keep the main thread alive
    try:
        while True:
            time.sleep(1)
    except Exception as e:
        logger.critical(f'Unhandled exception in main loop: {e}')
        sys.exit(1)

if __name__ == '__main__':
    try:
        main()
    except Exception as e:
        logger.critical(f'Fatal startup error: {e}')
        sys.exit(1)

Save this file as /home/pi/vent_controller.py. You can test it manually by running python3 /home/pi/vent_controller.py in your terminal. Press the button to hear the relay click, then press Ctrl+C to exit.

Configuring the systemd Service

Now we wrap the script in a systemd unit file. This tells the Raspberry Pi OS initialization process exactly how, when, and as whom to execute your code.

  1. Create the service file: sudo nano /etc/systemd/system/vent-controller.service
  2. Paste the following configuration block:
[Unit]
Description=Greenhouse Vent Relay Controller
After=multi-user.target

[Service]
Type=simple
ExecStart=/usr/bin/python3 /home/pi/vent_controller.py
WorkingDirectory=/home/pi
User=pi
Group=pi
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
Environment=PYTHONUNBUFFERED=1

[Install]
WantedBy=multi-user.target

Critical Directives Explained

  • ExecStart: Must use the absolute path to both the Python binary and your script. Systemd does not inherit your user's $PATH.
  • User=pi: Runs the script as the standard user, preventing root-level security vulnerabilities and ensuring access to user-specific Python packages.
  • Restart=on-failure: If the script crashes (exits with a non-zero code), systemd waits 5 seconds (RestartSec) and restarts it automatically.
  • Environment=PYTHONUNBUFFERED=1: Do not skip this. Python buffers stdout/stderr by default. Without this flag, your logs won't appear in journalctl until the buffer fills up or the script crashes, making real-time debugging impossible.

Enable and start the service:

sudo systemctl daemon-reload
sudo systemctl enable vent-controller.service
sudo systemctl start vent-controller.service

Debugging: First Three Things to Check

When deploying embedded Python, the script will inevitably fail on the first boot attempt. When your service fails to start, do not guess. Follow this exact diagnostic sequence.

1. Check the Journal Logs (Not the Terminal)

Because the script runs in the background, terminal output is invisible. You must query the systemd journal:

journalctl -u vent-controller.service -e -n 50

This pulls the last 50 lines of logs specifically for your service. If you see systemd[1]: vent-controller.service: Main process exited, code=exited, status=1/FAILURE, it means Python threw an unhandled exception. The lines immediately preceding this error in the journal will contain your Python traceback.

2. Verify Absolute Paths and Executable Permissions

Exact Error: Failed at step EXEC spawning /usr/bin/python3: No such file or directory or code=exited, status=203/EXEC.
Cause: You used a relative path like python3 vent_controller.py in ExecStart, or the python binary is located elsewhere (e.g., in a virtual environment).
Fix: Run which python3 to find the exact path, and update the ExecStart line accordingly. Ensure your .py file is readable by the pi user.

3. Resolve Module Import and Permission Errors

Exact Error: ModuleNotFoundError: No module named 'gpiozero' or PermissionError: [Errno 13] Permission denied.
Cause: You installed packages using sudo pip3 install (which installs to root's directory) or you are trying to access a hardware interface (like I2C/SPI) that the pi user doesn't have group permissions for.
Fix: For modules, install them in user-space: pip3 install --user gpiozero. For hardware permissions, ensure your user is in the correct groups via sudo usermod -aG gpio,i2c,spi pi (refer to the Raspberry Pi OS configuration docs for interface specifics).

Pro-Tip for Virtual Environments: If you are using a Python venv (highly recommended for complex projects), change your ExecStart line to point directly to the virtual environment's Python binary: ExecStart=/home/pi/myenv/bin/python3 /home/pi/vent_controller.py. This bypasses all system-level path issues.

How to Extend or Simplify This Build

To Simplify: If you don't have a button, strip out the gpiozero.Button logic and replace the while True loop with a simple time.sleep() interval to create a basic timed cyclical relay. The systemd configuration remains exactly the same.

To Extend: To integrate this into a smart home, add the paho-mqtt library to your Python script. You can publish the relay's state to an MQTT broker (like Mosquitto) and subscribe to Home Assistant topics, allowing you to trigger the physical greenhouse vent remotely while retaining the physical button override. Because systemd handles the network dependency via the After=multi-user.target directive, your MQTT connection will automatically retry if the Pi boots before the WiFi router is fully online.