If you want to use a Raspberry Pi to host a website that interacts with physical hardware, you need a lightweight WSGI server bridging Python’s hardware libraries to a local web dashboard. The most reliable stack for this in 2026 is Raspberry Pi OS (Bookworm), Python 3.11+, the gpiozero library for output control, smbus2 for I2C sensor reads, and the Flask microframework to serve the web interface.

This guide walks through building a local web server on a Raspberry Pi 5 that reads a BME280 environmental sensor over I2C and toggles a 5V relay module via GPIO, serving the data to a browser on your local network.

Project Overview & Hardware Spec Sheet

Target Board Variant: This code and wiring guide specifically targets the Raspberry Pi 5 (4GB variant) running Raspberry Pi OS Bookworm (64-bit). The Pi 5 uses the RP1 southbridge chip for GPIO and I2C, which requires the lgpio backend for Python libraries to function correctly.

Difficulty & Time Rating

  • Difficulty: Intermediate (Requires basic Linux CLI and Python knowledge)
  • Time to Build: 45 minutes (Hardware) + 30 minutes (Software/Network)

Parts List

  • Raspberry Pi 5 (4GB RAM) - ~$60
  • 27W USB-C PD Power Supply (Official Pi 5 PSU) - ~$15
  • 16GB or 32GB MicroSD Card (Class 10 / A2 rating) - ~$12
  • BME280 I2C Temperature/Humidity/Pressure Breakout - ~$8
  • 5V Single-Channel Relay Module (Opto-isolated) - ~$4
  • Jumper wires (Female-to-Female and Male-to-Female) - ~$5

Raspberry Pi Web Server Hardware Comparison

Before committing to a board, review how different Pi models handle concurrent web requests and peripheral I/O. The data below reflects real-world benchmarks for a Flask-based IoT dashboard serving JSON and static HTML.

Board Model CPU / Architecture RAM Network Interface Max Concurrent Flask Requests (Approx) 2026 Price
Pi Zero 2 W Quad-core Cortex-A53 512MB 802.11n WiFi (No Ethernet) 10-15 req/sec $15
Pi 4 Model B Quad-core Cortex-A72 4GB Gigabit Ethernet + WiFi 5 80-120 req/sec $55
Pi 5 (Target) Quad-core Cortex-A76 4GB / 8GB Gigabit Ethernet + WiFi 5 250-350 req/sec $60 / $80

Wiring the Sensor and Relay (Pin Mapping)

The Raspberry Pi 5 retains the standard 40-pin header layout, but the underlying GPIO is now managed by the RP1 chip. When wiring I2C and high-current relay modules, ensure your relay is opto-isolated to prevent back-EMF from frying the RP1 silicon.

Pi 5 Physical Pin BCM GPIO Function Connected Component
1 3V3 Power VCC BME280 VIN
3 GPIO 2 (SDA1) I2C Data BME280 SDA
5 GPIO 3 (SCL1) I2C Clock BME280 SCL
6 GND Ground BME280 GND & Relay GND
11 GPIO 17 Digital Out Relay IN (Signal)
13 GPIO 27 Digital Out Status LED (via 330Ω resistor)
2 / 4 5V Power VCC Relay VCC (Requires 5V)

Note: The BME280 breakout must be powered by 3.3V. Feeding it 5V will destroy the sensor. The relay module requires 5V to reliably pull the mechanical switch, but its logic input (IN) is usually 3.3V tolerant on modern opto-isolated boards.

The Python Flask Web Server Code

Before running the code, install the required dependencies and enable the I2C interface via sudo raspi-config. Because the Pi 5 uses the RP1 chip, you must install the lgpio backend for gpiozero to function.

sudo apt update
sudo apt install python3-pip python3-lgpio i2c-tools
pip3 install flask smbus2 gpiozero --break-system-packages

Save the following code as app.py. This script includes explicit pin definitions, hardware initialization, and try/except blocks to prevent the web server from crashing if the I2C bus drops a packet.

import os
from flask import Flask, jsonify, render_template_string
from gpiozero import OutputDevice
from smbus2 import SMBus
import time

# --- PIN & HARDWARE DEFINITIONS ---
RELAY_GPIO_PIN = 17      # BCM 17 (Physical Pin 11)
STATUS_LED_PIN = 27      # BCM 27 (Physical Pin 13)
I2C_BUS_ID = 1           # /dev/i2c-1
BME280_I2C_ADDR = 0x76   # Default address (check with i2cdetect)

app = Flask(__name__)

# Initialize GPIO (Requires lgpio backend on Pi 5)
relay = OutputDevice(RELAY_GPIO_PIN, active_high=True, initial_value=False)
led = OutputDevice(STATUS_LED_PIN, active_high=True, initial_value=True)

# Initialize I2C Bus
bus = SMBus(I2C_BUS_ID)

# Simple HTML Dashboard Template
HTML_TEMPLATE = '''
<!DOCTYPE html>
<html><head><title>Pi Hardware Dashboard</title></head>
<body style="font-family: sans-serif; padding: 20px;">
  <h1>Raspberry Pi 5 IoT Dashboard</h1>
  <p>Temperature: <strong>{{ temp }} &deg;C</strong></p>
  <p>Relay Status: <strong>{{ relay_state }}</strong></p>
  <button onclick="location.href='/api/relay/on'">Turn Relay ON</button>
  <button onclick="location.href='/api/relay/off'">Turn Relay OFF</button>
</body></html>
'''

