SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Cloudflare Tunnel with no open ports

Run Cloudflare Tunnel on a VPS: named tunnel, credentials file, ingress rules, systemd service, then close 80 and 443 and bind the app to localhost.

What Cloudflare Tunnel does, and what no open ports really means

Cloudflare Tunnel puts a small daemon called cloudflared on your VPS. That daemon opens a connection outward to Cloudflare and holds it open. Requests for your hostname land on Cloudflare's edge and are pushed back down that existing connection, so nothing has to connect in to your server.

The step most guides never reach: installing the tunnel closes nothing. If 80 and 443 are still open in your firewall and your app still listens on 0.0.0.0, you have added a second way in rather than replacing the first. Your origin IP is still reachable, and anyone who finds it walks straight past Cloudflare. Closing those ports is a manual step, and it is the step that makes everything above it worth doing.

cloudflared needs outbound reach to region1.v2.argotunnel.com and region2.v2.argotunnel.com on port 7844. It uses UDP for the QUIC protocol and falls back to TCP for HTTP/2. On a network with egress filtering, allow both, or force the TCP path with --protocol http2.

Before you start

  • A domain already on a Cloudflare account, with Cloudflare's nameservers serving the zone. cloudflared tunnel route dns writes records into that zone, so the zone has to exist first.
  • Outbound access on port 7844 from the VPS, UDP and TCP.
  • An app already listening locally, even if it is only python3 -m http.server 8080 for the first test.
  • sudo on the box, and a second SSH session open before you touch the firewall.

Install cloudflared on Ubuntu or Debian

Cloudflare attaches a .deb package to every cloudflared release, so the install is one download and one dpkg call.

curl -fsSL -o /tmp/cloudflared.deb \
  https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb
sudo dpkg -i /tmp/cloudflared.deb
cloudflared --version

Run dpkg --print-architecture first if you are not certain what the box is. On 64-bit ARM the filename ends in arm64 rather than amd64 and nothing else changes. cloudflared --version printing a version string is the only confirmation you need before moving on.

A package installed this way sits outside apt's update path, so apt-get upgrade will never move it and its updates become your job. sudo cloudflared update fetches the newest release and replaces the binary in place; once the service exists, follow it with sudo systemctl restart cloudflared so the running process is the new binary. Put that on the same schedule as the rest of your patching, because a tunnel daemon is internet-facing software even though it opens no port.

Log in and create a named tunnel

cloudflared tunnel login

On a headless VPS no browser opens, so copy the printed URL into the browser on your laptop and pick the zone. When it finishes, ~/.cloudflared/cert.pem exists.

cert.pem is your account credential. It authorizes creating tunnels, writing DNS records into that zone, and deleting tunnels. The running tunnel never uses it. Treat it like a password, because a copy of that one file is enough for someone to publish new hostnames on your domain.

cloudflared tunnel create homelab

A successful run prints both lines below, and the UUID in them is the value you will paste into the config file.

Tunnel credentials written to /home/you/.cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json. cloudflared chose this file based on where your origin certificate was found. Keep this file secret. To revoke these credentials, delete the tunnel.
Created tunnel homelab with id 6ff42ae2-765d-4adf-8112-31c55c1551ef

That JSON file is the tunnel's identity, and it is the only credential the running service needs. Anyone holding it can register as your tunnel and receive your traffic. There is no way to rotate it on its own: revoking it means cloudflared tunnel delete homelab and creating a new tunnel.

Keep the credentials file somewhere sane

The service runs as root, so put the file in a root-owned directory instead of leaving it under a home directory that a backup job or a shared login might reach.

sudo install -d -m 700 -o root -g root /etc/cloudflared
sudo install -m 600 -o root -g root \
  ~/.cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json \
  /etc/cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json
rm ~/.cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json
sudo ls -l /etc/cloudflared
cloudflared tunnel list

ls -l should show -rw------- root root on the JSON file. cloudflared tunnel list reads cert.pem, so it keeps working, and it should print the tunnel name, its UUID and how many connections it currently holds.

Write config.yml with real ingress rules

