I gave my Orange Pi a permanent public URL with a Cloudflare tunnel — no router port-forward, no static IP, no cert juggling. Full command-by-command walkthrough

I wanted a service running on my Orange Pi (an ARM SBC sitting on my home LAN) to be reachable from anywhere over HTTPS, without poking holes in my router, without begging my ISP for a static IP, and without messing with Let’s Encrypt renewal. The trick is a Cloudflare Tunnel: it opens an outbound connection from the Pi to Cloudflare, and Cloudflare serves your site at the edge. Your home IP stays private, no ports are open, and TLS is handled by Cloudflare — “HTTPS only” is automatic.

I’m calling the setup “Polaris”: a stable public subdomain fronting a small Go web app on the Pi, locked down so only the tunnel can reach it. Here’s the entire procedure, every command included.

Why do it this way

  • No port-forward. You never touch the router, so it works behind CGNAT / double-NAT too.
  • Your home IP isn’t published. The tunnel is outbound-only; the public DNS points at Cloudflare, not your LAN.
  • No local cert. Cloudflare terminates TLS at the edge; the app just listens on localhost:80.
  • Stable URL. A “named tunnel” gives you a fixed subdomain that survives reboots (unlike a quick tunnel’s random URL).

Prereqs

  • A Cloudflare account and a real Cloudflare-managed domain (NS delegated to CF). A .xyz is ~$1/yr, or use a free delegatable TLD like us.kg / pp.ua.
    • ⚠️ DuckDNS / FreeDNS subdomains do not work as Cloudflare public hostnames — CF requires the domain’s nameservers to be Cloudflare’s.
  • SSH key access to the Pi as a normal user (I’ll assume user user on 192.168.1.138).
  • A Go app to serve (I’ll use a generic polaris Go binary + its web/ frontend).

Step 0 — Connect your domain to Cloudflare and create the tunnel (Namecheap example, any host)

The rest of this post is Orange Pi–specific, but first your domain has to actually live on Cloudflare — the tunnel can’t use it as a public hostname until the nameservers are Cloudflare’s. This is the generic, any-OS flow (I used Namecheap as the registrar; the idea is identical elsewhere). If you already manage your domain on Cloudflare, skip to Part A.

Prerequisites

  • A domain registered with Namecheap.
  • A (free) Cloudflare account.

Step 1: Add your domain to Cloudflare

  1. Log in to your Cloudflare dashboard.
  2. Click “Add a Site” (or “Add a domain”) and enter your Namecheap domain name.
  3. Select a plan (the Free plan is sufficient for this use case) and click Continue.
  4. Cloudflare will scan for existing DNS records. Leave the default settings and click Continue.
  5. On the next page, you’ll be given two Cloudflare nameservers (e.g. sri.ns.cloudflare.com and summer.ns.cloudflare.com). Copy these — you need them in the next step.

Step 2: Update nameservers at Namecheap

  1. Log in to Namecheap, go to Domain List, click Manage next to your domain.
  2. Scroll to the Nameservers section.
  3. From the dropdown, select “Custom DNS”.
  4. Enter the two Cloudflare nameserver addresses you copied in Step 1.
  5. Click the green checkmark / Save Changes.

Important: NS changes can take up to 24–48 hours to propagate globally. The domain is only fully active on Cloudflare after that. You can check status in the Cloudflare dashboard before proceeding.

Step 3: Create a Cloudflare Tunnel

  1. In the Cloudflare dashboard, open Zero Trust (or Cloudflare One) from the left menu.
  2. Go to Networks → Connectors → Cloudflare Tunnels.
  3. Click “Create a tunnel”.
  4. Name it (e.g. my-tunnel) and click Save.

Step 4: Install and configure cloudflared

cloudflared is the daemon that runs on your host and connects it to Cloudflare.

