# Tailscale vs Cloudflare Tunnel vs a cloud firewall

> I scanned one Droplet behind ufw, a cloud firewall, Tailscale and a Cloudflare Tunnel. What each one left open, what it costs, and how fast it was.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-11-06 | Topics: [Security](https://flaviocopes.com/tags/security/) | Canonical: https://flaviocopes.com/tailscale-vs-cloudflare-tunnel-vs-firewall/

A DigitalOcean Cloud Firewall that allows only TCP 80 and 443 drops every other new inbound connection before the packet reaches the Droplet. The web app on those two ports still gets every request anyone on the internet sends.

Tailscale gives your devices a private route to the server, so SSH and admin tools need no public port. Cloudflare Tunnel lets the server connect out to Cloudflare and serve a public hostname with every inbound port closed.

I wanted to see what each of these does to a real server, so I set up a $6 Droplet in Frankfurt and scanned it from my Mac after each change. The commands for a permanent setup are in [how to secure a VPS with Tailscale and Cloudflare Tunnel](https://flaviocopes.com/secure-vps/). If Tailscale is new to you, [the Tailscale tutorial](https://flaviocopes.com/tailscale/) sets up the same Droplet from scratch.

## The short version, with a house

Think of the server as a house on a busy street. Out of the box it has a door for every port that listens: SSH, the website, maybe a database or an admin panel. Bots walk down the street all day trying every handle.

A **cloud firewall** bricks up every door except the ones you list. Allow 80 and 443, and the front door stays open for visitors while the back doors are gone. The firewall sits in DigitalOcean's network, not in the house, so even someone who breaks in can't take the bricks down from inside.

**Tailscale** is a private tunnel from your own devices to the house. You brick up the back door completely and come in through the tunnel. Strangers can't see a back door at all.

**Cloudflare Tunnel** removes the front door too. Visitors talk to a receptionist across the street, Cloudflare, and the receptionist relays each message to you over a phone line you dialed yourself. Nobody needs your address, and there's no door to kick in.

All three still pass along what visitors send, since a website has to accept requests from strangers. A booby-trapped letter goes off inside the house whoever delivered it. I come back to that at the end, because none of these tools solve it.

## The test server

The Droplet, `tailscale-demo`, ran Ubuntu 24.04 with three things listening:

- a small web page on port 80, served by Python
- Postgres 17 in Docker, started with `-p 5432:5432`, the way most tutorials publish a database
- OpenSSH on port 22

ufw was on, set to deny incoming and allow only SSH and the web page:

```text
$ ufw status
Status: active

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW       Anywhere
80/tcp                     ALLOW       Anywhere
22/tcp (v6)                ALLOW       Anywhere (v6)
80/tcp (v6)                ALLOW       Anywhere (v6)
```

```text
$ docker ps
NAMES     IMAGE         PORTS
db        postgres:17   0.0.0.0:5432->5432/tcp, [::]:5432->5432/tcp
```

After every change I ran the same scan from my Mac, which is outside DigitalOcean's network:

```bash
nmap -Pn -sT -p 22,80,443,3306,5432,6379,8080 164.92.130.118
```

`open` means something answered. `filtered` means the packet was dropped and nothing came back.

## What the scans found

| | ufw only | Cloud firewall, 80 and 443 | Cloud firewall, nothing inbound, plus Tailscale | Same, plus Cloudflare Tunnel |
| --- | --- | --- | --- | --- |
| 22, SSH | open | filtered | filtered | filtered |
| 80, web | open | open | filtered | filtered |
| 5432, Postgres in Docker | **open** | filtered | filtered | filtered |
| Other ports | filtered | filtered | filtered | filtered |
| SSH from my Mac | public IP | tailnet | tailnet | tailnet |
| Web page for strangers | `http://164.92.130.118` | `http://164.92.130.118` | none | `https://….trycloudflare.com` |
| Web page for me | public IP | public IP or tailnet | tailnet | tunnel or tailnet |

The bold cell is the surprise most people find the hard way.

```mermaid
flowchart TB
  accTitle: What reaches the Droplet in each setup
  accDescr: A cloud firewall can allow public HTTP and drop everything else. Tailscale and cloudflared dial out, so SSH and a tunneled site can work with no new inbound allow.
  subgraph Edge["Cloud firewall"]
    direction TB
    V["Visitor"] --> FW["Firewall"]
    FW -->|"80 and 443"| App["Web app"]
    FW -->|"other inbound"| No["Dropped"]
  end
  subgraph Mesh["Tailscale"]
    direction TB
    Lap["Your laptop"] --> TS["Tailnet"]
    TS --> SSH["SSH on the Droplet"]
  end
  subgraph Tun["Cloudflare Tunnel"]
    direction TB
    CD["cloudflared dials out"] --> CF["Cloudflare"]
    CF --> Host["Your hostname"]
  end
  Edge ~~~ Mesh
  Mesh ~~~ Tun
```

## ufw alone, and the Docker port

With ufw denying incoming and allowing 22 and 80, this was the scan:

```text
PORT     STATE    SERVICE
22/tcp   open     ssh
80/tcp   open     http
443/tcp  filtered https
3306/tcp filtered mysql
5432/tcp open     postgresql
6379/tcp filtered redis
8080/tcp filtered http-proxy
```

Postgres answered on the public IP, and nothing in `ufw status` mentions port 5432.

[Docker's packet filtering page](https://docs.docker.com/engine/network/packet-filtering-firewalls/) explains why. Published container traffic is handled in the `nat` table, before it reaches ufw's input chain. `ufw deny incoming` can be true while a published port still answers.

There are two fixes, and I'd use both. Bind the port to localhost, `-p 127.0.0.1:5432:5432`, when only the host needs it. And put a firewall in front of the server that Docker can't touch.

## The cloud firewall

[Cloud Firewalls](https://docs.digitalocean.com/products/networking/firewalls/details/features/) are DigitalOcean's free, stateful filter. They drop traffic before it reaches the Droplet, so Docker never sees the packet.

I created one that allows 80 and 443 in, and everything out:

```bash
doctl compute firewall create --name ts-demo-web \
  --inbound-rules "protocol:tcp,ports:80,address:0.0.0.0/0,address:::/0 protocol:tcp,ports:443,address:0.0.0.0/0,address:::/0" \
  --outbound-rules "protocol:tcp,ports:all,address:0.0.0.0/0,address:::/0 protocol:udp,ports:all,address:0.0.0.0/0,address:::/0 protocol:icmp,address:0.0.0.0/0,address:::/0" \
  --droplet-ids 616283132
```

About 20 seconds later the scan changed:

```text
PORT     STATE    SERVICE
22/tcp   filtered ssh
80/tcp   open     http
443/tcp  filtered https
3306/tcp filtered mysql
5432/tcp filtered postgresql
6379/tcp filtered redis
8080/tcp filtered http-proxy
```

Postgres was gone from the internet, and so was public SSH. Port 443 shows `filtered` even though the cloud firewall allows it, because ufw on the Droplet still dropped it, and nothing listened there anyway.

SSH kept working, over Tailscale:

```text
$ ssh root@tailscale-demo hostname
tailscale-demo
```

The outbound rules matter. Tailscale and `cloudflared` both connect out. [DigitalOcean's create guide](https://docs.digitalocean.com/products/networking/firewalls/how-to/create/) suggests allowing all outbound for that reason. With no outbound rules, nothing outbound is allowed, both clients die, and so do package updates.

The panel's suggested inbound rule is SSH on port 22 from anywhere. If you add 80 and 443 and leave that rule, the Droplet isn't HTTP-only. Delete it, or narrow it to your current address as a `/32` until private SSH works. The command shape is in the [doctl guide](https://flaviocopes.com/doctl/). [Firewall Manager](https://flaviocopes.com/apps/firewall-manager/) opens that rule for your current IP and closes it again.

An allow for `0.0.0.0/0` covers IPv4 only. IPv6 is a separate source, `::/0`, and [the rule page](https://docs.digitalocean.com/products/networking/firewalls/how-to/configure-rules/) treats them as different entries.

Two firewalls on one Droplet merge their rules. Allows add up, so a wide allow plus a tight allow is the wide one. [The Ubuntu to Debian move](https://flaviocopes.com/ubuntu-to-debian-digitalocean/) ran into that: a new Droplet inherits every allow on every firewall you attach.

From the [limits page](https://docs.digitalocean.com/products/networking/firewalls/details/limits/), checked 3 November 2026:

| Limit | Value |
| --- | --- |
| Price | No extra charge on top of the Droplet |
| Rules | 50 inbound and outbound rules, combined |
| Droplets | 10 per firewall, or a tag when you have more. 5 tags per firewall |
| Deny entries | 1,000 source and destination entries across the deny rules |
| Logs | None. The drop happens before the Droplet |

What the cloud firewall can't do is read HTTP. Everything on port 80 stays public: the site, its `/admin` route, an upload endpoint, a forgotten dev server bound to the same port. One public TCP port was enough for the kernel bug in [CVE-2026-98432](https://flaviocopes.com/linux-tcp-cve-2026-98432/).

### Allow only Cloudflare

Many people put Cloudflare's proxy (the orange cloud in DNS) in front of a server like this and assume every visitor goes through it. Visitors don't have to. With 80 and 443 open to `0.0.0.0/0`, anyone who finds the server's IP can connect to it directly and skip Cloudflare's WAF, bot rules and DDoS protection. The IP leaks through old DNS records, certificate transparency logs, and scans of DigitalOcean's address ranges.

The fix is to allow 80 and 443 only from [Cloudflare's IP ranges](https://www.cloudflare.com/ips/). Cloudflare publishes them as plain text files, so we can turn them into firewall sources:

```bash
v4=$(curl -s https://www.cloudflare.com/ips-v4 | sed 's/^/address:/' | paste -sd, -)
v6=$(curl -s https://www.cloudflare.com/ips-v6 | sed 's/^/address:/' | paste -sd, -)
doctl compute firewall add-rules 4ef6fba2-fea9-4301-9d56-507a2f819638 \
  --inbound-rules "protocol:tcp,ports:80,$v4,$v6 protocol:tcp,ports:443,$v4,$v6"
```

Then remove the old allow for `0.0.0.0/0` and `::/0`, or the wide rule wins. The ranges change rarely, but they do change, so run it again now and then. I didn't include this setup in the scans above.

That gets you most of what a Tunnel gives, without running `cloudflared`. Two gaps remain: the port is still open to Cloudflare's network, and any Cloudflare customer can point their own proxied hostname at your IP. [Authenticated Origin Pulls](https://developers.cloudflare.com/ssl/origin-configuration/authenticated-origin-pull/) closes the second one. Cloudflare presents a client certificate, and your web server refuses requests without it.

### The same thing on Hetzner

Hetzner has its own [Cloud Firewalls](https://docs.hetzner.com/cloud/firewalls/overview), and they work like DigitalOcean's. They're free and [stateful](https://docs.hetzner.com/cloud/firewalls/faq), and filter traffic before it reaches the server. You attach one to servers directly, or to a label, so every server with that label gets it.

The 80 and 443 setup with the `hcloud` CLI:

```bash
hcloud firewall create --name web
hcloud firewall add-rule web --direction in --protocol tcp --port 80 --source-ips 0.0.0.0/0 --source-ips ::/0
hcloud firewall add-rule web --direction in --protocol tcp --port 443 --source-ips 0.0.0.0/0 --source-ips ::/0
hcloud firewall apply-to-resource web --type server --server my-server
```

Outbound works the other way round. With no outbound rules, Hetzner allows all outbound traffic, so Tailscale, `cloudflared` and package updates keep working. Once you add one outbound rule, everything else going out is denied. On DigitalOcean, no outbound rules means nothing goes out.

A Hetzner Cloud Firewall takes up to 500 rules, a server can have up to 5 firewalls, and you can have 50 firewalls across all projects.

Hetzner's dedicated servers are different. Their [Robot firewall](https://docs.hetzner.com/robot/dedicated-server/firewall/) is stateless, with at most 10 rules per direction. Stateless means it doesn't remember connections, so replies to the server's own outgoing connections get dropped unless you allow them. The usual rule accepts TCP with the `ack` flag on ports 32768 to 65535, plus UDP 53 out for DNS. Forget it and updates stop working.

## Tailscale, with nothing inbound

Next I removed both inbound rules. The cloud firewall now allowed nothing in:

```bash
doctl compute firewall remove-rules 4ef6fba2-fea9-4301-9d56-507a2f819638 \
  --inbound-rules "protocol:tcp,ports:80,address:0.0.0.0/0,address:::/0 protocol:tcp,ports:443,address:0.0.0.0/0,address:::/0"
```

Every port came back `filtered`, and `curl` to the public IP timed out:

```text
$ curl -m 10 http://164.92.130.118
curl: (28) Connection timed out after 10008 milliseconds
```

Over the tailnet, from the Mac, everything still worked: SSH, the web page, and the Postgres port.

```text
$ curl http://tailscale-demo
public site on tailscale-demo
$ nc -vz tailscale-demo 5432
Connection to tailscale-demo port 5432 [tcp/postgresql] succeeded!
```

I expected Tailscale to fall back to a relay once no inbound UDP was allowed. To force it to find a path again, I restarted `tailscaled` on the Droplet, then pinged it:

```text
$ tailscale ping tailscale-demo
pong from tailscale-demo (100.110.4.66) via 164.92.130.118:41641 in 32ms
```

Still a direct connection. Both ends send UDP out to each other, and the stateful firewall lets the replies in. [Tailscale's firewall page](https://tailscale.com/docs/reference/faq/firewall-ports) says a relay takes over when a direct path fails, so the worst case is slower, not broken.

This is the setup for anything only you and a few people use: an admin panel, a database, a staging copy. Strangers get no hostname and no open port.

Who you trust here is Tailscale's coordination server, the auth key that enrolled the machine, and the node keys on your devices. The coordination server hands out keys and rules, and doesn't carry traffic. What a compromised coordination server could do, and the October 2026 Tailscale SSH fixes, are in [Tailscale alternatives](https://flaviocopes.com/tailscale-alternatives/).

I used plain OpenSSH over the tailnet. `tailscale set --ssh` is a separate switch that makes Tailscale intercept port 22 on the tailnet address and log you in with node keys. [The Tailscale SSH docs](https://tailscale.com/docs/features/tailscale-ssh) say it's on all plans. Leave it off when your existing keys are the login.

[Tailscale Funnel](https://tailscale.com/docs/features/tailscale-funnel) is a third switch, in beta and off by default. It publishes one local service on a `ts.net` URL, on port 443, 8443 or 10000, and the Tailscale process on your machine terminates TLS. I left it off.

The Personal plan is $0 for non-commercial use, with up to 6 users and unlimited user devices, from the [pricing page](https://tailscale.com/pricing/). Standard is $8 per user per month. A tailnet on a custom domain starts a 14-day Enterprise trial instead of Personal.

## Cloudflare Tunnel, with nothing inbound

Strangers still can't see the site. Cloudflare Tunnel fixes that without opening a port: `cloudflared` on the Droplet connects out to Cloudflare, and Cloudflare sends visitors down that connection.

For the test I used a [quick tunnel](https://flaviocopes.com/cloudflare-quick-tunnels/), which needs no account and no domain:

```bash
cloudflared tunnel --no-autoupdate --url http://localhost:80
```

A few seconds later it printed a random `trycloudflare.com` URL and registered a connection to Cloudflare's Frankfurt edge:

```text
https://vendors-turbo-core-providence.trycloudflare.com
INF Registered tunnel connection connIndex=0 ... location=fra03 protocol=quic
```

The page loaded from anywhere, served by Cloudflare:

```text
$ curl -i https://vendors-turbo-core-providence.trycloudflare.com
HTTP/2 200
content-length: 30
cf-ray: a55e31f2fc156c29-MXP
server: cloudflare
public site on tailscale-demo
```

And the public IP was still closed on every port:

```text
22/tcp  filtered ssh
80/tcp  filtered http
443/tcp filtered https
```

A quick tunnel is for an afternoon. For a real site you create a named tunnel and route your own hostname to it, which is free on every Cloudflare plan. The steps are in [the Tunnel guide](https://flaviocopes.com/cloudflare-tunnel/).

`cf-ray` ending in `MXP` means my request entered Cloudflare in Milan, near me. Cloudflare terminated HTTPS there and forwarded the request to Frankfurt through the tunnel. [The routing page](https://developers.cloudflare.com/tunnel/routing/) describes an HTTP route as proxying incoming HTTPS to your local service over HTTP. So Cloudflare sees every request in clear text, which is how its WAF and caching work. Funnel's relays, by comparison, are documented as unable to read the stream.

Publishing a hostname doesn't need a paid plan. Putting a login in front of it with Cloudflare Access is free up to 50 users, then $7 per user per month, from [Zero Trust pricing](https://www.cloudflare.com/plans/zero-trust-services/). Proxied uploads on the Free plan stop at 100 MB, from Cloudflare's [413 page](https://developers.cloudflare.com/support/troubleshooting/http-status-codes/4xx-client-error/error-413/).

SSH can go through the tunnel too, with Access in front, either as a terminal in the browser or with `cloudflared` as a proxy command for your own `ssh`. Cloudflare also has a client app, the [Cloudflare One Client](https://developers.cloudflare.com/cloudflare-one/team-and-resources/devices/cloudflare-one-client/), that reaches private IPs through a tunnel the way Tailscale does. [Tailscale alternatives](https://flaviocopes.com/tailscale-alternatives/) covers that path.

Who you trust is the Cloudflare account, the tunnel credential on the Droplet, and anyone who can edit DNS or Access. A dashboard login that can change the route can point your hostname somewhere else.

## How fast each path was

I fetched the page 10 times from my Mac in Italy through each path. These are the medians:

| Path to the same page | Median of 10 requests |
| --- | --- |
| Tailnet, `http://tailscale-demo` | 72 ms |
| Cloudflare quick tunnel, HTTPS | 111 ms |
| Public IP, port 80 opened for the test | 137 ms |

The tailnet was the fastest because it's a direct WireGuard connection that's already up, so each request only pays for the TCP handshake to Frankfurt.

The tunnel beat the public IP, even with HTTPS on top. My TCP and TLS handshakes ended at Cloudflare in Milan, a few milliseconds away, and the tunnel to Frankfurt was already open. Going to the public IP, every request opened a new TCP connection all the way to Frankfurt. The public numbers were also the noisiest, from 65 to 182 ms.

These are numbers from one connection on one evening. They show the shape, not a benchmark.

## The three side by side

| | Cloud firewall, 80 and 443 only | Tailscale for admin | Cloudflare Tunnel for the site |
| --- | --- | --- | --- |
| What my scan found open | Port 80. Everything else filtered | Nothing | Nothing |
| What it leaves open | The whole app on those ports, by IP and by domain | The app, if some other layer still publishes it | The app, after Cloudflare forwards the request |
| Can visitors skip Cloudflare? | Yes, by IP, unless you allow only Cloudflare's ranges | There's no public path | No |
| Who you trust | The DigitalOcean account and its API tokens | Coordination, auth keys, node keys, your identity provider | The Cloudflare account, the tunnel credential, Access policies |
| Day to day | Rules in the panel. No extra daemon | One client. SSH to a tailnet name | `cloudflared` plus DNS. Access if you want a login |
| Price on 3 Nov 2026 | $0 on top of the Droplet | Personal $0, non-commercial. Standard $8 per user per month | $0 to publish. Access $0 up to 50 users, then $7 per user per month |
| A limit that bites | 50 rules, no logs, allows from two firewalls add up | 6 users and 3 ACL groups on Personal | 100 MB proxied uploads on Free. Cloudflare reads the HTTP |

## Which one to use

```mermaid
flowchart TB
  accTitle: Which layer to use for a small VPS
  accDescr: A private app stays on Tailscale with inbound denied. A public site either allows 80 and 443 or uses a tunnel. SSH stays off the public internet in both public cases.
  Q1{"Do strangers open the site?"}
  Q1 -->|No| Priv["Tailscale, inbound denied"]
  Q1 -->|Yes| Q2{"Can Cloudflare terminate TLS?"}
  Q2 -->|Yes| Tun["Tunnel, no inbound allows"]
  Q2 -->|No| Http["Firewall allows 80 and 443"]
  Tun --> Adm["SSH on Tailscale or Access"]
  Http --> Adm
```

When only you and a few people use the app, skip public HTTP. Put the machines on Tailscale and give the cloud firewall no inbound rules. That's the third column of my scan: nothing open, everything working over the tailnet.

When strangers must load the site, and you're fine with Cloudflare terminating TLS, use a Tunnel and keep the firewall with no inbound rules. SSH goes over Tailscale, or over Access for someone who shouldn't join your tailnet. That's the layout in the [VPS guide](https://flaviocopes.com/secure-vps/), and the fourth column of the scan.

When strangers must load the site and TLS should end on your server, allow 80 and 443 and nothing else. SSH goes over Tailscale. The app is then on the public internet, and its own auth, uploads and admin routes are the remaining work.

In every case, add the cloud firewall. ufw alone let Postgres onto the internet, and the cloud firewall is what closed it.

A mail server, a game server, or anything that needs arbitrary inbound UDP is a poor fit for the Tunnel.

## Use them together

**Public site, TLS on the Droplet, private SSH.** The cloud firewall allows 80 and 443. Tailscale carries SSH. Delete the public SSH rule only after `ssh` to the tailnet name works from your laptop, and after you've logged in through the provider's web console once. Creating the Droplet is the [first VPS walkthrough](https://flaviocopes.com/digitalocean-create-vps/).

**Nothing inbound.** The site is a Tunnel, the firewall has no inbound rules, and outbound stays open. Both `cloudflared` and Tailscale dial out, and the stateful firewall lets their replies in.

**A contractor who shouldn't join the tailnet.** They sign in through Access in the browser while you use Tailscale. Keep both paths only if you need them, because each has its own accounts and permissions.

**SSH closed by default, no Tailscale.** The firewall allows 80 and 443 and nothing else. You add an SSH rule for your current IP only while you work, then remove it. That's how I run my newsletter server, with [Firewall Manager](https://flaviocopes.com/apps/firewall-manager/) opening and closing the rule. SSH is unreachable most of the time and there's no coordination server to trust, so it's stricter than Tailscale, and a bit slower to get into.

## What none of them fix

Piotr Obidowski [made this point on X](https://x.com/pobidowski/status/2118448488287539378): Tailscale and Cloudflare Tunnel don't make a server safe. He's right. A public app still gets every request anyone sends. One unknown bug in PHP, nginx, an image library or your own upload handler, and the attacker runs code as your app's user, with access to whatever that user can read: the database, the `.env` file, other sites on the same server. AI tools are making those bugs faster to find.

The firewall, Tailscale and the Tunnel shrink the attack surface from many doors to one. The work on that one door is the same on any host, Vercel included:

- Keep the OS, the web server, the runtime and your dependencies updated. On Ubuntu, `unattended-upgrades` installs security fixes on its own.
- Run the app as its own low-privilege user, or in a container, so a break-in isn't root.
- Turn on Cloudflare's managed WAF rules, which block many known exploit patterns before they reach you.
- Give the app's secrets the smallest permissions that work, so a leaked key can do little.
- Keep backups somewhere the server can't delete them.

When I was done, I deleted the Droplet and the firewall. The whole test cost a few cents. For the rest of a server's setup, users, updates and keys, see the [VPS course](https://flaviocopes.com/courses/vps/) and the [Linux server security course](https://flaviocopes.com/courses/linux-server-security/).
