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
.xyzis ~$1/yr, or use a free delegatable TLD likeus.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
useron192.168.1.138). - A Go app to serve (I’ll use a generic
polarisGo binary + itsweb/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
- Log in to your Cloudflare dashboard.
- Click “Add a Site” (or “Add a domain”) and enter your Namecheap domain name.
- Select a plan (the Free plan is sufficient for this use case) and click Continue.
- Cloudflare will scan for existing DNS records. Leave the default settings and click Continue.
- On the next page, you’ll be given two Cloudflare nameservers (e.g.
sri.ns.cloudflare.comandsummer.ns.cloudflare.com). Copy these — you need them in the next step.
Step 2: Update nameservers at Namecheap
- Log in to Namecheap, go to Domain List, click Manage next to your domain.
- Scroll to the Nameservers section.
- From the dropdown, select “Custom DNS”.
- Enter the two Cloudflare nameserver addresses you copied in Step 1.
- 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
- In the Cloudflare dashboard, open Zero Trust (or Cloudflare One) from the left menu.
- Go to Networks → Connectors → Cloudflare Tunnels.
- Click “Create a tunnel”.
- 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.
- Install
cloudflaredfor your OS (instructions on Cloudflare’s site, or Part A for apt). - Authenticate the client with your account:
This opens a browser — log in and pick your domain to authorize the tunnel.cloudflared tunnel login - Create the tunnel from the CLI (name matches Step 3):
This generates a Tunnel ID (UUID) and a credentials file.cloudflared tunnel create my-tunnel - Write the config file
~/.cloudflared/config.yml(create the dir first:mkdir -p ~/.cloudflared):Replacetunnel: 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:404UUIDwith your Tunnel ID andlocalhost:8080with your local service’s address. (Polaris listens onlocalhost:80, so use that in the Orange Pi parts below.)
Step 5: Configure public hostnames
- Back in the dashboard (Zero Trust → Networks → Connectors → Cloudflare Tunnels), open your tunnel.
- Go to the Public Hostname tab → Add a public hostname.
- Enter your subdomain (e.g.
www, or blank for the rootpolarissocial.xyz) and pick your domain. - In Service, choose HTTP and enter your local address (e.g.
localhost:8080). - 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)
- Cloudflare Zero Trust → Networks → Tunnels → Create a tunnel.
- Name it
polaris. Copy the install command it shows:The token is a secret — paste it, never echo it into logs or chat.sudo cloudflared service install <TOKEN> - On the Pi:
ssh user@192.168.1.138 sudo cloudflared service install <TOKEN> # pasted, not echoed sudo systemctl enable --now cloudflared - Back in the dashboard, add a Public Hostname: subdomain (e.g.
polaris) → your CF domain, servicehttp://localhost/. Save.
First-time Zero Trust setup may ask for a credit card for verification — it does not charge you. The
service installcommand 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 loginneeds 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
:80after the tunnel is live. Onlycloudflaredon localhost needs port 80; leaving it LAN-open defeats the point. cloudflared tunnel runwith 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/fstabusesnfs+vers=4.2. Use thenfs4fstype; after any reboot rundf -h /public /personal /mediato 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.
