👁1views
How to Set Up a Cloudflare Tunnel on a Raspberry Pi (From Zero to Live)

CloudScale AI SEO - Article Summary
  • 1.
    What it is
    Cloudflare Tunnel setup on a Raspberry Pi is covered end-to-end — from creating a free Cloudflare account and scoped API token to running a single script that installs cloudflared, configures DNS, and starts a systemd service.
  • 2.
    Why it matters
    Exposing a Raspberry Pi service publicly without port-forwarding or a static IP eliminates router configuration risk and gives you free HTTPS, DDoS protection, and automatic boot recovery via systemd.
  • 3.
    Key takeaway
    One setup script handles the complete Cloudflare Tunnel deployment on Raspberry Pi — no open ports, no static IP, live HTTPS in minutes.

Goal: Expose a service running on your Raspberry Pi to the public internet, securely, without port-forwarding or a static IP, using Cloudflare Tunnel.

What You’ll End Up With

  • A free Cloudflare account with your domain’s DNS managed by Cloudflare
  • A Cloudflare Tunnel (cloudflared) running as a systemd service on your Pi
  • A public HTTPS URL like https://myapp.yourdomain.com that proxies to any local service (e.g. WordPress on port 80, Home Assistant on port 8123)
  • A single script that handles the full setup and teardown

Prerequisites

RequirementNotes
Raspberry Pi (any model)Running Raspberry Pi OS (64-bit recommended)
A domain namee.g. yourdomain.com purchased anywhere (Namecheap, GoDaddy, etc.)
SSH access to the PiOr a keyboard and monitor attached
Internet connectionPi must be online

Part 1: Create a Cloudflare Account

1.1 Sign Up

  1. Go to https://dash.cloudflare.com/sign-up
  2. Enter your email and a strong password, then click Create Account
  3. Verify your email address

1.2 Add Your Domain to Cloudflare

  1. After logging in, click Add a site on the dashboard
  2. Enter your domain name (e.g. yourdomain.com) and click Add site
  3. Select the Free plan and click Continue
  4. Cloudflare will scan your existing DNS records; review them and click Continue

1.3 Update Your Nameservers

Cloudflare will give you two nameservers like:

aida.ns.cloudflare.com
brad.ns.cloudflare.com

Log in to wherever you purchased your domain and replace the existing nameservers with these two. The exact steps vary by registrar:

  • Namecheap: Domain List > Manage > Nameservers > Custom DNS
  • GoDaddy: My Domains > DNS > Nameservers > Change > Enter Custom
  • Google Domains: DNS > Custom name servers

Nameserver propagation can take 5 minutes to 48 hours. Cloudflare will email you when it’s active. Your domain will show Active in the dashboard when ready.

Part 2: Get Your Cloudflare API Token

You need an API token so the setup script can create DNS records and configure the tunnel programmatically.

2.1 Create a Scoped API Token

  1. Go to https://dash.cloudflare.com/profile/api-tokens
  2. Click Create Token
  3. Click Use template next to Edit zone DNS
  4. Under Zone Resources, change the dropdown to Specific zone and select your domain
  5. Click Continue to summary, then Create Token
  6. Copy the token now – it is only shown once

The token will look like:

abc123XYZexampleTokenString_abcdefghijklmnop

Store this token securely (e.g. in a password manager). Treat it like a password.

2.2 Find Your Zone ID and Account ID

Both are on your domain’s overview page in Cloudflare:

  1. Go to https://dash.cloudflare.com and click your domain
  2. On the right sidebar under API, copy:
    • Zone ID (e.g. abc123def456abc123def456abc12345)
    • Account ID (e.g. xyz789xyz789xyz789xyz789xyz78901)

Part 3: How the Tunnel Works (Optional Reading)

Before running the automated script, here is what happens under the hood:

  1. cloudflared is installed on the Pi
  2. A named tunnel is created via the Cloudflare API, generating a UUID and a credentials JSON file
  3. A DNS CNAME record is created: myapp.yourdomain.com points to <tunnel-uuid>.cfargotunnel.com
  4. A config.yml is written that maps the hostname to your local service
  5. cloudflared is installed as a systemd service so it starts on boot

The Pi never needs an open port or a static IP. Traffic flows: browser > Cloudflare edge > tunnel > Pi.

Part 4: The Setup Script

