Documentation

Protect a private origin with Cloudflare Tunnel

Step-by-step guide Updated 4 August 2026

Protect a private origin with Cloudflare Tunnel

Use this when you want the VPS to have no public web ports. Cloudflare handles the public hostname and TLS certificate; cloudflared opens an outbound-only connection to StyloBot inside your Compose network.

The result: no open 80/443 firewall rules, no origin certificate to renew, and your application is never directly addressable from the internet.

flowchart LR
    A[Visitor] --> B[Cloudflare edge<br/>public HTTPS]
    B --> C[Outbound Cloudflare Tunnel]
    C --> D[StyloBot]
    D --> E[Your app]

Before you start

  • Your domain is active in Cloudflare.
  • You have completed Add to Docker Compose, with the application and stylobot on the internal Compose network.
  • Remove public ports: mappings from both your app and StyloBot. The tunnel service can reach stylobot:8080 internally.

1. Create the named tunnel

In Cloudflare Zero Trust → Networks → Tunnels, create a Cloudflared tunnel. Add a public hostname, for example www.example.com, and set its service to:

http://stylobot:8080

Copy the tunnel token into a local .env next to compose.yml:

CF_TUNNEL_TOKEN=replace-with-the-token-from-cloudflare

Keep .env out of version control. The hostname stylobot works only from the Compose network; do not replace it with localhost when cloudflared is a separate container.

2. Add the tunnel connector

services:
  cloudflared:
    image: cloudflare/cloudflared:latest
    command: tunnel --no-autoupdate run --token ${CF_TUNNEL_TOKEN}
    depends_on:
      - stylobot
    restart: unless-stopped

There are no ports: entries here. Start it and check the connector:

docker compose up -d
docker compose logs -f cloudflared

Cloudflare should report the tunnel as healthy. Loading your public hostname now follows the path in the diagram, through StyloBot before it reaches the app.

3. Preserve the browser signals that a tunnel terminates

Cloudflare knows the real browser protocol, TLS version, and ASN. StyloBot cannot see them unless Cloudflare forwards them. Create one Rules → Transform Rules → Modify Request Header rule for the hostname and add these dynamic values:

Header name Cloudflare expression Why it matters
Sb-Http-Version http.request.version Distinguishes browser HTTP/2 or HTTP/3 from the tunnel's internal HTTP/1.1 hop.
X-Client-TLS-Version cf.tls_version Populates the TLS profile.
X-Client-TLS-Cipher cf.tls_cipher Adds cipher information to the signature.
X-Client-TLS-Ext-Sha1 cf.tls_client_extensions_sha1 Retains a stable client-extension fingerprint.
X-Client-ASN ip.geoip.asnum Identifies the source network.

Make a new request after deploying the rule, then inspect its dashboard signature. Historical signatures keep their historical values. Cloudflare Enterprise customers can also forward JA3/JA4; the exact mapping is in the proxy signal reference.

Avoid the common Cloudflare traps

What you see Fix
Cloudflare returns 502, but the tunnel is healthy In a containerised tunnel, the origin service must be http://stylobot:8080, not http://localhost:8080.
The public URL redirects in a loop Let Cloudflare own public TLS and configure the app to trust the forwarded HTTPS scheme. Do not add a second public proxy just to fix it.
All detections look like HTTP/1.1 Add the Transform Rule and inspect a new detection. The tunnel's internal hop is HTTP/1.1 by design.
The origin is still exposed Remove every host ports: mapping and close inbound 80/443. Cloudflared needs outbound access only.

What this is good for

Cloudflare Tunnel is especially useful for older sites, admin tools, and small VPS deployments where reducing public attack surface matters more than operating your own edge proxy. For a legacy app on a separate private host, see the brownfield retrofit guide.

Next: choose an enforcement policy after you have observed real visitor traffic.