On a Debian/ARM SBC (like the Orange Pi in Parts A–C), prefer the apt install shown in Part A (A0) over a manual binary. The login/create/config steps below are the same either way.

  1. Install cloudflared for your OS (instructions on Cloudflare’s site, or Part A for apt).
  2. Authenticate the client with your account:
    cloudflared tunnel login
    
    This opens a browser — log in and pick your domain to authorize the tunnel.
  3. Create the tunnel from the CLI (name matches Step 3):
    cloudflared tunnel create my-tunnel
    
    This generates a Tunnel ID (UUID) and a credentials file.
  4. Write the config file ~/.cloudflared/config.yml (create the dir first: mkdir -p ~/.cloudflared):
    tunnel: UUID
    credentials-file: /home/your-username/.cloudflared/UUID.json
    ingress:
      - hostname: polarissocial.xyz
        service: http://localhost:8080/
      - hostname: www.polarissocial.xyz
        service: http://localhost:8080/
      - service: http_status:404
    
    Replace UUID with your Tunnel ID and localhost:8080 with your local service’s address. (Polaris listens on localhost:80, so use that in the Orange Pi parts below.)

Step 5: Configure public hostnames

  1. Back in the dashboard (Zero Trust → Networks → Connectors → Cloudflare Tunnels), open your tunnel.
  2. Go to the Public Hostname tab → Add a public hostname.
  3. Enter your subdomain (e.g. www, or blank for the root polarissocial.xyz) and pick your domain.
  4. In Service, choose HTTP and enter your local address (e.g. localhost:8080).
  5. Click Save — Cloudflare creates the DNS record for you.

Step 6: Start the tunnel

cloudflared tunnel run my-tunnel

To run it as a background service on boot:

cloudflared service install
systemctl start cloudflared

After this, your domain is routed through the tunnel to your local service. The sections below pick up the Orange Pi specifics: installing cloudflared via apt (A0), the dashboard token-install alternative (A1), the systemd unit, deploying the Go app (Part B), and locking the box down (Part C). If you used this Step 0 dashboard flow, A1 is already done — you can jump to Part B.


Part A — the Cloudflare Tunnel (Polaris)

A0. Install cloudflared on the Pi (via apt, not a manual binary)

# on the Pi
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflared-ascii.gpg \
  | sudo tee /usr/share/keyrings/cloudflared-archive-keyring.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflared-archive-keyring.gpg] https://pkg.cloudflare.com/cloudflared any main" \
  | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update
sudo apt-get install -y cloudflared

If a stale skeleton service unit exists, clear it first:

sudo cloudflared service uninstall   # safe to run even if nothing's there

A1. Create the tunnel + public hostname (Zero Trust dashboard — the easy path)

  1. Cloudflare Zero Trust → Networks → Tunnels → Create a tunnel.
  2. Name it polaris. Copy the install command it shows:
    sudo cloudflared service install <TOKEN>
    
    The token is a secret — paste it, never echo it into logs or chat.
  3. On the Pi:
    ssh user@192.168.1.138
    sudo cloudflared service install <TOKEN>   # pasted, not echoed
    sudo systemctl enable --now cloudflared
    
  4. Back in the dashboard, add a Public Hostname: subdomain (e.g. polaris) → your CF domain, service http://localhost/. Save.

First-time Zero Trust setup may ask for a credit card for verification — it does not charge you. The service install command wires up the systemd unit against the token; cloudflared tunnel run (no args) auto-discovers it.

A2. Alternative — build the tunnel from the CLI (no dashboard)

# on the Pi
cloudflared tunnel login          # prints a URL — open it in a browser to authenticate
cloudflared tunnel create polaris

~/.cloudflared/config.yml:

tunnel: YOUR-TUNNEL-ID
credentials-file: /home/user/.cloudflared/YOUR-TUNNEL-ID.json
ingress:
  - hostname: polaris.polarissocial.xyz
    service: http://localhost/
  - service: http_status:404
cloudflared tunnel route dns polaris polaris.your-domain.com
cloudflared tunnel run polaris    # foreground test; Ctrl-C once you've verified

Systemd unit /etc/systemd/system/cloudflared.service:

[Unit]
Description=Cloudflare Tunnel
After=network.target

[Service]
Type=simple
User=user
ExecStart=/usr/local/bin/cloudflared tunnel run
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now cloudflared.service

Part B — deploy the Go app behind the tunnel

B1. Build it (cross-compile ARM64 from any machine with Go)

cd ~/code/go/polaris
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o /tmp/polaris-arm64 ./cmd/polaris
cd web && npm install && npm run build && cd ..

