Skip to main content

Cloudflare Tunnel

Cloudflare Tunnel (the cloudflared daemon) creates an outbound connection to the Cloudflare network and publishes your service without a public IP and without open ports 80 and 443. The host needs neither a reverse proxy with a certificate nor a public address: traffic arrives from Cloudflare over the already established tunnel, and TLS terminates on the Cloudflare side.

1. When a tunnel fits​

The tunnel fits if:

  • the server has no static public IP or the provider blocks inbound ports;
  • the origin address has to be hidden from scanning;
  • you already use Cloudflare for DNS and attack filtering.

The tunnel does not fit if:

  • the panel must work without depending on a third-party service;
  • you are not ready for Cloudflare to see all traffic in the clear;
  • a stable SSH terminal to nodes is needed: the tunnel drops idle WebSocket connections noticeably more aggressively than a direct proxy.

The traffic scheme looks like this: the browser connects to Cloudflare over HTTPS, the Cloudflare edge passes the request over the already open tunnel to the host, and cloudflared serves it to the local service over HTTP. The host needs neither a certificate nor an open port — only the daemon's outbound connection faces the outside.

danger

A Quick Tunnel (an address like *.trycloudflare.com) is suitable only for testing: it has a concurrent connection limit, a random domain, and no availability guarantees. For production, use a named tunnel and your own domain.

2. Installing cloudflared​

The daemon does not require inbound ports: it establishes outbound connections to the nearest Cloudflare node itself. In the firewall it is enough to allow outbound connections to port 7844 (UDP for the quic protocol, TCP for http2); port 443 is needed only for update checks. If your provider blocks UDP, add the --protocol http2 flag to the start command.

mkdir -p /opt/xlada/cloudflared && cd /opt/xlada/cloudflared

curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg \
-o /usr/share/keyrings/cloudflare-main.gpg

echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] \
https://pkg.cloudflare.com/cloudflared any main" \
> /etc/apt/sources.list.d/cloudflared.list

apt update && apt install -y cloudflared
cloudflared --version

3. Authorization and tunnel creation​

# A link will open: confirm access to the domain in the browser
cloudflared tunnel login

# Create a named tunnel. The JSON credentials file will appear in ~/.cloudflared/
cloudflared tunnel create xlada

# Look at the UUID — it will be needed in config.yml
cloudflared tunnel list

4. DNS records​

cloudflared tunnel route dns xlada panel.example.com
cloudflared tunnel route dns xlada sub.example.com

The commands create CNAME records for the tunnel address; nothing has to be added manually. Proxying (the orange cloud) is enabled automatically. Set the SSL/TLS mode to Full or Full (strict) and enable Always Use HTTPS so that a visitor cannot reach the panel over http.

5. Tunnel configuration​

/opt/xlada/cloudflared/config.yml
# UUID from the output of `cloudflared tunnel list`
tunnel: 00000000-0000-0000-0000-000000000000

# JSON credentials file created by the cloudflared tunnel create command
credentials-file: /root/.cloudflared/00000000-0000-0000-0000-000000000000.json

# Logs and the local tunnel metrics page
loglevel: info
logfile: /var/log/cloudflared.log
metrics: 127.0.0.1:49312

ingress:
# Subscription page: traffic goes through the local proxy on 8081
- hostname: sub.example.com
service: http://127.0.0.1:8081
originRequest:
# Keep the original Host, otherwise the app will see 127.0.0.1
httpHostHeader: sub.example.com

# Panel: local proxy on 8080 from section 6
- hostname: panel.example.com
service: http://127.0.0.1:8080
originRequest:
httpHostHeader: panel.example.com
connectTimeout: 30s

# Mandatory catch-all rule: everything that did not match
- service: http_status:404

Both services point to the local proxy rather than directly to ports 3000 and 3010. This way the headers are always formed by the same node, and the panel gets predictable values regardless of how cloudflared behaves.

Validate the configuration before starting — the command will point out typos in the rules:

cloudflared tunnel ingress validate
cloudflared tunnel ingress rule https://panel.example.com/

6. Headers and the local proxy​

The panel and the subscription page drop the connection if a request arrives without X-Forwarded-For and X-Forwarded-Proto: https. Understand how these headers behave in the tunnel:

  • Cloudflare accepts the request and overwrites X-Forwarded-Proto with the scheme the visitor came in on. With Always Use HTTPS enabled this is always https;
  • Cloudflare appends X-Forwarded-For rather than replacing it: if the client sent its own header, the values it supplied come first in the chain, and the real address comes last. The leftmost value cannot be relied upon;
  • cloudflared passes the request to the originRequest service as is and cannot force X-Forwarded-Proto: https. If the visitor came over http, the panel will reject such a request.

That is why the reliable option is to put a small proxy in front of the panel that sets the headers itself. Below is the full config of both local origin servers: the panel on 127.0.0.1:8080 and the subscription page on 127.0.0.1:8081.

/etc/nginx/sites-available/xlada-origin
# Local origin servers for Cloudflare Tunnel.

map $http_upgrade $cf_connection_upgrade {
default upgrade;
'' close;
}

gzip on;
gzip_vary on;
gzip_proxied any;
gzip_comp_level 6;
gzip_min_length 256;
gzip_types
application/javascript
application/json
application/manifest+json
image/svg+xml
text/css
text/javascript
text/plain
text/xml;

