Skip to main content

Command Palette

Search for a command to run...

How I Got gRPC Working Through Cloudflare Tunnel (The Hard Way)

Published
7 min read
S
I hate slow systems.

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: true in cloudflared config, the CN value doesn't matter. localhost works 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-go automatically advertises ALPN: h2 when you use credentials.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

  1. grpc:// is not a valid cloudflared service protocol — use h2c:// or https://

  2. Cloudflare Free plan cannot proxy gRPC on public hostnames — cloudflare's gRPC toggle requires Pro plan to work properly

  3. cloudflared connects to origins using h2 (TLS), not h2c (cleartext) — your gRPC origin MUST serve TLS, even behind a private Docker network

  4. http2Origin: true is mandatory — without it, cloudflared uses HTTP/1.1 to the origin, which gRPC servers reject immediately

  5. noTLSVerify: true is safe inside Docker — the tunnel itself provides encryption; internal self-signed certs are fine

  6. Always test with grpcurl before testing Flutter — if grpcurl bluppi.saikat.in:443 list returns Unauthenticated (same as localhost), your Flutter app will work

  7. College/restricted networks block UDP and non-standard ports — always force --protocol http2 to use TCP 443


References


Written after 6 hours of debugging. Hopefully this saves you the same pain.