Replacing Our VPN with Cloudflare Tunnels in an Afternoon
I killed a WireGuard VPN for a client last month and replaced it with Cloudflare Tunnels. It took four hours and I haven't touched it since.
I've set up more VPNs than I care to admit. OpenVPN configs that rotted for three years, WireGuard peers that broke every time someone got a new laptop, IPSec nightmares for a client whose IT guy left and took the password sheet with him. Last month I replaced one of those setups with Cloudflare Tunnels and I'm annoyed it took me this long to do it.
The short version: a small tunnel daemon runs on your origin server, dials out to Cloudflare's edge, and Cloudflare reverse-proxies traffic to it — all without you opening a single inbound firewall port. You layer identity-aware access policies on top. That's it. No certificates to distribute, no split-tunnel drama, no "did you connect to VPN first?" tickets.
What Problem This Actually Solves
Traditional VPNs grant network access. You're either on or you're off. Once you're on, you can usually see more than you should because nobody bothered to segment properly. I've seen this bite clients badly — a contractor with VPN credentials leaving and the offboarding checklist missing the "revoke VPN" step.
What Cloudflare Tunnels (and Zero Trust more broadly) does differently is flip the model. Instead of "here's the network, good luck," it's "here's this specific application, for this specific authenticated identity, for this session." The origin server never accepts inbound connections from the public internet at all. There's no IP to scan, no port to probe. The attack surface shrinks to almost nothing.
For my use case last month — a small biotech running an internal Laravel app that about a dozen people needed to reach remotely — it was exactly right. They'd been on WireGuard, which worked fine until it didn't, which was roughly every third new hire.
Standing Up a Tunnel
You need a Cloudflare account (free tier works for this), a domain on Cloudflare's nameservers, and a server you control. The daemon is called cloudflared.
On Ubuntu:
# Add Cloudflare's repo and install
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo gpg --dearmor -o /usr/share/keyrings/cloudflare-main.gpg
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared focal main' | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install cloudflared
# Authenticate — opens a browser link
cloudflared tunnel login
# Create the tunnel
cloudflared tunnel create my-app-tunnel
That last command spits out a tunnel UUID and drops a credentials JSON file into ~/.cloudflared/. Hold onto that UUID.
Now write a config file at /etc/cloudflared/config.yml:
tunnel: <your-tunnel-uuid>
credentials-file: /home/deploy/.cloudflared/<uuid>.json
ingress:
- hostname: app.example.com
service: http://localhost:8000
- hostname: flower.example.com
service: http://localhost:5555
- service: http_status:404
That last catch-all rule is required — cloudflared will refuse to start without it and the error message is not helpful.
Create the DNS record and install as a system service:
cloudflared tunnel route dns my-app-tunnel app.example.com
sudo cloudflared service install
sudo systemctl enable --now cloudflared
At this point app.example.com is live through the tunnel. The app is publicly reachable, which is only half the job.
Locking It Down with Access
The access policies live in Cloudflare's Zero Trust dashboard (formerly "Cloudflare for Teams"). Go to Access → Applications → Add an Application → Self-hosted.
Set the application domain to app.example.com, then define a policy. The simplest useful policy is email-based OTP: anyone whose email matches your domain gets a one-time code sent to them and can log in. No SAML, no Okta required. For a 12-person biotech team that's plenty.
For clients with an existing identity provider, Cloudflare Access supports SAML, Google Workspace, Azure AD, GitHub, and a handful of others. I've wired it to Google Workspace twice and it takes maybe 20 minutes.
You can also restrict by IP CIDR range, by device posture (is the device managed? does it have a cert?), or combine conditions. For a client that has field staff hitting internal tools from iPads on carrier networks, the email OTP path has been the least-friction option.
Reaching Internal Services That Aren't HTTP
Most of my use cases are web apps, but sometimes I need to get a developer SSH access to a server without a bastion host. Cloudflare handles this too.
On the server, add to your tunnel config:
ingress:
- hostname: ssh.example.com
service: ssh://localhost:22
- service: http_status:404
On the developer's machine, add a ~/.ssh/config stanza:
Host ssh.example.com
ProxyCommand cloudflared access ssh --hostname %h
Now ssh deploy@ssh.example.com drops you into the Cloudflare Access browser flow, authenticates, and connects. The server has port 22 firewalled off from the public internet. This is a meaningfully better story than "here's a bastion, don't forget to whitelist your home IP."
For database access from a local machine — say, a developer needs a psql session against a staging Postgres — cloudflared access tcp proxies arbitrary TCP. It's clunkier than SSH but it works.
The Gotchas That Will Bite You
The credentials file path is absolute and the service runs as root. I've had the systemd service fail silently because I put the credentials file in a home directory the service user couldn't read. Put it somewhere like /etc/cloudflared/ and chown root:root it.
Websockets need explicit configuration. I had a Laravel app running Laravel Echo Server and the websocket connections were dropping constantly. Turned out I needed disableChunkedEncoding: true in the ingress rule for that service. The docs mention this but not prominently.
ingress:
- hostname: app.example.com
service: http://localhost:8000
originRequest:
disableChunkedEncoding: true
The free tier rate-limits Access authentication requests. I haven't hit this in production but it's worth knowing if you're running something with lots of simultaneous logins.
Long-polling and SSE (Server-Sent Events) can be flaky. I had a client app that used SSE for real-time notifications and we saw dropped connections under moderate load. We ended up switching that endpoint to short polling for anything going through the tunnel. Not ideal, but pragmatic.
cloudflared update replaces the binary but doesn't restart the service. Write a cron job or you'll be running a two-year-old daemon and not know it.
# /etc/cron.weekly/update-cloudflared
#!/bin/bash
cloudflared update && systemctl restart cloudflared
The Zero Trust dashboard and the regular Cloudflare dashboard are separate UIs with separate navigation and occasionally confusing overlap. DNS records created by tunnel route dns show up in the regular DNS tab, but the tunnel itself only lives in Zero Trust. I've watched clients get lost between the two more than once.
When I'd Reach for This
Cloudflare Tunnels make sense when:
- You have internal web apps that a distributed team needs to reach. Small SaaS companies, professional services firms, field staff hitting internal tools — this is the sweet spot.
- You want to kill inbound firewall rules entirely. No ports open means a dramatically smaller attack surface.
- You need access control that's tied to identity, not network location, and you don't want to run your own IdP.
- You're replacing a VPN that keeps breaking because nobody owns it. Tunnels are simpler to reason about and there's nothing to configure on end-user machines except a browser.
I'd skip it (or supplement it) when:
- You need full network-level VPN access — developers who need to hit arbitrary internal IPs or services that aren't proxiable. Cloudflare does have a WARP client that can do this, but it's a different product and meaningfully more complex to administer.
- Your compliance requirements demand that traffic not traverse a third-party CDN. Healthcare clients with strict BAA requirements should have their counsel weigh in before routing production PHI through Cloudflare's edge, even with a signed BAA in place.
- You're running high-throughput data pipelines. Cloudflare's network is fast, but you're adding a hop. For a LIMS data ingestion pipeline I worked on, we kept the bulk transfer path on a direct peered connection and only used the tunnel for the management UI.
- You want to avoid vendor lock-in on your network topology. This is real. Your DNS, your access policies, your routing — it all lives in Cloudflare now.
The Laravel Side: Trusting Forwarded IPs
One practical thing I always have to remember: when traffic comes through Cloudflare, the REMOTE_ADDR your app sees is a Cloudflare IP, not the real client. For rate limiting and logging that matters.
In config/trustedproxy.php (or App\Http\Middleware\TrustProxies):
// Trust all Cloudflare IPs — pull the current list from
// https://www.cloudflare.com/ips-v4 and https://www.cloudflare.com/ips-v6
protected $proxies = [
'173.245.48.0/20',
'103.21.244.0/22',
'103.22.200.0/22',
'103.31.4.0/22',
'141.101.64.0/18',
'108.162.192.0/18',
'190.93.240.0/20',
'188.114.96.0/20',
'197.234.240.0/22',
'198.41.128.0/17',
'162.158.0.0/15',
'104.16.0.0/13',
'104.24.0.0/14',
'172.64.0.0/13',
'131.0.72.0/22',
];
protected $headers = Request::HEADER_X_FORWARDED_FOR;
Cloudflare also passes the real IP in a CF-Connecting-IP header, which you can log directly if you prefer.
I've been doing this long enough to be skeptical of anything that promises to replace a gnarly operational problem "in an afternoon." Cloudflare Tunnels mostly delivers on that, with the usual asterisks. The footguns are real but they're findable.
If you're maintaining a VPN that requires a Slack message every time someone gets a new laptop, this is worth your afternoon.
Need help shipping something like this? Get in touch.