Port forwarding used to be the only way to get a self-hosted app onto the public internet, and it's still how half the tutorials out there teach it — which is…
Port forwarding used to be the only way to get a self-hosted app onto the public internet, and it's still how half the tutorials out there teach it — which is a problem, because forwarding ports on your home router paints a target on your IP and leaves you babysitting firewall rules forever. Cloudflare Tunnel solves this by flipping the connection: instead of the internet reaching in, your server reaches out to Cloudflare and it proxies traffic back through that outbound connection. No open ports, no dynamic DNS hacks, and you get free TLS as a bonus. This is the setup I now use for every home lab and side-project VPS I run.
What you'll need
- A domain added to Cloudflare (free plan is fine)
- A Linux server or Raspberry Pi you control (this guide assumes Ubuntu/Debian; commands are similar elsewhere)
- Root or sudo access on that machine
- A service already running locally that you want to expose (e.g., a web app on
localhost:3000)
curl installed
Step 1: Install cloudflared
cloudflared is Cloudflare's tunnel daemon. Grab the latest release directly rather than relying on stale distro packages.
curl -L --output cloudflared.deb https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb
cloudflared --version
If you're on ARM (Raspberry Pi, etc.), swap amd64 for arm64 or arm depending on your architecture.
Step 2: Authenticate with Cloudflare
cloudflared tunnel login
This opens a browser link (or gives you a URL to paste manually if you're headless). Pick the domain you want to use. It drops a certificate at ~/.cloudflared/cert.pem that authorizes this machine to create tunnels under your account.
Step 3: Create the tunnel
cloudflared tunnel create homelab
This generates a tunnel with a UUID and stores credentials in ~/.cloudflared/<UUID>.json. Note the UUID — you'll need it in the config.
Step 4: Write the config file
Create ~/.cloudflared/config.yml:
tunnel: homelab
credentials-file: /home/youruser/.cloudflared/<UUID>.json
ingress:
- hostname: app.yourdomain.com
service: http://localhost:3000
- hostname: grafana.yourdomain.com
service: http://localhost:3001
- service: http_status:404
A few things that trip people up here:
- The
credentials-file path must be absolute — ~ doesn't expand reliably when cloudflared runs as a systemd service.
- You need a catch-all
service: http_status:404 as the last rule, or the tunnel refuses to start. Every ingress list needs a default.
- Each
hostname is matched top-to-bottom, first match wins — order matters if you ever use wildcards.
Step 5: Point DNS at the tunnel
cloudflared tunnel route dns homelab app.yourdomain.com
cloudflared tunnel route dns homelab grafana.yourdomain.com
This creates CNAME records pointing at <UUID>.cfargotunnel.com automatically — you don't touch the Cloudflare dashboard DNS tab at all.
Step 6: Run it as a service
Test first:
cloudflared tunnel run homelab
Hit app.yourdomain.com in a browser. If it loads, install it as a systemd service so it survives reboots:
sudo cloudflared service install
sudo systemctl enable --now cloudflared
sudo systemctl status cloudflared
cloudflared service install reads your existing ~/.cloudflared/config.yml, so make sure it's finished before running this step — it copies things into /etc/cloudflared/.
What actually goes wrong
The service starts but the site 502s. Almost always means your service: URL in the ingress config is wrong, or the app is only listening on 127.0.0.1 when cloudflared expects localhost to resolve the same way — check with curl http://localhost:3000 locally first before blaming the tunnel.
DNS resolves but nothing connects. You probably ran route dns before the tunnel had fully registered, or you have a stale A record for that subdomain fighting the new CNAME. Delete any conflicting record in the Cloudflare dashboard manually — route dns won't overwrite an existing record of a different type.
Systemd service fails silently after a reboot. Check journalctl -u cloudflared -n 50. Nine times out of ten it's a permissions issue — the credentials JSON file needs to be readable by whatever user the systemd unit runs as, and if you generated it under your own user but the service runs as root (or vice versa), it'll fail to find it.
"Error: no ingress rules were defined" — you edited the config after running service install and forgot to systemctl restart cloudflared. The daemon doesn't hot-reload the config file.
It works from your phone on wifi but not on cellular. This is virtually never a tunnel problem — check if your ISP or mobile carrier is doing DNS-over-HTTPS weirdness or has cached an old record. Give it a few minutes; Cloudflare's edge propagates fast but not instantly.
Security notes worth taking seriously
Don't stop at "it's not port-forwarded, so it's safe." A tunnel still exposes whatever you point it at to the whole internet unless you add access control:
- Use Cloudflare Access (also free for small teams) to put an authentication wall in front of anything sensitive — Grafana dashboards, admin panels, internal tools. It's a few clicks in the Zero Trust dashboard and adds an email-OTP or SSO login before traffic even reaches your ingress rule.
- Keep
cloudflared updated — sudo apt update && sudo apt upgrade cloudflared periodically, since it's your literal front door.
- Scope the tunnel token's permissions if you're running this in a shared or production environment — don't reuse the same tunnel credentials file across unrelated servers.
- If a service doesn't need to be public, don't put it in the ingress file at all. Every hostname you add is one more thing someone can port-scan by guessing subdomains.
Where to go from here
Once you've got one tunnel running, the natural next step is running cloudflared inside a Docker container alongside your other self-hosted services in a docker-compose.yml, so the whole stack — app, tunnel, and reverse proxy — comes up with one docker compose up -d. It also pairs nicely with Cloudflare Access if you want to start locking down admin routes without standing up your own auth server.
Further reading:
Hashtags
#cloudflaretunnel #selfhosting #homelab #linux #reverseproxy #devops