Write the config at /etc/cloudflared/config.yml, not in your home directory. Here is why. cloudflared service install copies whatever config it finds to /etc/cloudflared/config.yml and then hard-codes --config /etc/cloudflared/config.yml into the systemd unit. If you author the file in ~/.cloudflared/config.yml, that copy is a one-time snapshot. Every later edit to the home copy changes nothing, the service keeps serving the old rules, and nothing warns you. Authoring the file in place removes the whole problem.

tunnel: 6ff42ae2-765d-4adf-8112-31c55c1551ef
credentials-file: /etc/cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json
loglevel: info

ingress:
  - hostname: app.example.com
    service: http://127.0.0.1:8080
  - hostname: files.example.com
    service: http://127.0.0.1:8081
  - hostname: grafana.example.com
    path: ^/api/
    service: http://127.0.0.1:3000
  - service: http_status:404

Rules are read top to bottom and the first match wins. A rule with no hostname matches every hostname, which is why the catch-all has to sit last. Leave it out and the config is rejected with The last ingress rule must match all URLs (i.e. it should not have a hostname or path filter). http_status:404 is a built-in service that answers 404 and does nothing else. It earns its place: without it, a request for a hostname you never meant to publish falls through to whichever real rule happens to be last.

Use 127.0.0.1 in the service: URL rather than localhost. On Ubuntu, localhost resolves to ::1 first, and an app bound only to the IPv4 loopback refuses that connection. The log line is dial tcp [::1]:8080: connect: connection refused and the visitor sees a 502.

Plain http:// is correct here because the hop never leaves the machine. Use https:// only when the local app insists on TLS (transport layer security), and expect x509: certificate is valid for example.com, not localhost when its certificate does not match the name you dialled. Fix that with originServerName under originRequest, or accept the risk with noTLSVerify: true.

Check the rules before starting anything:

sudo cloudflared --config /etc/cloudflared/config.yml tunnel ingress validate
sudo cloudflared --config /etc/cloudflared/config.yml tunnel ingress rule https://app.example.com/login

ingress validate reports the config valid or names the rule that broke it. ingress rule takes one URL and prints the first rule matching it, which is the quickest way to learn that a path regex is not matching what you assumed.

Point DNS at the tunnel

cloudflared tunnel route dns homelab app.example.com
cloudflared tunnel route dns homelab files.example.com
cloudflared tunnel route dns homelab grafana.example.com

Each call writes a proxied CNAME record pointing at 6ff42ae2-765d-4adf-8112-31c55c1551ef.cfargotunnel.com. That target resolves only inside Cloudflare's network, so the public DNS answer for your hostname is a Cloudflare address and your VPS IP never appears in it. A wildcard hostname in config.yml still needs a matching DNS record for every name you actually use.

When a record is already there, the command fails like this:

Failed to add route: code: 1003, reason: Failed to create record app.example.com with err An A, AAAA, or CNAME record with that host already exists.

That existing record is almost always the old A record pointing at your VPS's public IP, which is exactly the record you want gone. Delete it in the Cloudflare dashboard, then run the command again. Leaving it in place means DNS keeps publishing your origin IP, so the tunnel hides nothing.

Install it as a service so it survives a reboot

Run it in the foreground once, because a mistake is much easier to read on your own terminal than in the journal.

sudo cloudflared --config /etc/cloudflared/config.yml tunnel run homelab

A healthy start logs several Registered tunnel connection lines, one per edge location, each with its own connIndex. Load one of your hostnames in a browser and confirm the ingress rules send you where you expect, then stop it with Ctrl-C.

sudo cloudflared --config /etc/cloudflared/config.yml service install
systemctl status cloudflared

That writes /etc/systemd/system/cloudflared.service along with cloudflared-update.service and cloudflared-update.timer, then runs systemctl enable cloudflared.service and systemctl start cloudflared.service for you. enable is the half that matters here, because it is what brings the tunnel back after a reboot. The unit's ExecStart is cloudflared --no-autoupdate --config /etc/cloudflared/config.yml tunnel run, which is why that config path is not negotiable.

