The most reliable way to deploy a lightweight web server in Raspberry Pi for IoT dashboards and sensor APIs is using Python's Flask framework on a Raspberry Pi 5 (4GB) running Raspberry Pi OS (64-bit, Bookworm). While heavier stacks like Django or Node.js/Express work, Flask provides the lowest overhead for reading GPIO/I2C hardware directly from your web routes. This guide walks through building a hardware-aware API server, mapping the I2C pins, and debugging the exact I/O errors that crash most beginner builds.

Hardware Selection: Which Pi for a Web Server?

Before wiring anything, you need to match the board to your expected network traffic. A web server in Raspberry Pi hardware is bound by CPU threading limits and network interface speeds. Here is how the current lineup handles concurrent Flask requests and sensor polling.

Board Variant CPU / Architecture RAM Network Interface Max Concurrent Flask Requests (Est.) Typical Price (2026)
Raspberry Pi 5 (4GB) 2.4GHz Quad-core Cortex-A76 4GB LPDDR4X Gigabit Ethernet ~150-200 (with gunicorn) $60
Raspberry Pi 4 Model B (4GB) 1.8GHz Quad-core Cortex-A72 4GB LPDDR4 Gigabit Ethernet ~80-120 (with gunicorn) $55
Raspberry Pi Zero 2 W 1.0GHz Quad-core Cortex-A53 512MB LPDDR2 WiFi only (No Ethernet) ~20-30 $15
Bench Note: The Pi 5's PCIe 2.0 interface allows you to bypass the internal USB bus bottleneck that limited the Pi 4's Gigabit Ethernet. If your web server handles high-throughput file uploads or dense JSON payloads, the Pi 5 will sustain line-rate Gigabit transfers, whereas the Pi 4 will throttle around 700 Mbps under heavy CPU load.

Parts List & Pin Mapping

This build targets the Raspberry Pi 5 (4GB). The code and wiring are 100% backward-compatible with the Pi 4, but the Pi 5 requires the official 27W USB-C PD power supply to prevent brownout warnings when polling I2C sensors under network load.

Bill of Materials

  • Board: Raspberry Pi 5 (4GB) with active cooler or passive aluminum case
  • Sensor: Adafruit BME280 I2C/SPI Breakout (Product ID 2652) - Do not use the cheaper BMP280; it lacks humidity sensing.
  • Storage: 32GB Samsung EVO Plus microSD (A2 rated for database write endurance)
  • Power: Official Raspberry Pi 27W USB-C PD Power Supply
  • Wiring: 4x Female-to-Female jumper wires (silicone, 26 AWG)

GPIO Pin Mapping (I2C1 Bus)

The Raspberry Pi exposes multiple I2C buses, but I2C1 is the default hardware bus with built-in 1.8kΩ pull-up resistors. Always use this bus for sensors.

BME280 Pin Raspberry Pi 5 GPIO Pin Physical Pin # Function
VIN (or 3Vo) 3V3 Power Pin 1 3.3V Power Input
GND Ground Pin 6 Common Ground
SCL GPIO 3 (SCL1) Pin 5 I2C Clock
SDA GPIO 2 (SDA1) Pin 3 I2C Data

Step-by-Step Build & Flask Code

We will use a Python virtual environment to isolate our Flask and Adafruit Blinka dependencies from the system Python, which is a strict requirement in Raspberry Pi OS Bookworm (PEP 668 compliance).

1. Enable I2C and Install System Dependencies

Open your terminal and run the configuration tool:

sudo raspi-config

Navigate to Interface Options > I2C and enable it. Reboot, then install the underlying I2C tools:

sudo apt update
sudo apt install python3-venv python3-pip i2c-tools -y

2. Verify Hardware Connection

Before writing code, confirm the Pi sees the sensor. The default I2C address for the Adafruit BME280 is 0x77 (some clones use 0x76).

i2cdetect -y 1

You should see 77 in the grid output. If the grid is empty, check your wiring.

3. Setup Virtual Environment & Install Libraries

mkdir ~/pi-web-server && cd ~/pi-web-server
python3 -m venv venv
source venv/bin/activate
pip install flask adafruit-circuitpython-bme280

4. The Flask API Server Code

Create a file named app.py and paste the following complete, error-handled code. This script initializes the sensor safely and serves a JSON endpoint.

from flask import Flask, jsonify
import adafruit_bme280
import busio
import board
import time

app = Flask(__name__)

# Initialize I2C and Sensor with error handling
bme280 = None
try:
    i2c = busio.I2C(board.SCL, board.SDA)
    # Address 0x77 is default for Adafruit; change to 0x76 for generic clones
    bme280 = adafruit_bme280.Adafruit_BME280_I2C(i2c, address=0x77)
    bme280.sea_level_pressure = 1013.25
    print('BME280 Sensor initialized successfully.')
except ValueError as e:
    print(f'Hardware Init Error: {e}. Check I2C wiring.')
except RuntimeError as e:
    print(f'Sensor Not Found: {e}. Verify address with i2cdetect.')

