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_REDIRECTS because 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 413 and long requests fail with error 524
  • 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.

Django behind CloudflareVisitorHTTPS requestCloudflare edgecache, WAF, TLSOrigin NginxFull (strict) TLSDjangoreal client IPLock the origin to Cloudflare so forwarded headers can be trusted.

Quick answer

  1. Proxy the DNS records (orange cloud) and set SSL/TLS mode to Full (strict).
  2. Install a valid certificate on the origin: a Cloudflare Origin CA certificate or Let's Encrypt.
  3. Restrict origin ports 80 and 443 to Cloudflare's published IP ranges, or use Cloudflare Tunnel so the origin has no open ports.
  4. In Nginx, use set_real_ip_from for Cloudflare ranges and real_ip_header CF-Connecting-IP.
  5. In Django, set SECURE_PROXY_SSL_HEADER based on the X-Forwarded-Proto header Nginx passes.
  6. Cache static assets at the edge; do not cache HTML for logged-in users.
  7. 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:

  1. Set the A/AAAA records for example.com and www to your server IP with proxy status Proxied.
  2. Go to SSL/TLS → Overview and select Full (strict).
only Full (strict) protects both hopsVisitor to CloudflareCloudflare to originOrigin cert checkedFlexible✓HTTPS✕plain HTTP✕noFull✓HTTPS✓HTTPS!noFull (strict)✓HTTPS✓HTTPS✓yesFlexible plus SECURE_SSL_REDIRECT is the classic infinite redirectdjango-deployment.com
A Cloudflare Origin CA certificate is enough for Full (strict) and lasts up to 15 years.

The modes differ in how Cloudflare connects to your origin:

ModeBrowser to CloudflareCloudflare to origin
FlexibleHTTPSHTTP
FullHTTPSHTTPS, certificate not validated
Full (strict)HTTPSHTTPS, 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;
trust the header only from Cloudflareset_real_ip_from173.245.48.0/20;set_real_ip_from 103.21.244.0/22;# …every published Cloudflare rangereal_ip_headerCF-Connecting-IP;1231Only these peers may set the client address.2Ranges change; regenerate the list on a schedule.3Becomes $remote_addr, so logs and X-Real-IP carry the visitor.Without it, every request appears to come from a Cloudflare addressdjango-deployment.com
Rate limiting and admin allowlists are useless until this is in place.

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_certificate plus ssl_verify_client on).
  • Cloudflare Tunnel: cloudflared on 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-Control headers.
  • /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.
cache the files, not the pagesCache at the edge✓/static/ with hashed file names✓public media with long max-agepages only with explicit rulesAlways bypass✕/admin/ and /accounts/✕responses that set cookies✕API and form POST endpointsCaching a logged-in page can show one user’s data to anotherdjango-deployment.com
Default Cloudflare behaviour already skips HTML; the danger is a broad Cache Everything rule.

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 524 if 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-ray header
  • static assets show cf-cache-status: HIT after the first request; HTML shows DYNAMIC or BYPASS
  • 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:// or https://

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:

ErrorMeaningCheck on the origin
520origin returned an empty or invalid responseGunicorn crash, Nginx 444, response headers too large
521origin refused the connectionNginx down, firewall blocking Cloudflare ranges
522connection timed outfirewall dropping packets, server overloaded, wrong origin IP
523origin unreachableDNS record points to an invalid or unroutable IP
524origin took too long to respondslow view exceeding the proxy read timeout
525TLS handshake failedno certificate for the hostname on port 443, protocol mismatch
526invalid origin certificate in Full (strict)expired, self-signed, or wrong hostname certificate
each code marks where the request stoppedDNS and origin IP523: origin address unreachableTCP connection521 refused, 522 timed outTLS handshake525 failed, 526 invalidcertificateHTTP response520 invalid reply, 524 too slowRead from the top: a fault in one layer hides everything below itdjango-deployment.com
Codes in the top two layers mean Django was never reached at all.

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-IP in 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

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.

Keep going