Zero-IP Hosting: Cloudflare Tunnels for Total Isolation


Hide your origin server with Cloudflare Tunnel: Docker setup, firewall lockdown, leak checklist, and a script to verify nothing is exposed.


By FELFAL Bouabid · October 12, 2026

Zero-IP hosting means your origin server has no open inbound port and no public address that anyone can usefully connect to. Instead of waiting for visitors, a small program on the server, cloudflared, dials out to Cloudflare and carries their requests back through that outbound connection. This article shows how to set it up with Docker, how to verify from outside that nothing is exposed, and which leaks the tunnel does not close.

A caveat up front. 'Total isolation' in the title means network isolation of the origin. It does not mean Cloudflare cannot see your traffic, and it does not make a vulnerable application safe. Section 3 spells out exactly what you get and what you do not.

1. Why a public origin IP is a liability

A server with a public address and open ports is found whether or not anyone knows your domain, because internet-wide scanners index open ports continuously. Three problems follow:

  • Direct attacks skip your protection. If an attacker learns the origin IP, they can send requests straight to it and bypass the WAF and DDoS filtering you configured at the CDN.
  • The IP leaks in ordinary ways. Old DNS records, mail headers, forgotten subdomains and outbound requests all reveal it.
  • Allowlisting Cloudflare ranges helps, but only partly. A firewall that accepts web traffic only from Cloudflare blocks direct requests, yet the machine still has open ports that scanners can see and probe.

A tunnel removes the open port altogether, so there is nothing to allowlist and nothing to find.

2. How a tunnel changes the picture

  1. cloudflared runs on your host and opens four outbound connections to at least two distinct Cloudflare data centers. It connects outbound on port 7844.
  2. The public hostname, for example app.example.com, is a proxied DNS record pointing at TUNNEL_ID.cfargotunnel.com. Visitors resolve Cloudflare addresses, never yours.
  3. A request travels from the visitor to the Cloudflare edge, where TLS ends and WAF, caching and DDoS protection apply. It then goes down one of your tunnel connections to cloudflared, which forwards it to the service URL you configured, such as http://web:80.
  4. Your firewall can block all inbound traffic. Only egress on port 7844 is required.

The leg between cloudflared and your application never touches the public internet. On a single host or a private Docker network it can be plain HTTP.

3. What isolation gives you, and what it does not

You get:

  • No open inbound ports on the origin.
  • No DNS record that points at the origin address.
  • No way to reach the application except through Cloudflare, so direct-to-IP attacks stop working.
  • Cloudflare's edge in front of every request, with whatever WAF and rate-limit rules you configure.

You do not get:

  • Privacy from Cloudflare. TLS terminates at their edge, so they can see request and response content.
  • Independence. If Cloudflare or your tunnel is down, your site is down.
  • A safe application. The tunnel carries HTTP to your app. A SQL injection or an unpatched plugin is exactly as reachable as before.
  • A harmless credential. For a remotely-managed tunnel, anyone who holds the token can run the tunnel. Treat it like a password.
  • Protection from other leaks. Mail headers, old DNS history and outbound requests can still expose the IP (section 6).

4. Setup with Docker

Cloudflare recommends remotely-managed tunnels for most uses: the configuration lives at Cloudflare and you manage it from the dashboard, the API or Terraform. Locally-managed tunnels, created with the command line and a configuration file, are intended for development, testing and legacy setups. This guide uses the remotely-managed form.

  1. In the Cloudflare dashboard go to Networking, then Tunnels, and select Create Tunnel. Give it a name such as production-web.
  2. Copy the token from the install command shown for your system. It is the long string that starts with eyJ. Do not run that command, you will use Docker instead.
  3. Under Routes, add a published application route: choose the hostname, and set the service URL to http://web:80. The name web is the Docker service name from the file below. Your tunnel needs a proxied CNAME for that hostname pointing at TUNNEL_ID.cfargotunnel.com, so check it exists. If you configure the tunnel through the API instead of the dashboard, you must create the record and a final catch-all ingress rule yourself.
  4. On the server, put the token in a file named .env, make it readable only by you, and keep it out of version control:
echo 'TUNNEL_TOKEN=paste-your-token-here' > .env
chmod 600 .env
echo '.env' >> .gitignore
  1. Save this as docker-compose.yml and run docker compose up -d:
services:
  web:
    image: nginx:stable-alpine      # your application goes here
    restart: unless-stopped
    networks: [internal]
    # No ports: section on purpose. Nothing is published on the host.

  cloudflared:
    image: cloudflare/cloudflared:latest   # pin an exact tag you have tested
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    environment:
      TUNNEL_TOKEN: ${TUNNEL_TOKEN}
    networks: [internal]
    depends_on: [web]

