Every idea I’ve had starts the same way: it runs on my laptop, it works, and I want someone else to see it. For a while the next step was always a cloud bill. A machine, a database, a bucket, a domain pointed at it, and a monthly charge for something three people will look at. For a side project, or the first month of a real one, that’s the wrong order: you pay for production before you know whether there will be one.
This post is the other order. The service keeps running on the machine it was built on, and a Cloudflare Tunnel gives it a public HTTPS hostname. Nothing on the laptop listens to the internet: no port forwarding, no router settings, no exposed IP address. The machine dials out to Cloudflare, and Cloudflare sends requests back down that connection. The tunnel is free, and the TLS certificate, DDoS protection and caching come with the Cloudflare zone you probably already have.
The example is real. One of my products runs on Fly.io, and its mobile app gets over-the-air JavaScript updates from the backend (self-hosted Expo Updates). I wanted a second copy of that update server running on my Mac for development builds, with production left exactly as it was. The outputs below are from that afternoon, including the part where it didn’t work.
What a Cloudflare Tunnel actually is#
A normal server waits for connections. To reach a laptop from the internet, you’d have to open a port on your router, forward it to the laptop, and hope your ISP gives you a public IPv4 address (in a lot of places, mine included, it doesn’t). Then everything listening on that port is reachable by every scanner on the internet, all day.
A tunnel turns that around. cloudflared runs next to your app and opens outbound connections to Cloudflare’s edge, four of them to two nearby data centres. A DNS record in your zone points a hostname at the tunnel. When a request arrives for that hostname, Cloudflare terminates TLS and forwards the request down one of those connections. cloudflared then hands it to whichever local service your config names.
For security, the important part is that your router has no inbound rule and your IP address never appears in DNS. Someone scanning your home connection finds nothing, because nothing on it is listening.
Setting up cloudflared, start to finish#
You need a domain on Cloudflare (the free plan is enough) and cloudflared installed (brew install cloudflared on a Mac).
1. Log in once. This opens a browser, you pick the zone, and it saves a certificate that lets this machine create tunnels and DNS records in that zone:
cloudflared tunnel login2. Create the tunnel. I keep everything for this project in its own folder, because the credentials file is the tunnel’s identity: anyone who has it can serve traffic for your hostnames.
mkdir -p ~/.cloudflared/myapp && cd ~/.cloudflared/myapp
cloudflared tunnel create --credentials-file ./creds.json myapp-local
chmod 600 creds.jsonTunnel credentials written to ./creds.json. Keep this file secret. To revoke these credentials, delete the tunnel.
Created tunnel myapp-local with id 1ade030c-…3. Point hostnames at it. One command per hostname creates a proxied CNAME to <tunnel-id>.cfargotunnel.com:
cloudflared tunnel route dns myapp-local ota.example.com
cloudflared tunnel route dns myapp-local checkin-local.example.com4. Write the ingress rules. This is the part that matters for security, so it gets its own section below.
5. Run it. My app already runs under Docker Compose, so cloudflared is one more container on the same network. It reaches the app by service name, and the app doesn’t need a port published on the host at all:
# compose.tunnel.yml, used alongside the project's docker-compose.yml
services:
cloudflared:
image: cloudflare/cloudflared:2026.10.7
command: tunnel --config /etc/cloudflared/config.yml run
volumes:
- ${HOME}/.cloudflared/myapp:/etc/cloudflared:ro
restart: unless-stopped
depends_on: [api, edge]docker compose -f docker-compose.yml -f ~/.cloudflared/myapp/compose.tunnel.yml up -d cloudflaredINF Registered tunnel connection connIndex=0 … location=icn06 protocol=quic
INF Registered tunnel connection connIndex=1 … location=icn07 protocol=quic
INF Registered tunnel connection connIndex=2 … location=icn07 protocol=quic
INF Registered tunnel connection connIndex=3 … location=icn06 protocol=quicFour connections to Seoul, and the laptop is on the internet, from the outside in. restart: unless-stopped brings it back after a reboot as soon as Docker starts.
Expose paths, not servers#
This is the mistake I nearly made, and I’d guess it’s the most common one. The easy config is a single line: hostname → http://localhost:8000. That publishes the whole development server.
Development servers aren’t built for that. Mine runs with DEBUG=True, which shows full tracebacks and settings on any error. It also runs with authentication switched off (AUTH_MODE=disabled), so that any bearer token logs you in as a test user. That’s exactly right on localhost and a disaster on a public hostname. Yours probably has something similar: an admin with a default password, a seeding endpoint, a debug toolbar, or an API that trusts its caller because only you ever call it.
So the tunnel config is an allowlist. cloudflared checks each request against the ingress rules in order, by hostname and by a regular expression on the path. The first match wins, and the last rule catches everything else:
tunnel: myapp-local
credentials-file: /etc/cloudflared/creds.json
ingress:
# OTA: the two read-only endpoints the phone calls, and the files they link.
- hostname: ota.example.com
path: ^/api/expo-updates/(manifest|status)/$
service: http://api:8000
- hostname: ota.example.com
path: ^/media/
service: http://api:8000
# Device check-ins, authenticated by each device's own key.
- hostname: checkin-local.example.com
path: ^/c/$
service: http://edge:8081
# Everything else, including /admin/ and the publish endpoint: 404 at the edge.
- service: http_status:404The publishing endpoint lives on the same server under the same prefix (/api/expo-updates/import-update/), and it isn’t in the list. Neither is the admin, or the rest of the API. Those requests never leave Cloudflare.
You don’t have to trust your regexes; cloudflared will tell you which rule a URL matches:
cloudflared tunnel --config config.yml ingress rule https://ota.example.com/admin/https://ota.example.com/api/expo-updates/manifest/ service: http://api:8000
https://ota.example.com/api/expo-updates/import-update/ service: http_status:404
https://ota.example.com/admin/ service: http_status:404
https://ota.example.com/media/expo-updates/x.js service: http://api:8000
https://checkin-local.example.com/c/ service: http://edge:8081
https://checkin-local.example.com/health service: http_status:404I’d put that check in a script and run it every time the config changes. It takes seconds, and a regex that matches more than you meant is the kind of mistake you only find out about from someone else.
Two more rules of thumb:
- What you expose should authenticate itself. The OTA endpoints serve public, read-only data. Device check-ins are signed with per-device keys. Neither one depends on the network to keep it safe. If you want to show someone a page that has no login, put Cloudflare Access in front of that hostname (free for up to 50 users) instead of building a login.
- Don’t run the tunnel as a background part of your dev workflow. Run it on purpose, for the hostname you mean, and stop it when you’re done.
docker compose stop cloudflaredtakes everything off the internet in one command.
The redirect that ate my hostnames#
With the tunnel connected and the rules verified, I tried it from outside:
https://ota.example.com/api/expo-updates/status/?… 301
https://ota.example.com/api/expo-updates/manifest/ 301
https://ota.example.com/admin/ 301
https://checkin-local.example.com/c/ 301Every path returned the same 301, including ones the tunnel should have answered with 404. That means the request never reached cloudflared. The Location header said https://www.example.com/, and the server: cloudflare header settled it: Cloudflare was answering on its own.
I’d done this to myself. Months ago I added a zone-level redirect rule, “any other subdomain → www”, as a sinkhole for scanners that walk invented subdomains. Redirect rules run in Cloudflare’s request pipeline before the request is sent to the origin, and a tunnel is just an origin. The same rule caught my preview environments before Workers could see them. The fix is the same as last time: one more exception in the rule’s expression.
… and not http.host in {"ota.example.com" "checkin-local.example.com"}If your tunnel hostname answers with a redirect or an error page from Cloudflare instead of your app, check Rules → Redirect Rules, Page Rules and any WAF custom rules for the zone before you debug the tunnel. cloudflared logs every request it forwards, so an empty log means the request never got that far.
What it can’t do#
- UDP from the public internet. Public tunnel hostnames carry HTTP(S) (plus WebSockets and gRPC). My product’s devices normally check in over CoAP on UDP, and that can’t go through a tunnel; the devices on the local copy fall back to HTTPS check-ins, which cost them a few seconds on each one. If your idea is a game server or VoIP, a tunnel isn’t the tool.
- Uptime. It’s your laptop. Close the lid and the site is down; Cloudflare shows a 530 error page. That’s fine for a demo, a beta with ten people, or a staging copy. It isn’t fine for anything someone depends on.
- Things baked into a build. A mobile app’s update URL is native config, so only builds made with the tunnel’s URL use it. My store builds keep talking to production, and that’s why this setup was safe to try.
The cost: is Cloudflare Tunnel free?#
A Cloudflare zone on the free plan, the tunnel, its certificate, and the DNS records: $0. The machine was already running. The only real cost is that you’re now the uptime team.
What it saves you is the phase between “it works on my machine” and “it’s worth paying to host”. You can send a link to a tester, a designer or a first customer the same afternoon. If nobody cares, you’ve lost nothing. If they do, moving to a real host is one change: point the CNAME somewhere else.
