Raspberry Pi 4 Web Server with Nginx, Gunicorn and Flask (systemd Setup)
Running python3 app.py is fine for testing a Flask project, but the built-in development server is single-purpose, stops when you log out, and is not meant to face real users. A proper deployment on a Raspberry Pi 4 uses three layers: Flask for your application code, Gunicorn to run it with several worker processes, and Nginx in front to accept connections, serve static files and pass the rest to Gunicorn.
In this tutorial you will build a small status dashboard that reports the Pi's CPU temperature, load and uptime through a JSON API, run it as a systemd service that starts at boot, and publish it on port 80 through Nginx.
How the Pieces Fit Together
Browser --HTTP:80--> Nginx --+-- /static/* -> files on disk
|
+-- everything else -> 127.0.0.1:8000
Gunicorn (2 workers)
-> Flask app| Component | Role | Runs as |
|---|---|---|
| Nginx | Listens on port 80, serves static files, proxies requests | System service, www-data |
| Gunicorn | Python WSGI server, manages worker processes | systemd service, your user |
| Flask | Your routes and logic | Inside each Gunicorn worker |
| systemd | Starts Gunicorn at boot and restarts it on crash | PID 1 |
Gunicorn listens only on 127.0.0.1, so it cannot be reached directly from the network. All outside traffic goes through Nginx.
Why Not Just Use the Flask Development Server?
The development server started by app.run() is designed for convenience while coding. It reloads on file changes and, with debug=True, shows an interactive debugger in the browser that can execute Python code. Left running on a network, that debugger is a serious security hole. It also has no process supervision: if it crashes, nothing restarts it.
Gunicorn solves the process side. It starts a master process that forks a fixed number of workers and replaces any worker that dies. A common rule of thumb is two workers per CPU core plus one, but on a Pi 4 that also runs other services, two or three workers are usually plenty for a small dashboard. Nginx solves the network side: it handles slow clients, keeps connections alive, serves static files without touching Python, and is the natural place to add HTTPS later.
Choosing the Image
Use Raspberry Pi OS Lite (64-bit) for a dedicated web server. Without the desktop, more RAM is free for workers, and fewer packages need updating. Give the Pi a fixed IP address, as shown in the first-boot article, so that bookmarks and any router port rules keep working after a reboot.
Creating the Flask Application
Bookworm marks the system Python as externally managed, so pip install outside a virtual environment is refused. Create a project folder with its own venv:
sudo apt install -y nginx python3-venv mkdir -p ~/statusapp/static && cd ~/statusapp python3 -m venv venv ./venv/bin/pip install flask gunicorn
# app.py - small status dashboard for a Raspberry Pi 4
import os
import time
from flask import Flask, jsonify
app = Flask(__name__)
def cpu_temp_c():
"""Read the SoC temperature from the kernel's thermal zone (millidegrees)."""
with open('/sys/class/thermal/thermal_zone0/temp') as f:
return int(f.read()) / 1000
def uptime_hours():
with open('/proc/uptime') as f:
return float(f.read().split()[0]) / 3600
@app.route('/api/status')
def status():
load1, load5, load15 = os.getloadavg()
return jsonify(
temp_c=round(cpu_temp_c(), 1),
load=[round(load1, 2), round(load5, 2), round(load15, 2)],
uptime_h=round(uptime_hours(), 1),
time=time.strftime('%Y-%m-%d %H:%M:%S'),
)
@app.route('/')
def index():
# Minimal page; JavaScript polls the API every 2 seconds
return '''<!doctype html>
<html><head><title>Pi 4 Status</title>
<link rel="stylesheet" href="/static/style.css"></head>
<body><h1>Raspberry Pi 4 Status</h1><pre id="out">loading...</pre>
<script>
async function refresh() {
const r = await fetch('/api/status');
document.getElementById('out').textContent =
JSON.stringify(await r.json(), null, 2);
}
refresh(); setInterval(refresh, 2000);
</script></body></html>'''
if __name__ == '__main__':
# Development only: python3 app.py
app.run(host='0.0.0.0', port=5000, debug=True)
Add a file static/style.css with any styling, for example body { font-family: sans-serif; margin: 2em; }. Test Gunicorn by hand with ./venv/bin/gunicorn -w 2 -b 127.0.0.1:8000 app:app and, from a second SSH session, curl http://127.0.0.1:8000/api/status.
Running Gunicorn with systemd
[Unit]
Description=Pi 4 status dashboard (Gunicorn + Flask)
After=network.target
[Service]
# Replace yourname with your Linux username
User=yourname
Group=www-data
WorkingDirectory=/home/yourname/statusapp
# Two workers suit a small app; each worker is a separate process
ExecStart=/home/yourname/statusapp/venv/bin/gunicorn --workers 2 --bind 127.0.0.1:8000 app:app
Restart=on-failure
RestartSec=3
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload sudo systemctl enable --now statusapp systemctl status statusapp # should show active (running) journalctl -u statusapp -f # follow the logs
Configuring Nginx as a Reverse Proxy
server {
listen 80;
server_name _; # answer for any host name or IP
# Serve CSS, JS and images directly from disk - faster than Python
location /static/ {
alias /home/yourname/statusapp/static/;
expires 1h;
}
# Everything else goes to Gunicorn
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
sudo ln -s /etc/nginx/sites-available/statusapp /etc/nginx/sites-enabled/ sudo rm /etc/nginx/sites-enabled/default sudo nginx -t # syntax check sudo systemctl reload nginx
Open http://pi4lab.local/ from any device on your network. The numbers refresh every two seconds.
Troubleshooting
- 502 Bad Gateway: Nginx cannot reach Gunicorn. Check
systemctl status statusappand confirm it binds to 127.0.0.1:8000. - 403 or 404 on /static/ files: Nginx runs as
www-dataand needs execute permission on every folder in the path. Runchmod 755 /home/yournameor move the static folder under/var/www. - Service fails with status=203/EXEC: the path in
ExecStartis wrong. Copy it exactly fromls ~/statusapp/venv/bin/gunicorn. - Code changes do not appear: Gunicorn loads the app once. Run
sudo systemctl restart statusappafter editing. - Exposing to the internet: do not just forward port 80. Add HTTPS with a certificate and consider a tunnel service or VPN instead of an open port.
Summary
Keep Python packages in a venv, run Flask under Gunicorn bound to localhost, let systemd start and restart it, and put Nginx in front to handle port 80 and static files. This same three-layer structure works for any Flask project on a Pi 4, including GPIO control panels and sensor dashboards built from the earlier articles in this series.