The Problem: Changing URLs for Local Apps
When developing, I often need to expose an app running on localhost to the internet to test webhooks, demo to a client, or let a teammate take a look. Tools like ngrok or Cloudflare's quick tunnels (trycloudflare.com) are fast, but the URL usually changes every time you restart, so you have to keep updating webhook URLs, OAuth callbacks, and so on.
The fix: use Cloudflare Tunnel with your own domain. The link stays the same, HTTPS is free, and there's no need to open ports or touch your router.
Requirements
- A domain whose nameservers you can change.
- A Cloudflare account (the Free plan is enough).
- A local app already running, e.g.
http://localhost:3000.
1. Add your domain to Cloudflare
- Log in to the Cloudflare Dashboard, choose Add a domain, and enter your domain (e.g.
example.com). - Select the Free plan. Cloudflare scans your existing DNS records; review them and continue.
- Cloudflare gives you two nameservers (like
xxx.ns.cloudflare.com). Go to your domain registrar and replace the current nameservers with these two. - Wait a few minutes to a few hours until the domain status becomes Active.
Tip: use a dedicated subdomain for dev, such as app.example.com, so you don't touch your production domain.
2. Install cloudflared
Windows
Open PowerShell:
winget install --id Cloudflare.cloudflared
# verify
cloudflared --version
If winget isn't available, download cloudflared-windows-amd64.exe from the cloudflared GitHub releases page, rename it to cloudflared.exe, and add it to your PATH.
Linux (Debian/Ubuntu)
sudo mkdir -p --mode=0755 /usr/share/keyrings
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg >/dev/null
echo 'deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared any main' | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt-get update && sudo apt-get install cloudflared
# verify
cloudflared --version
For other distros (CentOS, Fedora, Arch...), see the official Cloudflare documentation.
3. Create the tunnel
3.1. Log in
cloudflared tunnel login
This opens a browser so you can pick the domain to authorize. When it finishes, a cert.pem file is saved to ~/.cloudflared/ (Windows: C:\Users\<username>\.cloudflared\). On a headless Linux server, copy the URL printed in the terminal and open it on another machine.
3.2. Create the tunnel
cloudflared tunnel create dev-tunnel
This returns a Tunnel ID (UUID) and creates a credentials file <UUID>.json in the .cloudflared directory. Keep this file safe: anyone who has it can run your tunnel.
# list tunnels
cloudflared tunnel list
3.3. Write the config file
Create config.yml in the .cloudflared directory:
tunnel: <TUNNEL-UUID>
credentials-file: /home/<user>/.cloudflared/<TUNNEL-UUID>.json
ingress:
- hostname: app.example.com
service: http://localhost:3000
- hostname: api.example.com
service: http://localhost:8080
# the last rule is required (catch-all)
- service: http_status:404
On Windows, the credentials path looks like this:
credentials-file: C:\Users\<username>\.cloudflared\<TUNNEL-UUID>.json
Each hostname + service block is a "route": requests for that hostname are forwarded to the matching local service. A single tunnel can serve multiple apps at once.
4. Add routes (DNS) for the tunnel
cloudflared tunnel route dns dev-tunnel app.example.com
cloudflared tunnel route dns dev-tunnel api.example.com
This automatically creates a CNAME record pointing app.example.com to <TUNNEL-UUID>.cfargotunnel.com, in Proxied mode (orange cloud). Every new subdomain you add to config.yml needs its own route dns command.
5. Run the tunnel
cloudflared tunnel run dev-tunnel
Open https://app.example.com. If you see your local app, it works. The link stays the same no matter how many times you stop and restart the tunnel.
Run in the background as a service
Linux: the service reads its config from /etc/cloudflared/, so copy the files there:
sudo mkdir -p /etc/cloudflared
sudo cp ~/.cloudflared/config.yml /etc/cloudflared/config.yml
sudo cp ~/.cloudflared/<TUNNEL-UUID>.json /etc/cloudflared/
# change credentials-file in config.yml to /etc/cloudflared/<TUNNEL-UUID>.json
sudo cloudflared service install
sudo systemctl enable --now cloudflared
sudo systemctl status cloudflared
Windows (PowerShell run as Administrator):
cloudflared service install
Note: the service runs under a system account, so it may not be able to read your user's .cloudflared folder. If you hit errors, use a dashboard-managed tunnel and install the service with a token (see below).
Alternative: create the tunnel in the Dashboard (token-based)
Go to Zero Trust → Networks → Tunnels → Create a tunnel, choose Cloudflared, give it a name, and Cloudflare shows an install command with a token. The Public Hostname mapping (domain → local service) is also configured right in the UI:
cloudflared service install <TOKEN>
This works the same on Windows and Linux, and changing routes later is just a few clicks on the web.
6. Other useful settings for development
Disable caching on your dev domain
Cloudflare may cache static files (JS, CSS, images), so you edit code and don't see the changes. Two options:
- Development Mode: go to Caching → Configuration and enable Development Mode. Cloudflare temporarily bypasses the cache and turns it off automatically after 3 hours. It applies to the whole zone, not just one subdomain.
- Cache Rule (recommended): go to Caching → Cache Rules → Create rule, set the condition
Hostname equals app.example.com, and choose Bypass cache. This only affects your dev subdomain.
Your app should also send Cache-Control: no-store in the dev environment.
DNS and SSL settings
- The tunnel's DNS record must stay Proxied. Don't switch it to DNS only.
- The free Universal SSL certificate only covers the root domain and one subdomain level.
app.example.comis fine, buta.app.example.comis not. Use flat names such asapp-dev.example.com.
Local app uses HTTPS or needs a custom Host header
ingress:
- hostname: app.example.com
service: https://localhost:8443
originRequest:
noTLSVerify: true # skip verification for self-signed local certs
httpHostHeader: localhost # if the app checks the Host header
- service: http_status:404
Protect your dev link with Cloudflare Access
A public link can be opened by anyone who knows the URL. Use Zero Trust → Access → Applications to require login (email OTP, Google, GitHub...) before access. The Free plan supports a limited number of users.
Handy management commands
cloudflared tunnel list # list tunnels
cloudflared tunnel info dev-tunnel # connector status
cloudflared tunnel ingress validate # validate config.yml
cloudflared tunnel cleanup dev-tunnel # clean up stale connectors
cloudflared tunnel delete dev-tunnel # delete a tunnel (stop it first)
Common errors
- Error 1033: the tunnel isn't running or lost its connection.
- 502 Bad Gateway: the local app isn't responding. Check the port and protocol (
httpvshttps). - Domain doesn't resolve:
route dnswasn't run, or the nameserver change hasn't finished. - Code changes don't show up: it's caching; see the caching section above.
Summary
cloudflared tunnel login
cloudflared tunnel create dev-tunnel
# write config.yml with ingress rules
cloudflared tunnel route dns dev-tunnel app.example.com
cloudflared tunnel run dev-tunnel
You only set this up once. After that, your local app always lives at a fixed address with HTTPS, and you never have to update webhook URLs again.