Expose Django with Cloudflare Tunnel
Serve a Django app through Cloudflare Tunnel with no open inbound ports: cloudflared setup, ingress rules, DNS routing, real client IPs, HTTPS settings, and protecting the admin.
Problem statement
A normal Django server has ports 80 and 443 open to the internet, a public IP that attackers can scan and hit directly, and TLS certificates to renew. Some deployments cannot or should not work that way: a server behind NAT, an office machine, a homelab, or a production host where the goal is to accept no inbound connections at all.
Cloudflare Tunnel solves this with an outbound-only connection. The cloudflared daemon on your server connects out to Cloudflare, and Cloudflare routes requests for your hostname through that connection to Django on localhost. This guide sets up a tunnel for Django and handles the parts that differ from a normal proxy setup: client IPs, HTTPS detection, and locking down the origin.
- Add your domain to Cloudflare and install
cloudflaredon the server. - Create a tunnel (
cloudflared tunnel create myapp) and route DNS to it (cloudflared tunnel route dns myapp example.com). - Write
/etc/cloudflared/config.ymlwith an ingress rule mappingexample.comtohttp://127.0.0.1:8000and a finalhttp_status:404rule. - Install and start the service with
cloudflared service install. - Bind Gunicorn to
127.0.0.1only, and close ports 80 and 443 in the firewall. - Set
SECURE_PROXY_SSL_HEADERand read the client IP fromCF-Connecting-IP.
Step-by-step solution
1. Install cloudflared
On Debian or Ubuntu, add Cloudflare's package repository as described in the cloudflared documentation, then install:
sudo apt update && sudo apt install cloudflared
cloudflared --version
Using the package repository keeps cloudflared updated with the rest of the system.
2. Create the tunnel
cloudflared tunnel login
cloudflared tunnel create myapp
cloudflared tunnel list
login opens a browser to authorise the domain and saves a certificate to ~/.cloudflared/cert.pem. create writes a credentials file named after the tunnel UUID. Treat that file as a secret: anyone with it can run your tunnel.
Route the hostname to the tunnel. This creates a proxied CNAME record:
cloudflared tunnel route dns myapp example.com
cloudflared tunnel route dns myapp www.example.com
You can also create and manage tunnels from the Cloudflare dashboard instead, in which case the server only needs a tunnel token and the ingress rules live in the dashboard.
3. Write the configuration
sudo mkdir -p /etc/cloudflared
sudo cp ~/.cloudflared/<TUNNEL-UUID>.json /etc/cloudflared/
# /etc/cloudflared/config.yml
tunnel: <TUNNEL-UUID>
credentials-file: /etc/cloudflared/<TUNNEL-UUID>.json
ingress:
- hostname: example.com
service: http://127.0.0.1:8000
- hostname: www.example.com
service: http://127.0.0.1:8000
- service: http_status:404
The last rule is a required catch-all. Rules are evaluated in order, so put specific hostnames and paths first.
Validate the rules:
cloudflared tunnel ingress validate --config /etc/cloudflared/config.yml
cloudflared tunnel ingress rule https://example.com/ --config /etc/cloudflared/config.yml
4. Run cloudflared as a service
sudo cloudflared service install
sudo systemctl status cloudflared --no-pager
The service reads /etc/cloudflared/config.yml. Check the tunnel's connections:
cloudflared tunnel info myapp
5. Serve Django on localhost only
Gunicorn should listen only on the loopback interface:
ExecStart=/srv/myapp/venv/bin/gunicorn config.wsgi:application --bind 127.0.0.1:8000 --workers 3
cloudflared can proxy straight to Gunicorn. Keep Nginx in between (tunnel to http://127.0.0.1:80) if you want it to serve static files, enforce upload limits, or rate limit; otherwise serve static files with WhiteNoise.
Close inbound web ports:
sudo ufw delete allow "Nginx Full" 2>/dev/null || true
sudo ufw default deny incoming
sudo ufw allow OpenSSH
sudo ufw enable
sudo ufw status
SSH can also move behind Cloudflare Access later, leaving no inbound ports at all.
6. Configure Django
Cloudflare terminates HTTPS at the edge and sets forwarding headers on requests that reach your origin through the tunnel:
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")
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
Enable Always Use HTTPS in Cloudflare's SSL/TLS settings so plain HTTP never reaches Django, and leave SECURE_SSL_REDIRECT off or on; with the edge redirect in place it has nothing left to redirect.
For client IPs, every request reaches Gunicorn from 127.0.0.1. Cloudflare provides the visitor's address in CF-Connecting-IP:
class CloudflareRealIPMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
ip = request.META.get("HTTP_CF_CONNECTING_IP")
if ip:
request.META["REMOTE_ADDR"] = ip
return self.get_response(request)
Trusting this header is safe here only because the origin is not reachable except through the tunnel. If Django is also reachable directly, anyone can forge it.
7. Protect the admin with Cloudflare Access
Create a self-hosted Access application for example.com/admin/ and require your identity provider or email one-time codes. Requests to the admin are then authenticated at Cloudflare before they reach the tunnel, in addition to Django's own login.
Verification check:
curl -sI https://example.com/ | grep -iE "^(HTTP|server|cf-ray)"
sudo ss -tlnp | grep -E ":80 |:443 |:8000 "
Expect a 200 with server: cloudflare and a cf-ray header, and Gunicorn listening only on 127.0.0.1:8000. From another machine, a direct request to the server's IP on port 80 or 443 should fail to connect.
Explanation
cloudflared keeps several long-lived outbound connections to nearby Cloudflare data centres. When a request for example.com arrives at Cloudflare, it is sent down one of those connections, and cloudflared forwards it to the local service in the matching ingress rule. Since the connection is initiated from inside, NAT and firewalls do not matter, and there is no public origin for attackers to bypass Cloudflare's protections.
Edge cases / notes
WebSockets and uploads
Tunnels carry WebSockets, so Django Channels works. Cloudflare's plan-dependent upload size limit applies to all requests, as it does for any proxied site.
High availability
Run cloudflared for the same tunnel on two servers; Cloudflare distributes requests across the connected replicas. Each server needs the credentials file and the same configuration.
Rollback
The tunnel is independent of releases. If the tunnel itself fails, pointing DNS back to a public IP requires reopening ports, so keep that procedure documented.
When to automate this
Tunnels, DNS routes, and Access applications can be managed with Cloudflare's Terraform provider, and the cloudflared config file belongs in your server provisioning. That keeps every environment's routing reviewable.
Next steps
- Compare with a classic proxied setup in Deploy Django Behind Cloudflare.
- Fix logging and rate limiting by IP with Fix Wrong Client IP Addresses in Django Behind a Proxy.
- Keep Gunicorn supervised with How to Configure systemd for Gunicorn and Django.
- Harden admin access with the Django Admin Security Checklist.
FAQ
Do I need an SSL certificate on the server with Cloudflare Tunnel?
No. Cloudflare terminates HTTPS at the edge and the tunnel connection is encrypted. The local hop from cloudflared to Gunicorn is plain HTTP on localhost.
Why does Django see every request coming from 127.0.0.1?
Requests arrive from the local cloudflared process. Use the CF-Connecting-IP header for the visitor's address, and only trust it when Django is unreachable except through the tunnel.
Why does the tunnel return a 502 error?
cloudflared could not reach the service in the ingress rule. Check that Gunicorn is running and listening on the exact address and port configured, and read journalctl -u cloudflared.
Can I use Cloudflare Tunnel for a production Django site?
Yes. It is widely used in production. Run at least two cloudflared replicas for redundancy and monitor the tunnel's health.