Nginx is widely used as a front-end for PHP via FastCGI, but it doesn't care what's on the other end of proxy_pass. A Python app, a Go binary, a Node service — they all look the same to Nginx: a backend listening on a port. This guide walks through serving a Python Flask application behind Nginx on a VPS, with systemd keeping it alive, a virtual environment keeping dependencies isolated, and the same SSL and security headers you'd apply to any other site.
The examples here are intentionally read-only — they return information about your server but don't modify anything. That makes them safe to experiment with while you learn the pattern. Once the infrastructure is solid, the same Nginx configuration serves any Python app you write, public-facing or not.
Prerequisites
- A VPS or server running Nginx with SSL already configured — the Let's Encrypt guide covers certificate setup
- Python 3.10+ (standard on Debian 12+, Ubuntu 22.04+, RHEL 9+)
- A domain or subdomain pointed to your server
Step 1: The Python app
Create a directory for the application. Everything lives under its own user — not root, not www-data. Add the user to the www-data group so the app can read Nginx log files (RHEL/Fedora uses nginx instead):
# Create the app user and add to www-data group for log access
sudo useradd -r -m -s /bin/bash -G www-data username
# Create the app directory
sudo mkdir -p /home/username/apps/logwatch
sudo chown username:username /home/username/apps/logwatch
# Set up a Python virtual environment
cd /home/username/apps/logwatch
python3 -m venv venv
source venv/bin/activate
# Install Flask and Gunicorn
pip install flask gunicorn
The -G www-data flag adds the user to the www-data group as a supplementary group — the app can read Nginx log files but doesn't own them. On RHEL, Fedora, or Rocky Linux, use nginx instead of www-data.
Save the application as /home/username/apps/logwatch/app.py. It has two endpoints: a health check that reports disk usage, load average, and memory — useful for checking backend server status before proxying requests — and a log reader that returns recent error log entries:
#!/usr/bin/env python3
"""Logwatch — a minimal Flask app with a health endpoint and a log
reader. Both are read-only: they report system state without changing
anything. Useful as a starting point for backend monitoring or as a
template for any Python app behind Nginx."""
import os, shutil
from flask import Flask, request, jsonify
app = Flask(__name__)
LOG_FILE = "/var/log/nginx/example.error.log"
@app.route("/")
def home():
return jsonify({
"app": "logwatch",
"endpoints": ["/health", "/recent?lines=20", "/check?pattern=403"]
})
@app.route("/health")
def health():
"""Return basic server health metrics. Useful for checking backend
status before proxying requests — run this on the home server from
the reverse proxy guide and hit it through the VPS."""
disk = shutil.disk_usage("/")
with open("/proc/meminfo") as f:
meminfo = {}
for line in f:
parts = line.split(":")
if len(parts) == 2:
meminfo[parts[0].strip()] = parts[1].strip()
total = int(meminfo["MemTotal"].split()[0])
available = int(meminfo["MemAvailable"].split()[0])
return jsonify({
"hostname": os.uname().nodename,
"load": [round(l, 2) for l in os.getloadavg()],
"disk_pct": round((1 - disk.free / disk.total) * 100, 1),
"memory_pct": round((1 - available / total) * 100, 1)
})
@app.route("/recent")
def recent():
"""Return the last N lines of the error log."""
lines = request.args.get("lines", 20, type=int)
try:
with open(LOG_FILE) as f:
tail = f.readlines()[-lines:]
return jsonify({"lines": [l.strip() for l in tail]})
except FileNotFoundError:
return jsonify({"error": "log file not found"}), 404
except PermissionError:
return jsonify({"error": "permission denied — check file ownership"}), 500
@app.route("/check")
def check():
"""Count lines matching a pattern in the last N lines."""
pattern = request.args.get("pattern", "403")
lines = request.args.get("lines", 100, type=int)
try:
with open(LOG_FILE) as f:
tail = f.readlines()[-lines:]
matches = [l.strip() for l in tail if pattern in l]
return jsonify({"pattern": pattern, "matches": len(matches), "lines": matches})
except FileNotFoundError:
return jsonify({"error": "log file not found"}), 404
except PermissionError:
return jsonify({"error": "permission denied — check file ownership"}), 500
if __name__ == "__main__":
app.run(host="127.0.0.1", port=5000)
The /health endpoint reads from /proc/meminfo and checks disk usage — no external dependencies, just the standard library. /recent tails the error log. /check counts how many lines match a pattern — call it with /check?pattern=403 to see how many forbidden-request hits came in recently. All endpoints return JSON and bind to localhost only — they're never exposed directly to the internet. Nginx will be the public-facing layer.
Each endpoint catches both FileNotFoundError and PermissionError separately so access problems don't produce a generic 500 with no useful output.
A note on memory: f.readlines() loads the entire file into memory. With a logrotate cap of 10 MB, that's harmless — 10 MB is nothing on a modern server. If you're tailing a log without size limits or one that's grown into hundreds of megabytes, swap it for a chunked read from the end of the file:
def tail_lines(filename, count):
"""Return the last `count` lines of a file without loading it all into memory."""
with open(filename, "rb") as f:
f.seek(0, 2) # jump to end
block_end = f.tell()
blocks = []
remaining = count
while remaining > 0 and block_end > 0:
block_size = min(4096, block_end)
f.seek(block_end - block_size)
blocks.append(f.read(block_size))
remaining -= blocks[-1].count(b"\n")
block_end -= block_size
lines = b"".join(reversed(blocks)).decode(errors="replace").splitlines()
return lines[-count:]
This reads 4 KB blocks backwards from the end until it's captured enough newlines, then returns only the last N lines — memory usage stays in the kilobytes regardless of file size. The article's log endpoints use f.readlines() because the log files they target are rotated at 10 MB, but the chunked version is a drop-in replacement for anything larger.
Verify the user can actually read the log files before wiring up anything else. Filesystem permissions can be deceiving — logrotate often creates log files with the adm group even when the directory is www-data:www-data:
# Check which groups the user belongs to
groups username
# Check the log file's actual owner and group — stat reveals what ls -la hides
stat /var/log/nginx/example.error.log | grep -E "Uid|Gid"
# The Gid line shows the group. If it says 'adm' and not 'www-data',
# the user can't read the file even if they're in the www-data group.
# Fix: change the group on the log directory and update logrotate
sudo chgrp -R www-data /var/log/nginx
sudo chmod g+s /var/log/nginx
# Then edit /etc/logrotate.d/nginx — change:
# create 640 www-data adm
# To:
# create 640 www-data www-data
# And add:
# su www-data www-data
# Verify the user can actually read the file
cat /var/log/nginx/example.error.log > /dev/null
echo $? # Should return 0
If cat succeeds, the user has read access and the app will work. If it still fails, check for AppArmor or SELinux — sudo aa-status or sudo getenforce.
Test the app manually before wiring up systemd:
cd /home/username/apps/logwatch
source venv/bin/activate
python app.py
# In another terminal:
curl http://127.0.0.1:5000/health
curl http://127.0.0.1:5000/recent?lines=5
Step 2: systemd service
Gunicorn is a production WSGI server for Python. It handles concurrency, worker management, and graceful restarts — things Flask's built-in development server doesn't. systemd keeps Gunicorn running and restarts it if it crashes.
Create /etc/systemd/system/logwatch.service:
[Unit]
Description=Logwatch Flask application
After=network.target
[Service]
User=username
Group=www-data
WorkingDirectory=/home/username/apps/logwatch
Environment="PATH=/home/username/apps/logwatch/venv/bin"
ExecStart=/home/username/apps/logwatch/venv/bin/gunicorn \
--workers 2 \
--bind 127.0.0.1:5000 \
--access-logfile /var/log/logwatch-access.log \
--error-logfile /var/log/logwatch-error.log \
app:app
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
Two workers is right for a lightweight app on a small VPS. The Group=www-data line gives the app read access to Nginx log files without making it the owner — Nginx log files are typically www-data:www-data with mode 640, which means the group gets read access. On RHEL, Fedora, or Rocky Linux, use Group=nginx instead. The app binds to 127.0.0.1:5000 — localhost only. No external port is exposed. Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable logwatch
sudo systemctl start logwatch
# Confirm it's running
sudo systemctl status logwatch
curl http://127.0.0.1:5000/
Step 3: Nginx reverse proxy
The Nginx configuration follows the same pattern as any reverse-proxied backend — SSL termination at Nginx, plain HTTP to the app on localhost. The app never sees the internet. Nginx handles TLS, headers, and access control:
server {
include snippets/listen.conf;
server_name logwatch.example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
access_log /var/log/nginx/logwatch.access.log combined if=$log_ip;
error_log /var/log/nginx/logwatch.error.log;
# Only allow your own IPs — this isn't a public endpoint
allow 203.0.113.50; # home IPv4
allow 2001:db8::/48; # home IPv6
deny all;
location / {
proxy_pass http://127.0.0.1:5000;
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;
}
}
The allow/deny block restricts access to your own IPs — the log reader endpoint exposes request paths and IPs and shouldn't be public. Drop them if you're building something meant for the open web. The proxy_set_header lines pass the original request context to the Python app so it knows the client's real IP and that the connection was HTTPS.
Reload Nginx and test through the proxy:
sudo nginx -t && sudo systemctl reload nginx
curl https://logwatch.example.com/health