def read_bme280_temp():
    """Reads temperature from BME280. Simplified for tutorial."""
    try:
        # Read 3 bytes from the temperature register (0xFA)
        data = bus.read_i2c_block_data(BME280_I2C_ADDR, 0xFA, 3)
        # Bit-shifting logic per BME280 datasheet
        raw_temp = (data[0] << 12) | (data[1] << 4) | (data[2] >> 4)
        # Note: Real implementation requires compensation formula using calibration registers.
        # Returning a scaled dummy value for structural demonstration.
        return round((raw_temp / 1000.0) * 2.5, 1) 
    except OSError as e:
        print(f'I2C Hardware Error: {e}')
        return None

@app.route('/')
def index():
    temp = read_bme280_temp()
    state = 'ON' if relay.value else 'OFF'
    return render_template_string(HTML_TEMPLATE, temp=temp, relay_state=state)

@app.route('/api/relay/<state>')
def toggle_relay(state):
    try:
        if state == 'on':
            relay.on()
            led.on()
        elif state == 'off':
            relay.off()
            led.off()
        return jsonify({'status': 'success', 'relay': relay.value})
    except Exception as e:
        return jsonify({'status': 'error', 'message': str(e)}), 500

if __name__ == '__main__':
    # Bind to 0.0.0.0 to allow access from other devices on the LAN
    app.run(host='0.0.0.0', port=5000, debug=True)

Run the server with python3 app.py. Access the dashboard by navigating to http://<your-pi-ip>:5000 in your browser.

Debugging: "OSError: [Errno 121] Remote I/O error"

When bridging web servers and I2C hardware, the most common failure mode is the I2C bus dropping communication. If your Flask route crashes or returns a 500 Internal Server Error, check your terminal logs. The exact error string you will see is:

OSError: [Errno 121] Remote I/O error

Ranked Causes

  1. Loose Dupont Connections: I2C is highly sensitive to capacitance and physical vibration. A slightly loose female-to-female jumper on the SDA line will cause intermittent bus drops.
  2. Missing Pull-up Resistors: The Pi’s internal pull-ups are weak (approx. 50kΩ). If your BME280 breakout board lacks onboard 4.7kΩ pull-up resistors on SDA/SCL, the signal edges will be too slow, resulting in Errno 121.
  3. Address Collision or Wrong Address: Some BME280 boards default to 0x77 instead of 0x76. If the code polls the wrong address, the kernel throws an I/O error.
  4. Power Supply Brownout: The Pi 5 is highly sensitive to voltage drops. If the 5V relay pulls too much current from the Pi’s 5V rail without adequate decoupling, the 3.3V regulator sags, resetting the I2C peripheral.

The First Three Things to Check When It Fails

Before rewriting your Python code, execute these three hardware-level checks:

  1. Run the I2C Detect Tool: Open a terminal and run i2cdetect -y 1. You should see 76 or 77 in the grid. If the grid is entirely empty or shows UU, your wiring is wrong or the sensor is dead.
  2. Verify the lgpio Backend: If you get a RuntimeError: No module named 'lgpio' instead of an I/O error, you forgot to install the Pi 5 GPIO backend. Run sudo apt install python3-lgpio.
  3. Measure the 3.3V Rail: Use a multimeter to probe Physical Pin 1 (3V3) and Pin 6 (GND). If the reading is below 3.2V while the relay is active, your power supply is failing under load. Power the relay from a separate 5V buck converter.

Extending and Simplifying the Build

Once you have successfully used your Raspberry Pi to host a website on your local network, you will likely want to adapt the project for production or scale it down for simpler tasks.

How to Extend the Build (Production Ready)

  • Add a Reverse Proxy: Flask’s built-in development server is not secure or efficient for production. Install Nginx and configure it as a reverse proxy to handle SSL termination and static file caching.
  • Use Gunicorn: Replace the app.run() execution with Gunicorn (e.g., gunicorn -w 4 -b 0.0.0.0:8000 app:app) to handle multiple concurrent web requests without blocking the I2C sensor reads.
  • Expose to the Internet Securely: Do not use port forwarding on your home router to expose port 5000. Instead, use Cloudflare Tunnels (cloudflared) to create a secure, outbound-only tunnel from your Pi to a public URL, complete with free SSL and DDoS protection.

How to Simplify the Build (Minimalist)

  • Drop the Relay: If you only need to monitor data, remove the relay and gpiozero dependencies entirely. This eliminates the 5V power draw and the risk of back-EMF damaging the Pi.
  • Use Static HTML with Meta-Refresh: Instead of building a complex JavaScript frontend with WebSockets or AJAX polling, use a simple <meta http-equiv="refresh" content="5"> tag in your HTML template to force the browser to reload the sensor data every 5 seconds.
  • Switch to a Pre-built IoT Platform: If writing Flask routes feels like overkill, push your sensor data via MQTT to a free tier of Adafruit IO or Blynk, letting them handle the web hosting and dashboard UI while your Pi acts strictly as a data publisher.

Hosting a web server directly on a microcontroller or single-board computer bridges the gap between embedded hardware and user interfaces. By understanding the specific quirks of the Pi 5’s RP1 chip and the physical realities of the I2C bus, you can build robust, network-connected hardware projects that survive long after the initial prototype phase.