Deployment10 min read

Cloudflare Tunnel: expose a VPS app without opening ports

You have just deployed an application on your VPS and find yourself stuck: dynamic IP, a corporate firewall blocking inbound port 443, or simply a refusal to expose your server directly to the Internet. A Cloudflare Tunnel solves this by reversing the connection: it is `cloudflared` that calls Cloudflare, not the other way around. The result is your application reachable over HTTPS on your domain, without opening any inbound port and without touching your DNS configuration.

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 cloudflared traverses 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

01

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 cloudflared

Verify the installation:

cloudflared --version

The 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.

02

Authenticate cloudflared with Cloudflare

On the VPS (or locally if you have graphical access), run:

cloudflared tunnel login

A 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.

03

Create a named tunnel

Create a tunnel with a descriptive name:

cloudflared tunnel create my-tunnel

Cloudflare generates a UUID identifier and a credentials file ~/.cloudflared/<UUID>.json. Note the UUID; you will need it in the following steps.

04

Write the config.yml configuration file

Create /etc/cloudflared/config.yml:

sudo mkdir -p /etc/cloudflared

File 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:404

The 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".

05

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.com

Then test the tunnel in foreground mode to validate the configuration:

cloudflared tunnel run my-tunnel

Open https://app.your-domain.com in a browser. If the application responds, stop the process (Ctrl+C) and proceed to the next step.

06

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 cloudflared

The 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.target

With this service active, the tunnel is operational from VPS startup, without manual intervention.

07

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 enable

Your 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

CriterionCloudflare TunnelLocal reverse proxy (Nginx/Traefik)
Inbound port requiredNo — outbound connection onlyYes — 80/443 must be reachable
Public TLSManaged by Cloudflare, automaticLet's Encrypt via ACME (certbot, Traefik…)
Dynamic IP / strict NATTransparent — no DNS update neededProblematic — requires DynDNS or a fixed IP
Load balancingPaid plan (Cloudflare Load Balancing)Available natively (Traefik, Nginx upstream)
LatencySlightly higher (routing via Cloudflare POP)Minimal — direct traffic to the server
External dependencyYes — Cloudflare must be reachableNo — 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 locations
cloudflared 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.

A VPS with root access to install cloudflared

The `cloudflared` daemon and its systemd service require root access — something shared hosting does not provide. A ServOrbit VPS gives you full control: OS choice, root access, dedicated IPv4, and Marketplace templates to get started quickly.

Need help?

Browse our help center and FAQ, or write to our team — support in French, English and Arabic.