Protect a private origin with Cloudflare Tunnel
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
styloboton the internal Compose network. - Remove public
ports:mappings from both your app and StyloBot. The tunnel service can reachstylobot:8080internally.
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.