Overview

A Cloudflare Tunnel connects a server behind NAT or a firewall to the internet without opening inbound ports. The cloudflared daemon opens outbound connections to Cloudflare’s edge; traffic flows through those connections. Use a tunnel when you want a private VPS or home server reachable over HTTPS with Cloudflare’s cloudflare-security-headers applied, without exposing SSH or a public IP.

Prerequisites

  • A Cloudflare account with the target domain added and nameservers pointed to Cloudflare. See cloudflare-dns.
  • A server running the origin service (a web app, a reverse proxy, etc.) that listens on a local port.
  • sudo access on the origin server to install cloudflared and register a systemd service.

Steps

1. Install cloudflared

Ubuntu / Debian

curl -L https://pkg.cloudflare.com/cloudflare-main.gpg | \
  sudo gpg --dearmor -o /usr/share/keyrings/cloudflare-main.gpg
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] \
  https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" | \
  sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install -y cloudflared
cloudflared --version

macOS

brew install cloudflared

2. Authenticate with Cloudflare

cloudflared tunnel login

A browser opens. Select the zone (domain) this tunnel will use. cloudflared writes a certificate to ~/.cloudflared/cert.pem.

3. Create a named tunnel

cloudflared tunnel create my-tunnel
# Tunnel credentials written to /root/.cloudflared/<UUID>.json

Record the UUID printed in the output. The credentials file is the tunnel’s identity; back it up.

4. Create the tunnel config

Write /etc/cloudflared/config.yml (or ~/.cloudflared/config.yml for a user-level tunnel):

tunnel: <UUID>
credentials-file: /root/.cloudflared/<UUID>.json
 
ingress:
  - hostname: app.example.com
    service: http://localhost:3000
  - service: http_status:404

The last service: http_status:404 is a required catch-all. Requests that do not match any hostname rule return a 404. Add more hostname entries for additional services on the same server.

5. Route the hostname through DNS

cloudflared tunnel route dns my-tunnel app.example.com

This creates a CNAME record pointing app.example.com to <UUID>.cfargotunnel.com. The record is automatically proxied through Cloudflare (orange cloud). Verify it appeared:

cloudflared tunnel info my-tunnel

6. Run as a systemd service

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

cloudflared service install writes a systemd unit that reads /etc/cloudflared/config.yml. The service starts on boot and restarts on failure.

For user-level installs (non-root), use cloudflared service install --user.

Verify it worked

# 1. The tunnel is listed as HEALTHY in the Cloudflare dashboard
#    under Zero Trust > Networks > Tunnels.
 
# 2. The hostname resolves through the tunnel.
curl -sI https://app.example.com | head -3
# HTTP/2 200
 
# 3. The service restarts cleanly.
sudo systemctl restart cloudflared
curl -sI https://app.example.com | head -1
# HTTP/2 200

Common errors

  • tunnel login never opens a browser. Run on a headless server? Copy the URL from stdout and open it on a different machine, then authenticate. The cert writes back to the server.
  • ERR_TUNNEL_CONNECTION_FAILED in browser. The origin service is not listening on the port in config.yml. Verify with ss -tlnp | grep 3000.
  • Changes to config.yml do not take effect. The service caches the config. Run sudo systemctl restart cloudflared.
  • Multiple tunnels and wrong hostname served. Each tunnel has its own UUID and credentials file. Confirm the UUID in config.yml matches the tunnel created in step 3.
  • TLS handshake errors on the origin. By default cloudflared connects to the origin over plain HTTP. If the origin requires HTTPS, set service: https://localhost:443 and add originServerName: app.example.com under the ingress rule.