Expose locally-running apps to stable public HTTPS URLs using a single per-developer Cloudflare Tunnel (cloudflared). Use whenever you need an external service to reach your local dev server — receiving webhooks (Box, Stripe, GitHub, Twilio, etc.), testing OAuth callbacks, sharing a work-in-progress with a teammate, or previewing on a real device. Also use when the user mentions cloudflared, "tunnel", "trycloudflare", "public URL for localhost", "webhook can't reach my machine", or asks how to route a domain to their local port.
72
89%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Route public hostnames on a Cloudflare-managed domain to apps running on
localhost. A named tunnel gives stable hostnames that survive restarts —
register them with external services once and forget them. This is the right
choice for webhook development, where re-registering a URL every session is
painful.
(If you only need a throwaway URL for a single session and don't own a
Cloudflare domain, cloudflared tunnel --url http://localhost:<port> prints a
random *.trycloudflare.com URL with no account or config needed. The rest of
this skill covers the named-tunnel setup, which is what you want for anything
recurring.)
Run a single tunnel named after yourself (<username>) and route every
project through it. A tunnel isn't tied to one app — its ingress list maps
many hostnames to many local ports, and one cloudflared process serves them
all. This keeps setup to one login, one credentials file, one config, and one
running process no matter how many projects you work on.
Give each project a hostname suffixed with your username so they never collide
with another developer's. Keep it a single label (<app>--<username>, not
<app>.<username>) — a nested subdomain adds a DNS level that a one-level
wildcard cert won't cover:
<username> (e.g. mhale)<app>--<username>.<your-domain>
(e.g. fmi-atlas--mhale.rolemodel.dev, other-app--mhale.rolemodel.dev)Substitute your real username, app slugs, and Cloudflare domain throughout.
cloudflared installed (brew install cloudflared on macOS).cloudflared tunnel login.cloudflared tunnel loginThis opens a browser. Authenticate with the account that manages the domain and
authorize the domain you'll route to. It writes a cert.pem into
~/.cloudflared/.
cloudflared tunnel create <username>This provisions the tunnel and writes a credentials file
~/.cloudflared/<TUNNEL-ID>.json. You do this once — every project reuses this
same tunnel.
Run this once per project, all pointing at your one tunnel:
cloudflared tunnel route dns <username> <app>--<username>.<your-domain>One ~/.cloudflared/config.yml holds an ingress rule per project. Look up the
tunnel ID first:
cloudflared tunnel list | awk '$2 == "<username>" { print $1 }' # the IDtunnel: <tunnel-id>
credentials-file: /Users/<username>/.cloudflared/<TUNNEL-ID>.json
ingress:
- hostname: <app>--<username>.<your-domain>
service: http://localhost:3000 # this project's port
- hostname: <other-app>--<username>.<your-domain>
service: http://localhost:3001 # another project's port
- service: http_status:404 # catch-all; required last ruleThe final catch-all http_status:404 rule is mandatory — cloudflared rejects
a config whose last ingress rule has a hostname.
Keep credentials-file in sync with the ID from tunnel list. If you ever
delete and recreate the tunnel, the ID changes and this line must be updated —
a stale ID here points at a tunnel that no longer exists, so cloudflared fails
to start and no routes appear in the Cloudflare UI.
Web frameworks reject requests whose Host header isn't in an allowlist, so a
tunneled request 403s until you permit the domain. In a Rails app, add a regex
covering the whole domain to config/environments/development.rb so every
project's hostname is accepted without editing config per app:
config.hosts << /\A.*\.<your-domain-escaped>\z/ # e.g. /\A.*\.rolemodel\.dev\z/Other frameworks have an equivalent (Vite's server.allowedHosts, Next.js
allowedDevOrigins, Django's ALLOWED_HOSTS, etc.) — set it there instead.
Rather than babysitting cloudflared tunnel run in a foreground terminal,
install it as a background service so the tunnel is always up and reconnects on
its own — you never have to remember to start it before your app.
On macOS, install it as a per-user launch agent (starts at login, reads your
existing ~/.cloudflared/config.yml — no sudo, no root):
cloudflared service installVerify the generated plist actually runs the tunnel. Some cloudflared
versions write a launch agent whose ProgramArguments is only the binary path
with no subcommand, so launchd runs a bare cloudflared, which exits with
"use cloudflared tunnel run to start tunnel " and then crash-loops via
KeepAlive. Check ~/Library/LaunchAgents/com.cloudflare.cloudflared.plist; if
ProgramArguments lacks a tunnel/run entry, fix it to:
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/cloudflared</string>
<string>--no-autoupdate</string>
<string>--config</string>
<string>/Users/<username>/.cloudflared/config.yml</string>
<string>tunnel</string>
<string>run</string>
</array>then reload it: launchctl unload <plist> && launchctl load <plist>.
Manage the agent through launchd (user domain — no sudo), and reload it after
any config.yml change:
launchctl list | grep cloudflared # col 1 = PID (running), col 2 = last exit code
launchctl unload ~/Library/LaunchAgents/com.cloudflare.cloudflared.plist # stop
launchctl load ~/Library/LaunchAgents/com.cloudflare.cloudflared.plist # start
cloudflared service uninstall # remove the servicecloudflared logs everything (including INF lines) to stderr, so watch
~/Library/Logs/com.cloudflare.cloudflared.err.log — look for Registered tunnel connection on success. Then confirm routing end to end:
curl -sS -o /dev/null -w "HTTP %{http_code}\n" https://<app>--<username>.<your-domain>/(sudo cloudflared service install instead installs a boot-time launch daemon
that reads config from /etc/cloudflared/, logs to /Library/Logs/, and is
managed with sudo launchctl. The login agent above is the better fit for a dev
machine — it runs as you and uses the config you already set up.)
No new tunnel needed — reuse the one you have:
cloudflared tunnel route dns <username> <new-app>--<username>.<your-domain>ingress rule mapping that hostname to the new project's port.launchctl stop com.cloudflare.cloudflared && launchctl start com.cloudflare.cloudflared6695348
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.