Three failures at this step have exact messages. possible conflicting configuration in /home/you/.cloudflared/config.yml and /etc/cloudflared/config.yml means both files exist and cloudflared refuses to guess: delete the one you do not want. configuration file ... must contain entries for the tunnel to run and its associated credentials (tunnel: TUNNEL-UUID, credentials-file: CREDENTIALS-FILE) means your config uses the quick url: shorthand instead of the named-tunnel keys, and that shorthand cannot run as a service. cloudflared service is already installed means an older unit is still in place, so run sudo cloudflared service uninstall first.

There is no reload. After you edit /etc/cloudflared/config.yml, run sudo systemctl restart cloudflared. Then prove the reboot claim instead of assuming it:

sudo reboot
# once it is back
systemctl is-enabled cloudflared
systemctl is-active cloudflared
journalctl -u cloudflared -n 50 --no-pager
curl -sI https://app.example.com

is-enabled printing enabled and is-active printing active is the whole point of this section. Once the routes exist and the service is running, cert.pem has no further job on the server: rm ~/.cloudflared/cert.pem. Adding a hostname later just means running cloudflared tunnel login again.

Close 80 and 443, or the tunnel is only an extra path

Two changes, and you need both. Doing one without the other leaves the origin reachable.

First, bind the app to the loopback address. In nginx that means listen 127.0.0.1:8080; in place of listen 80;, the same edit walked through in this nginx reverse proxy config explainer. In Docker Compose it means ports: - "127.0.0.1:8080:80". The plain "8080:80" form publishes on every interface, and Docker writes its own NAT (network address translation) rules that packets hit before ufw ever sees them, so a ufw deny rule will not stop it. That trap has its own writeup: why Docker published ports ignore ufw.

sudo ss -lntp

Every service you moved should now show 127.0.0.1:8080 in the Local Address column. A line reading 0.0.0.0:8080 or *:8080 is still listening to the world.

Second, close the ports.

sudo ufw status numbered
sudo ufw delete allow 'Nginx Full'
sudo ufw status

Delete the allow rules for 80 and 443 rather than stacking deny rules on top of them, because ufw stops at the first matching rule and a stale allow higher in the list wins. Keep your SSH rule. The ufw firewall basics guide covers the rest of that ruleset. Most VPS providers also run a separate network firewall in their control panel, and that one is not ufw, so close 80 and 443 there as well.

Now verify from somewhere else, because curl http://127.0.0.1:8080 on the box itself proves nothing about the outside world.

# run these from a different machine
nc -vz 203.0.113.10 443
curl -sI https://app.example.com

A refused or timed-out nc against the raw IP, plus a 200 through the hostname, is the result you are looking for. Checking whether a port is really open covers more ways to test it.

The tunnel is transport, not authentication. Anything you publish through it is publicly reachable unless you put a login in front, either Cloudflare Access at the edge or an OAuth2 proxy sitting in front of the app on the box. SSH also needs its own answer, because the tunnel does not cover it: keep port 22 open but restricted to your own source addresses.

What Cloudflare Tunnel buys you, and what it costs

What you gain is real. Your origin IP is no longer published, no inbound port is exposed, the setup works from a machine with no public IP at all, the public certificate is Cloudflare's problem so no ACME (automatic certificate management environment) client runs on your box, and volumetric attacks are absorbed at the edge instead of on your bandwidth allowance.

The costs are just as real. Cloudflare terminates TLS at its edge: your visitor's request is decrypted there and re-encrypted into the tunnel, so Cloudflare can read the traffic. That is what makes their firewall, caching and Access rules possible, and no setting turns it off while you still use their proxy. If a third party holding your plaintext is unacceptable, stop here and pick something else.

Cloudflare also becomes a hard dependency for reachability. When cloudflared is not connected, visitors get Cloudflare's Error 1033 page instead of your app, and you have deliberately removed the direct route they could have fallen back to.

Only HTTP, HTTPS and WebSocket reach a public hostname from an ordinary browser. Any other TCP protocol, SSH or RDP (remote desktop protocol) or a game server, needs software on the client side too: cloudflared access tcp forwarding a local port, or the WARP client. There is no client-free path for those.