networks:
  internal: {}    # a normal bridge network: cloudflared still needs outbound access

The tunnel should show as Healthy in the dashboard within a minute or so. Some details worth knowing:

  • There is no ports section anywhere. The web container is reachable only from cloudflared over the internal network, not from the host and not from the internet.
  • Do not mark the network as internal in the Docker sense, because cloudflared needs outbound connections to Cloudflare.
  • The documentation's own Docker example uses the latest tag. For production, pin an exact version and upgrade deliberately. The no-autoupdate flag stops the container from updating itself.
  • cloudflared reads the token from the TUNNEL_TOKEN environment variable. Versions from 2025.4.0 on can also read it from a file with the token-file option. If you use that, make sure the container's user can read the file.
  • Without Docker, install cloudflared and run its service install command with your token. It runs as a system service.

5. Lock down the host

The tunnel only isolates the origin if the host actually has nothing else listening. Check, then close the rest:

# What is listening, and on which addresses?
sudo ss -tlnp

# Default-deny inbound, allow outbound (needed for the tunnel on port 7844)
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw enable

Before you enable a deny-all rule, make sure you still have a way in, such as your provider's web console, or move SSH behind the tunnel first. Otherwise you can lock yourself out of your own server.

Two traps:

  • Docker and ufw. Ports published by Docker are handled by Docker's own firewall rules, which can bypass ufw, so a published port may stay open even though ufw says deny. Docker's documentation on packet filtering explains this. The simplest defense is never to publish ports, as in the compose file above. If you must publish one, bind it to 127.0.0.1.
  • Anything on all addresses. In the ss output, any service listening on 0.0.0.0 or on the IPv6 wildcard is reachable from outside unless a firewall stops it. Each one needs an explanation or a fix.

6. Close the other leaks

The tunnel hides the address. These habits can still give it away:

  • DNS history. If your domain ever had an A record pointing at this server, public datasets may keep that record. The only reliable remedy is to move to a new IP after the migration and leave the old one closed.
  • Stray records. Subdomains such as mail, ftp, cpanel or dev that point straight at the server, and any DNS-only record, expose it. List every record and remove or proxy them.
  • Outbound email. A message sent directly from your server carries its IP in the headers. Send through an email service.
  • Outbound requests. Webhooks, link previews and image fetchers show your IP to the sites they contact. If that matters, send egress through a separate proxy or NAT gateway, and never fetch user-supplied URLs without validation, which is also an SSRF risk.
  • Neighbors. Reverse-IP lookups list the other sites on the same address. Keep unrelated, non-tunneled sites off it.
  • Verbose errors. Debug pages and some headers print hostnames or addresses. Turn them off in production.


Zero-IP Hosting: Cloudflare Tunnels for Total Isolation



7. Protect what is still reachable

Every request that reaches your application now comes through Cloudflare, so use that:

  • Turn on Cloudflare's managed WAF rules and add rate limits to login, search and API endpoints. Available features depend on your plan, so check what yours includes.
  • Put an Access policy in front of admin paths so an identity check happens at the edge before a request enters the tunnel.
  • Keep the application and its dependencies patched. The tunnel does not filter application-layer attacks by itself.
  • For SSH or RDP through a tunnel, the client side also needs cloudflared. Treat that as an administrative convenience, protected by Access, not as a public service.

8. Operations

Replicas. A replica is another cloudflared instance pointing at the same tunnel. Each creates four more connections, and you can run up to 25 replicas per tunnel. Traffic goes to the geographically closest one. Run at least two, on separate hosts, so one machine does not become a single point of failure. For health checks and failover logic, Cloudflare points to its Load Balancing product instead of replicas.

Monitoring. cloudflared exposes Prometheus metrics, by default on 127.0.0.1 using the first free port from 20241 to 20245. The dashboard shows each tunnel's health.

Token rotation. Rotate the token regularly. In the dashboard select your tunnel and choose Rotate token. The old token cannot open new connections, but connectors that are already running stay up until restarted. Then update the token on each replica and restart it. If a token is compromised, rotate it immediately, force-disconnect all existing connections through the API, and reinstall every replica with the new token. For several replicas, do it outside working hours and in batches.

Updates. Pin the image version, read the release notes and upgrade on purpose. An unattended update restarts the process and can interrupt active connections.

Outages. Decide in advance what visitors see if Cloudflare or the tunnel is unreachable. A status page on a separate provider is cheap.

9. Verify from outside

