Connecting a Java App on Cloudflare Containers to Temporal on My Raspberry Pi

Cloudflare won't carry gRPC through a tunnel public hostname. Here's how I got a Spring Boot worker running on Cloudflare Containers to talk to a self-hosted Temporal cluster on my home network anyway — with a cloudflared TCP sidecar, service token auth, and no open ports on my router.

The setup

I run a production Spring Boot app on Cloudflare Containers. It’s a Temporal worker: it polls a task queue, picks up workflow tasks, and executes long-running business processes with all the retry and durability guarantees Temporal is known for. For the past year those workflows ran against Temporal Cloud, and Temporal Cloud is a good product. But my usage is one namespace and one mostly-idle worker, and the monthly bill after the trial was hard to justify for a workload that small.

Meanwhile, sitting on a shelf in my house, a Raspberry Pi was already running a full Temporal cluster in Docker Compose, serving a different side project. I’ve written before about deploying Temporal with Podman Quadlets, so self-hosting Temporal is well-trodden ground for me. The question wasn’t whether I could run the server. It was whether a container running inside Cloudflare’s network could reach a gRPC service inside my house without me opening a single port on my router.

Spoiler: yes, but not the way I planned.

The plan that looked clean on paper

The Pi already had a cloudflared tunnel: the outbound-only daemon that connects to Cloudflare’s edge and routes traffic for chosen hostnames down to local services. No port forwarding, nothing listening on my router. My existing ingress served a plain HTTP API and had been rock solid.

So the architecture wrote itself:

  1. Add a tunnel ingress rule mapping temporal.example.com to the Temporal frontend on localhost:7233.
  2. Put a Cloudflare Access application in front of the hostname with a Service Auth policy, so only clients presenting a service token get through. This replaces Temporal Cloud’s mTLS as the authentication layer.
  3. Have the Java SDK send the token as gRPC metadata headers on every request.

Temporal’s frontend speaks gRPC, gRPC is just HTTP/2, Cloudflare proxies HTTP/2. What could go wrong?

The wall

With the Access application configured and a service token in hand, I ran a sanity check with the Temporal CLI. It failed, but not with an auth error. It failed with a parse error, because the response wasn’t gRPC at all. It was a bare HTML 403 Forbidden page from Cloudflare’s edge.

The confusing part: a plain curl to the same hostname with the same service token headers sailed straight through Access and reached the tunnel. Identity wasn’t the problem. So I built a minimal gRPC-shaped request by hand (HTTP/2 prior knowledge, content-type: application/grpc, te: trailers) and got the bare 403 again. The edge was rejecting the request because it looked like gRPC, before Access even evaluated the policy.

Some digging turned up the full picture:

  • Cloudflare has (had?) a zone-level gRPC toggle, but the setting no longer exists in the zone settings API. Reading or writing it returns “unrecognized zone setting.”
  • Cloudflare Tunnel does not support gRPC on public hostnames. There’s a long-standing open issue on cloudflared where gRPC request bodies and trailers get stripped in transit. gRPC works over Cloudflare’s private-network/WARP routing, but not through a plain public hostname.
  • Cloudflare Access, as a reverse proxy, doesn’t support gRPC either.

This is the kind of limitation you only find at the bottom of a debugging session, because every individual component advertises the pieces you need: the tunnel proxies HTTP/2, Access authenticates requests, gRPC is HTTP/2. The composition is what’s unsupported.

The detour through “just pay someone”

At this point the sunk-cost alarm went off and I priced the alternatives properly. Three candidates:

A VPS (Hetzner). The classic answer. Rent a small box, run the same Docker Compose stack, secure the Temporal frontend with its native mTLS, point the worker at it directly. Architecturally this is the cleanest option: no proxy games at all, and it reuses the mTLS plumbing my container already had from the Temporal Cloud days. I got as far as the order form before discovering Hetzner had raised cloud prices substantially a month earlier, and their budget tier isn’t offered in US regions at all. Still cheaper than Temporal Cloud, but the “this is practically free” math I’d done from stale pricing no longer held.

