# 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.

![](https://cdn.hashnode.com/uploads/covers/67093bfb638dddf76481cbb9/fa59eda8-a56c-41a8-a787-e9781ac23d6a.png align="center")

## Part 1: Setting Up Cloudflare Tunnel

### Create the tunnel

```bash
# 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)

```yaml
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

```yaml
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:**

```plaintext
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:

```yaml
# docker-compose.yml
command: tunnel --protocol http2 --config /etc/cloudflared/config.yml run
```

Or in `config.yml`:

```yaml
protocol: http2
```

* * *

### Mistake 2: Too many HA connections

**Symptom:**

```plaintext
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:

```yaml
ha-connections: 1
```

* * *

### Mistake 3: `grpc://` is not a valid cloudflared service protocol

**Symptom:**

```plaintext
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:**

```plaintext
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:**

*   [cloudflared issue #491 — gRPC support](https://github.com/cloudflare/cloudflared/issues/491)
    
*   [cloudflared issue #1304 — Support h2c origin servers](https://github.com/cloudflare/cloudflared/issues/1304)
    

**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

```bash
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

```bash
# 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:

```plaintext
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`.

```yaml
  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`

```yaml
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](https://github.com/cloudflare/cloudflared/issues/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

```bash
# 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

*   [cloudflared issue #491 — gRPC support request](https://github.com/cloudflare/cloudflared/issues/491)
    
*   [cloudflared issue #1304 — Support h2c origin servers](https://github.com/cloudflare/cloudflared/issues/1304)
    
*   [Cloudflare Docs — Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/)
    

* * *

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