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.
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â
# 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-Protowith the scheme the visitor came in on. With Always Use HTTPS enabled this is alwayshttps; - Cloudflare appends
X-Forwarded-Forrather 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; cloudflaredpasses the request to theoriginRequestservice as is and cannot forceX-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.
# 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â
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:
# 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,
.envprovides theCLOUDFLARE_ZERO_TRUST_CLIENT_IDandCLOUDFLARE_ZERO_TRUST_CLIENT_SECRETvariables; - 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-IPheader with the client address; the recommended source of truth is exactly that header, notX-Forwarded-For. If nginx stands in front of the panel, trust only the Cloudflare ranges. The full/etc/nginx/conf.d/real-ip.conffile looks like this:
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 like3010stay 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.