To build a reliable, production-ready web server on a Raspberry Pi 5 that interacts with physical hardware, use the Raspberry Pi 5 (8GB) running Raspberry Pi OS (64-bit) Bookworm, and deploy a Python Flask application served by Gunicorn. The Pi 5’s new RP1 southbridge chip fundamentally changes how GPIO is accessed, rendering older libraries like RPi.GPIO obsolete. This guide provides the exact hardware BOM, the updated gpiozero pin mapping, complete compilable code with error handling, and the specific debugging paths for Pi 5 architecture.

The Pi 5 Web Server Decision Matrix

Before writing code, you must choose the right web framework for an embedded environment. The Pi 5 has ample RAM (up to 8GB), but thermal throttling and SD card I/O bottlenecks still dictate a lightweight footprint. Here is the decision path for selecting your stack:

Framework Async Support Learning Curve Pi 5 Idle RAM Verdict
Flask + Gunicorn No (Sync/WSGI) Low ~35 MB Default Pick: Best balance of simplicity, low resource use, and vast documentation for hardware APIs.
FastAPI + Uvicorn Yes (ASGI) Medium ~50 MB Choose when you need high-concurrency WebSockets for real-time sensor streaming.
Node-RED Yes (Event-loop) Low (Visual) ~180 MB Choose only if the project is purely IoT data routing with zero custom UI requirements.
Decision Termination: For 90% of maker projects involving relay control and sensor polling, Flask + Gunicorn is the definitive choice. It avoids the overhead of Node.js and the strict typing boilerplate of FastAPI, letting you focus on the hardware integration.

Hardware BOM and GPIO Pin Mapping

The Raspberry Pi 5 requires a 27W USB-C PD power supply to prevent brownouts when driving GPIO pins. Do not use a standard 15W Pi 4 brick; the Pi 5 will throttle and drop USB/GPIO power under load.

Parts List

  • Board: Raspberry Pi 5 (8GB variant) — ~$80
  • Thermal: Official Raspberry Pi 5 Active Cooler — ~$5 (Mandatory; passive cooling fails under web server load)
  • Power: Official 27W USB-C PD Power Supply — ~$12
  • Actuator: 5V Single-Channel Relay Module (Optocoupler isolated, SRD-05VDC-SL-C) — ~$3
  • Input: 6x6mm Tactile Pushbutton Switch — ~$1
  • Wiring: 22 AWG Female-to-Female Jumper Wires

Spec-Sheet: Pin Mapping Table

The Pi 5 maintains the standard 40-pin header layout, but the underlying BCM GPIO numbers map to the RP1 chip. Always reference the BCM GPIO number in your code, not the physical pin number.

Physical Pin BCM GPIO Function Connected Component
Pin 2 5V Power Relay Module VCC
Pin 6 GND Ground Relay GND & Button GND
Pin 11 GPIO 17 Digital Output Relay IN (Signal)
Pin 13 GPIO 27 Digital Input Button Signal (Internal Pull-up)

Build Sequence: Environment and Code

This build targets Raspberry Pi OS (64-bit) Bookworm. Bookworm uses lgpio as the default pin factory for gpiozero, which is critical for Pi 5 compatibility.

Step 1: OS and Dependency Setup

  1. Flash Raspberry Pi OS (64-bit) Bookworm using Raspberry Pi Imager. Enable SSH and set your username/password in the advanced settings.
  2. Boot the Pi 5, connect via SSH, and update the system: sudo apt update && sudo apt upgrade -y
  3. Install the required Python packages. We use a virtual environment to comply with PEP 668 (externally managed environment rules in Bookworm):
    mkdir ~/pi-server && cd ~/pi-server
    python3 -m venv venv
    source venv/bin/activate
    pip install flask gunicorn gpiozero psutil lgpio

Step 2: The Application Code

Save the following complete, compilable code as app.py. It includes pin definitions, hardware fallbacks for headless testing, and proper error handling.

import os
import logging
from flask import Flask, jsonify, request
from gpiozero import LED, Button
from gpiozero.exc import GPIODeviceError
import psutil

# Configure logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')

app = Flask(__name__)

# --- PIN DEFINITIONS (BCM Numbering) ---
RELAY_GPIO = 17   # Physical Pin 11
BUTTON_GPIO = 27  # Physical Pin 13

# --- HARDWARE INITIALIZATION ---
relay = None
button = None

try:
    # Pi 5 uses lgpio backend automatically via gpiozero >= 2.0
    relay = LED(RELAY_GPIO, active_high=True, initial_value=False)
    button = Button(BUTTON_GPIO, pull_up=True, bounce_time=0.05)
    logging.info("GPIO initialized successfully via lgpio backend.")
except GPIODeviceError as e:
    logging.critical(f"GPIO Initialization failed: {e}. Running in headless/simulated mode.")
    # Fallback allows the web server to run for API testing even if GPIO fails

