#!/usr/bin/env bash # # setup-cloudflared.sh — install cloudflared on Debian 12 (bookworm) and run a # Cloudflare Tunnel as a systemd service that forwards to a local port (default 3000). # # Modes (pick one): # # --hostname app.example.com [--name my-tunnel] # Locally-managed tunnel. Logs in to Cloudflare (opens a URL you visit in a # browser), creates the tunnel, writes /etc/cloudflared/config.yml with # ingress -> http://localhost:PORT, and creates the DNS CNAME. # # --token # Dashboard-managed tunnel (Zero Trust > Networks > Tunnels). The ingress is # configured in the dashboard: set the public hostname's service to # http://localhost:PORT there. # # --quick # No Cloudflare account needed. Runs a trycloudflare.com quick tunnel. The # URL is random and changes every time the service restarts. Testing only. # # Other options: # --port N Local port to expose (default: 3000) # -h, --help Show this help # # The tunnel runs as the unprivileged 'cloudflared' user. A daily systemd timer # (cloudflared-update.timer) upgrades cloudflared via apt and restarts the # tunnel when a new version is installed. # # Re-running the script is safe; it updates the config and restarts the service. set -euo pipefail PORT=3000 MODE="" HOSTNAME_FQDN="" TUNNEL_NAME="" TOKEN="" CONF_DIR=/etc/cloudflared UNIT_FILE=/etc/systemd/system/cloudflared.service BIN=/usr/bin/cloudflared SVC_USER=cloudflared log() { printf '\033[1;32m==>\033[0m %s\n' "$*"; } warn() { printf '\033[1;33mWARN:\033[0m %s\n' "$*" >&2; } die() { printf '\033[1;31mERROR:\033[0m %s\n' "$*" >&2; exit 1; } usage() { awk 'NR < 3 { next } !/^#/ { exit } { sub(/^# ?/, ""); print }' "$0"; exit "${1:-0}"; } # ---------------------------------------------------------------- arguments -- while [[ $# -gt 0 ]]; do case "$1" in --hostname) HOSTNAME_FQDN="${2:?--hostname needs a value}"; MODE=named; shift 2 ;; --name) TUNNEL_NAME="${2:?--name needs a value}"; shift 2 ;; --token) TOKEN="${2:?--token needs a value}"; MODE=token; shift 2 ;; --quick) MODE=quick; shift ;; --port) PORT="${2:?--port needs a value}"; shift 2 ;; -h|--help) usage 0 ;; *) warn "Unknown option: $1"; usage 1 ;; esac done [[ -n "$MODE" ]] || { warn "Choose a mode: --hostname, --token, or --quick"; usage 1; } [[ "$PORT" =~ ^[0-9]+$ ]] && (( PORT >= 1 && PORT <= 65535 )) || die "Invalid port: $PORT" [[ $EUID -eq 0 ]] || die "Run as root (sudo $0 ...)" . /etc/os-release [[ "${ID:-}" == debian ]] || warn "This script targets Debian; detected '${ID:-unknown}'. Continuing anyway." [[ "${VERSION_CODENAME:-}" == bookworm ]] || warn "Written for Debian 12 (bookworm); detected '${VERSION_CODENAME:-unknown}'." SERVICE_URL="http://localhost:${PORT}" TUNNEL_NAME="${TUNNEL_NAME:-$(hostname -s)-${PORT}}" # ------------------------------------------------------------------ install -- install_cloudflared() { log "Installing prerequisites" export DEBIAN_FRONTEND=noninteractive apt-get update -qq apt-get install -y -qq ca-certificates curl gnupg jq >/dev/null log "Adding Cloudflare apt repository" install -d -m 0755 /usr/share/keyrings curl -fsSL https://pkg.cloudflare.com/cloudflare-public-v2.gpg \ -o /usr/share/keyrings/cloudflare-public-v2.gpg echo 'deb [signed-by=/usr/share/keyrings/cloudflare-public-v2.gpg] https://pkg.cloudflare.com/cloudflared any main' \ > /etc/apt/sources.list.d/cloudflared.list log "Installing cloudflared" apt-get update -qq apt-get install -y -qq cloudflared >/dev/null log "$($BIN --version)" } # Unprivileged system account the daemon runs as. It only makes outbound # connections and proxies to localhost, so it needs no root privileges. create_service_user() { if ! getent passwd "$SVC_USER" >/dev/null; then log "Creating system user '$SVC_USER'" useradd --system --user-group --no-create-home \ --home-dir /nonexistent --shell /usr/sbin/nologin "$SVC_USER" fi } # ----------------------------------------------------------- systemd unit ---- # $1 = systemd service Type, $2 = cloudflared arguments, $3 = optional EnvironmentFile write_unit() { local type="$1" args="$2" envfile="${3:-}" log "Writing $UNIT_FILE" cat > "$UNIT_FILE" < ${SERVICE_URL} After=network-online.target Wants=network-online.target [Service] Type=${type} User=${SVC_USER} Group=${SVC_USER} ${envfile:+EnvironmentFile=${envfile}} ExecStart=${BIN} --no-autoupdate ${args} Restart=on-failure RestartSec=5s TimeoutStartSec=120 # Sandboxing NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes PrivateTmp=yes [Install] WantedBy=multi-user.target EOF systemctl daemon-reload systemctl enable cloudflared >/dev/null 2>&1 log "Starting cloudflared service" systemctl restart cloudflared } # ---------------------------------------------------------------- updater ---- # cloudflared's built-in autoupdate disables itself for apt installs, and the # unprivileged, read-only service couldn't replace its own binary anyway. So a # root timer upgrades it through apt and restarts the tunnel only when the # version actually changed (same idea as Cloudflare's cloudflared-update.timer). install_updater() { log "Installing daily auto-update timer" cat > /usr/local/sbin/cloudflared-update <<'EOF' #!/bin/sh set -eu before=$(dpkg-query -W -f='${Version}' cloudflared) # Refresh only the cloudflared repo; leave other package lists untouched. apt-get update -qq \ -o Dir::Etc::sourcelist=/etc/apt/sources.list.d/cloudflared.list \ -o Dir::Etc::sourceparts=- -o APT::Get::List-Cleanup=0 DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --only-upgrade \ -o DPkg::Lock::Timeout=300 cloudflared after=$(dpkg-query -W -f='${Version}' cloudflared) if [ "$before" != "$after" ]; then echo "cloudflared upgraded ${before} -> ${after}; restarting tunnel" systemctl try-restart cloudflared.service else echo "cloudflared ${after} is up to date" fi EOF chmod 0755 /usr/local/sbin/cloudflared-update cat > /etc/systemd/system/cloudflared-update.service <<'EOF' [Unit] Description=Upgrade cloudflared via apt and restart the tunnel if updated After=network-online.target Wants=network-online.target [Service] Type=oneshot ExecStart=/usr/local/sbin/cloudflared-update EOF cat > /etc/systemd/system/cloudflared-update.timer <<'EOF' [Unit] Description=Daily cloudflared update check [Timer] OnCalendar=daily RandomizedDelaySec=1h Persistent=true [Install] WantedBy=timers.target EOF systemctl daemon-reload systemctl enable --now cloudflared-update.timer >/dev/null 2>&1 } # ------------------------------------------------------------------- modes --- setup_named() { local cert=/root/.cloudflared/cert.pem uuid creds if [[ ! -f "$cert" ]]; then log "Logging in to Cloudflare. Open the URL below in a browser and pick the zone for ${HOSTNAME_FQDN}." $BIN tunnel login else log "Using existing Cloudflare login ($cert)" fi uuid="$($BIN tunnel list --name "$TUNNEL_NAME" --output json 2>/dev/null | jq -r '.[0].id // empty')" if [[ -z "$uuid" ]]; then log "Creating tunnel '$TUNNEL_NAME'" $BIN tunnel create "$TUNNEL_NAME" uuid="$($BIN tunnel list --name "$TUNNEL_NAME" --output json | jq -r '.[0].id // empty')" [[ -n "$uuid" ]] || die "Tunnel was created, but its ID could not be found" else log "Tunnel '$TUNNEL_NAME' already exists ($uuid)" fi install -d -m 0755 "$CONF_DIR" creds="$CONF_DIR/${uuid}.json" if [[ ! -f "$creds" ]]; then if [[ -f "/root/.cloudflared/${uuid}.json" ]]; then install -m 0600 "/root/.cloudflared/${uuid}.json" "$creds" else log "Fetching credentials for existing tunnel" $BIN tunnel token --cred-file "$creds" "$uuid" fi fi # Only the service user may read the tunnel secret. cert.pem stays in # /root/.cloudflared, so the daemon cannot create or delete tunnels. chown "$SVC_USER:$SVC_USER" "$creds" chmod 0600 "$creds" log "Writing $CONF_DIR/config.yml (${HOSTNAME_FQDN} -> ${SERVICE_URL})" cat > "$CONF_DIR/config.yml" < tunnel ${TUNNEL_NAME}" if ! $BIN tunnel route dns "$uuid" "$HOSTNAME_FQDN"; then warn "DNS route failed. If a record for ${HOSTNAME_FQDN} already exists, delete it in the" warn "Cloudflare dashboard or re-run: cloudflared tunnel route dns --overwrite-dns $uuid $HOSTNAME_FQDN" fi write_unit notify "--config $CONF_DIR/config.yml tunnel run" PUBLIC_URL="https://${HOSTNAME_FQDN}" } setup_token() { install -d -m 0755 "$CONF_DIR" # Keep the token out of the unit file and out of `ps` output. systemd reads # this as root before dropping to $SVC_USER, so it can stay root-only. (umask 077; printf 'TUNNEL_TOKEN=%s\n' "$TOKEN" > "$CONF_DIR/tunnel.env") write_unit notify "tunnel run" "$CONF_DIR/tunnel.env" PUBLIC_URL="(the public hostname set in the Zero Trust dashboard)" } setup_quick() { write_unit simple "tunnel --url ${SERVICE_URL}" log "Waiting for the trycloudflare.com URL" PUBLIC_URL="" for _ in $(seq 1 30); do PUBLIC_URL="$(journalctl -u cloudflared --since '-2min' --no-pager -o cat 2>/dev/null \ | grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' | tail -n1 || true)" [[ -n "$PUBLIC_URL" ]] && break sleep 1 done PUBLIC_URL="${PUBLIC_URL:-(not found yet; check: journalctl -u cloudflared | grep trycloudflare)}" } # -------------------------------------------------------------------- main --- install_cloudflared create_service_user case "$MODE" in named) setup_named ;; token) setup_token ;; quick) setup_quick ;; esac install_updater sleep 3 if systemctl is-active --quiet cloudflared; then log "cloudflared is running" else systemctl status cloudflared --no-pager || true die "cloudflared failed to start. See: journalctl -u cloudflared -e" fi if ! ss -ltnH "sport = :${PORT}" | grep -q .; then warn "Nothing is listening on port ${PORT} yet. Visitors will get a 502 until your app is running." fi cat < Tunnels > your tunnel > Public Hostname), set the service to: ${SERVICE_URL} ------------------------------------------------------------------ EOF fi