AWS Lightsail. A fixed-price bundle with enough RAM for the Temporal stack, US region, boring and reliable. Roughly triple the equivalent European VPS for the same job. Fine, but paying a premium to avoid a problem I didn’t have.

Cloudflare Containers itself. The tempting one: my worker already lives there, why not the server too? Two dealbreakers. First, Containers have no persistent disk — the filesystem is wiped on every restart — so Postgres would have to live in a managed database elsewhere, and the Temporal server becomes another stateless container. Second, and decisive: Containers are billed per second of runtime, priced for workloads that scale to zero. A Temporal server must run 24/7. Doing the arithmetic on an always-on instance produced a number embarrassingly close to what I was trying to stop paying Temporal Cloud. The platform isn’t wrong, it’s just built for a different shape of workload. My worker is cheap there precisely because it sleeps.

Against all of that, the Pi was already running the server, already had the tunnel, and cost nothing. The only thing standing in the way was a protocol limitation.

The fix: stop asking Cloudflare to understand gRPC

The insight that unlocked it: Cloudflare Tunnel can’t proxy gRPC, but it’s perfectly happy to carry raw TCP. cloudflared has a client-side counterpart, cloudflared access tcp, which opens a local listener and pipes whatever bytes arrive over an authenticated WebSocket to the tunnel, which delivers them to the origin service. Cloudflare never parses the stream. It can’t mangle what it doesn’t inspect.

The ingress rule on the Pi becomes:

- hostname: temporal.example.com
  service: tcp://localhost:7233

And the container image gets a tiny sidecar. The Dockerfile pulls in the static cloudflared binary, and the entrypoint starts it before the JVM when the right environment variables are present:

if [ -n "$TEMPORAL_TUNNEL_HOSTNAME" ] && [ -n "$CF_ACCESS_CLIENT_ID" ] && [ -n "$CF_ACCESS_CLIENT_SECRET" ]; then
  cloudflared access tcp \
    --hostname "$TEMPORAL_TUNNEL_HOSTNAME" \
    --url 127.0.0.1:7233 \
    --service-token-id "$CF_ACCESS_CLIENT_ID" \
    --service-token-secret "$CF_ACCESS_CLIENT_SECRET" &
  export TEMPORAL_ADDRESS=127.0.0.1:7233
fi
exec java -jar /app/app.jar

The sidecar authenticates to Cloudflare Access with the service token, and from the Java SDK’s point of view there’s simply a plaintext Temporal server on localhost. Zero TLS configuration in the application. The code that connects to localhost:7233 in local development connects to localhost:7233 in production, and the entire cross-network, authenticated, encrypted transport is somebody else’s problem — specifically, two cloudflared processes talking WebSocket to each other through Cloudflare’s edge.

┌─────────────────────────────┐
│   Cloudflare Container      │
│                             │
│  Spring Boot ──▶ 127.0.0.1:7233
│  (Temporal SDK)     │       │
│                cloudflared  │
│                access tcp   │
└─────────────────────┼───────┘
                      │ TCP over WebSocket (TLS)
                      │ + Access service token
              ┌───────▼───────┐
              │ Cloudflare    │
              │ edge + Access │
              └───────┬───────┘
                      │ outbound-only tunnel
┌─────────────────────┼───────┐
│   Raspberry Pi      │       │
│                cloudflared  │
│                     │       │
│              tcp://localhost:7233
│                     ▼       │
│   Temporal frontend (gRPC)  │
│   + Postgres + UI (Docker)  │
└─────────────────────────────┘

Minutes after the ingress change, temporal workflow list worked from my laptop through the same sidecar pattern. The production worker followed: its pollers showed up on the task queue in the Pi’s Temporal UI, identified by a hostname that made me smile. cloudchamber, Cloudflare’s internal name for the Containers runtime, phoning into a Raspberry Pi next to my router.

The cutover gotcha that ate an evening

One war story worth telling, because the failure signature is so good. After deploying the new image, the container crash-looped on startup with this buried in the gRPC stack trace:

NotSslRecordException: not an SSL/TLS record: 000006040000000000000500004000

The old Temporal Cloud deployment had authenticated with mTLS, and the client certificates were still present in the container’s environment as leftover secrets. The startup script saw them and helpfully enabled TLS — pointing client certificates at a local sidecar that speaks plaintext.

