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 |
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
- 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. - User Permissions: If running outside the
pior default user, ensure your user is in thei2cgroup (sudo usermod -aG i2c $USER). - Port Conflicts: Run
sudo lsof -i :5000to ensure a zombie Flask process isn't holding the port open from a previous crash.
Exact Error Strings & Ranked Causes
OSError: [Errno 121] Remote I/O errorWhere it happens: Inside the
/api/sensor route during a read attempt.Causes (Ranked):
- 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=100000to your I2C initialization or slow down the read rate. - 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.
- 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).
PermissionError: [Errno 13] Permission denied: '/dev/i2c-1'Where it happens: On script startup during
busio.I2C() initialization.Causes (Ranked):
- Wrong User Context: You are running the script via a cron job or systemd service as
rootor a user not in thei2cgroup. Fix: AddUser=pito your systemd service file. - I2C Disabled: The interface was turned off in
raspi-config. Fix: Re-enable and reboot.
OSError: [Errno 98] Address already in useWhere it happens: On script startup during
app.run().Causes (Ranked):
- Zombie Process: You hit Ctrl+C but the child process survived. Fix:
killall python3orfuser -k 5000/tcp. - 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
gunicornand rungunicorn -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-mqttlibrary 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
socketlibrary 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.htmlfile and serve it usingpython3 -m http.server 80or a lightweight C-based server likelighttpd.
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.






