After my server got hit by SSH brute-force attacks at midnight, security stopped being an afterthought. So when I started self-hosting Gitea, Vaultwarden, and Uptime Kuma, the question was never “how do I expose this?” but “how do I expose this without painting a target on my machine?” Standard port forwarding works — until a misconfigured rule or a freshly-discovered vulnerability makes it spectacularly not work.
Cloudflare Tunnel solved it. What follows is a direct comparison of both approaches, where Tunnel falls short, and a full Linux setup — systemd service, DNS routing, and Zero Trust Access policies included.
Approach Comparison: Reverse Proxy vs. Cloudflare Tunnel
Traditional: Reverse Proxy + Port Forwarding
The classic setup: router forwards port 80/443 to a local Nginx or Caddy instance, which proxies to your app. You handle Let’s Encrypt renewals, fail2ban rules, firewall policies, and WAF configuration yourself. It works. But your real IP lands in public DNS, your ports show up on Shodan within an hour of going live, and every exposed service becomes an attack surface you personally own.
Residential ISPs add another wrinkle. Many block inbound port 80/443 entirely, forcing you onto non-standard ports or a separate VPS just to terminate TLS.
Cloudflare Tunnel: Outbound-Only Model
Cloudflare Tunnel flips the connection direction. You run cloudflared — a daemon on your server — which establishes a persistent outbound connection to Cloudflare’s edge. When a visitor hits your domain, traffic enters through Cloudflare and routes into that tunnel. Your server never listens on a public port.
Block all inbound traffic at the firewall. Your app still works. Your real IP never touches public DNS. Cloudflare handles TLS termination, DDoS mitigation, and optionally, Zero Trust authentication before any request reaches your app.
Pros and Cons
Traditional Reverse Proxy
- Pros: Full control, no third-party dependency, supports any protocol (TCP/UDP/raw sockets), lower latency for geographically nearby users
- Cons: Real IP exposed and scannable, you own every security layer, residential ISP port blocks are common, one misconfigured proxy block can expose internals
Cloudflare Tunnel
- Pros: Zero inbound ports required, real IP hidden from public DNS, free DDoS protection and CDN, Zero Trust Access adds an auth layer before traffic hits your app, free tier covers up to 50 users
- Cons: All traffic routes through Cloudflare infrastructure (privacy consideration), slight added latency for users far from Cloudflare PoPs, domain must use Cloudflare DNS, raw TCP/UDP tunneling requires Cloudflare WARP on the free tier
Recommended Setup
Web apps and dashboards on a home server or VPS? Use Cloudflare Tunnel with Zero Trust Access policies. No open inbound ports, Cloudflare as your WAF, and a hard authentication gate before any request touches your application code. Even if your app carries an unpatched CVE, an attacker can’t reach it without first clearing Cloudflare’s auth layer.
Latency-sensitive services, game servers, raw TCP protocols like direct SSH or database connections — stick with WireGuard or a traditional reverse proxy with tight firewall rules. The practical split: web apps go through Cloudflare Tunnel, server administration goes through WireGuard on a non-standard port with an IP allowlist.
Implementation Guide
Prerequisites
- A domain with DNS managed by Cloudflare (free account works)
- Linux server with your app running locally (this guide uses Debian/Ubuntu)
- Cloudflare Zero Trust account (free tier)
Step 1: Install cloudflared
curl -L --output cloudflared.deb \
https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i cloudflared.deb
cloudflared --version
For RPM-based distros or macOS, grab the appropriate binary from the GitHub releases page. The binary is statically linked — no runtime dependencies to manage.
Step 2: Authenticate with Cloudflare
cloudflared tunnel login
This opens a browser window. Log in and select the domain you want to use. Cloudflared writes a certificate to ~/.cloudflared/cert.pem. Keep this file secure — it authorizes tunnel creation for your entire account.
Step 3: Create a Named Tunnel
cloudflared tunnel create my-homelab
Output includes a tunnel UUID. The credentials file saves to ~/.cloudflared/<UUID>.json. Note that UUID — you’ll need it in the next step.
Step 4: Write the Config File
Create ~/.cloudflared/config.yaml:
tunnel: <YOUR-TUNNEL-UUID>
credentials-file: /home/youruser/.cloudflared/<UUID>.json
ingress:
- hostname: gitea.yourdomain.com
service: http://localhost:3000
- hostname: uptime.yourdomain.com
service: http://localhost:3001
- service: http_status:404
That final catch-all rule returns 404 for any hostname that doesn’t match an ingress entry. Skip it and cloudflared will error on unmatched requests — potentially crashing the daemon.
Step 5: Route DNS to the Tunnel
cloudflared tunnel route dns my-homelab gitea.yourdomain.com
cloudflared tunnel route dns my-homelab uptime.yourdomain.com
This creates CNAME records pointing to your tunnel in Cloudflare DNS automatically. No manual DNS panel editing required.
Step 6: Run as a systemd Service
sudo cloudflared service install
sudo systemctl enable cloudflared
sudo systemctl start cloudflared
sudo systemctl status cloudflared
Service won’t start? Check the logs:
sudo journalctl -u cloudflared -f
Nine times out of ten, the culprit is a wrong path in credentials-file. Verify the UUID in the config matches the actual filename on disk.
Step 7: Add Zero Trust Access Authentication
Most guides skip this step. That’s a mistake. Zero Trust Access is what turns cloudflared from a clever routing trick into an actual security boundary. In the Cloudflare Zero Trust dashboard:
- Go to Access → Applications → Add an application
- Select Self-hosted
- Enter your hostname, e.g.,
gitea.yourdomain.com - Under Policies, add a rule — allow emails matching
@yourdomain.com, or list specific addresses
Once active, Cloudflare gates every visitor with a login screen before forwarding anything to your app. For personal use, the email OTP option needs no OAuth setup — Cloudflare sends a one-time code to the allowed address. No passwords. No auth service to maintain.
Verify the Setup
# List active tunnels and their status
cloudflared tunnel list
# Confirm nothing is listening on public ports 80/443
ss -tlnp | grep ':80\|:443'
# Expected: empty output
# Test your app through the tunnel
curl -I https://gitea.yourdomain.com
Hardening After Setup
Now close the perimeter. Cloudflared only needs outbound connectivity, so blocking all inbound changes nothing — except eliminating the entire surface area for port scanners:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh # remove this once you have WireGuard or WARP
sudo ufw enable
Rotate your tunnel credentials periodically:
cloudflared tunnel rotate-secret my-homelab
Monitor tunnel health in the dashboard under Zero Trust → Networks → Tunnels. You’ll see connector uptime, active connections, and per-tunnel traffic volume.
One Docker gotcha worth calling out early: if you run cloudflared in a container, mount the config directory as a volume and pass the path explicitly with --config /etc/cloudflared/config.yaml. Without that flag, the binary’s default path lookup fails silently inside containers — it starts with an empty config and no error message. That is a deeply unpleasant thing to debug at 2 AM.
Where Tunnel Falls Short
HTTP and HTTPS work cleanly. Raw TCP is a different story. Direct SSH sessions, database connections, custom protocols — these need either Cloudflare WARP-to-Tunnel (available on the free Zero Trust tier, though it requires extra configuration) or a separate VPN layer. The WARP path means every client needs the WARP app installed. Fine for a personal setup, but it adds friction fast when you’re sharing access with others.
My setup after that midnight brute-force incident: web apps through Cloudflare Tunnel with Zero Trust Access, server administration through WireGuard on a high-numbered port with an IP allowlist. Brute-force bots are automated and they never stop probing. The only countermeasure that actually works is not giving them anything to probe.

