Contents

Behind a CDN (Cloudflare)

Hide your origin behind a big CDN so blocking you means blocking the whole CDN.

Put a CDN in front of your origin and the only address a censor can see is the CDN’s — blocking you means blocking Cloudflare. The visible IP belongs to a shared edge fronting millions of ordinary sites, and under TLS the visible SNI is your CDN hostname, not your server. Host-based and IP-based blocking now has to take down the whole CDN to reach you, which most regulators are unwilling to do.

The censor's view stops at the Cloudflare edge: a shared anycast IP and a hostname that resolves to the CDN. The origin is never on the wire.

The CDN routes you by Host, so that is what you configure

Behind a CDN there is no direct socket to your origin — the CDN picks your backend from the request’s Host/SNI, so the hostname is the config. In XHTTP the dialer builds the request URL straight from your transport settings: the scheme is https whenever TLS is present, and the Host header comes from host (falling back to the TLS serverName).

transport/internet/splithttp/dialer.go
if tlsConfig != nil || realityConfig != nil {
	requestURL.Scheme = "https"
}
requestURL.Host = transportConfiguration.Host
if requestURL.Host == "" && tlsConfig != nil {
	requestURL.Host = tlsConfig.ServerName
}

So the practical mapping is: connect to the CDN edge, present your CDN hostname as SNI, and let the CDN’s own dashboard route that hostname to your origin. A real XHTTP-behind-CDN outbound:

{
	"protocol": "vless",
	"settings": {
		"vnext": [
			{
				"address": "proxy.example.com",
				"port": 443,
				"users": [{ "id": "", "encryption": "none" }]
			}
		]
	},
	"streamSettings": {
		"network": "xhttp",
		"security": "tls",
		"tlsSettings": {
			"serverName": "proxy.example.com",
			"alpn": ["h2", "http/1.1"]
		},
		"xhttpSettings": {
			"host": "proxy.example.com",
			"path": "/assets",
			"mode": "packet-up",
			"scMaxEachPostBytes": 1000000
		}
	}
}

Every key here exists in the source: network, security, tlsSettings.serverName, tlsSettings.alpn, and the XHTTP host / path / mode / scMaxEachPostBytes. The path is what your CDN rule matches on to forward to the origin.

Pick a transport the CDN can actually carry

A CDN terminates TLS at its edge and re-originates a fresh HTTP request to your backend — so anything that needs the client’s raw bytes to survive end-to-end is dead on arrival. Raw TCP has no HTTP envelope for the CDN to route, and REALITY needs the client’s own TLS ClientHello to reach the origin untouched. Behind a terminating CDN neither is possible.

TransportEnvelopeSurvives CloudflareWhy
XHTTPordinary HTTPYes (packet-up)uploads are bounded POSTs, download is SSE
WebSocketHTTP/1.1 UpgradeYesCDNs proxy Upgrade: websocket natively
HTTPUpgradeHTTP/1.1 UpgradeYessame Upgrade dance, thinner than WS
gRPCHTTP/2 + trailersPartiallyneeds gRPC explicitly enabled at the CDN
Raw TCPnoneNonothing for the CDN to route
REALITYforged TLSNoTLS terminated before it reaches origin
transport/internet/websocket/dialer.go transport/internet/httpupgrade/dialer.go

Prefer packet-up, because the CDN buffers request bodies

Cloudflare does not stream a request body to your origin — it buffers the upload, then forwards it — so a transport that relies on one long-lived streaming POST stalls. XHTTP’s packet-up mode is built for exactly this: the upload is chopped into independent, bounded POST requests rather than one infinite body.

transport/internet/splithttp/config.go

In packet-up each POST is capped by scMaxEachPostBytes (default 1000000, one megabyte), the dialer sends them as discrete requests, and the download is a single long-lived GET that the CDN happily streams back. stream-up instead sends the client’s data as one continuous POST body — precisely the shape a buffering CDN refuses to forward incrementally.

Don't

mode: stream-up behind Cloudflare

One continuous upload POST. The CDN buffers the whole body before it reaches the origin, so uploads stall and the tunnel wedges.

Do

mode: packet-up behind Cloudflare

Uploads become a stream of bounded POSTs, each under scMaxEachPostBytes. The CDN forwards each one as an ordinary request, and downloads stream back over a single GET.

WebSocket and HTTPUpgrade sidestep the body question entirely — both force ALPN http/1.1 and ride a bidirectional Upgrade socket the CDN proxies natively, at the cost of a handshake round-trip. gRPC works too, but only if you turn on gRPC support at the CDN, which is why it is “partial”. See XHTTP and WebSocket for the transport-level mechanics.