Runss -tlnp on your host after a Cloudflare Tunnel no open ports setup and nothing answers on port 80 or 443. That empty result is the entire security claim, and this guide treats it as a property you measure, not a promise you take on faith.
The shape of the work is short: install the daemon, create a named tunnel, write ingress rules, run it under systemd, then confirm the firewall surface is gone with two commands you already have. The proof step is where this differs from the Cloudflare docs, and it is worth slowing down for.
Why a Cloudflare Tunnel Removes Your Inbound Attack Surface
Port-forwarding inverts the trust model you actually want. You open 443 to the entire internet so that one reverse proxy can answer, then spend the rest of the server's life patching that proxy against everyone who finds the open port. A tunnel deletes the question. No listener exists for a scanner to reach, so the entire class of attacks that begins with "connect to the exposed service" has nowhere to land.
What cloudflared Actually Does on the Wire
cloudflared is a dial-out agent. On startup it opens four persistent connections to Cloudflare's edge, spread across the two nearest points of presence, and holds them open. Requests to your hostname arrive at Cloudflare first, then ride back down those already-established connections to the daemon, which proxies them to your local service over loopback.
The transport detail matters for the firewall claim. The daemon prefers QUIC over UDP 7844 to reachregion1.v2.argotunnel.com andregion2.v2.argotunnel.com, and falls back to HTTP/2 over TCP 7844 when UDP is blocked. Both are outbound, no inbound rule, no forwarded port, no public IP required, which is also why this works behind CGNAT. If you run an SNI-enforcing egress firewall, allowlistcftunnel.com,h2.cftunnel.com,quic.cftunnel.com, and bothregion1.v2.argotunnel.com andregion2.v2.argotunnel.com outbound on port 7844 (TCP and UDP), and the tunnel can establish.
Outbound-Only Versus a Forwarded Port, Counted
The security difference is easier to trust when you count the moving parts. Here is the traditional self-hosting stack against the tunnel equivalent:
| Persistent component | nginx + certbot + firewall | cloudflared tunnel |
|---|---|---|
| nginx reverse proxy listening on:80/:443 | yes | no |
| certbot agent | yes | no |
| cron certificate-renewal job | yes | no |
| firewall ACCEPT rule, port 80 | yes | no |
| firewall ACCEPT rule, port 443 | yes | no |
| outbound-only daemon | no | yes |
| Inbound listening sockets | 2 | 0 |
| Persistent components total | 5 | 1 |
Five persistent components, two of them sockets the whole internet can reach, collapse to one process that only dials out. Cloudflare terminates TLS at the edge and issues the public certificate, which eliminates the cron renewal job entirely. Fewer components mean fewer things to patch, monitor, and get woken up about at 2 a.m.
What You Need Before the First Command
Two prerequisites, both non-negotiable. First, a domain whose nameservers are delegated to Cloudflare, so the daemon can write a CNAME into the zone on your behalf. A domain parked at your registrar's nameservers will let you through the authentication step and then fail silently at DNS routing. Second, a service already listening locally; even a throwawaypython3 -m http.server 8080 works. The tunnel proxies to an origin; it does not invent one.
Install cloudflared and Link Your Cloudflare Account
On Debian or Ubuntu, the package install is the cleanest path:
curl -L https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64.deb -o cloudflared.deb
sudo dpkg -i cloudflared.deb
Swapamd64 forarm64 on a Raspberry Pi or ARM VPS. Then authenticate:
cloudflared tunnel login
This opens a browser URL, asks which zone to authorize, and drops acert.pem into~/.cloudflared/. That certificate is account-level authorization to create tunnels and edit DNS, not the tunnel's own credential, which comes next.
Create a Named Tunnel and Map a Hostname to It
Before running any command, make the named-versus-quick decision deliberately.
A quick tunnel (cloudflared tunnel --url http://localhost:8080) spins up an ephemeraltrycloudflare.com subdomain with no persistent account state. Genuinely useful for a five-minute demo. It caps at 200 concurrent requests, does not support Server-Sent Events, and disappears when the process dies. Use a quick tunnel to show a colleague something this afternoon; use a named tunnel for anything that needs to exist tomorrow.
Create a named tunnel:
cloudflared tunnel create my-tunnel
This prints a tunnel UUID and writes~/.cloudflared/<UUID>.json. That JSON file is the tunnel's identity, the long-lived secret that lets this daemon register as this specific tunnel. Back it up. Lose it, and there is no recovery; you delete the tunnel and start over with a new UUID.
Write the Ingress Rules in config.yml
Create~/.cloudflared/config.yml. Ingress rules are matched top to bottom; first match wins, so order is logic, not decoration:
tunnel: 6ff42ae2-765d-4adf-8112-31c55c1551ef
credentials-file: /home/youruser/.cloudflared/6ff42ae2-765d-4adf-8112-31c55c1551ef.json
ingress:
- hostname: app.example.com
service: http://localhost:3000
- hostname: api.example.com
service: http://localhost:8080
- service: http_status:404
The last entry,service: http_status:404, is mandatory, and it carries no hostname filter on purpose: it is the catch-all that matches everything the rules above did not. Leave it off and the daemon does not fail at request time. It refuses to start at all. That behavior is covered in the troubleshooting section below.
Create the DNS Record
Map each hostname to the tunnel:
cloudflared tunnel route dns my-tunnel app.example.com
cloudflared tunnel route dns my-tunnel api.example.com
Each call writes a proxied CNAME pointing at<UUID>.cfargotunnel.com. Nothing in that record references your server's IP address, which is the property you are buying here.
Run cloudflared Under systemd So It Survives Reboots
Running the daemon in a terminal is fine for an initial test; it is useless for production. Drop this unit file in/etc/systemd/system/cloudflared.service:
[Unit]
Description=cloudflared tunnel
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/local/bin/cloudflared tunnel run my-tunnel
Restart=on-failure
RestartSec=5
User=cloudflared
[Install]
WantedBy=multi-user.target
Restart=on-failure withRestartSec=5 is the line that makes the tunnel boring in the right way: a crashed daemon comes back in five seconds, and a rebooted host brings the service up automatically. Enable and start:
sudo systemctl enable --now cloudflared
Confirm the tunnel reports HEALTHY in the Zero Trust dashboard under Networks > Tunnels. A healthy status means the edge is holding those outbound connections open and is ready to route traffic.
Verify the Cloudflare Tunnel No Open Ports Claim with ss and nmap
Asserting zero inbound exposure is not the same as showing it. This is the step the rest of the SERP skips.
ss -tlnp: Nothing Is Listening on 80 or 443
List the host's TCP listeners:
$ sudo ss -tlnp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 127.0.0.1:3000 0.0.0.0:* users:(("node",pid=812,fd=18))
LISTEN 0 128 0.0.0.0:22 0.0.0.0:* users:(("sshd",pid=701,fd=3))
Read it closely. Your app is on127.0.0.1:3000, bound to loopback and unreachable from outside the box. SSH sits on 22. There is no row for port 80 or 443, andcloudflared itself appears nowhere in this list, because it is dialing out, not listening. Zero inbound sockets added. That is the local half of the proof.
External nmap: The Ground Truth
Local output can be fooled by a misread or a NAT quirk. Confirm from outside. From a machine that is not your server:
$ nmap -p 80,443,22 203.0.113.42
PORT STATE SERVICE
22/tcp open ssh
80/tcp filtered http
443/tcp filtered https
filtered is the result you want on 80 and 443: nmap received no response at all, so it cannot determine whether anything sits behind those addresses.closed would mean the host actively refused the connection, which still implies a live stack received the packet.filtered is quieter; to a scanner, those ports look like a wall, not a door. Port 22 readsopen here only because SSH is still listening directly; route SSH through the tunnel as well and you can close that too.
Gate the Tunnel Behind a Cloudflare Access Policy
A public hostname is reachable by anyone who learns the name. For a personal dashboard, put an identity check in front of it without standing up an identity provider.
In Zero Trust, go to Access > Applications, add a self-hosted application forapp.example.com, and attach a policy with the action Allow and the rule type One-time PIN. Scope it to your own email address. No OAuth app, no SAML metadata, no IdP configuration. When you visit the hostname, Cloudflare emails a one-time code; you paste it, and the edge sets a session cookie. The request never reachescloudflared until that check passes, so authentication runs in front of your origin rather than inside it. Five minutes of configuration, zero IdP setup.
Three Failures That Catch Most First-Time Setups
502 Bad Gateway After the Tunnel Registers
The tunnel is healthy and the edge is routing, but every response is 502. The fault lies betweencloudflared and your origin, almost always a wrongservice: URL. Check in order: the local service is not actually running, it listens on a different port from the one inconfig.yml, or you wrotehttps://localhost for an origin that speaks plain HTTP. A 502 means the edge reached the daemon successfully; the daemon could not reach what you pointed it at.
cloudflared Exits with Code 1 on the First Start
The service flaps andsystemctl status shows exit code 1 with no obvious crash message. The leading cause is a missing or non-final catch-all rule. When noservice: http_status:404 entry exists as the last ingress rule, the daemon refuses to start and logs:
failed to validate ingress rules: the last ingress rule must match all hostnames (hostname, path fields must be empty)
This is a startup-time validation check, not a runtime failure, which is why a config that looks complete still dies immediately. Add the catch-all, confirm it is last, and restart the service.
Hostname Resolves but the Dashboard Shows Inactive
DNS answers, the CNAME is correct, yet the dashboard shows the tunnel as INACTIVE. The record and the running daemon are pointing at different tunnels. This happens when you create one tunnel, route DNS to it, and then run the service against a different tunnel name or a staleconfig.yml. Confirm the UUID inconfig.yml, thecredentials-file path, and thetunnel run argument all reference the same tunnel.
Serving Several Subdomains Through One Named Tunnel
One tunnel can carry many hostnames. Stack ingress rules, most-specific first, catch-all last:
ingress:
- hostname: api.example.com
service: http://localhost:8080
originRequest:
connectTimeout: 30s
- hostname: app.example.com
service: http://localhost:3000
- hostname: admin.example.com
service: https://localhost:8443
originRequest:
noTLSVerify: true
httpHostHeader: admin.internal
- service: http_status:404
TheoriginRequest block tunes per-service behavior independently. A long-runningapi. endpoint gets a higherconnectTimeout. Anadmin. origin that speaks HTTPS with a self-signed certificate getsnoTLSVerify: true to stop the daemon rejecting it, plus anhttpHostHeader override when the backend is particular about the Host header it receives. Runcloudflared tunnel route dns my-tunnel <hostname> once per new subdomain; the running daemon picks up new routes on the next config reload.
FAQs
Does Cloudflare Tunnel work without paying for any Cloudflare plan?
Yes. Cloudflare Tunnel is available on the free plan, including Cloudflare Access policies for a small number of users. You need a domain on Cloudflare, but the tunnel itself costs nothing for typical self-hosting use.
Can I route multiple subdomains through a single named tunnel?
Yes. One named tunnel serves any number of hostnames. Add an ingress rule per subdomain inconfig.yml, runcloudflared tunnel route dns once for each, and keep theservice: http_status:404 catch-all as the final entry.
Does Cloudflare see my unencrypted origin traffic?
It depends on where your origin runs. Cloudflare terminates TLS at its edge, and the hop fromcloudflared to your origin uses whatever scheme your ingress URL specifies. When the origin ishttp://localhost, that traffic never leaves your machine: it stays on the loopback interface. If the daemon and the service run on different hosts, use anhttps:// origin URL or install an origin certificate so that leg is encrypted.
What happens to the tunnel if cloudflared crashes or reboots?
Under a systemd unit withRestart=on-failure andRestartSec=5, a crashed daemon restarts within five seconds, andenable brings it back automatically after a reboot. A named tunnel stores credentials in~/.cloudflared/<UUID>.json, so it reconnects as the same tunnel with the same routes, no re-registration needed.
Can I run cloudflared in Docker without host networking?
Yes. The daemon makes only outbound connections, so default bridge networking works fine; host networking mode is not required. Mount the credentials JSON andconfig.yml into the container, or pass a tunnel token, and the container dials out to Cloudflare's edge like any other outbound client.
Named Tunnel Versus Quick Tunnel: Which Should I Use in Production?
Use a named tunnel in production. Quick tunnels create throwawaytrycloudflare.com hostnames, cap at 200 concurrent requests, and do not support Server-Sent Events, suited to demos, not real services. Named tunnels persist credentials, hold a stable hostname, and survive restarts without intervention.
Found this useful? Subscribe for more self-hosting and infrastructure deep-dives. Next up: combining Cloudflare Tunnel with Docker Compose for a fully containerized zero-port stack.