@app.route('/')
def index():
    return '<h1>Pi Sensor API</h1><p>Visit <a href="/api/sensor">/api/sensor</a> for JSON data.</p>'

@app.route('/api/sensor')
def get_sensor_data():
    if bme280 is None:
        return jsonify({'error': 'Sensor not initialized. Check hardware.'}), 503
    
    try:
        temp_c = round(bme280.temperature, 2)
        humidity = round(bme280.humidity, 2)
        pressure = round(bme280.pressure, 2)
        altitude = round(bme280.altitude, 2)
        
        return jsonify({
            'temp_c': temp_c,
            'humidity_pct': humidity,
            'pressure_hpa': pressure,
            'altitude_m': altitude,
            'timestamp': int(time.time())
        })
    except OSError as e:
        # Catches I2C bus dropouts during read
        return jsonify({'error': f'I2C Read Failed: {str(e)}'}), 500

if __name__ == '__main__':
    try:
        # host='0.0.0.0' exposes the server to your local network
        app.run(host='0.0.0.0', port=5000, debug=False)
    except PermissionError:
        print('Error: Port requires root privileges. Use port > 1024 or run with sudo.')
    except OSError as e:
        print(f'Network Binding Error: {e}. Is port 5000 already in use?')

Run the server using python3 app.py. Access it via http://<your-pi-ip>:5000/api/sensor from any device on your LAN.

Debugging: When the Server or Sensor Fails

Embedded web servers fail at the intersection of network stacks and physical hardware. When your dashboard stops updating or the script crashes on boot, follow this decision path.

The First 3 Things to Check

  1. I2C Bus Visibility: Run i2cdetect -y 1. If the address disappears, your Pi's 3.3V rail might be browning out, or a jumper wire has vibrated loose.
  2. User Permissions: If running outside the pi or default user, ensure your user is in the i2c group (sudo usermod -aG i2c $USER).
  3. Port Conflicts: Run sudo lsof -i :5000 to ensure a zombie Flask process isn't holding the port open from a previous crash.

Exact Error Strings & Ranked Causes

Error 1: OSError: [Errno 121] Remote I/O error
Where it happens: Inside the /api/sensor route during a read attempt.
Causes (Ranked):
  1. I2C Clock Stretching Timeout: The BME280 is holding the SCL line low to process data, but the Pi's I2C controller times out. Fix: Add i2c_baudrate=100000 to your I2C initialization or slow down the read rate.
  2. Missing Pull-up Resistors: You are using a generic clone sensor without onboard pull-ups. Fix: Add 4.7kΩ resistors between SDA/SCL and 3.3V.
  3. Loose Ground: The GND wire has high resistance. Fix: Verify continuity from Pi Pin 6 to Sensor GND with a multimeter (should be < 1 ohm).
Error 2: PermissionError: [Errno 13] Permission denied: '/dev/i2c-1'
Where it happens: On script startup during busio.I2C() initialization.
Causes (Ranked):
  1. Wrong User Context: You are running the script via a cron job or systemd service as root or a user not in the i2c group. Fix: Add User=pi to your systemd service file.
  2. I2C Disabled: The interface was turned off in raspi-config. Fix: Re-enable and reboot.
Error 3: OSError: [Errno 98] Address already in use
Where it happens: On script startup during app.run().
Causes (Ranked):
  1. Zombie Process: You hit Ctrl+C but the child process survived. Fix: killall python3 or fuser -k 5000/tcp.
  2. Systemd Conflict: You have a background service already running the API. Fix: sudo systemctl stop pi-sensor-api.

Extending and Simplifying the Build

Once the baseline API is stable, you can scale the architecture up for production or strip it down for low-power edge nodes.

How to Extend (Production Scaling)

  • Use Gunicorn: The built-in Flask server is single-threaded and meant for development. For production, install gunicorn and run gunicorn -w 4 -b 0.0.0.0:5000 app:app. This spawns 4 worker processes, allowing the Pi 5 to handle concurrent dashboard requests without blocking the I2C sensor reads.
  • Add MQTT Publishing: Instead of relying on HTTP polling, use the paho-mqtt library to push sensor readings to a Mosquitto broker every 5 seconds. This decouples the sensor read rate from the web request rate, eliminating I2C bus contention.
  • Reverse Proxy: Place Nginx in front of Flask to handle SSL termination (HTTPS via Let's Encrypt) and cache static dashboard assets.

How to Simplify (Low-Power / Offline Nodes)

  • Drop Flask for Raw Sockets: If you only need to send data to a single custom client, use Python's built-in socket library to serve raw TCP strings. This cuts RAM usage by ~30MB.
  • Switch to Pi Zero 2 W: If your web server only serves a local HTML file updated via cron (no dynamic API), abandon Flask entirely. Write the sensor data to a static index.html file and serve it using python3 -m http.server 80 or a lightweight C-based server like lighttpd.

For deeper reading on Pi hardware interfaces, refer to the official Raspberry Pi compute documentation, and for advanced routing patterns, consult the Flask 3.0 documentation. Always ensure your I2C wiring is secure and your power supply is rated for the board's peak transient draw to prevent silent data corruption.