How I Got gRPC Working Through Cloudflare Tunnel (The Hard Way)
A complete guide to exposing a Go gRPC backend behind college NAT using Cloudflare Tunnel, Docker Compose, and TLS, including every mistake I made along the way.
Background
I'm a student building Bluppi, a Flutter app with a Go gRPC backend. My server runs on my laptop on college WiFi, behind a NAT I don't control, no port forwarding, no static IP.
My stack:
Backend: Go gRPC server (
bluppi-api:50051) + Go gRPC gateway (bluppi-gateway:50050) + Python FastAPI (bluppi-audio-api:8000)Infrastructure: Docker Compose
Tunnel: Cloudflare Tunnel (
cloudflared)Client: Flutter (Android/iOS)
Domain:
bluppi.saikat.in
What should have been a simple "expose my API to the internet" turned into a 6-hour debugging session. Here's everything I learned.
Part 1: Setting Up Cloudflare Tunnel
Create the tunnel
# Login to Cloudflare
cloudflared tunnel login
# Create tunnel
cloudflared tunnel create bluppi
# Route your domain to the tunnel
cloudflared tunnel route dns bluppi bluppi.saikat.in
# Copy credentials to project
mkdir -p cloudflared
cp ~/.cloudflared/<tunnel-id>.json cloudflared/
cp ~/.cloudflared/cert.pem cloudflared/
Initial config.yml (what I started with)
tunnel: <tunnel-id>
credentials-file: /etc/cloudflared/<tunnel-id>.json
ingress:
- hostname: bluppi.saikat.in
path: /api/v2/.*
service: grpc://bluppi-gateway:50050
- hostname: bluppi.saikat.in
path: /api/rest/.*
service: http://bluppi-audio-api:8000
- hostname: bluppi.saikat.in
service: grpc://bluppi-api:50051
- service: http_status:404
Initial docker-compose.yml tunnel service
tunnel:
container_name: bluppi-tunnel
image: cloudflare/cloudflared:latest
command: tunnel --config /etc/cloudflared/config.yml run
restart: unless-stopped
networks:
- cloudflared
depends_on:
- bluppi-api
- bluppi-gateway
- audio-api
volumes:
- ./cloudflared:/etc/cloudflared:ro
Part 2: The Mistakes (And How to Fix Them)
Mistake 1: Port 7844 blocked (college network)
Symptom:
dial tcp 198.41.192.107:7844: i/o timeout
cloudflared defaults to connecting Cloudflare's edge on TCP port 7844. College networks often block non-standard ports.
Fix: Force HTTP/2 protocol which falls back to port 443:
# docker-compose.yml
command: tunnel --protocol http2 --config /etc/cloudflared/config.yml run
Or in config.yml:
protocol: http2
Mistake 2: Too many HA connections
Symptom:
already connected to this server, trying another address
cloudflared opens 4 parallel connections by default, but the Delhi edge (del03) only had 1-2 distinct IPs reachable from my network.
Fix: Reduce HA connections:
ha-connections: 1
Mistake 3: grpc:// is not a valid cloudflared service protocol
Symptom:
malformed HTTP response "\x00\x00\x06\x04..."
grpc:// is not a recognised protocol in cloudflared. Those bytes are raw HTTP/2 frames being received by cloudflared which expected HTTP/1.x.
Fix: Initially, I changed grpc:// to h2c:// (HTTP/2 cleartext) for internal gRPC services. However, this is a trap. While h2c:// stops this immediate error, it leads you right into Mistake 4 because of Cloudflare's proxy limitations.
Mistake 4: Cloudflare Free plan blocks gRPC on public hostnames
Symptom:
malformed header: missing HTTP content-type
Even with the gRPC toggle enabled in the Cloudflare Dashboard, the Free plan does not fully proxy gRPC through orange-cloud (proxied) hostnames. Cloudflare strips the content-type: application/grpc header.
References:
Fix: This is a fundamental limitation, no simple config change fixes it. You cannot use h2c://. The real solution is adding TLS to your origin server and using https:// (Detailed in Part 3).
Part 3: The Real Fix (TLS on the Origin)
Step 1: Generate self-signed certificates
mkdir -p certs
openssl req -x509 -newkey rsa:4096 \
-keyout certs/server.key \
-out certs/server.crt \
-days 365 -nodes \
-subj "/CN=localhost"
Note on CN: Since we use
noTLSVerify: truein cloudflared config, the CN value doesn't matter.localhostworks fine.
Step 2: Configure your gRPC Server
Regardless of whether you are using Go, Python, Node, or Rust, you must configure your gRPC server to use the generated server.crt and server.key. Your specific language's gRPC library will handle the TLS implementation.
Step 3: Verify TLS + ALPN on your server
# Check TLS works
openssl s_client -connect localhost:50051
# Check ALPN h2 is advertised (critical for gRPC)
openssl s_client -connect localhost:50051 -alpn h2
You must see:
ALPN protocol: h2
If you see No ALPN negotiated, gRPC will not work through the tunnel. Check your server's TLS configuration.
Key point:
grpc-goautomatically advertisesALPN: h2when you usecredentials.NewServerTLSFromFile. This is what enables cloudflared to negotiate HTTP/2.
Part 4: Final Working Configuration
docker-compose.yml
Since cloudflared is running on the host, we just need to make sure our backend services expose their ports to localhost.
tunnel:
container_name: bluppi-tunnel
image: cloudflare/cloudflared:latest
command: tunnel --no-autoupdate --protocol http2 --ha-connections 1 --config /etc/cloudflared/config.yml run
restart: unless-stopped
networks:
- cloudflared
depends_on:
- bluppi-api
- bluppi-gateway
- audio-api
volumes:
- ./cloudflared:/etc/cloudflared:ro
networks:
cloudflared:
name: cloudflared
cloudflared/config.yml
tunnel: <tunnel-token>
credentials-file: /etc/cloudflared/<tunnel-id>.json
ha-connections: 1
protocol: http2
ingress:
# 1. Dedicated REST API
- hostname: bluppi.saikat.in
path: /api/rest/.*
service: http://bluppi-audio-api:8000
# 2. gRPC Gateway
- hostname: bluppi.saikat.in
path: /api/v2/.*
service: https://bluppi-gateway:50050
originRequest:
noTLSVerify: true
http2Origin: true
# 3. Pure gRPC API (Catch-All)
- hostname: bluppi.saikat.in
service: https://bluppi-api:50051
originRequest:
noTLSVerify: true
http2Origin: true
- service: http_status:404
Why https:// not h2c://?cloudflared does NOT support h2c:// (HTTP/2 cleartext) as an origin protocol — see issue #1304. You must use https:// with a TLS-enabled origin, then add noTLSVerify: true to accept the self-signed cert, and http2Origin: true to force HTTP/2 negotiation.
Part 6: Verification
Test gRPC end-to-end with grpcurl
# Install grpcurl (or you can use Postman)
# Test locally (bypassing tunnel) — baseline
grpcurl -plaintext localhost:50051 list
# Expected: Unauthenticated (server is running, auth is rejecting unauthenticated requests) (as I have setup JWT auth)
# Test through Cloudflare tunnel
grpcurl bluppi.saikat.in:443 list
# Expected: same Unauthenticated error = tunnel is fully transparent ✅
Part 7: Complete Mistake Summary
| # | Symptom | Root Cause | Fix |
|---|---|---|---|
| 1 | dial tcp ...:7844: i/o timeout |
College network blocks port 7844 | --protocol http2 |
| 2 | already connected to this server |
Del03 has limited edge IPs, 4 connections clash | ha-connections: 1 |
| 3 | malformed HTTP response \x00\x00\x06\x04 |
grpc:// is not valid in cloudflared |
Use h2c:// |
| 4 | use of closed network connection / EOF |
cloudflared uses HTTP/1.1 to origin by default | http2Origin: true |
| 5 | No ALPN negotiated |
gRPC server not advertising h2 during TLS handshake | Use credentials.NewServerTLSFromFile in Go |
| 6 | QUIC timeout on --token mode |
cloudflared token mode defaults to QUIC (UDP), also blocked | --protocol http2 flag |
Key Takeaways
grpc://is not a valid cloudflared service protocol — useh2c://orhttps://Cloudflare Free plan cannot proxy gRPC on public hostnames — cloudflare's gRPC toggle requires Pro plan to work properly
cloudflared connects to origins using h2 (TLS), not h2c (cleartext) — your gRPC origin MUST serve TLS, even behind a private Docker network
http2Origin: trueis mandatory — without it, cloudflared uses HTTP/1.1 to the origin, which gRPC servers reject immediatelynoTLSVerify: trueis safe inside Docker — the tunnel itself provides encryption; internal self-signed certs are fineAlways test with
grpcurlbefore testing Flutter — ifgrpcurl bluppi.saikat.in:443 listreturnsUnauthenticated(same as localhost), your Flutter app will workCollege/restricted networks block UDP and non-standard ports — always force
--protocol http2to use TCP 443
References
Written after 6 hours of debugging. Hopefully this saves you the same pain.