If your local checkout is broken (symptom: internal/ exists but there’s no go.mod and no cmd/polaris/main.go), clone fresh and build there instead of fighting the corrupt tree:

git clone <your-remote> /tmp/polaris-fresh
cd /tmp/polaris-fresh
GOOS=linux GOARCH=arm64 CGO_ENABLED=0 go build -o /tmp/polaris-arm64 ./cmd/polaris
cd web && npm install && npm run build

B2. Ship it to the Pi + restart

# from your build host
scp /tmp/polaris-arm64 user@192.168.1.138:/tmp/polaris-arm64

# on the Pi
sudo systemctl stop polaris
sudo cp ~/code/go/polaris/polaris ~/code/go/polaris/polaris.$(date +%F).bak   # date-stamped backup
sudo cp /tmp/polaris-arm64 ~/code/go/polaris/polaris
sudo chmod +x ~/code/go/polaris/polaris
rsync -a ~/code/go/polaris/web/dist/ ~/code/go/polaris/web/dist/
rsync -a --delete internal/ cmd/ go.mod go.sum ~/code/go/polaris/
# IMPORTANT: if migrations/ has new *.sql, run them before starting (schema drift crashes boot)
sudo systemctl start polaris

B3. Verify the app is up (the tunnel only forwards to localhost:80)

curl -fsS http://localhost/health     # from the Pi → 200 means the app is serving

Part C — lock it down for public exposure

Run once when the tunnel first goes public (idempotent).

sudo apt-get install -y ufw
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 192.168.1.0/24 to any port 22   proto tcp   # SSH: LAN only
sudo ufw allow from 192.168.1.0/24 to any port 5900 proto tcp   # VNC: LAN only
sudo ufw --force enable

Once the tunnel is live, seal off LAN access to the web port so only the tunnel can reach the app:

sudo ufw delete allow from 192.168.1.0/24 to any port 80   proto tcp
sudo ufw delete allow from 192.168.1.0/24 to any port 5900 proto tcp
# if the app also listens on a backend port, block it from LAN too:
sudo ufw deny from 192.168.1.0/24 to any port 8080 proto tcp

SSH hardening (key-only, root off) — verify key auth works before logging out:

sudo sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin no/'  /etc/ssh/sshd_config
sudo sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
sudo sshd -t && sudo systemctl restart ssh

Stop bloat services (keep rpcbind — NFS needs it):

for svc in cups brltty accounts-daemon; do sudo systemctl disable --now "$svc" 2>/dev/null; done

Verify end-to-end

# 1. App is serving locally
curl -fsS http://localhost/health          # → 200

# 2. Tunnel process is up
systemctl status cloudflared --no-pager | head

# 3. Public edge (run from ANY machine, not just the LAN)
curl -fsSI https://polaris.polarissocial.xyz/  # → 200 + a Cloudflare TLS cert

If step 3 fails but step 1 works, the tunnel ingress or the public-hostname DNS route is misconfigured — re-check Part A step 4 (dashboard) or the route dns command (CLI).


Pitfalls I hit / planned around

  • tunnel login needs a browser. It prints a URL you must open to authenticate. Can’t be done from bare SSH.
  • Zero Trust asks for a card on first setup — verification only, no charge.
  • DuckDNS / FreeDNS won’t work as the public hostname. Use a real CF-managed domain.
  • Close LAN :80 after the tunnel is live. Only cloudflared on localhost needs port 80; leaving it LAN-open defeats the point.
  • cloudflared tunnel run with no args auto-discovers the tunnel from the installed token/config — don’t pass a stale tunnel name.
  • NFS mounts can die on reboot if /etc/fstab uses nfs + vers=4.2. Use the nfs4 fstype; after any reboot run df -h /public /personal /media to confirm they survived.
  • Run DB migrations before restarting the app if migrations/ has new .sql.
  • The tunnel token is a secret — never echo it into logs or chat.

Why this is worth owning

This is the same reason people self-host anything: the endpoint is yours. Cloudflare is just the front door — you can swap it, drop it, or move the app to a different box without losing control of the service itself. No vendor decides whether your thing keeps existing, and your LAN topology (and IP) stays private behind the outbound tunnel. For a $35 SBC that sips a few watts, that’s a pretty durable little public node.