Deploy Django Behind Cloudflare
Put Django behind Cloudflare correctly: Full (strict) TLS, origin lockdown, real client IPs, HTTPS detection, cache rules, and request limits.
Problem statement
Cloudflare in front of a Django site adds a CDN, DDoS protection, and TLS at the edge. Turning on the orange cloud in DNS takes a minute. Doing it without adjusting the origin causes a predictable set of problems:
ERR_TOO_MANY_REDIRECTSbecause Cloudflare talks HTTP to the origin while Django redirects to HTTPS- every request appears to come from a Cloudflare IP, breaking rate limiting, logging, and abuse detection
- attackers bypass Cloudflare entirely by connecting to the origin IP
- large uploads fail with
413and long requests fail with error524 - cached HTML leaks one user's page to another after an overly broad cache rule
This guide configures Cloudflare and a Nginx plus Gunicorn origin so that TLS is end-to-end, client IPs are correct, the origin only accepts Cloudflare traffic, and caching is limited to what is safe.
Quick answer
- Proxy the DNS records (orange cloud) and set SSL/TLS mode to Full (strict).
- Install a valid certificate on the origin: a Cloudflare Origin CA certificate or Let's Encrypt.
- Restrict origin ports 80 and 443 to Cloudflare's published IP ranges, or use Cloudflare Tunnel so the origin has no open ports.
- In Nginx, use
set_real_ip_fromfor Cloudflare ranges andreal_ip_header CF-Connecting-IP. - In Django, set
SECURE_PROXY_SSL_HEADERbased on theX-Forwarded-Protoheader Nginx passes. - Cache static assets at the edge; do not cache HTML for logged-in users.
- Account for upload size limits (100 MB on Free and Pro plans) and the 100-second proxy read timeout.
Step-by-step solution
1. DNS and SSL mode
In the Cloudflare dashboard for the zone:
- Set the
A/AAAArecords forexample.comandwwwto your server IP with proxy status Proxied. - Go to SSL/TLS → Overview and select Full (strict).
The modes differ in how Cloudflare connects to your origin:
| Mode | Browser to Cloudflare | Cloudflare to origin |
|---|---|---|
| Flexible | HTTPS | HTTP |
| Full | HTTPS | HTTPS, certificate not validated |
| Full (strict) | HTTPS | HTTPS, valid certificate required |
Flexible mode is the usual cause of redirect loops: Django sees plain HTTP, redirects to HTTPS, and Cloudflare fetches the origin over HTTP again. Full (strict) avoids the loop and encrypts the whole path.
2. Origin certificate
Two good options:
- Cloudflare Origin CA certificate: generate it under SSL/TLS → Origin Server. It is trusted by Cloudflare only (browsers do not trust it), valid for up to 15 years, and works well when all traffic goes through Cloudflare.
- Let's Encrypt: use the DNS-01 challenge (for example with the Certbot Cloudflare DNS plugin) so validation works regardless of proxying. Browsers trust it, which helps if you ever disable the proxy.
Nginx server block with the origin certificate:
server {
listen 443 ssl;
http2 on;
server_name example.com www.example.com;
ssl_certificate /etc/ssl/cloudflare/origin.pem;
ssl_certificate_key /etc/ssl/cloudflare/origin.key;
location /static/ {
alias /srv/myapp/static/;
expires 1y;
add_header Cache-Control "public, immutable";
}
location / {
proxy_pass http://unix:/run/myapp/gunicorn.sock;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 80;
server_name example.com www.example.com;
return 301 https://$host$request_uri;
}
The one-year immutable cache on /static/ is only safe with hashed filenames from ManifestStaticFilesStorage or WhiteNoise's compressed manifest storage.
3. Restore the real client IP
Nginx sees Cloudflare's edge IPs as $remote_addr. Cloudflare sends the visitor's IP in CF-Connecting-IP. Use the realip module, trusting that header only from Cloudflare's ranges:
/etc/nginx/conf.d/cloudflare-realip.conf:
# IPv4 ranges from https://www.cloudflare.com/ips-v4
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
# ... add every range from the published lists, IPv4 and IPv6 ...
real_ip_header CF-Connecting-IP;
Generate the file from the published lists rather than copying ranges by hand, and refresh it periodically:
{
for ip in $(curl -s https://www.cloudflare.com/ips-v4) $(curl -s https://www.cloudflare.com/ips-v6); do
echo "set_real_ip_from $ip;"
done
echo "real_ip_header CF-Connecting-IP;"
} | sudo tee /etc/nginx/conf.d/cloudflare-realip.conf >/dev/null
sudo nginx -t && sudo systemctl reload nginx
After this, $remote_addr is the visitor IP, so access logs, Nginx rate limits, and the X-Forwarded-For $remote_addr header passed to Django all carry the real client address. Setting X-Forwarded-For to $remote_addr (instead of appending with $proxy_add_x_forwarded_for) prevents clients from injecting fake earlier entries.
4. Lock down the origin
If the origin accepts connections from anyone, attackers who discover its IP bypass Cloudflare's DDoS protection, WAF rules, and rate limits entirely. Allow web ports only from Cloudflare:
sudo ufw default deny incoming
sudo ufw allow from 203.0.113.10 to any port 22 proto tcp # your admin IP
for ip in $(curl -s https://www.cloudflare.com/ips-v4) $(curl -s https://www.cloudflare.com/ips-v6); do
sudo ufw allow from "$ip" to any port 443 proto tcp
sudo ufw allow from "$ip" to any port 80 proto tcp
done
sudo ufw enable
Stronger alternatives:
- Authenticated Origin Pulls: Cloudflare presents a client certificate, and Nginx rejects connections without it (
ssl_client_certificateplusssl_verify_client on). - Cloudflare Tunnel:
cloudflaredon the server makes an outbound connection to Cloudflare, so the origin needs no inbound web ports at all.
Also make sure the origin IP is not exposed through other DNS records (for example an unproxied mail or ftp record on the same server).
5. Django settings
ALLOWED_HOSTS = ["example.com", "www.example.com"]
CSRF_TRUSTED_ORIGINS = ["https://example.com", "https://www.example.com"]
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
Django reads X-Forwarded-Proto from Nginx, which sets it from its own connection scheme. With Full (strict), that is https for every proxied request. Because Nginx overwrites the header, a client cannot fake it.
For client IPs in Django (logging, throttling), read REMOTE_ADDR if Gunicorn is configured to trust the proxy, or X-Forwarded-For as set by Nginx. Do not read CF-Connecting-IP in Django unless the origin is locked to Cloudflare.
6. Caching rules
By default, Cloudflare caches responses for common static file extensions and does not cache HTML. Keep it that way for dynamic Django pages:
- Static assets: allow edge caching and respect origin
Cache-Controlheaders. /admin/,/accounts/, and any authenticated area: create a cache rule that bypasses cache.- Public HTML pages: only cache if they are identical for every visitor and you have a purge process on deploy. Django pages that include CSRF tokens or user-specific content must not be cached at the edge.
Disable features that rewrite HTML or JavaScript, such as Rocket Loader, if the admin or your frontend behaves oddly behind Cloudflare.
7. Limits to plan for
- Request body size: Cloudflare's maximum upload size depends on plan (100 MB on Free and Pro). Larger uploads need direct-to-storage uploads with presigned URLs.
- Timeouts: Cloudflare returns error
524if the origin does not start responding within 100 seconds by default. Move long operations to background tasks. - WebSockets: supported through the proxy; long idle connections may be closed, so send periodic pings.
8. Verify
# Response comes through Cloudflare
curl -sI https://example.com/ | grep -i -E "^server|cf-ray|cf-cache-status"
# Static files are cached at the edge (second request should show HIT)
curl -sI https://example.com/static/css/app.3f2a1b.css | grep -i cf-cache-status
# Origin rejects direct connections from non-Cloudflare networks
curl -skI --resolve example.com:443:ORIGIN_IP https://example.com/ --max-time 5
# Nginx logs show real visitor IPs
sudo tail -n 5 /var/log/nginx/access.log
Verification check:
- responses include a
cf-rayheader - static assets show
cf-cache-status: HITafter the first request; HTML showsDYNAMICorBYPASS - the direct origin request times out or is refused from an outside network
- access logs contain visitor IPs, not Cloudflare ranges
- no redirect loop on
http://orhttps://
Explanation
Cloudflare becomes another proxy hop between the browser and Django. Each hop changes the connection's scheme and source IP, so the origin must be told which hop to trust for which information: Nginx trusts Cloudflare for the client IP, Django trusts Nginx for the scheme. Locking the origin to Cloudflare is what makes those trust decisions safe; without it, the headers can be forged by connecting directly.
Edge cases / notes
Cloudflare IP ranges change
The published ranges change rarely, but they do change. Regenerate the realip and firewall rules on a schedule, and alert if the downloaded list is empty.
Health checks and monitoring
External uptime monitors hit Cloudflare, not the origin. Monitor the origin separately through an allowed path, such as a tunnel or a private network check.
Email and other non-HTTP services
Only HTTP(S) is proxied on standard plans. Records for mail or SSH must be DNS-only, which exposes their IPs; host them on different IPs than the web origin where possible.
Troubleshooting Cloudflare error codes
Cloudflare's own error pages indicate which side of the proxy failed:
| Error | Meaning | Check on the origin |
|---|---|---|
520 | origin returned an empty or invalid response | Gunicorn crash, Nginx 444, response headers too large |
521 | origin refused the connection | Nginx down, firewall blocking Cloudflare ranges |
522 | connection timed out | firewall dropping packets, server overloaded, wrong origin IP |
523 | origin unreachable | DNS record points to an invalid or unroutable IP |
524 | origin took too long to respond | slow view exceeding the proxy read timeout |
525 | TLS handshake failed | no certificate for the hostname on port 443, protocol mismatch |
526 | invalid origin certificate in Full (strict) | expired, self-signed, or wrong hostname certificate |
For 521 and 522, check whether Cloudflare's ranges are allowed after any firewall change, and whether Nginx is listening:
sudo ss -ltnp | grep -E ':443|:80'
sudo ufw status numbered | head -20
sudo tail -n 50 /var/log/nginx/error.log
For 526, confirm the certificate Nginx presents:
openssl s_client -connect ORIGIN_IP:443 -servername example.com </dev/null 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
Development mode and cache purges
When debugging caching issues, Development Mode temporarily bypasses the Cloudflare cache for the zone. It turns itself off after a few hours. For deploys, purge only the URLs that changed (for example unhashed files like /favicon.ico) rather than everything; hashed static files do not need purging.
Bots and security features interacting with Django
- Bot protection challenges can block legitimate API clients, webhooks, and uptime monitors. Create rules that skip challenges for paths like
/api/and/webhooks/, and rely on authentication and signature checks there. - WAF managed rules occasionally block valid form submissions containing code snippets or SQL-like text. Review WAF events for false positives on specific paths rather than disabling the WAF globally.
- Email address obfuscation rewrites email addresses in HTML and injects a script; disable it if it interferes with your templates or Content Security Policy.
Common mistakes
- Setting SSL mode to Flexible to "fix" a certificate error, which creates redirect loops and removes encryption to the origin.
- Trusting
CF-Connecting-IPin Django without locking the origin to Cloudflare. - Caching everything with a broad cache rule, leaking pages rendered for logged-in users.
- Leaving the origin IP in historical DNS records or mail headers, which lets attackers find and target it directly.
Next steps
- If you see redirect loops, follow Fix Too Many Redirects with Django HTTPS Behind a Proxy.
- Get client IPs right end to end with Fix Wrong Client IP Addresses in Django Behind a Proxy.
- Make static assets safe to cache forever with Long-Lived Caching for Django Static Files.
FAQ
Which Cloudflare SSL mode should I use for Django?
Full (strict). It requires HTTPS with a valid certificate between Cloudflare and the origin, which encrypts the whole path and avoids the redirect loops that Flexible mode causes.
How do I get the real visitor IP in Django behind Cloudflare?
Configure Nginx's realip module with Cloudflare's IP ranges and real_ip_header CF-Connecting-IP, then pass $remote_addr to Django in X-Forwarded-For. Lock the origin to Cloudflare so the header cannot be spoofed.
Should Cloudflare cache Django HTML pages?
Usually not. Pages with sessions, CSRF tokens, or user-specific content must never be cached at the edge. Cache static assets and only cache HTML that is identical for every visitor.
Why do large uploads fail behind Cloudflare?
Cloudflare limits request body size by plan, with 100 MB on Free and Pro. Upload large files directly to object storage with presigned URLs instead of through Django.