This single script handles everything: installing cloudflared, creating the tunnel via API, setting up DNS, writing the config, and installing the systemd service. It also has a teardown subcommand to remove everything cleanly.

4.1 Create the Script

Paste the entire block below into your Pi terminal. It writes the script to disk and makes it executable in one step:

cat > /usr/local/bin/cf-tunnel.sh << 'CFSCRIPT'
#!/usr/bin/env bash
# cf-tunnel.sh - Manage a Cloudflare Tunnel on a Raspberry Pi
# Usage: sudo cf-tunnel.sh setup
#        sudo cf-tunnel.sh teardown

set -euo pipefail

# ==============================================================================
# CONFIGURATION - fill these in before running
# ==============================================================================
CF_API_TOKEN=""       # API token from dash.cloudflare.com/profile/api-tokens
CF_ACCOUNT_ID=""      # Account ID from your domain's overview page
CF_ZONE_ID=""         # Zone ID from your domain's overview page
CF_DOMAIN=""          # Root domain, e.g. yourdomain.com
CF_SUBDOMAIN=""       # Subdomain to expose, e.g. myapp -> myapp.yourdomain.com
TUNNEL_NAME=""        # Name for this tunnel, e.g. pi-home
LOCAL_SERVICE=""      # Local service URL, e.g. http://localhost:80
# ==============================================================================

CONFIG_DIR="/root/.cloudflared"
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
log()  { echo -e "${GREEN}[ok]${NC} $*"; }
info() { echo -e "${CYAN}[..]${NC} $*"; }
warn() { echo -e "${YELLOW}[!!]${NC} $*"; }
die()  { echo -e "${RED}[xx]${NC} $*" >&2; exit 1; }

require_root() {
  [[ $EUID -eq 0 ]] || die "Run as root: sudo $0 $*"
}

check_config() {
  local missing=()
  [[ -z "$CF_API_TOKEN"  ]] && missing+=("CF_API_TOKEN")
  [[ -z "$CF_ACCOUNT_ID" ]] && missing+=("CF_ACCOUNT_ID")
  [[ -z "$CF_ZONE_ID"    ]] && missing+=("CF_ZONE_ID")
  [[ -z "$CF_DOMAIN"     ]] && missing+=("CF_DOMAIN")
  [[ -z "$CF_SUBDOMAIN"  ]] && missing+=("CF_SUBDOMAIN")
  [[ -z "$TUNNEL_NAME"   ]] && missing+=("TUNNEL_NAME")
  [[ -z "$LOCAL_SERVICE" ]] && missing+=("LOCAL_SERVICE")
  [[ ${#missing[@]} -gt 0 ]] && die "Missing config: ${missing[*]} -- edit the CONFIGURATION block at the top of this script."
}

cf_api() {
  local method="$1" path="$2" data="${3:-}"
  local args=(-s -X "$method"
    -H "Authorization: Bearer $CF_API_TOKEN"
    -H "Content-Type: application/json")
  [[ -n "$data" ]] && args+=(-d "$data")
  curl "${args[@]}" "https://api.cloudflare.com/client/v4${path}"
}

verify_token() {
  info "Verifying Cloudflare API token..."
  local status
  status=$(cf_api GET "/user/tokens/verify" | python3 -c \
    "import sys,json; print(json.load(sys.stdin).get('result',{}).get('status',''))" 2>/dev/null || echo "")
  [[ "$status" == "active" ]] || die "API token invalid or missing DNS edit permission."
  log "Token verified"
}

install_cloudflared() {
  if command -v cloudflared &>/dev/null; then
    warn "cloudflared already installed: $(cloudflared --version)"
    return
  fi
  local arch cf_arch
  arch=$(uname -m)
  case "$arch" in
    aarch64) cf_arch="arm64" ;;
    armv7l)  cf_arch="arm"   ;;
    x86_64)  cf_arch="amd64" ;;
    *)       die "Unsupported architecture: $arch" ;;
  esac
  info "Downloading cloudflared for $arch..."
  curl -fsSL \
    "https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cf_arch}.deb" \
    -o /tmp/cloudflared.deb
  dpkg -i /tmp/cloudflared.deb
  rm -f /tmp/cloudflared.deb
  log "cloudflared installed: $(cloudflared --version)"
}