# ─── XLADA panel: cloudflared serves panel.example.com here ──────────
server {
listen 127.0.0.1:8080;
server_name panel.example.com;

client_max_body_size 32m;

# Panel build assets: the hash in the file name allows caching them
location ^~ /assets/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Connection "";

expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}

location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;

# WebSocket — SSH terminal to nodes
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $cf_connection_upgrade;

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;

# Only the real client address, without an inserted chain
proxy_set_header X-Forwarded-For $remote_addr;

# The key line: the panel expects https
proxy_set_header X-Forwarded-Proto https;

proxy_connect_timeout 10s;
proxy_send_timeout 86400s;
proxy_read_timeout 86400s;
}
}

# ─── Subscription page: cloudflared serves sub.example.com here ──────
server {
listen 127.0.0.1:8081;
server_name sub.example.com;

client_max_body_size 10m;

location ^~ /assets/ {
proxy_pass http://127.0.0.1:3010;
proxy_http_version 1.1;

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header Connection "";

expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}

location / {
proxy_pass http://127.0.0.1:3010;
proxy_http_version 1.1;

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto https;

# WebSocket: not used right now, but the config is ready for it
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $cf_connection_upgrade;

proxy_connect_timeout 10s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;
}
}
ln -sf /etc/nginx/sites-available/xlada-origin /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

Both server names match httpHostHeader in the config.yml rules, so the panel and the subscription page receive the original domain rather than 127.0.0.1.

7. Running as a container​

/opt/xlada/cloudflared/docker-compose.yml
services:
cloudflared:
image: cloudflare/cloudflared:latest
container_name: cloudflared
restart: unless-stopped
# The tunnel goes outbound, inbound ports are not needed
network_mode: host
command: tunnel --no-autoupdate --config /etc/cloudflared/config.yml run
volumes:
- ./config.yml:/etc/cloudflared/config.yml:ro
- ./credentials.json:/etc/cloudflared/credentials.json:ro
# Copy the JSON credentials file from ~/.cloudflared/ next to the compose file
cp /root/.cloudflared/00000000-0000-0000-0000-000000000000.json \
/opt/xlada/cloudflared/credentials.json

docker compose up -d && docker compose logs -f

In the container, the config.yml file differs only in the path to the tunnel credentials. The full file for this method:

/opt/xlada/cloudflared/config.yml
# UUID from the output of `cloudflared tunnel list`
tunnel: 00000000-0000-0000-0000-000000000000

# Tunnel credentials live next to docker-compose.yml
credentials-file: /etc/cloudflared/credentials.json

# Logs and the local tunnel metrics page
loglevel: info
logfile: /var/log/cloudflared.log
metrics: 127.0.0.1:49312

ingress:
# Subscription page: local proxy on 8081
- hostname: sub.example.com
service: http://127.0.0.1:8081
originRequest:
httpHostHeader: sub.example.com

# Panel: local proxy on 8080
- hostname: panel.example.com
service: http://127.0.0.1:8080
originRequest:
httpHostHeader: panel.example.com
connectTimeout: 30s

# Mandatory catch-all rule: everything that did not match
- service: http_status:404

8. Running as a service​

mkdir -p /etc/cloudflared
cp config.yml /etc/cloudflared/config.yml
cp /root/.cloudflared/*.json /etc/cloudflared/

cloudflared service install
systemctl enable --now cloudflared
systemctl status cloudflared

9. Additional panel protection​

The panel's public address is visible to everyone, so a single password is not enough. Two ways to close the login:

  • Cloudflare Access — a Zero Trust policy in front of the application: the panel opens only after logging in to your identity provider. The subscription page can be left public or protected separately; for it, .env provides the CLOUDFLARE_ZERO_TRUST_CLIENT_ID and CLOUDFLARE_ZERO_TRUST_CLIENT_SECRET variables;
  • A WAF rule with a country or address list restriction, if administrators work from known networks.

Both mechanisms complement rather than replace the panel's own settings: enable two-factor authentication and do not disable the login attempt limit.

10. Limitations​

  • Real client IP. Cloudflare adds the CF-Connecting-IP header with the client address; the recommended source of truth is exactly that header, not X-Forwarded-For. If nginx stands in front of the panel, trust only the Cloudflare ranges. The full /etc/nginx/conf.d/real-ip.conf file looks like this:
/etc/nginx/conf.d/real-ip.conf
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;

real_ip_header CF-Connecting-IP;
real_ip_recursive on;

The list of Cloudflare ranges is updated periodically — check it against the current one.

  • WebSocket and the SSH terminal. WebSocket works through Cloudflare, but idle connections are dropped. For the terminal to nodes it is more reliable to connect to the panel directly and serve the subscription page through the tunnel.
  • Limits. The free plan has restrictions on the size of uploaded files and the number of concurrent connections; the values change periodically.
  • SSL/TLS. The Flexible mode will lead to a redirect loop and loss of headers. Use Full or Full (strict).
  • Ports. The tunnel exposes only the standard Cloudflare ports (443, 80, and some alternative ones). Internal addresses like 3010 stay local.

11. Verification​

cloudflared tunnel info xlada
cloudflared tunnel ingress validate

curl -sI https://panel.example.com | head -3
curl -s -o /dev/null -w '%{http_code}\n' https://sub.example.com/

journalctl -u cloudflared -n 30
tail -n 30 /var/log/cloudflared.log

The panel root must return the login page, not 502 and not an error about headers. On 502, check the service in config.yml and that the local proxy is listening on the required port.

What's next​