Request bodies are capped at the edge, and an upload over the limit is rejected with HTTP 413 before it ever reaches your app. As of August 2026 that ceiling is 100 MB on the free and Pro plans and higher on the paid tiers, so check Cloudflare's current limits page before you design around a number. Cloudflare's self-serve terms also restrict using the proxy mainly to serve video and other large non-HTML files, which is worth reading before you point a media library at a free tunnel.

Cloudflare Tunnel, a reverse SSH tunnel, or Tailscale Funnel

All three are outbound-only, so all three work from a box with no inbound port and no public IP. They differ on who holds your plaintext and on what hostname the public sees.

A reverse SSH tunnel needs a second machine that does have a public IP, and that machine becomes the front door: the certificate, the reverse proxy and the firewall on it are all yours to run. Nobody else decrypts anything. It is more moving parts, and it needs autossh or a systemd unit with Restart=always before it survives a network blip. The reverse SSH tunnel walkthrough for CGNAT goes through that build.

Tailscale Funnel is the closest comparison. It is outbound-only in the same way, and TLS terminates on your own machine, so Tailscale's relays never see plaintext. The price is the naming and the ports: Funnel serves only names under your tailnet's ts.net domain, and only on 443, 8443 and 10000. The difference between Tailscale Serve and Funnel covers both sides.

So pick on the constraint that actually binds you. Choose Cloudflare Tunnel when the public has to reach your own domain and you accept that Cloudflare reads the traffic. Choose Tailscale Funnel when a ts.net hostname is acceptable and handing plaintext to a proxy is not. Choose a reverse SSH tunnel when you already own a public box and want no third party in the path at all.

FAQ

Do I still need port 443 open with Cloudflare Tunnel?

No. cloudflared connects outward to Cloudflare on port 7844 and every request arrives back down that connection, so no inbound port is used. Installing the tunnel does not close anything for you, though. Delete the allow rules for 80 and 443 in ufw, close them in your provider's separate network firewall, bind the app to 127.0.0.1, and delete any leftover A record that still publishes your VPS IP. Confirm with sudo ss -lntp on the box and nc -vz <your-ip> 443 from a different machine.

Why does my hostname show Cloudflare Error 1033?

Error 1033 means Cloudflare holds the DNS record for that hostname but cannot find a healthy cloudflared connected to receive the request. Either the process is down, or it is running and cannot reach Cloudflare. Check systemctl status cloudflared and journalctl -u cloudflared -n 50, then confirm outbound port 7844 is allowed for both UDP and TCP, since a firewall that blocks UDP and QUIC without allowing the TCP fallback produces exactly this. cloudflared tunnel info homelab shows the connections Cloudflare currently sees, and an empty list puts the fault on your side.

Why do I get a 502 Bad Gateway through the tunnel?

A 502 means cloudflared was reached but could not reach your local service, so the fault sits between those two and not at Cloudflare. Read the log. dial tcp [::1]:8080: connect: connection refused means nothing is listening where you said, and the [::1] in that message usually means you wrote localhost in the service: URL while the app binds IPv4 only, so write http://127.0.0.1:8080 instead. HTTP/1.x transport connection broken: malformed HTTP response is the opposite mismatch: you wrote https:// at an origin speaking plain HTTP.

Can I run SSH, RDP or a game server over Cloudflare Tunnel?

Not from a plain client. A public hostname through a tunnel carries HTTP, HTTPS and WebSocket, which is what a browser speaks. Any other TCP protocol needs software on the client machine as well, either cloudflared access tcp forwarding a local port or the WARP client. If you want SSH from any machine with nothing installed, the tunnel is the wrong tool. Keep port 22 open and restricted by source address.

Does Cloudflare see my traffic through the tunnel?

Yes. Cloudflare terminates TLS at its edge, decrypts the request there, and re-encrypts it into the tunnel to your server. That decryption is what makes their firewall, caching and Access policies work, and it also means your plaintext exists on their machines. No configuration avoids it while you use their proxy. If that is unacceptable, use Tailscale Funnel or run your own reverse proxy on a public box.

#cloudflare-tunnel#cloudflared#zero-trust#firewall#self-hosting