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.

Django via Cloudflare TunnelVisitorHTTPS to CloudflareCloudflare edgeTLS, WAF, cachecloudflaredoutbound connectionGunicorn127.0.0.1:8000The server opens no inbound web ports; cloudflared dials out and carries requests back in.
  1. Add your domain to Cloudflare and install cloudflared on the server.
  2. Create a tunnel (cloudflared tunnel create myapp) and route DNS to it (cloudflared tunnel route dns myapp example.com).
  3. Write /etc/cloudflared/config.yml with an ingress rule mapping example.com to http://127.0.0.1:8000 and a final http_status:404 rule.
  4. Install and start the service with cloudflared service install.
  5. Bind Gunicorn to 127.0.0.1 only, and close ports 80 and 443 in the firewall.
  6. Set SECURE_PROXY_SSL_HEADER and read the client IP from CF-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.

ingress rules map hostnames to Djangotunnel: <TUNNEL-UUID>credentials-file: /etc/cloudflared/<TUNNEL-UUID>.jsoningress:- hostname: example.comservice:http://127.0.0.1:8000- service:http_status:4041231The tunnel secret: readable by the cloudflared user only.2Plain HTTP on localhost; TLS is Cloudflare’s job.3Required catch-all for any other hostname.Rules match top to bottom; the last one must match everythingdjango-deployment.com
Validate with cloudflared tunnel ingress validate before restarting the service.

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
no inbound ports at allOpen ports80 and 443 open to the internet✕origin IP can be found and attackedcertificates managed on the serverCloudflare Tunnel✓cloudflared connects outbound✓firewall can deny all inbound✓TLS ends at CloudflareGunicorn listens on 127.0.0.1 only; cloudflared is its only clientdjango-deployment.com
Keep SSH reachable another way, such as a VPN or Cloudflare Access for SSH.

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.

public site, protected adminAccess policyWho gets through/noneeveryone/admin/✓Access applicationstaff emails, then Django login/api/webhooks/!service token or bypassthe sending service onlyAccess adds a login in front of Django; it does not replace Django authdjango-deployment.com
Moving the admin to a less obvious path still helps against untargeted scans.

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

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.

Keep going