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.
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 2 | 5V Power | Relay Module | VCC |
| Pin 6 | Ground | Relay Module | GND |
| Pin 11 | GPIO 17 | Relay Module | IN (Signal) |
| Pin 1 | 3.3V Power | Push Button | Terminal 1 (VCC) |
| Pin 13 | GPIO 27 | Push Button | Terminal 2 (Signal) |
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.
- Create the service file:
sudo nano /etc/systemd/system/vent-controller.service - 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 injournalctluntil 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).
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.






