How to Configure Flask Behind Nginx with X-Forwarded-For Headers for GDPR Logging
Flask behind Nginx: X-Forwarded-For headers for GDPR logging. When running Flask behind a reverse proxy like Nginx, the app typically logs the proxy’s IP (127.0.0.1) instead of the client’s real IP — essential for GDPR Art. 30 audit trails. We address this by configuring Nginx proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;, Flask ProxyFix(x_for=1), and gunicorn --forwarded-allow-ips 127.0.0.1. Performance impact is typically negligible.
Why X-Forwarded-For in Flask+Nginx?
When a snake strikes, it relies on precise targeting. The snake’s brain processes sensory input to identify exactly where to strike — without accurate positioning, the strike fails. Similarly, when Flask sits behind Nginx, it receives requests from the proxy rather than directly from clients. Without proper configuration, Flask sees only Nginx’s IP address, losing the critical identifying information needed for security auditing and GDPR compliance.
GDPR (Arts. 33/34) typically requires logging the true client IP for data subject requests and breach reporting. Proxy setups usually log only the proxy IP, complicating compliance.
| Setup | Flask remote_addr | GDPR Compliant? | Fix |
|---|---|---|---|
| Direct connection | 203.0.113.42 | Yes | N/A |
| Nginx proxy | 127.0.0.1 | No | X-Forwarded-For |
| Docker + Nginx | 172.17.0.1 | No | X-Forwarded-For + network config |
Real-world impact: Without proper configuration, audit logs typically show 127.0.0.1 for all requests, which complicates security investigations and GDPR compliance audits.
Step 1: Nginx Config (Pass Real IP)
Before we get into the technical details, let’s understand what Nginx needs to do. When Nginx acts as a reverse proxy, it receives the original client request and forwards it to Flask. By default, Nginx doesn’t automatically pass the client’s IP address to the backend application — we must configure it explicitly.
/etc/nginx/sites-available/flask-app:
server {
listen 80;
server_name yourdomain.com;
location / {
proxy_pass http://127.0.0.1:5000;
# Essential headers for proper request forwarding
proxy_set_header Host $host; # Preserve original hostname
proxy_set_header X-Real-IP $remote_addr; # Client's actual IP
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # Chain of proxies
proxy_set_header X-Forwarded-Proto $scheme; # http or https
}
}
Verify and reload Nginx:
$ sudo nginx -t && sudo systemctl reload nginx
Key configuration detail: The $proxy_add_x_forwarded_for variable appends the client’s IP address to any existing X-Forwarded-For header, properly handling request chains through multiple proxies.
Step 2: Flask App (Trust Proxy Headers)
Now that we’ve configured Nginx to pass the correct headers, let’s configure Flask to trust them. By default, Flask’s request.remote_addr reflects the immediate peer — typically Nginx’s IP (127.0.0.1). We’ll use Werkzeug’s ProxyFix middleware to parse the forwarded headers and update the request environment accordingly.
Flask 3.0+ Werkzeug ProxyFix:
from flask import Flask, request
from werkzeug.middleware.proxy_fix import ProxyFix
app = Flask(__name__)
# Configure ProxyFix to trust one level of proxy (Nginx)
# x_for: Trust X-Forwarded-For header
# x_proto: Trust X-Forwarded-Proto header (http/https)
# x_host: Trust X-Forwarded-Host header
# x_port: Trust X-Forwarded-Port header
app.wsgi_app = ProxyFix(
app.wsgi_app,
x_for=1,
x_proto=1,
x_host=1,
x_port=1
)
@app.route('/')
def index():
# request.remote_addr is now the real client IP
real_ip = request.remote_addr
app.logger.info(f"GDPR log: User {real_ip} accessed /")
return f"Real IP logged: {real_ip}"
Alternative manual approach (if you can’t modify the WSGI app):
def get_real_ip():
"""Extract the first IP from X-Forwarded-For chain."""
xff = request.headers.get('X-Forwarded-For', '').split(',')
return xff[0].strip() if xff else request.remote_addr
# Usage
real_ip = get_real_ip()
app.logger.info(f"GDPR: {real_ip}")
Note: The manual approach bypasses the standard proxy handling. Use ProxyFix whenever possible for consistency with other proxy-aware middleware.
Step 3: Gunicorn Prod (Trust Localhost)
When running Flask with Gunicorn in production, we’ll need to tell Gunicorn which proxy IPs to trust. Gunicorn rejects forwarded headers by default for security — so we’ll whitelist the Nginx proxy.
gunicorn.conf.py:
# Bind to localhost only (Nginx connects here)
bind = "127.0.0.1:5000"
# Trust headers from these IPs only
forwarded_allow_ips = "127.0.0.1"
# Worker configuration
workers = 4
worker_class = "sync"
Run the application:
$ gunicorn -c gunicorn.conf.py app:app
Security note: Setting forwarded_allow_ips = "127.0.0.1" ensures Gunicorn trusts only the local Nginx proxy. Avoid "*" in production unless you have additional authentication — it opens risks of IP spoofing.
Alternative environment variable approach:
$ export FORWARDED_ALLOW_IPS="127.0.0.1"
$ gunicorn app:app
This achieves the same result without a separate configuration file.
Step 4: Docker Compose (Full Stack)
When using Docker, networking works differently since Flask and Nginx run in separate containers. We’ll configure both services and use a shared network for communication.
docker-compose.yml:
version: '3.8'
services:
nginx:
image: nginx:alpine
ports:
- "80:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- flask
networks:
- app-network
flask:
build: .
# Bind to all interfaces inside container
command: gunicorn -b 0.0.0.0:5000 -c gunicorn.conf.py app:app
environment:
# Trust requests from Docker's internal gateway
- FORWARDED_ALLOW_IPS=172.17.0.1
networks:
- app-network
networks:
app-network:
driver: bridge
Docker-specific considerations:
- The
FORWARDED_ALLOW_IPSenvironment variable tells Gunicorn to trust requests from Docker’s default gateway (172.17.0.1) - Nginx must be configured to pass
X-Forwarded-Forheaders to the Flask container - Use internal Docker networking (bridge network) rather than
localhostfor service-to-service communication
Run the stack:
$ docker-compose up --build
Step 5: Verify IP Logging
Before production deployment, let’s verify that IP logging works correctly.
Test 1: Simulate external request
# This simulates a request coming through Nginx
$ curl -H "X-Forwarded-For: 203.0.113.42" http://localhost/
# Check Flask logs
$ journalctl -u gunicorn -f
# Output: GDPR log: User 203.0.113.42 accessed /
Test 2: Verify Nginx logs
# Tail Nginx access log to see real client IP
$ tail -f /var/log/nginx/access.log
# Output: 203.0.113.42 - - [16/Mar/2026:10:30:45 +0000] "GET / HTTP/1.1" 200 23 "-" "curl/7.68.0"
Test 3: Simulate proxy chain
# Test with multiple IPs (proxy chain)
$ curl -H "X-Forwarded-For: 203.0.113.42, 10.0.0.1, 192.168.1.1" http://localhost/
# Flask should log the first (original client) IP
# Output: GDPR log: User 203.0.113.42 accessed /
Test 4: Browser verification
- Open Chrome DevTools (F12)
- Go to Network tab
- Make a request to your application
- Inspect request headers
- Look for
X-Forwarded-Forheader presence
Test 5: Python test script
import requests
# Test from your local machine
response = requests.get('http://yourdomain.com/')
print(f"Status: {response.status_code}")
# Check server logs for your actual IP
Expected results:
- Nginx logs show real client IPs
- Flask logs show real client IPs (not 127.0.0.1)
X-Forwarded-Forheader is present in requests
Common Pitfalls & Fixes
Even with the correct configuration, several issues can prevent proper IP logging. Here are the most common problems and their solutions.
Issue 1: Flask still logs 127.0.0.1
Symptoms: All requests show 127.0.0.1 in Flask logs despite Nginx configuration.
Causes and fixes:
| Cause | How to diagnose | Fix |
|---|---|---|
| Nginx config not reloaded | Check Nginx error logs | sudo nginx -t && sudo systemctl reload nginx |
| Wrong Flask config | Check if ProxyFix is applied | Ensure app.wsgi_app = ProxyFix(...) is called |
| Gunicorn not trusting proxy | Check Gunicorn startup logs | Set forwarded_allow_ips = "127.0.0.1" |
Test command:
$ curl -H "X-Forwarded-For: 1.2.3.4" http://localhost/
$ journalctl -u gunicorn | grep "GDPR log"
# Should show: GDPR log: User 1.2.3.4 accessed /
Issue 2: IPv6 addresses logged as ::1
Symptoms: IPv6 requests show ::1 instead of real IPv6 address.
Fix: Add IPv6 trusted sources in Nginx:
# In your Nginx server block
set_real_ip_from 127.0.0.1;
set_real_ip_from ::1;
real_ip_header X-Forwarded-For;
Issue 3: Gunicorn ignores forwarded headers
Symptoms: Flask receives 127.0.0.1 even with correct Nginx config.
Causes:
- Gunicorn’s security feature rejects forwarded headers
forwarded_allow_ipsnot set or incorrect
Fix:
# In gunicorn.conf.py
forwarded_allow_ips = "127.0.0.1"
Security warning: Never use forwarded_allow_ips = "*" in production — it allows header spoofing.
Issue 4: Cloudflare/CDN proxying
Symptoms: IPs show Cloudflare’s IPs instead of real client IPs.
Fix: Use Cloudflare’s CF-Connecting-IP header:
proxy_set_header X-Real-IP $http_cf_connecting_ip;
proxy_set_header X-Forwarded-For $http_cf_connecting_ip;
Issue 5: Multiple proxies in chain
Symptoms: X-Forwarded-For contains multiple IPs, but Flask shows the wrong one.
Explanation: The X-Forwarded-For header format is: client, proxy1, proxy2
Fix: Flask’s ProxyFix with x_for=1 correctly extracts the first (original client) IP. For manual parsing:
def get_real_ip():
xff = request.headers.get('X-Forwarded-For', '')
if xff:
# Take the first IP in the chain (original client)
return xff.split(',')[0].strip()
return request.remote_addr
Issue 6: Docker networking problems
Symptoms: Connection refused or timeouts between containers.
Fix: Ensure both services are on the same Docker network:
networks:
app-network:
driver: bridge
And configure Nginx to use the container name:
proxy_pass http://flask:5000;
GDPR Logging Checklist
Use this checklist to verify your Flask+Nginx setup is GDPR-compliant:
- Nginx configuration:
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - Nginx config reloaded:
sudo nginx -t && sudo systemctl reload nginx - Flask ProxyFix applied:
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, ...) - Gunicorn trusted IPs set:
forwarded_allow_ips = "127.0.0.1"or env var - Logs include required fields: timestamp, IP, user-agent, endpoint, status
- Log retention policy: 30+ days per Art. 5(1)(e)
- Log access controls: Encrypted storage, role-based access per Art. 32
- Tested with real traffic: Verified logs show real client IPs
- Documented configuration: Team knows how to maintain setup
Audit command to verify only real IPs are logged:
$ grep "GDPR log" /var/log/gunicorn.log | awk '{print $NF}' | sort | uniq
# Should show real IPs, not 127.0.0.1 or 172.17.0.1
Note: This checklist focuses on technical IP logging. Full GDPR compliance also involves data retention (Art. 5(1)(e)), consent, and rights procedures — consult legal experts for those aspects. Alternatives like logging user agents or timestamps may also be needed depending on your use case.
Summary
Configuring Flask behind Nginx with proper X-Forwarded-For headers is essential for GDPR compliance and security auditing. The setup involves three coordinated changes: Nginx must forward the original client IP, Flask must trust these forwarded headers, and Gunicorn must allow requests from the proxy.
By following this guide, you’ll ensure your application logs accurate client IPs for compliance and security monitoring — without sacrificing performance or security.
Related articles:
- 36. Flask CSP - Security hardening for Flask applications
- 35. Flask vs FastAPI - Framework comparison for real-time applications
- 34. Flask Startup - Performance optimization techniques
Sponsored by Durable Programming
Need help maintaining or upgrading your Python application? Durable Programming specializes in keeping Python apps secure, performant, and up-to-date.
Hire Durable Programming