# --- WEB ROUTES ---
@app.route('/')
def index():
    return "<h1>Pi 5 Hardware Server</h1><p>Use /api/status or /api/relay/on</p>"

@app.route('/api/status', methods=['GET'])
def get_status():
    cpu_temp = psutil.sensors_temperatures().get('cpu_thermal', [None])[0]
    temp_val = cpu_temp.current if cpu_temp else 'N/A'
    
    return jsonify({
        "relay_state": relay.value if relay else "simulated_off",
        "button_pressed": button.is_pressed if button else "simulated_false",
        "cpu_temp_c": temp_val
    })

@app.route('/api/relay/<state>', methods=['POST'])
def control_relay(state):
    if relay is None:
        return jsonify({"error": "Hardware not initialized"}), 503
    
    if state == 'on':
        relay.on()
        logging.info("Relay engaged.")
    elif state == 'off':
        relay.off()
        logging.info("Relay disengaged.")
    else:
        return jsonify({"error": "Invalid state. Use 'on' or 'off'."}), 400
        
    return jsonify({"relay_state": relay.value})

if __name__ == '__main__':
    # Dev server only. Use Gunicorn for production.
    app.run(host='0.0.0.0', port=5000, debug=True)

Step 3: Production Deployment with Gunicorn

Never use the Flask development server in production. It lacks concurrency and will drop requests. Run the app using Gunicorn, binding to all interfaces so you can access it from your local network:

gunicorn --bind 0.0.0.0:8000 --workers 2 --threads 2 app:app

Debugging: Exact Errors and the "First Three" Checklist

When migrating code from a Pi 4 to a Pi 5, or deploying headless, you will hit specific architectural walls. Here are the exact error strings and their ranked fixes.

Ranked Error Causes

Error 1: RuntimeError: This module can only be run on a Raspberry Pi!

  • Cause: Your code or a dependency is importing the legacy RPi.GPIO library. The Pi 5’s RP1 chip does not map to the old Broadcom memory addresses.
  • Fix: Remove RPi.GPIO from your requirements. Refactor to use gpiozero (which uses lgpio on Bookworm) or use lgpio directly.

Error 2: lgpio.error: 'GPIO busy' or gpiozero.exc.GPIOPinInUse

  • Cause: A previous instance of your script crashed without releasing the pin, or another service (like a background MQTT script) holds the file lock on the GPIO chip.
  • Fix: Run sudo lsof | grep gpiochip to find the PID holding the pin, then kill -9 [PID]. If stuck, a hard reboot clears the RP1 state.

Error 3: ConnectionRefusedError: [Errno 111] Connection refused (from external PC)

  • Cause: You started the server with flask run or app.run() without specifying the host, causing it to bind only to 127.0.0.1 (localhost).
  • Fix: Always use Gunicorn with --bind 0.0.0.0:8000 or pass host='0.0.0.0' to the Flask dev server.
The "First Three" Checklist for Pi 5 Failures:
1. Verify Pin Factory: Run python3 -c "import gpiozero; print(gpiozero.Device.pin_factory)". It must output <gpiozero.pins.lgpio.LGPIOFactory object>. If it says RPiGPIO, your environment is misconfigured.
2. Check Power Supply: Run vcgencmd get_throttled. If it returns anything other than 0x0, your power supply is inadequate, causing the RP1 chip to drop GPIO connections.
3. Confirm Bind Address: Run sudo ss -tulpn | grep 8000 to ensure Gunicorn is listening on 0.0.0.0:8000, not 127.0.0.1.

Scaling the Architecture

Once the baseline server is stable, you must decide how to scale based on your deployment environment.

How to Simplify (The Static Route)

If you only need to serve a single HTML dashboard and don't require dynamic Python API endpoints, strip out Flask entirely. Use lighttpd or Nginx to serve static HTML/JS files, and use a lightweight C-based daemon (like pigpiod) to handle GPIO via socket commands. This reduces RAM usage to under 10 MB and eliminates Python dependency rot.

How to Extend (The Production Route)

For a robust, 24/7 home automation server, extend the build with these three layers:

  1. Reverse Proxy: Install Nginx (sudo apt install nginx) to sit in front of Gunicorn. Nginx handles SSL termination (via Let's Encrypt) and static asset caching, passing only API calls to Python.
  2. Systemd Service: Create a /etc/systemd/system/piserver.service file to auto-start Gunicorn on boot and restart it on crash. Use the Restart=always and WatchdogSec=30 directives.
  3. Containerization: If you plan to replicate this across multiple Pi 5 nodes, wrap the Flask app and Gunicorn in a Docker container. Note: You must pass --device /dev/gpiochip0 to the docker run command to grant the container access to the Pi 5's RP1 GPIO headers.

For deeper reading on the Pi 5's RP1 architecture and GPIO changes, consult the official Raspberry Pi hardware documentation. For production WSGI deployment patterns, reference the Gunicorn deployment guide.