create_tunnel() {
  mkdir -p "$CONFIG_DIR"
  info "Checking for existing tunnel '$TUNNEL_NAME'..."
  local existing count
  existing=$(cf_api GET "/accounts/${CF_ACCOUNT_ID}/cfd_tunnel?name=${TUNNEL_NAME}&is_deleted=false")
  count=$(echo "$existing" | python3 -c \
    "import sys,json; print(len(json.load(sys.stdin).get('result',[])))" 2>/dev/null || echo "0")

  if [[ "$count" -gt 0 ]]; then
    warn "Tunnel '$TUNNEL_NAME' already exists, reusing it"
    TUNNEL_ID=$(echo "$existing" | python3 -c \
      "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
    log "Tunnel ID: $TUNNEL_ID"
    return
  fi

  info "Creating tunnel '$TUNNEL_NAME'..."
  local secret payload response
  secret=$(openssl rand -hex 32)
  payload=$(python3 -c "import json; print(json.dumps({'name':'${TUNNEL_NAME}','tunnel_secret':'${secret}'}))")
  response=$(cf_api POST "/accounts/${CF_ACCOUNT_ID}/cfd_tunnel" "$payload")
  TUNNEL_ID=$(echo "$response" | python3 -c \
    "import sys,json; print(json.load(sys.stdin)['result']['id'])" 2>/dev/null || echo "")
  [[ -n "$TUNNEL_ID" ]] || die "Failed to create tunnel. Response: $response"
  log "Tunnel created: $TUNNEL_ID"

  python3 -c "
import json
print(json.dumps({'AccountTag':'${CF_ACCOUNT_ID}','TunnelSecret':'${secret}','TunnelID':'${TUNNEL_ID}'},indent=2))
" > "${CONFIG_DIR}/${TUNNEL_ID}.json"
  chmod 600 "${CONFIG_DIR}/${TUNNEL_ID}.json"
  log "Credentials written to ${CONFIG_DIR}/${TUNNEL_ID}.json"
}

create_dns_record() {
  local hostname="${CF_SUBDOMAIN}.${CF_DOMAIN}"
  local target="${TUNNEL_ID}.cfargotunnel.com"
  local payload
  payload=$(python3 -c "import json; print(json.dumps({'type':'CNAME','name':'${hostname}','content':'${target}','proxied':True,'ttl':1}))")

  info "Checking DNS record for $hostname..."
  local existing count
  existing=$(cf_api GET "/zones/${CF_ZONE_ID}/dns_records?name=${hostname}&type=CNAME")
  count=$(echo "$existing" | python3 -c \
    "import sys,json; print(len(json.load(sys.stdin).get('result',[])))" 2>/dev/null || echo "0")

  if [[ "$count" -gt 0 ]]; then
    local record_id
    record_id=$(echo "$existing" | python3 -c \
      "import sys,json; print(json.load(sys.stdin)['result'][0]['id'])")
    warn "DNS record exists, updating it"
    cf_api PUT "/zones/${CF_ZONE_ID}/dns_records/${record_id}" "$payload" > /dev/null
  else
    info "Creating DNS CNAME: $hostname -> $target"
    local response success
    response=$(cf_api POST "/zones/${CF_ZONE_ID}/dns_records" "$payload")
    success=$(echo "$response" | python3 -c \
      "import sys,json; print(json.load(sys.stdin).get('success','false'))" 2>/dev/null || echo "false")
    [[ "$success" == "True" ]] || die "Failed to create DNS record. Response: $response"
  fi
  log "DNS: $hostname -> $target (proxied)"
}

write_config() {
  local config_file="${CONFIG_DIR}/config.yml"
  local creds_file="${CONFIG_DIR}/${TUNNEL_ID}.json"
  local hostname="${CF_SUBDOMAIN}.${CF_DOMAIN}"

  info "Writing ${config_file}..."
  cat > "$config_file" << EOF
tunnel: ${TUNNEL_ID}
credentials-file: ${creds_file}

ingress:
  - hostname: ${hostname}
    service: ${LOCAL_SERVICE}
  - service: http_status:404
EOF
  chmod 600 "$config_file"
  log "Config written"
}

install_service() {
  if systemctl is-active --quiet cloudflared 2>/dev/null; then
    warn "cloudflared already running, restarting..."
    systemctl restart cloudflared
    return
  fi
  info "Installing cloudflared systemd service..."
  cloudflared --config "${CONFIG_DIR}/config.yml" service install
  systemctl enable cloudflared
  systemctl start cloudflared
  sleep 2
  systemctl is-active --quiet cloudflared \
    || die "Service failed to start. Check: journalctl -u cloudflared -n 50"
  log "cloudflared service is running"
}

cmd_setup() {
  require_root
  check_config
  verify_token
  install_cloudflared
  create_tunnel
  create_dns_record
  write_config
  install_service

  local hostname="${CF_SUBDOMAIN}.${CF_DOMAIN}"
  echo ""
  echo -e "${GREEN}============================================================${NC}"
  echo -e "${GREEN}  Cloudflare Tunnel Setup Complete${NC}"
  echo -e "${GREEN}============================================================${NC}"
  echo ""
  echo -e "  ${CYAN}Public URL:${NC}     https://${hostname}"
  echo -e "  ${CYAN}Tunnel Name:${NC}    ${TUNNEL_NAME}"
  echo -e "  ${CYAN}Tunnel ID:${NC}      ${TUNNEL_ID}"
  echo -e "  ${CYAN}Local Service:${NC}  ${LOCAL_SERVICE}"
  echo ""
  echo -e "  ${CYAN}Useful commands:${NC}"
  echo -e "    sudo systemctl status cloudflared"
  echo -e "    sudo journalctl -u cloudflared -f"
  echo -e "    sudo systemctl restart cloudflared"
  echo ""
  echo -e "${GREEN}============================================================${NC}"
}

cmd_teardown() {
  require_root
  check_config

  info "Stopping and removing cloudflared service..."
  systemctl stop cloudflared 2>/dev/null || true
  systemctl disable cloudflared 2>/dev/null || true
  cloudflared service uninstall 2>/dev/null || true
  log "Service removed"

  info "Looking up tunnel '$TUNNEL_NAME'..."
  local tunnel_list tunnel_id
  tunnel_list=$(cf_api GET "/accounts/${CF_ACCOUNT_ID}/cfd_tunnel?name=${TUNNEL_NAME}&is_deleted=false")
  tunnel_id=$(echo "$tunnel_list" | python3 -c \
    "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")

  if [[ -n "$tunnel_id" ]]; then
    info "Cleaning up tunnel connections..."
    cf_api DELETE "/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${tunnel_id}/connections" > /dev/null
    info "Deleting tunnel $tunnel_id..."
    cf_api DELETE "/accounts/${CF_ACCOUNT_ID}/cfd_tunnel/${tunnel_id}" > /dev/null
    log "Tunnel deleted"
  else
    warn "Tunnel '$TUNNEL_NAME' not found in Cloudflare, skipping"
  fi

  local hostname="${CF_SUBDOMAIN}.${CF_DOMAIN}"
  info "Looking up DNS record for $hostname..."
  local dns_record record_id
  dns_record=$(cf_api GET "/zones/${CF_ZONE_ID}/dns_records?name=${hostname}&type=CNAME")
  record_id=$(echo "$dns_record" | python3 -c \
    "import sys,json; r=json.load(sys.stdin)['result']; print(r[0]['id'] if r else '')" 2>/dev/null || echo "")

  if [[ -n "$record_id" ]]; then
    cf_api DELETE "/zones/${CF_ZONE_ID}/dns_records/${record_id}" > /dev/null
    log "DNS record for $hostname deleted"
  else
    warn "DNS record for $hostname not found, skipping"
  fi

  info "Removing local config..."
  rm -rf "$CONFIG_DIR"
  log "Local config removed"

  echo ""
  log "Teardown complete. The tunnel and DNS record have been removed from Cloudflare."
}

case "${1:-}" in
  setup)    cmd_setup ;;
  teardown) cmd_teardown ;;
  *)
    echo "Usage: sudo $0 <setup|teardown>"
    echo ""
    echo "  setup     Install cloudflared, create tunnel, set DNS, start service"
    echo "  teardown  Stop service, delete tunnel and DNS record from Cloudflare"
    exit 1
    ;;
