Cloudflare Tunnels for Secure Server Access
Cloudflare Tunnels (powered by cloudflared) let you expose services running on your server to the internet without opening any inbound ports in your firewall, eliminating the attack surface for direct server access. This guide covers installing cloudflared, creating persistent tunnels, configuring ingress rules for multiple services, and securing access with Cloudflare Zero Trust policies.
Prerequisites
- A domain added to your Cloudflare account
- A Cloudflare account (Zero Trust free plan supports up to 50 users)
- A Linux server running the services you want to expose
- Sudo access on the server
Installing cloudflared
Ubuntu/Debian
# Add the Cloudflare repository
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | \
sudo tee /usr/share/keyrings/cloudflare-main.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] \
https://pkg.cloudflare.com/cloudflared jammy main" | \
sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install -y cloudflared
CentOS/Rocky Linux
sudo rpm -v --import https://pkg.cloudflare.com/cloudflare-main.gpg
sudo tee /etc/yum.repos.d/cloudflared.repo << 'EOF'
[cloudflared]
name=cloudflared
baseurl=https://pkg.cloudflare.com/cloudflared/rpm/
enabled=1
gpgcheck=1
gpgkey=https://pkg.cloudflare.com/cloudflare-main.gpg
EOF
sudo dnf install -y cloudflared
Direct Binary Download
# For x86_64
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64 \
-o /usr/local/bin/cloudflared
chmod +x /usr/local/bin/cloudflared
# Verify
cloudflared --version
Authenticating with Cloudflare
# Login and authorize cloudflared to manage your zone
cloudflared tunnel login
# This opens a browser URL — visit it on any machine
# After authorization, a certificate is saved to:
# ~/.cloudflared/cert.pem
For headless servers, copy the URL from the terminal, open it in a browser, and authorize the domain.
Creating a Tunnel
# Create a named tunnel
cloudflared tunnel create my-server-tunnel
# Output:
# Tunnel credentials written to /root/.cloudflared/TUNNEL_ID.json
# Created tunnel my-server-tunnel with id TUNNEL_ID
# List existing tunnels
cloudflared tunnel list
# The tunnel ID is used in DNS routing
TUNNEL_ID=$(cloudflared tunnel list | grep my-server-tunnel | awk '{print $1}')
Create a DNS CNAME record pointing to the tunnel:
# Route a subdomain to the tunnel
cloudflared tunnel route dns my-server-tunnel app.example.com
cloudflared tunnel route dns my-server-tunnel grafana.example.com
This creates a CNAME in Cloudflare DNS: app.example.com → TUNNEL_ID.cfargotunnel.com
Configuring Ingress Rules
Create the tunnel configuration file:
sudo mkdir -p /etc/cloudflared
sudo tee /etc/cloudflared/config.yml << 'EOF'
tunnel: TUNNEL_ID
credentials-file: /root/.cloudflared/TUNNEL_ID.json
ingress:
# Route app.example.com to local Nginx on port 80
- hostname: app.example.com
service: http://localhost:80
# Route grafana.example.com to Grafana on port 3000
- hostname: grafana.example.com
service: http://localhost:3000
originRequest:
noTLSVerify: false
# Route portainer.example.com to HTTPS service
- hostname: portainer.example.com
service: https://localhost:9443
originRequest:
noTLSVerify: true # For self-signed certs on localhost
# Route SSH tunnel (see SSH section below)
- hostname: ssh.example.com
service: ssh://localhost:22
# Catch-all rule — required at the end
- service: http_status:404
EOF
Test the configuration:
cloudflared tunnel ingress validate --config /etc/cloudflared/config.yml
# Run the tunnel in the foreground to test
cloudflared tunnel --config /etc/cloudflared/config.yml run my-server-tunnel
Running as a System Service
# Install as a systemd service
sudo cloudflared service install
# Or manually create the service unit
sudo cloudflared tunnel --config /etc/cloudflared/config.yml service install
# Enable and start the service
sudo systemctl enable cloudflared
sudo systemctl start cloudflared
# Check status
sudo systemctl status cloudflared
sudo journalctl -u cloudflared -f
Zero Trust Access Policies
Cloudflare Zero Trust (formerly Access) lets you require authentication before users can reach your tunneled services.
- Go to Cloudflare One > Access > Applications
- Click Add an Application > Self-hosted
- Fill in:
- Application name: Grafana Internal
- Subdomain:
grafana/ Domain:example.com
- Under Policies, add a policy:
- Policy name: Allow team
- Action: Allow
- Include rule: Emails ending in
@yourcompany.com - Or: Specific email addresses
- Identity providers: Enable Google, GitHub, or one-time PIN (OTP)
This adds an authentication wall in front of any tunneled service — no code changes needed.
Using the Cloudflare API to create an Access application:
curl -s -X POST "https://api.cloudflare.com/client/v4/accounts/ACCOUNT_ID/access/apps" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-H "Content-Type: application/json" \
--data '{
"name": "Grafana Internal",
"domain": "grafana.example.com",
"type": "self_hosted",
"session_duration": "24h"
}'
Tunneling SSH and Other Protocols
Access SSH through Cloudflare without opening port 22:
# On the server — ensure the tunnel config includes:
# - hostname: ssh.example.com
# service: ssh://localhost:22
# On the client — install cloudflared locally
brew install cloudflared # macOS
# or download from GitHub for Linux
# Add to ~/.ssh/config on the client machine:
cat >> ~/.ssh/config << 'EOF'
Host ssh.example.com
ProxyCommand cloudflared access ssh --hostname %h
User ubuntu
IdentityFile ~/.ssh/my-key
EOF
# Connect normally — cloudflared handles the tunnel
ssh ssh.example.com
For TCP tunneling (databases, RDP):
# On client — start a local listener
cloudflared access tcp --hostname db.example.com --url localhost:5432
# Then connect to localhost:5432 with your postgres client
psql -h localhost -p 5432 -U myuser mydb
Troubleshooting
Tunnel fails to start
# Check credentials file exists and is readable
ls -la /root/.cloudflared/*.json
# Validate config syntax
cloudflared tunnel ingress validate --config /etc/cloudflared/config.yml
# View detailed logs
journalctl -u cloudflared --since "10 minutes ago"
502 Bad Gateway when accessing tunneled service
# Verify the local service is running and accessible
curl -v http://localhost:3000
# Check the ingress hostname matches exactly
cloudflared tunnel info my-server-tunnel
DNS record not found
# Re-route DNS
cloudflared tunnel route dns my-server-tunnel app.example.com
# Verify in Cloudflare DNS dashboard that CNAME exists
Authentication loop with Zero Trust
# Clear browser cookies for the domain
# Verify email domain matches your Access policy
# Check the application session duration
Conclusion
Cloudflare Tunnels provide a powerful way to expose VPS services securely without opening firewall ports, while Zero Trust Access policies add authentication and identity verification in front of any application. This combination is ideal for managing internal tools like Grafana, Portainer, and Jupyter Notebook without exposing them to the public internet.


