Migrate from Tailscale to Cloudflare Warp
How it works
Every port on your server becomes reachable by name from anywhere, with no public DNS record and no open inbound port. Each laptop or phone runs the Cloudflare One Agent. Cloudflare Gateway resolves a private hostname and checks who you are, and an outbound-only tunnel on the server delivers the traffic.
architecture · device → Cloudflare Gateway → tunnel → server
Traffic flows left to right. The server opens the tunnel from its side, so your router needs no port forwarding.
Time: about 60 minutes of setup, then a few days running alongside Tailscale before you remove it.
Before you start
You need a Linux server on a LAN, a Cloudflare account, and about 60 minutes. Keep Tailscale running until Step 8; it is your way back in if something breaks.
Placeholders used in this guide. Replace them with your own values.
|
Placeholder |
What it is |
Example |
|
<SERVER_IP> |
The server's LAN IP |
192.168.1.50 |
|
<HOSTNAME> |
The private name you will type to reach it |
devbox.int.example.com |
|
<TEAM> |
Your Zero Trust team name |
myteam |
|
<TUNNEL> |
The tunnel's name |
devbox |
|
<ADMINS_LIST> |
Gateway email list of people allowed in |
devbox-admins |
|
<YOU> |
Your login email |
Requirements
• A Cloudflare account. The Zero Trust Free plan covers up to 50 users.
• An identity provider for login: Google, GitHub, Microsoft, or Cloudflare's one-time email PIN.
• A Linux server with systemd and outbound internet. cloudflared uses port 7844 (UDP for QUIC, TCP as fallback).
• A domain you own for <HOSTNAME>. No public DNS record is created; using your own domain just avoids name collisions.
• The Cloudflare One Agent (formerly WARP / 1.1.1.1) on each laptop and phone.
Step 1: Set up the Zero Trust org (10 min)
All of this is in the Cloudflare dashboard under Zero Trust (Cloudflare One). Cloudflare renames menu items often; search the dashboard for the setting name if a path below has moved.
1. Open Zero Trust, pick a team name (<TEAM>), and choose the Free plan.
2. Settings → Authentication: add a login method. We used Google sign-in.
3. Reusable components → Lists: create an Emails list named <ADMINS_LIST> and add <YOU>. Every policy below points at this list, so adding a person later is one edit.
4. Devices → Management → Device enrollment permissions: add a rule that allows emails in <ADMINS_LIST>.
5. Traffic policies → Traffic settings: turn on the Proxy for TCP and UDP. Without it, network policies cannot see WARP traffic and nothing reaches the server.
Step 2: Install cloudflared and create the tunnel (15 min)
The tunnel only dials out to Cloudflare, so the server opens no inbound port to the internet. Run these on the server.
1. Install cloudflared (Debian/Ubuntu shown; brew install cloudflared also works):
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
2. Log in and create the tunnel. login prints a URL; open it on any machine and approve.
cloudflared tunnel login
cloudflared tunnel create <TUNNEL> # prints <TUNNEL_ID>, writes ~/.cloudflared/<TUNNEL_ID>.json
chmod 400 ~/.cloudflared/<TUNNEL_ID>.json
3. Write ~/cloudflared/config.yml. It has no public hostnames; anything that arrives by hostname gets a 404.
tunnel: <TUNNEL_ID>
credentials-file: /home/<user>/.cloudflared/<TUNNEL_ID>.json
# WARP routing is always on in current cloudflared.
# Do not add the old warp-routing.enabled key; it is obsolete and causes a warning.
ingress:
- service: http_status:404
4. Check it: cloudflared tunnel --config ~/cloudflared/config.yml ingress validate
5. Run it as a systemd user service. Save as ~/.config/systemd/user/<TUNNEL>-cloudflared.service (fix the binary path with which cloudflared):
[Unit]
Description=<TUNNEL> Cloudflare Tunnel (WARP private-network access)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/cloudflared --no-autoupdate --config %h/cloudflared/config.yml tunnel run
Restart=always
RestartSec=10
[Install]
WantedBy=default.target
6. Start it and make it survive reboots:
sudo loginctl enable-linger $USER # user services start at boot without a login
systemctl --user daemon-reload
systemctl --user enable --now <TUNNEL>-cloudflared
cloudflared tunnel info <TUNNEL> # should list active connectors
Prefer a system service? sudo cloudflared service install does the same as root. Keep this tunnel separate from any tunnel that publishes public websites, so private routes never mix with public apps.
Step 3: Route only the server's IP through the tunnel (2 min)
Route a single /32, not your whole LAN. Then the tunnel can reach this one machine and nothing else on your network.
cloudflared tunnel route ip add <SERVER_IP>/32 <TUNNEL>
cloudflared tunnel route ip list # should show only <SERVER_IP>/32
Need other LAN devices later (a NAS, a printer)? Add each as its own /32 rather than routing the subnet.
Step 4: Device profile and split tunnels (15 min)
This is the step most likely to go wrong. The default WARP profile excludes all private ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), so traffic to <SERVER_IP> skips WARP and goes to whatever local network you are on. You need a profile that sends exactly <SERVER_IP> into WARP.
1. Devices → Device profiles → Create profile. Name it <TUNNEL> admins. Match on: User email in list <ADMINS_LIST>. Give it a lower precedence number than Default so it wins.
2. Service mode: Traffic and DNS. DNS-only or traffic-only modes break Step 5.
3. Split Tunnels: Exclude mode. Delete the private range that contains <SERVER_IP>, then add back every part of that range except <SERVER_IP>/32. This prints the exact list to paste in:
python3 -c "import ipaddress as i; [print(n) for n in i.ip_network('192.168.0.0/16').address_exclude(i.ip_network('192.168.1.50/32'))]"
Swap in your range and IP. For a /16 you get 16 entries. Check each one; we had a /32 typed where a /16 belonged.
4. On each laptop and phone: install the Cloudflare One Agent, log in to team <TEAM>, and connect.
5. Confirm the profile. In the agent's settings, the profile name should be <TUNNEL> admins. If it says Default, log out of the team and log back in. A device enrolled before the profile existed keeps the old one until you do.
The rest of your office LAN stays outside WARP, so local printers and devices keep working. iPhones run one VPN at a time, so Tailscale must be off there.
Step 5: Give the server a private hostname (5 min)
A Gateway DNS policy answers <HOSTNAME> with <SERVER_IP> for enrolled devices only. Nothing is published in public DNS.
Traffic policies → Firewall policies → DNS → Add a policy:
|
Field |
Value |
|
Name |
Local route to <TUNNEL> |
|
Condition 1 |
Host is <HOSTNAME> |
|
Condition 2 |
User Email in list <ADMINS_LIST> |
|
Action |
Override → <SERVER_IP> |
• Do not pick a name ending in .local, .lan, or .internal. Those are on WARP's Local Domain Fallback list, so lookups never reach Gateway.
• Use the list in Condition 2, not a single email. We matched one email at first and had to remember to change it when adding people.
• ssh <user>@<SERVER_IP> works whenever WARP is connected, even if DNS is misbehaving. Use it to separate DNS problems from routing problems.
Step 6: Decide who can reach the server (5 min)
Two Gateway network policies: allow your list, block everyone else. Anyone in your Zero Trust org who is not on the list gets nothing, even with WARP connected.
Traffic policies → Firewall policies → Network:
|
Order |
Name |
Conditions |
Action |
|
1 |
<TUNNEL> allow admins |
Destination IP is <SERVER_IP> AND User Email in list <ADMINS_LIST> |
Allow |
|
2 |
<TUNNEL> block others |
Destination IP is <SERVER_IP> |
Block |
• Lower precedence runs first. Put both above any broad Allow rule you already have.
• These policies only control who can reach the IP. SSH keys and app logins still apply on the server.
Adding a person later
1. Add their email to <ADMINS_LIST>. Because Steps 1, 4, 5 and 6 all use the list, this covers enrollment, profile, DNS and access.
2. Have them install the agent and log in to <TEAM>.
3. Add their SSH public key to ~/.ssh/authorized_keys on the server, or create them a user.
Step 7: Bind services to 0.0.0.0 and test (10 min)
Every port on the server is now reachable at <HOSTNAME>:<port>, but only for services listening on 0.0.0.0 (or <SERVER_IP>). cloudflared connects to <SERVER_IP>:<port>, so anything bound to 127.0.0.1 or to the Tailscale 100.x address is invisible.
1. Find services that will not be reachable:
ss -ltnp | grep -E '127\.0\.0\.1|100\.'
2. Rebind them. Common flags: vite --host 0.0.0.0, next dev -H 0.0.0.0, uvicorn app:app --host 0.0.0.0. Docker's -p 8080:8080 already binds 0.0.0.0.
3. Test from a client off your LAN (phone hotspot), with Tailscale quit:
dig +short <HOSTNAME> # expect <SERVER_IP>
ssh <user>@<HOSTNAME>
curl -I http://<HOSTNAME>:<port>
4. Confirm it really went through the tunnel. In journalctl -u ssh on the server, a WARP login shows source <SERVER_IP> (cloudflared connects locally). A LAN login shows the client's own address.
If you use AI coding agents on the server, add a rule to your global CLAUDE.md or AGENTS.md so they stop handing you localhost links:
This host is reached over Cloudflare WARP. Anything you start that serves a URL
(dev servers, previews, docs sites, web UIs) must bind 0.0.0.0, never localhost only.
Report URLs as http://<HOSTNAME>:<port>, not localhost or *.ts.net links.
If a tool can only bind loopback, say so instead of returning a localhost URL.
Step 8: Cut over from Tailscale
Run both side by side until every device works over WARP from outside your LAN. Then remove Tailscale. Tick these off as you go:
• ☐ SSH over WARP works from your laptop on a non-office network
• ☐ SSH over WARP works from your phone on cellular
• ☐ tailscale serve status checked; each proxied port is rebound to 0.0.0.0 (or moved to an Access-protected public hostname on a separate tunnel)
• ☐ Services listening only on the 100.x Tailscale IP are rebound
• ☐ Other tailnet users moved over (add them to <ADMINS_LIST>, Step 6)
• ☐ Bookmarks, SSH configs, docs and agent rules point at <HOSTNAME>, not *.ts.net
• ☐ Tailscale uninstalled on the server (sudo tailscale down, then remove the package) and removed from the Tailscale admin console
• ☐ Tailscale uninstalled on your devices
• ☐ Stale device registrations removed in Devices, and any API tokens made during setup deleted
Rollback, any time: cloudflared tunnel route ip delete <SERVER_IP>/32, or systemctl --user disable --now <TUNNEL>-cloudflared. Either cuts WARP access to the server immediately.
Troubleshooting
Most failures trace back to the device profile (Step 4). Start there.
|
Symptom |
Likely cause and fix |
|
Works in the office, times out everywhere else |
Device is on the Default profile, which keeps <SERVER_IP> local. Log out of the team in the agent and log back in. |
|
Name resolves but connection times out |
Split tunnel still excludes <SERVER_IP>, the TCP proxy is off (Step 1), or the tunnel is down. |
|
Name does not resolve |
Agent disconnected, mode is not Traffic and DNS, Tailscale's MagicDNS took over DNS (quit Tailscale), or your email is not in the DNS policy. |
|
Connection blocked |
Email not in <ADMINS_LIST>, or the Block policy sits above the Allow policy. |
|
Connection refused |
WARP is fine; the service is bound to 127.0.0.1 or the Tailscale IP (Step 7). |
|
Tunnel down |
journalctl --user -u <TUNNEL>-cloudflared. Check outbound port 7844 (UDP and TCP) is allowed. |
Did traffic reach Cloudflare at all? Open Logs → Gateway → Network and filter on destination <SERVER_IP>. No entries for a device means its traffic is going to the local network, which points back to the split tunnel.
Harmless log noise: the tunnel log may show steady connection refused errors for ports like 443 (TCP) or 10001 (UDP). Those are discovery probes from apps on your Mac hitting ports where nothing listens.
Useful server commands
systemctl --user status <TUNNEL>-cloudflared
journalctl --user -u <TUNNEL>-cloudflared -f
cloudflared tunnel info <TUNNEL>
cloudflared tunnel route ip list