Run this from a machine on a different network, never from the server itself, otherwise loopback listeners show up as open. It checks 22 common ports on the origin IP and tests whether common hostnames still resolve to that IP. A non-zero exit code means something is exposed, so you can run it from cron or CI. Only scan servers you own.

import socket
import sys
from concurrent.futures import ThreadPoolExecutor

PORTS = [21, 22, 25, 80, 110, 143, 443, 465, 587, 993, 995, 2082, 2083,
         3000, 3306, 5432, 6379, 8000, 8080, 8443, 9000, 27017]
SUBDOMAINS = ['', 'www', 'mail', 'ftp', 'cpanel', 'webmail', 'dev', 'staging',
              'api', 'admin', 'ssh', 'direct', 'origin']


def port_open(ip, port, timeout=3.0):
    try:
        with socket.create_connection((ip, port), timeout=timeout):
            return True
    except OSError:                      # refused, filtered, unreachable: all 'not open'
        return False


def open_ports(ip):
    with ThreadPoolExecutor(max_workers=32) as pool:
        flags = list(pool.map(lambda p: port_open(ip, p), PORTS))
    return [p for p, ok in zip(PORTS, flags) if ok]


def leaking_names(domain, origin_ip):
    leaks = []
    for sub in SUBDOMAINS:
        name = f'{sub}.{domain}' if sub else domain
        try:
            infos = socket.getaddrinfo(name, None)
        except socket.gaierror:
            continue                     # name does not resolve: nothing leaks
        if origin_ip in {info[4][0] for info in infos}:
            leaks.append(name)
    return leaks


if __name__ == '__main__':
    if len(sys.argv) != 3:
        sys.exit('usage: python check_exposure.py example.com ORIGIN_IP')
    domain, origin_ip = sys.argv[1], sys.argv[2]
    ports = open_ports(origin_ip)
    names = leaking_names(domain, origin_ip)
    print('open ports on origin  :', ports or 'none')
    print('names resolving to it :', names or 'none')
    sys.exit(1 if ports or names else 0)

Run it as python check_exposure.py example.com 203.0.113.10, with your own domain and origin IP. The goal is two lines that both say none.

This script is a quick check, not a full audit. For all 65,535 TCP ports use nmap, again from outside:

nmap -Pn -p- 203.0.113.10

UDP needs a separate scan, and the hostname list only covers common names, so also review your DNS zone by hand.

The tunnel adds a hop, so also measure what visitors feel. Run this from the same place before and after the migration:

for i in $(seq 1 20); do
  curl -o /dev/null -s -w '%{time_connect} %{time_starttransfer} %{time_total}\n' https://app.example.com/
done

Whether the difference is noticeable depends on where your server and your visitors sit relative to Cloudflare's nearest data center. Record your own results:

  • Open ports found by the exposure check before the migration: ___
  • Open ports found after the migration: ____
  • Names resolving to the origin IP after the migration: ___
  • Median time to first byte before: ___ ms
  • Median time to first byte after: ___ ms

[Editor's note: fill in the blanks with the numbers from your own server, and state your host location and test location, before publishing. Do not publish estimates as measurements.]

10. When this is the wrong tool

  • Your service must accept connections from clients that cannot run cloudflared, for example a public mail server or a game server.
  • You cannot accept a third party terminating TLS and seeing request content, because of a privacy or compliance requirement.
  • Your workload is dominated by very large transfers or by extreme latency sensitivity. Check Cloudflare's terms and limits for your plan first.
  • You have no public site at all and only need private access, where a VPN or Cloudflare's private networking may be the simpler fit.

11. Checklist

  1. Create a remotely-managed tunnel and keep the token out of version control.
  2. Run cloudflared and the application on a private Docker network with no published ports.
  3. Point the hostname at the tunnel and confirm the tunnel shows Healthy.
  4. Default-deny inbound on the host, after making sure you have another way in.
  5. Remove every DNS record that points straight at the origin, and move to a new IP if the old one was ever public.
  6. Send mail through a service, and think about where outbound requests come from.
  7. Add WAF rules, rate limits and an Access policy for admin paths.
  8. Run at least two replicas, and write down your token rotation procedure.
  9. Run the exposure check from outside after every infrastructure change.

Summary

A tunnel turns your server from something that listens into something that only calls out, which removes the open port, hides the address and forces every request through Cloudflare. That is strong network isolation of the origin. It is not privacy from Cloudflare, not protection against application bugs, and not a substitute for cleaning up old DNS records and other leaks. Set it up, then verify from the outside that it holds.

References

Post a Comment

Previous Post Next Post