esac
CFSCRIPT

chmod +x /usr/local/bin/cf-tunnel.sh
echo "Script written to /usr/local/bin/cf-tunnel.sh"

4.2 Fill In Your Values

Open the script and edit the CONFIGURATION block at the top:

nano /usr/local/bin/cf-tunnel.sh

Update these lines:

CF_API_TOKEN="abc123XYZexampleTokenString_abcdefghijklmnop"  # from Part 2.1
CF_ACCOUNT_ID="xyz789xyz789xyz789xyz789xyz78901"              # from Part 2.2
CF_ZONE_ID="abc123def456abc123def456abc12345"                 # from Part 2.2
CF_DOMAIN="yourdomain.com"
CF_SUBDOMAIN="myapp"              # creates myapp.yourdomain.com
TUNNEL_NAME="pi-home"
LOCAL_SERVICE="http://localhost:80"

4.3 Run Setup

sudo cf-tunnel.sh setup

Example output:

[..] Verifying Cloudflare API token...
[ok] Token verified
[..] Downloading cloudflared for aarch64...
[ok] cloudflared installed: cloudflared version 2024.12.2
[..] Checking for existing tunnel 'pi-home'...
[ok] Tunnel created: a1b2c3d4-e5f6-7890-abcd-ef1234567890
[ok] Credentials written to /root/.cloudflared/a1b2c3d4-....json
[..] Creating DNS CNAME: myapp.yourdomain.com -> a1b2c3d4-....cfargotunnel.com
[ok] DNS: myapp.yourdomain.com -> a1b2c3d4-....cfargotunnel.com (proxied)
[ok] Config written
[ok] cloudflared service is running