Decode those first bytes and the error explains itself: 00 00 06 04 is a 6-byte HTTP/2 frame of type 0x04, a SETTINGS frame. The server answered with perfectly normal plaintext HTTP/2, and the client, expecting a TLS handshake, refused to parse it. The TLS stack was literally printing the evidence of the mismatch in hex. Deleting the stale secrets fixed it, and the entrypoint now explicitly unsets every mTLS variable when the sidecar is active, so no configuration drift can recreate that state.

The lesson generalizes: when you change how a connection is secured, hunt down every credential the old scheme left behind. Half-dead configuration is worse than missing configuration, because it activates code paths you thought you’d retired.

Security: what it means to point production at your house

This part deserved more thought than the plumbing. The threat model changes when the origin is your home network.

No inbound exposure. The tunnel is outbound-only. The Pi dials Cloudflare; nothing dials the Pi. My router has zero forwarded ports, my home IP appears nowhere in DNS, and if you port-scan me you find whatever you found before this project. That property is the whole reason to prefer a tunnel over the traditional port-forward-plus-dynamic-DNS approach.

Identity enforced at the edge. The Access application in front of the hostname has exactly one policy: a Service Auth token. There’s no login page fallback, no email-based bypass. An unauthenticated connection is rejected at Cloudflare’s edge, on Cloudflare’s hardware, before a byte reaches my house. Tokens are individually revocable in the Zero Trust dashboard, and revocation takes effect at the edge immediately. The incident response plan for a leaked token is one click.

Loopback-only binds everywhere. The Temporal frontend port is published to 127.0.0.1 on the Pi, never the LAN interface. Same for the Temporal web UI, which previously listened on the LAN. The UI can read workflow payloads and terminate executions with no authentication of its own, and once production data was involved, “anyone on my wifi” became an unacceptable audience. I reach it over an SSH tunnel now. The only ways into that Temporal server are the authenticated Cloudflare tunnel and an SSH session.

Honest limits. Two things I accepted rather than solved. First, Temporal’s frontend has no per-namespace authorization: any client that passes Access can touch every namespace on the cluster. Namespaces are organizational hygiene rather than a security boundary, so a leaked token means full cluster access until revoked. Second, workflow histories live in Postgres on an SD card in my house, which means the data is subject to my home’s physical security, and durability depends on nightly pg_dump backups shipped offsite rather than on enterprise storage. For genuinely sensitive payloads, Temporal supports client-side payload encryption via a custom data converter, which encrypts workflow data before it ever reaches the server. That’s the escalation path if the calculus changes.

A dependency I chose consciously. My house is now a production dependency: power, ISP, one small computer. Temporal is actually the ideal workload for that risk, because durable execution is the product. If the Pi drops offline, workers get connection errors, workflows pause, and everything resumes from exact state when the server returns. An outage degrades latency, not correctness. I wouldn’t serve a user-facing API this way; a workflow engine whose consumers are batch processes is a different story.

What I’d tell you

If you’re trying to reach a self-hosted service through Cloudflare Tunnel, the decision comes down to protocol:

  • Plain HTTP/HTTPS? A public hostname ingress works out of the box, and Access can protect it. This is the happy path and it’s great.
  • gRPC, or anything the edge might inspect and dislike? Don’t fight it. Go straight to a tcp:// ingress with a cloudflared access tcp sidecar on each client. You give up per-request inspection at the edge and gain complete protocol transparency. Auth still happens at the edge via service tokens.

And if you already know how to self-host Temporal, a small always-on cloud bill for a mostly-idle cluster is worth questioning. The Pi isn’t the right answer for every team or any real availability requirement. But for a single-operator production system whose workload is durable by design, hardware I already own plus a tunnel I already ran turned out to be the architecture with the fewest moving parts I don’t control. The monthly cost of the whole thing is the electricity to run a Raspberry Pi.

If this setup takes off, I’ll move the cluster somewhere with redundant power and someone else’s pager. The nice part of the sidecar architecture is that migrating is a DNS change and two secrets: nothing in the application knows where Temporal lives.