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
ComponentRoleRuns as
NginxListens on port 80, serves static files, proxies requestsSystem service, www-data
GunicornPython WSGI server, manages worker processessystemd service, your user
FlaskYour routes and logicInside each Gunicorn worker
systemdStarts Gunicorn at boot and restarts it on crashPID 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
~/statusapp/app.py - Flask status dashboard with a JSON API
# 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

/etc/systemd/system/statusapp.service - starts Gunicorn at boot
[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

/etc/nginx/sites-available/statusapp - proxy to Gunicorn, serve static files
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.

Verifying and Updating the Deployment

Check each layer separately when something does not work. Testing from the inside out tells you exactly which layer is at fault:

TestCommandExpected result
Gunicorn is runningsystemctl is-active statusappactive
App answers locallycurl -s http://127.0.0.1:8000/api/statusJSON with temp_c
Nginx config is validsudo nginx -tsyntax is ok, test is successful
Proxy workscurl -s http://localhost/api/statusSame JSON as above
Static files servedcurl -I http://localhost/static/style.cssHTTP 200 and an Expires header
Reachable on the LANBrowser on another devicePage loads and updates

Nginx writes its logs to /var/log/nginx/access.log and /var/log/nginx/error.log, while Gunicorn output goes to the systemd journal. When you deploy a new version, copy the files, run ./venv/bin/pip install -r requirements.txt if dependencies changed, and restart the service. Keeping a requirements.txt produced with ./venv/bin/pip freeze makes it easy to rebuild the same environment on a new SD card.

Troubleshooting

  • 502 Bad Gateway: Nginx cannot reach Gunicorn. Check systemctl status statusapp and confirm it binds to 127.0.0.1:8000.
  • 403 or 404 on /static/ files: Nginx runs as www-data and needs execute permission on every folder in the path. Run chmod 755 /home/yourname or move the static folder under /var/www.
  • Service fails with status=203/EXEC: the path in ExecStart is wrong. Copy it exactly from ls ~/statusapp/venv/bin/gunicorn.
  • Code changes do not appear: Gunicorn loads the app once. Run sudo systemctl restart statusapp after 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.