============================================================
  Cloudflare Tunnel Setup Complete
============================================================

  Public URL:     https://myapp.yourdomain.com
  Tunnel Name:    pi-home
  Tunnel ID:      a1b2c3d4-e5f6-7890-abcd-ef1234567890
  Local Service:  http://localhost:80

4.4 Teardown (when you need it)

To completely remove the tunnel, DNS record, and service:

sudo cf-tunnel.sh teardown

Part 5: Multiple Services on One Tunnel

You don’t need a separate tunnel per service. Edit /root/.cloudflared/config.yml directly to add more ingress rules:

tunnel: a1b2c3d4-e5f6-7890-abcd-ef1234567890
credentials-file: /root/.cloudflared/a1b2c3d4-e5f6-7890-abcd-ef1234567890.json

ingress:
  - hostname: wordpress.yourdomain.com
    service: http://localhost:80

  - hostname: homeassistant.yourdomain.com
    service: http://localhost:8123

  - hostname: grafana.yourdomain.com
    service: http://localhost:3000

  # required catch-all
  - service: http_status:404

For each new hostname, add a DNS record (substitute your own values):

curl -s -X POST "https://api.cloudflare.com/client/v4/zones/$CF_ZONE_ID/dns_records" \
  -H "Authorization: Bearer $CF_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d "{\"type\":\"CNAME\",\"name\":\"grafana.yourdomain.com\",\"content\":\"$TUNNEL_ID.cfargotunnel.com\",\"proxied\":true,\"ttl\":1}"

Then reload:

sudo systemctl restart cloudflared

Part 6: Verify and Debug

Check service status:

sudo systemctl status cloudflared

Tail live logs:

sudo journalctl -u cloudflared -f

Test DNS propagation:

dig myapp.yourdomain.com CNAME +short
# Expected: a1b2c3d4-....cfargotunnel.com

Test the endpoint:

curl -I https://myapp.yourdomain.com
# Expected: HTTP/2 200

Common Issues

SymptomLikely CauseFix
502 Bad GatewayLocal service is not runningStart the service on the Pi
1033 errorTunnel is not runningsudo systemctl start cloudflared
526 Invalid SSLLocal service using self-signed certUse http:// not https:// for local service URL
DNS does not resolveNameservers not propagatedWait up to 48 hours; check with dig NS yourdomain.com
Invalid tokenToken wrong or expiredRegenerate at dash.cloudflare.com/profile/api-tokens

Summary

StepWhat Happened
Part 1Created a free Cloudflare account and moved your domain DNS to Cloudflare
Part 2Generated a scoped API token and collected Account ID + Zone ID
Part 3Understood how the tunnel works
Part 4Ran one script to install, configure, and start the tunnel
Part 5Added multiple services to a single tunnel
Part 6Verified the setup and resolved common issues

Your Pi is now accessible from anywhere at https://myapp.yourdomain.com with:

  • No open ports on your router
  • No static IP required
  • Free HTTPS via Cloudflare’s edge
  • DDoS protection at the network layer
  • Automatic restart via systemd on reboot

Last updated: April 2026 | Tested on Raspberry Pi 4 (aarch64) running Raspberry Pi OS Bookworm 64-bit