Why a Cloudflare Tunnel instead of opening a port
The classic setup — opening ports 80 and 443 on the firewall, pointing a DNS record to the server IP, installing a reverse proxy — works well when you control the network. But three situations break it: an ISP or corporate network that blocks inbound connections on port 443, a dynamic IP that invalidates your DNS records every 24 hours, or a VPS behind a strict NAT that allows no port forwarding.
Cloudflare Tunnel bypasses all three with the same mechanism: the cloudflared daemon establishes a persistent outbound connection to Cloudflare's points of presence. Your server accepts nothing; Cloudflare receives HTTPS requests and forwards them through this encrypted tunnel. Inbound traffic never reaches your server directly.
What Cloudflare Tunnel brings in practice
- Zero open ports — the VPS firewall can block all inbound traffic (80 and 443 included) without affecting application accessibility.
- Automatic HTTPS — Cloudflare manages the client-side TLS certificate: no Let's Encrypt to configure, no renewal to monitor.
- NAT and dynamic IP transparent — the outbound connection from
cloudflaredtraverses any NAT; the server IP can change without reconfiguring DNS. - Corporate network or restrictive ISP — if inbound port 443 is blocked, the tunnel keeps working because it relies on outbound HTTP/2 or QUIC connections.
- Optional Zero Trust integration — tunnels pair with Cloudflare Access to restrict access to authenticated users, without a VPN.
- Cloudflare protection included — traffic passes through the Cloudflare network: DDoS mitigation, WAF and rate limiting apply without extra configuration.
- Free plan available — a simple tunnel without load-balancing is usable without a paid Cloudflare subscription.
Prerequisites
To follow this guide you need:
VPS with root access. Installing cloudflared as a systemd service — the only way to guarantee automatic restart — requires root privileges. Shared hosting or an instance without root access does not allow this setup.
Minimum resources. cloudflared uses less than 50 MB of RAM and negligible CPU. Count 1 vCPU and 512 MB of RAM as the strict minimum for the daemon alone; the real constraint comes from the application you are exposing.
A domain managed by Cloudflare. The domain must be registered or transferred to Cloudflare (or NS delegation to Cloudflare). Without an active Cloudflare zone, a named tunnel cannot create DNS records automatically.
Docker Engine (if you use the Docker Compose variant in this guide). Available on Ubuntu 22.04/24.04, Debian 12 and RHEL-compatible distributions.
A free Cloudflare account. No paid subscription is required for a single tunnel without load-balancing.
Install and configure cloudflared on the VPS
Install cloudflared via the Cloudflare repository
Cloudflare publishes cloudflared as .deb / .rpm packages and as a static binary. To install via APT on Debian/Ubuntu:
curl -fsSL https://pkg.cloudflare.com/cloudflare-main.gpg | sudo tee /usr/share/keyrings/cloudflare-main.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/cloudflare-main.gpg] https://pkg.cloudflare.com/cloudflared $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/cloudflared.list
sudo apt update && sudo apt install cloudflaredVerify the installation:
cloudflared --versionThe command should return a line such as cloudflared version 2025.x.x (built ...). The exact version depends on the time of installation; refer to the official cloudflare/cloudflared repository for the current number.
Authenticate cloudflared with Cloudflare
On the VPS (or locally if you have graphical access), run:
cloudflared tunnel loginA link appears in the terminal. Open it in a browser, select the Cloudflare zone to authorize, then confirm. A certificate ~/.cloudflared/cert.pem is created on the machine.
Create a named tunnel
Create a tunnel with a descriptive name:
cloudflared tunnel create my-tunnelCloudflare generates a UUID identifier and a credentials file ~/.cloudflared/<UUID>.json. Note the UUID; you will need it in the following steps.
Write the config.yml configuration file
Create /etc/cloudflared/config.yml:
sudo mkdir -p /etc/cloudflaredFile content (adapt <UUID>, your-domain.com and your application port):
tunnel: <UUID>
credentials-file: /home/<user>/.cloudflared/<UUID>.json
ingress:
- hostname: app.your-domain.com
service: http://localhost:3000
- service: http_status:404The last rule — service: http_status:404 without hostname — is mandatory: it acts as a catch-all rule. Without it, cloudflared refuses to start and returns the error "You must specify an ingress rule that matches all incoming requests".
Create the DNS record and start the tunnel
Automatically register the subdomain in your Cloudflare zone:
cloudflared tunnel route dns my-tunnel app.your-domain.comThen test the tunnel in foreground mode to validate the configuration:
cloudflared tunnel run my-tunnelOpen https://app.your-domain.com in a browser. If the application responds, stop the process (Ctrl+C) and proceed to the next step.
Install cloudflared as a systemd service
To make the tunnel restart automatically on reboot, install it as a system daemon:
sudo cloudflared service install
sudo systemctl enable cloudflared
sudo systemctl start cloudflared
sudo systemctl status cloudflaredThe systemd unit file created by Cloudflare is located at /etc/systemd/system/cloudflared.service. Its content looks like:
[Unit]
Description=cloudflared
After=network.target
[Service]
TimeoutStartSec=0
Type=notify
ExecStart=/usr/bin/cloudflared --no-autoupdate tunnel run
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=multi-user.targetWith this service active, the tunnel is operational from VPS startup, without manual intervention.
Integrate cloudflared into an existing Docker Compose stack
If your application already runs in a Docker Compose stack, add a cloudflared service to the same file. The token approach (without a credentials file) is simplest for a container:
services:
app:
image: my-image
networks:
- internal
cloudflared:
image: cloudflare/cloudflared:latest
command: tunnel --no-autoupdate run
environment:
- TUNNEL_TOKEN=${TUNNEL_TOKEN}
networks:
- internal
restart: unless-stopped
networks:
internal:Define TUNNEL_TOKEN in a .env file at the same level. The token is retrieved from the Cloudflare dashboard → Zero Trust → Networks → Tunnels → your tunnel → Configure → Docker connectors. The cloudflared service and your application share the internal network; point to the application by its Docker service name (http://app:3000 instead of http://localhost:3000).
Post-installation configuration
Once the tunnel is running, a few additional settings improve the robustness of the setup.
Retrieving the real client IP. By default, your application receives requests from 127.0.0.1 or from the tunnel's internal IP. To get the real visitor IP, read the CF-Connecting-IP header that Cloudflare injects automatically. Configure your application or local reverse proxy to trust this header.
End-to-end encryption. The tunnel encrypts the connection between cloudflared and Cloudflare. The connection between cloudflared and your local application is plain HTTP by default (loopback or internal Docker network). If your application exposes HTTPS locally, add originServerName: app.your-domain.com in the corresponding ingress rule so that cloudflared validates the certificate.
Multiple services, one tunnel. A tunnel can expose multiple services on distinct subdomains: simply add additional entries to the ingress block in config.yml, before the catch-all rule.
Hardening: close inbound ports 80 and 443
The main advantage of this architecture is being able to close all inbound ports on the VPS. Once the tunnel is validated, apply these UFW rules:
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow ssh
sudo ufw enableYour application remains accessible via the Cloudflare tunnel (which relies on outbound connections), and the SSH port stays open for administration. No direct connection on 80 or 443 reaches the server anymore.
Cloudflare Tunnel vs Nginx / Traefik: complementary approaches
A common objection: "I already have Nginx and Traefik doing this job, why add a Cloudflare layer?" The answer is that the two approaches do not solve the same problem.
A local reverse proxy (Nginx, Traefik, Caddy) handles routing between services on the same network and SSL renewal — but it assumes that inbound traffic reaches the server. If port 443 is blocked by the upstream network, the reverse proxy is useless.
Cloudflare Tunnel solves exactly what a local reverse proxy cannot: traffic reaches Cloudflare regardless of the server's network connectivity. The two are complementary: you can easily place Traefik behind the tunnel for internal routing, while letting Cloudflare handle public TLS.
Cloudflare Tunnel vs local reverse proxy
| Criterion | Cloudflare Tunnel | Local reverse proxy (Nginx/Traefik) |
|---|---|---|
| Inbound port required | No — outbound connection only | Yes — 80/443 must be reachable |
| Public TLS | Managed by Cloudflare, automatic | Let's Encrypt via ACME (certbot, Traefik…) |
| Dynamic IP / strict NAT | Transparent — no DNS update needed | Problematic — requires DynDNS or a fixed IP |
| Load balancing | Paid plan (Cloudflare Load Balancing) | Available natively (Traefik, Nginx upstream) |
| Latency | Slightly higher (routing via Cloudflare POP) | Minimal — direct traffic to the server |
| External dependency | Yes — Cloudflare must be reachable | No — works without a third party |
Troubleshooting: common error messages
You must specify an ingress rule that matches all incoming requests
The catch-all rule is missing or misplaced in config.yml. It must be the last entry in the ingress block, without hostname, with service: http_status:404.
Unable to locate config file in default locationscloudflared looks for its configuration in ~/.cloudflared/config.yml or /etc/cloudflared/config.yml. Specify the path explicitly with cloudflared tunnel --config /etc/cloudflared/config.yml run my-tunnel.
ERR connection to origin timed out in the logs
The target application is not reachable from cloudflared. Verify that the local service is running (curl http://localhost:3000) and that the port in config.yml matches. In a Docker Compose context, use the service name (http://app:3000) rather than localhost.
Expired token: tunnel credentials file not found or token is expired
Tokens generated through the Cloudflare interface have a limited lifetime if the connector was never registered. Regenerate the token from Zero Trust → Networks → Tunnels → Configure → Connectors, then update the TUNNEL_TOKEN variable in your .env.
Free plan limitations: load-balancing and SSH via tunnel
The free plan does not support load-balancing between multiple origins. SSH access via tunnel (cloudflared access ssh) on the free plan requires a specific Cloudflare Access configuration and is not enabled by default. The number of connectors per tunnel is limited to a few instances on the free plan.
Cloudflare Tunnel as a reference architecture on VPS
Cloudflare Tunnel illustrates well what root access on a VPS makes possible: installing cloudflared as a system daemon, modifying firewall rules, managing systemd services. On shared hosting without root access, none of these steps is feasible — the daemon cannot be installed, the firewall is not under your control, and the service cannot be configured to start at boot.
This architecture is particularly suited to situations where network connectivity is constrained or uncertain: development labs, offices with strict corporate firewalls, edge servers, or simply a refusal to expose a public IP. It pairs naturally with local reverse proxies such as Traefik or Nginx Proxy Manager for internal routing, and with system hardening to close direct attack surfaces.