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.

  1. Go to Cloudflare One > Access > Applications
  2. Click Add an Application > Self-hosted
  3. Fill in:
    • Application name: Grafana Internal
    • Subdomain: grafana / Domain: example.com
  4. Under Policies, add a policy:
    • Policy name: Allow team
    • Action: Allow
    • Include rule: Emails ending in @yourcompany.com
    • Or: Specific email addresses
  5. 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.