DEV Community

John
John

Posted on

Tailscale vs Cloudflare Tunnel: How to Reach Self-Hosted Docker Services Without a Public IP

Use Tailscale when every person and device that needs access can install a client, and use Cloudflare Tunnel when someone with only a browser and a link has to get in. Tailscale builds an encrypted WireGuard mesh between your own machines, so your Docker services stay invisible to the public internet and any TCP or UDP port works. Cloudflare Tunnel runs an outbound-only connector from your host to Cloudflare's edge, publishing a hostname on the public internet with TLS terminated by Cloudflare. Neither needs a static IP, a port forward or a manually issued certificate, and running both on the same host is a normal, supported setup.

TL;DR by reader profile:

  • Two person startup running internal tools (founders sharing Gitea, Grafana and a Postgres box): Tailscale, because nothing you self-host ever gets a public hostname and both of you already control every laptop involved.
  • Freelancer showing a client a staging site (a preview build that must open in Safari on someone else's phone): Cloudflare Tunnel, because the visitor installs nothing and the link just works.
  • Sysadmin living in SSH, RDP and database clients (managing five Docker hosts across two locations): Tailscale, because arbitrary TCP and UDP ports and direct peer-to-peer routing matter more than a pretty URL.
  • Homelabber streaming media to family (Jellyfin and Navidrome for four relatives): Tailscale, because large non-HTML media served through Cloudflare's free proxy sits against its terms, and direct WireGuard paths beat a proxied round trip.
  • Small team publishing a real public service (a marketing site plus a webhook receiver for Stripe): Cloudflare Tunnel, because you want external systems and strangers to reach it without an invitation.
  • Anyone who needs both at once (private admin panel, public app, one host): run both connectors side by side and split by hostname.

The central tradeoff is simple: Tailscale asks you to install a client on every device that needs access, while Cloudflare Tunnel asks you to route your traffic through a third party edge that terminates your TLS.


Table of contents


What do Tailscale and Cloudflare Tunnel actually do differently?

They solve two different problems that happen to share a symptom. Tailscale is a private network. Cloudflare Tunnel is a publishing pipe. Both remove the need for port forwarding, and that is where the similarity ends.

Tailscale installs a daemon, tailscaled, on each machine and gives it a stable address in the 100.64.0.0/10 range. Peers then negotiate direct WireGuard sessions with each other. Cloudflare Tunnel installs cloudflared, which dials out to Cloudflare's edge over HTTPS and waits for inbound requests aimed at a hostname on a domain in your Cloudflare account.

Aspect Tailscale Cloudflare Tunnel
What it creates A private mesh between your own devices, addressed over 100.64.0.0/10 A public hostname on a domain you have delegated to Cloudflare
Who can connect Only devices enrolled in your tailnet and allowed by your ACL policy Anyone on the internet, unless you add a Cloudflare Access policy
Transport WireGuard, direct peer-to-peer when NAT traversal succeeds, DERP relay when it fails TCP connections from cloudflared to Cloudflare, then HTTPS from the edge to the visitor
Protocol scope Any IP traffic, including UDP, SMB, RDP and database wire protocols HTTP and HTTPS by default, other protocols only through extra client tooling
Client requirement A Tailscale client on every participating device Nothing on the visitor side, just a browser
TLS ownership End to end between peers, no certificate to manage Terminated at Cloudflare's edge, certificate issued and held there

The free tiers reflect that split: Tailscale's Personal plan counts devices and users, while Cloudflare's Zero Trust free tier counts seats behind Access policies.


How does each one reach a Docker container with no public IP?

Both approaches ignore your router entirely. Neither one opens an inbound port, because both rely on connections that your host makes outward.

  • Tailscale on the host, services on published ports: you install the client on the Docker host, then reach containers at http://100.x.y.z:8096 using whatever port each container publishes. This is the simplest layout and needs no compose changes at all.
  • Tailscale as a sidecar container: you run the tailscale/tailscale image with NET_ADMIN and /dev/net/tun, then attach the app with network_mode: "service:tailscale". The app gets its own address and hostname in the tailnet, separate from the host, which is how you give one container an identity your ACLs can target.
  • Tailscale as a subnet router: you start the client with --advertise-routes=172.18.0.0/16 and approve the route in the admin console, so remote peers address containers on the Docker bridge network directly. Useful, but Docker's dynamically assigned subnets make this brittle unless you pin them in your compose file.
  • cloudflared on the host or in compose: the connector joins your Docker network and proxies to a service name, so an ingress rule points at http://nextcloud:80 rather than a published port. Your container never needs a host port binding, which is the cleanest firewall posture of the four.
  • Outbound requirements for both: cloudflared needs outbound TCP 443 and works behind almost any NAT, while Tailscale prefers UDP 41641 outbound for direct paths and falls back to relaying over 443 when UDP is blocked.

The practical difference: Tailscale gives the machine an address, Cloudflare Tunnel gives the service a URL.


How do you set up Tailscale on a Docker host, step by step?

The whole process takes about ten minutes on a fresh Debian or Ubuntu host, and most of that is waiting for an apt install.

  • Create the tailnet first: sign up, then note the ACL editor in the admin console. Everything you do later, including which laptop may reach which container, is expressed in that one JSON policy file, so it pays to open it before you enroll anything.
  • Install the client on the host: run curl -fsSL https://tailscale.com/install.sh | sh, then sudo tailscale up. The command prints a login URL. Approve it once and the machine keeps its address across reboots.
  • Use an auth key for unattended hosts: generate a pre-authorized key in the admin console and pass it with sudo tailscale up --authkey=tskey-auth-.... Tag the key, for example --advertise-tags=tag:docker, so ACLs can match the host by role instead of by name.
  • Disable key expiry on servers: device keys expire by default, which silently drops a headless box off the tailnet. Set the node to never expire in the console, or plan a renewal.
  • Turn on MagicDNS and verify: with MagicDNS enabled you reach the box at http://docker-host:8096 rather than memorizing a 100.x address. Confirm the path with tailscale status, which marks each peer as direct or relay.

Where that host lives is your decision. A home mini PC, a rented VPS, a NAS and a managed service are all valid. Yundera is a managed Personal Cloud Server, built on CasaOS, that runs self-hosted apps as Docker containers on a server dedicated to the user. In each case the install steps above are identical, because Tailscale only needs outbound connectivity.


How do you set up Cloudflare Tunnel on a Docker host, step by step?

The prerequisite is the part people miss: you need a domain whose nameservers point at Cloudflare. Without that delegation there is no hostname to publish, and no amount of connector configuration fixes it.

  • Delegate the domain: add the zone in the Cloudflare dashboard and switch your registrar's nameservers to the pair Cloudflare assigns. Propagation is usually quick, but allow for a slow registrar before you plan the rest.
  • Create the tunnel and copy the token: in Zero Trust, under Networks, create a tunnel and choose the Docker install method. Cloudflare hands you a long-lived token. Treat it as a credential, because anyone holding it can publish from your name.
  • Run the connector as a container: use cloudflare/cloudflared:latest with the command tunnel --no-autoupdate run --token $TUNNEL_TOKEN, put the token in an .env file rather than inline in docker-compose.yml, and attach the container to the same Docker network as your app.
  • Add public hostname routes: map app.example.com to a service URL such as http://gitea:3000. The DNS record is created for you as a proxied CNAME, so there is nothing to add by hand.
  • Decide on Access before you share the link: a bare public hostname is open to the internet and to crawlers. Adding an Access policy with email one time passcodes puts a login in front of it.
  • Where the connector runs: a home server, a NAS, a VPS and Yundera are all workable hosts, and on Yundera each app is reachable on a public HTTPS subdomain via NSL.SH mesh routing, so a tunnel is one choice among several rather than the only route in.

Verify with cloudflared tunnel list, which shows the connection count.


Which one is better for private admin panels only your team touches?

Tailscale, and the margin is not close. A Portainer instance on port 9443, a Traefik dashboard or a Grafana admin login has no business resolving in public DNS, and with a tailnet it never does.

  • Nothing to discover: a tailnet device has no public DNS record and answers nothing from the open internet, so credential stuffing and scanner traffic against your admin panel drop to zero by construction. A Cloudflare hostname stays publicly resolvable even with an Access policy in front, which means the login page itself is reachable and probeable.
  • Authentication you already own: Tailscale authenticates the device through your existing identity provider at enrollment, then every later connection rides that enrolled key. Nobody types a password into a self-hosted admin panel from an unknown browser.
  • ACLs scoped to a port: the policy file lets you write a rule granting tag:founder access to tag:docker:9443 and nothing else. The same box can hold a database on 5432 that only one machine may reach, expressed in a few lines of JSON rather than in iptables.
  • Tailscale SSH as a bonus: the client can terminate SSH itself, so you get access to the Docker host without distributing authorized_keys files or leaving port 22 listening anywhere.
  • The real cost: every admin needs the client installed. On a two person team with two laptops and two phones, that is four installs inside the Personal plan's limits. On a 30 person company with contractors on their own hardware, it becomes an onboarding task.

Use Cloudflare Tunnel for an admin panel only when you genuinely cannot install software on the accessing device, and then put an Access policy in front of it.


Which one is better for a service you need to share with clients?

Cloudflare Tunnel wins here for the same reason it loses on admin panels: the visitor needs nothing but a link. A client reviewing a staging build on an iPad in a meeting will not install a mesh VPN, and asking them to is how a review slips a week.

  • Zero friction on the far side: app.example.com resolves, serves a valid certificate and opens. No client, no invitation to accept, no device enrollment, no explanation of what a tailnet is.
  • Access policies instead of accounts: a Cloudflare Access policy with email one time passcodes lets you allow a named list of client addresses, for example everyone at @clientcompany.com, without creating users inside your self-hosted app. The visitor gets a six digit code by email and lands on your service.
  • Webhooks and machine callers work: Stripe, GitHub and any other service that posts to a URL can reach a tunnel hostname. They can never reach a tailnet address, so if your app receives webhooks, the decision is already made.
  • Sharing scales without seats on your side: 50 clients browsing a public hostname cost you nothing extra in connector configuration, while 50 Tailscale users means 50 enrollments and a plan that counts them.
  • What you give up: your traffic transits Cloudflare's edge, your certificate lives there, and the hostname is visible in Certificate Transparency logs, so the existence of the service is public even when the content is gated.

Tailscale has one credible answer for shared access: node sharing, which invites an external person's own tailnet device to reach a single machine. It works well for a contractor who already uses Tailscale. It does not work for a client who does not.


Do SSH, RDP, UDP and other non-HTTP services work on each one?

Tailscale treats everything as IP traffic, so the answer is yes by default. Cloudflare Tunnel is built around HTTP, and anything else needs either a client install on the far side or a paid Zero Trust feature, which undoes the main reason you picked it.

Protocol Tailscale Cloudflare Tunnel
SSH on 22 Works natively, plus optional Tailscale SSH with ACL-based authorization Needs cloudflared access ssh on the client, or browser rendered SSH through Zero Trust
RDP on 3389 Works natively, connect to the peer address in any RDP client Needs the WARP client enrolled, or browser rendered RDP on a paid Zero Trust plan
PostgreSQL on 5432 or MySQL on 3306 Works natively, point your client at the tailnet hostname Needs cloudflared access tcp --hostname db.example.com --url localhost:5432 on each developer machine
SMB on 445 Works, which is why Tailscale is common for NAS shares across sites Not a practical fit, SMB over a public HTTP edge is not the intended use
UDP services, for example a game server on 25565 or a DNS resolver on 53 Works, UDP is carried like any other IP traffic Private network UDP requires WARP, there is no plain tunnel equivalent
Plain HTTP and HTTPS apps Works Works, this is the native case

The pattern is consistent. If the thing you need to reach speaks anything other than HTTP, Tailscale handles it with no extra moving parts, while Cloudflare asks you to install cloudflared or WARP on the client. At that point you have a client install on every device anyway, and the comparison collapses back to which mesh you prefer.


How much latency and throughput do you give up with each?

Measure it yourself rather than trusting any published figure, because both results depend almost entirely on your network path. The shape of the cost, though, is predictable.

  • Tailscale on a direct path: two peers that complete NAT traversal exchange packets straight to each other, so added latency is roughly the encryption overhead plus the difference between the direct route and your old route. Confirm the path with tailscale ping hostname, which reports whether it went direct or via a relay, and check candidate paths with tailscale netcheck.
  • Tailscale on a DERP relay: when UDP is blocked, traffic detours through a Cloudflare-independent relay server chosen by region, adding that round trip to every packet. Relayed sessions are the single biggest performance cliff in a tailnet, and tailscale status flags them with relay next to the peer.
  • Kernel versus userspace WireGuard: on Linux the client uses the in-kernel WireGuard implementation where available and falls back to userspace wireguard-go otherwise, which costs CPU on low-power hosts. Tailscale's default MTU of 1280 bytes also means slightly more packets per megabyte than an untunneled link.
  • Cloudflare Tunnel is always a detour: the visitor connects to a Cloudflare data centre, which forwards to wherever your connector sits. If the visitor is next to your server but the chosen edge is not, you pay that geography twice on every request.
  • Hard protocol limits to plan around: Cloudflare returns error 524 when an origin takes longer than 100 seconds to respond, and the free plan caps request body size at 100 MB. Large uploads and long-running jobs need chunking or a different route.

For bulk transfers and interactive SSH, a direct tailnet path is the better instrument.


How much does each really cost over three years?

Both start at zero for a two person team, so the three year number is driven by what grows: users on one side, domains and plan tiers on the other. Check current pricing pages before you commit, because per user rates change.

Cost line Tailscale over 36 months Cloudflare Tunnel over 36 months
Entry tier Free Personal plan covers 3 users and 100 devices, which fits a founder pair with laptops, phones and several Docker hosts Tunnels are free on any Cloudflare plan, and Zero Trust access policies are free up to 50 seats
What makes it grow Headcount and contractors, since billing is per user per month on paid plans once you pass the free user count Feature tier, since larger upload limits, longer timeouts and advanced policies sit on paid plans
Mandatory extras None, you do not need to own a domain A registered domain delegated to Cloudflare, renewed annually for the full three years
Hidden operational cost Onboarding each new device and renewing or disabling key expiry on headless hosts Guarding the tunnel token, rotating it after staff changes, and auditing which hostnames are publicly resolvable
Escape hatch if pricing shifts Headscale, an open source control server you self-host, keeps the same clients working Nothing equivalent, moving off means rebuilding ingress with a reverse proxy and your own certificates

The honest summary: for two people, both are effectively free for 36 months and the money is not the deciding factor. The decision becomes financial somewhere around the point where you add a tenth teammate, and at that point Tailscale's per user billing is the line that moves while Cloudflare's stays flat.


Tailscale Serve and Funnel vs Cloudflare public hostnames: where do they overlap?

This is the one place the two products compete directly. Funnel publishes a tailnet service to the open internet, which is exactly what a Cloudflare public hostname does.

  • Serve is internal only: tailscale serve --bg 8096 puts a valid HTTPS certificate on your MagicDNS name, so you reach https://docker-host.tailnet-name.ts.net instead of an IP and a port. Nothing becomes public. This is the fastest way to stop browser certificate warnings on a self-hosted app.
  • Funnel is the public step: tailscale funnel --bg 8096 takes that same service and makes it reachable from any browser. You must first enable HTTPS certificates for the tailnet and grant the node the funnel attribute in your policy file, which is a deliberate speed bump.
  • Funnel's port restriction is the catch: inbound Funnel traffic is accepted on 443, 8443 and 10000 only. A Cloudflare hostname always answers on 443 with whatever path routing you configure, with no port list to work around.
  • The hostname is not yours: Funnel serves on a subdomain of ts.net, so you cannot publish app.yourcompany.com through it. If branding or a customer-facing URL matters, Cloudflare Tunnel or a reverse proxy is the answer.
  • Throughput expectations differ: Funnel traffic is relayed through Tailscale infrastructure and is positioned for demos, webhooks and light sharing rather than serving a busy site.
  • Where the service runs is a separate choice: a home server, a NAS, a VPS and Yundera all host the same containers, and each app on Yundera already answers on a public HTTPS subdomain via NSL.SH mesh routing, so Funnel may be redundant there.

Use Funnel for a quick public link. Use Cloudflare for a hostname you intend to keep.


Who can see your traffic, and what does each side log?

This is the sharpest privacy difference between the two, and it is structural rather than a matter of policy. With Tailscale your payload stays encrypted end to end between peers. With Cloudflare Tunnel your TLS terminates at the edge, so Cloudflare handles your requests in cleartext.

  • Tailscale sees metadata, not content: the coordination server distributes public keys and endpoint candidates so peers can find each other. It does not hold your private keys, which never leave the device. Even relayed traffic through DERP stays encrypted, because the relay forwards WireGuard packets it cannot read.
  • Cloudflare sees everything by design: terminating TLS is how it serves a certificate, applies WAF rules and enforces Access policies. Request headers, URLs, cookies and bodies pass through its proxy in plaintext. That is not a flaw, it is the only way an HTTP edge can work, but it means your self-hosted service is no longer a two party conversation.
  • What each dashboard records: Tailscale keeps network flow logs and device audit logs on paid tiers, showing which node talked to which and when. Cloudflare keeps Access authentication events naming who logged in, and HTTP analytics on the zone.
  • Public exposure is permanent: issuing a certificate for app.example.com writes that name into Certificate Transparency logs, which are searchable forever. A tailnet address appears in no public log at all.
  • The mitigation if you need Cloudflare anyway: keep anything sensitive behind a tailnet and publish only what genuinely needs a public URL, for example a marketing page or a webhook endpoint on a dedicated hostname.

If your threat model includes the transit provider, that rules out any proxy that holds your certificate.

Top comments (1)

Collapse
 
dhruv_malaviya profile image
Dhruv Malaviya •

Both tools solve the same condition: the box is hidden and you're building a door back to it. We inverted that at Krova Cloud: a Cube has no inbound address at all by default, so you decide per port. Map it and it's reachable, optionally locked to an IP allowlist. Custom-domain traffic terminates TLS at our proxy and reaches the Cube on its private address, which is your Cloudflare column without a third party's edge.

On media: bandwidth is unmetered, but our AUP requires written approval for large-scale media relays. Same category as Cloudflare's rule, drawn differently