Lupyd.
Lupyd Systems & Architecture

Why Lupyd Chose Cloudflare for Voice and Video Calls

We could have run our own SFU fleet or rented a proprietary video SDK. Instead, Lupyd runs 1:1 calls on Cloudflare TURN and group voice channels on RealtimeKit. Here's why, with the Rust and TypeScript code that makes it work.

L

Lupyd Team

Lupyd

Published: Oct 12, 2026
•
12 min read
Why Lupyd Chose Cloudflare for Voice and Video Calls

Who Does What in a Lupyd Call

Lupyd client · Tauri + React
  • 1:1 plain RTCPeerConnection
  • Group RealtimeKit Client + RtkMeeting
  • Holds only a Lupyd auth token
firefly-mls · Rust
  • Auth, group membership, channel permissions
  • CallSignal relay over WebSocket
  • Meeting state + member broadcast
  • Holds every Cloudflare secret
Cloudflare edge
  • TURN anycast relays for 1:1
  • SFU forwards group streams
  • RealtimeKit rooms + participant tokens
⇄ /webrtcConfig, /meeting/*, WebSocket
⇄ server-to-server REST (Bearer token)
⇄ media: DTLS-SRTP to clients

Lupyd keeps identity, permissions and secrets. Cloudflare moves the packets.

Every chat app eventually has to add calling, and the first decision is the expensive one: build your own media servers, rent a video SDK, or pick something in between. We picked Cloudflare: its TURN service for one-to-one calls and RealtimeKit for group voice channels. This post explains why, using the code we actually ship. It also covers what we didn't get from that choice.

The Problem: Calls Are Not Chat

Chat is forgiving. A message that arrives 400ms late is still a message. A message that fails can be retried, queued, stored and synced later. Firefly, our messaging engine, is built around exactly that: MLS-encrypted payloads, a WebSocket per device, and a Rust server that relays and persists.

Voice and video don't work that way. Audio that arrives 400ms late is useless, and you can't buffer your way out of a bad network. Calling needs three things that a messaging backend doesn't have:

  • NAT traversal. Most devices sit behind routers, carrier NAT or corporate firewalls. A direct peer-to-peer path often doesn't exist, so you need TURN relays close to your users.
  • Fan-out for groups. In a mesh, every participant uploads a separate stream to every other participant. That stops working after a handful of people. Groups need an SFU (Selective Forwarding Unit) that receives each stream once and forwards it to everyone else.
  • Geography. Relays and SFUs only help if they're close to both ends of the call. A single region in Mumbai is fine for Mumbai and a poor experience for a teammate in Berlin.

We're a small team. Every hour spent running media infrastructure is an hour not spent on the parts of Lupyd that make it Lupyd: encryption, groups and the product itself.

The Options We Weighed

Roughly, the choices fall into three groups:

Approach Examples What you get What it costs you
Self-hosted SFU + TURN mediasoup, Jitsi, Janus, self-hosted LiveKit, coturn Full control and no per-minute vendor bill You run, scale, patch and monitor media servers in every region you serve
Proprietary video SDK Agora, Twilio Video, Zoom Video SDK Polished SDKs and a global network Proprietary client stacks and tight coupling to one vendor's roadmap
Managed WebRTC on an edge network Cloudflare Realtime (TURN, SFU, RealtimeKit) Standard WebRTC, anycast relays near users, no servers to run Less low-level control, and a dependency on Cloudflare

Self-hosting is the "right" answer on paper, and we like owning our stack: Firefly is our own Rust server. But a good SFU deployment isn't one server. It's a fleet across regions, a TURN cluster that has to be reachable on port 443 through strict firewalls, autoscaling for call spikes, and someone on call when a region degrades. That's a separate product, and building it wasn't our goal.

Proprietary SDKs solve the operations problem, but they also own your client. Their media pipeline is a black box, and you integrate on their terms. For an app whose whole pitch is that you can see how the encryption works, that's an uncomfortable fit.

Cloudflare was in the middle. Under the hood it's standard WebRTC, so our one-to-one calls are a plain RTCPeerConnection with no SDK. It runs on a network we already used for the Lupyd CDN, and it gave us a managed group-meeting layer for the part we didn't want to build ourselves.

Why Cloudflare Won

1. Anycast TURN, without running TURN

Cloudflare's TURN service runs on its global anycast network. The client connects to one hostname and gets routed to a nearby data center, so we don't have to pick regions or manage relay IPs. We only call one API: "give this user short-lived credentials."

2. Standard WebRTC, so no lock-in at the client

One-to-one calls in Lupyd don't depend on any Cloudflare SDK. Cloudflare only provides the iceServers. The offer, answer, ICE candidates and media are all browser-native WebRTC, with signaling carried over Firefly. If we ever change TURN provider, we change one Rust function.

3. A managed SFU with a meeting layer on top

Group calls need participant lists, active-speaker detection, device switching, screen sharing and a video grid. RealtimeKit gives us all of that on top of Cloudflare's SFU, including prebuilt React components. In our codebase, group screen share is two method calls.

4. A server-side API that keeps secrets server-side

Every Cloudflare credential we hold (the TURN key and the RealtimeKit API token) lives in our Rust server's environment. Clients only ever receive two things: one-hour TURN credentials, or a per-participant meeting token. That fits how Firefly already works, where the server authorizes and the client never holds anything it shouldn't.

5. One less vendor

The Lupyd CDN already runs as a Cloudflare Worker with R2, D1 and Hyperdrive bindings. Using the same provider for realtime media means one account, one dashboard and one billing relationship, which matters when you're a small team.

One-to-One Calls: TURN as a Service

For 1:1 calls we use classic peer-to-peer WebRTC. The only Cloudflare piece is TURN. When a call starts, the client asks our server for an ICE configuration. The server checks the user's auth token, asks Cloudflare for credentials tied to that username, and returns them.

Here's the whole server-side TURN integration:

// firefly-mls/crates/server/src/shared_data.rs
pub struct WebrtcConfigGenerator {
    turn_token_id: String,
    turn_api_token: String,
}

impl WebrtcConfigGenerator {
    pub async fn generate(&self, username: &str, ttl: usize) -> Result<String, InternalError> {
        if *EMULATOR_MODE {
            return Ok(
                r#"{ "iceServers": [ { "urls": [ "stun:stun.lupyd.com:3478" ] } ] }"#.to_string(),
            );
        }

        let response = HTTP_CLIENT
            .post(format!(
                "https://rtc.live.cloudflare.com/v1/turn/keys/{}/credentials/generate",
                self.turn_token_id
            ))
            .header(header::CONTENT_TYPE, "application/json")
            .header(header::AUTHORIZATION, format!("Bearer {}", self.turn_api_token))
            .body(format!(
                r#"{{ "ttl": {}, "customIdentifier": "{}" }}"#,
                ttl, username
            ))
            .send()
            .await?;

        // ...status check elided...
        Ok(response.text().await?)
    }
}

Three details are worth pointing out:

  • customIdentifier is the username. Usage on the Cloudflare side can be attributed to a Lupyd account, which helps when investigating abuse or a cost spike.
  • The TTL is one hour. Leaked credentials expire quickly, and nobody gets a permanent relay.
  • Emulator mode short-circuits to STUN. Local development and tests don't need Cloudflare credentials at all.

The HTTP route that exposes it is authenticated like every other Firefly endpoint:

// firefly-mls/crates/server/src/lib.rs
"/webrtcConfig" => {
    let Ok(token) = sd.auth_handler.verify_from_headers(request.headers()).await
    else { return Ok(unauthorized_response()); };

    let username = token.username;
    let body = sd.webrtc_generator.generate(&username, 3600).await?;
    return Ok(ok_response(body));
}

On the client, the response goes straight into the RTCPeerConnection constructor:

// lupyd-desktop/src/context/firefly-context.tsx
const getWebrtcConfig = useCallback(async () => {
  const token = await auth.getToken();
  if (!token) throw new Error("User not authenticated");

  const response = await fetch(`${apiUrl}/webrtcConfig`, {
    headers: { authorization: `Bearer ${token}` },
  });
  return JSON.parse(await response.text()) as RTCConfiguration;
}, [auth]);

// lupyd-desktop/src/app/messages/[username]/call/page.tsx
const getConfiguration = async () => {
  const { iceServers } = (await firefly.getWebrtcConfig()) as any as { iceServers: RTCIceServer };
  return { iceServers: [iceServers] } satisfies RTCConfiguration;
};

// ...and inside CallSession.init()
this.pc = new RTCPeerConnection(await this.rtcConfiguration());

Everything after that is standard WebRTC. The SDP offer and answer and the trickled ICE candidates travel as typed CallSignal protobufs over the Firefly WebSocket. The server rings every online device of the callee, pins the call to whichever device answers, and sends CALL_DISMISS to the rest. If the callee is offline, it falls back to an FCM push and re-delivers the pending offer to any device that connects within 45 seconds. We covered that flow in Building Real-Time Calling in Lupyd.

The point here is that Cloudflare isn't in the signaling path. Call requests, answers and hangups never pass through it. It issues relay credentials tagged with the requesting username, and relays encrypted SRTP packets when a direct path can't be found.

Group Calls: RealtimeKit on Cloudflare's SFU

Group voice channels are where a managed SFU pays off. Our model is simple: each voice channel in a Lupyd group has at most one live meeting, and Lupyd decides who may join. Cloudflare provides the room.

Starting a Group Call

  1. 01 Client → Firefly CreateMeetingRequest { groupId, channelId }
  2. 02 Firefly member? voice-channel permission? no live meeting? Otherwise 403 / 409
  3. 03 Firefly → Cloudflare POST /realtime/kit/{app}/meetings, then POST …/participants
  4. 04 Firefly → group GroupMeetingSignal STARTED to every member socket
  5. 05 Client → Cloudflare SFU Client.init({ authToken }), then media flows

Permission checks happen in Firefly before Cloudflare is ever called. Clients only receive a participant token.

Creating the room

Creating a Cloudflare meeting takes one authenticated POST from Rust. Note the None branch: without credentials we return a mock meeting ID, which our integration tests rely on.

// firefly-mls/crates/server/src/meeting/mod.rs
async fn cf_create_meeting(
    config: &Option<RealtimeKitConfig>,
    title: &str,
) -> Result<String, InternalError> {
    let config = match config {
        Some(c) => c,
        None => {
            log::info!("Cloudflare RealtimeKit is not configured. Returning mock meeting ID.");
            return Ok(format!("mock-meeting-{}", uuid_timestamp()));
        }
    };

    let url = format!(
        "https://api.cloudflare.com/client/v4/accounts/{}/realtime/kit/{}/meetings",
        config.account_id, config.app_id
    );

    let res = HTTP_CLIENT
        .post(&url)
        .header("Authorization", format!("Bearer {}", config.api_token))
        .json(&CreateMeetingPayload { title })
        .send()
        .await?;

    // ...error handling and CfApiResponse decoding elided...
    Ok(api_res.result.unwrap().id)
}

Issuing a participant token

Each person who joins gets their own participant token, created with a preset (group_call_participant) that defines what they're allowed to do in the room:

// firefly-mls/crates/server/src/meeting/mod.rs
let url = format!(
    "https://api.cloudflare.com/client/v4/accounts/{}/realtime/kit/{}/meetings/{}/participants",
    config.account_id, config.app_id, cf_meeting_id
);

let custom_id = format!("user-{}-{}", name, uuid_timestamp());

let res = HTTP_CLIENT
    .post(&url)
    .header("Authorization", format!("Bearer {}", config.api_token))
    .json(&AddParticipantPayload {
        name,
        preset_name: "group_call_participant",
        custom_participant_id: &custom_id,
    })
    .send()
    .await?;

// The only thing that ever leaves our server for the client:
Ok(api_res.result.unwrap().token)

Lupyd decides, Cloudflare executes

This is the function that shows the split best. Before we spend a single API call on Cloudflare, Firefly checks group membership, checks the voice-channel permission stored in the MLS group extension, and makes sure the channel doesn't already have a live meeting:

// firefly-mls/crates/server/src/meeting/mod.rs
pub async fn create_meeting(sd: &SharedData, group_id: u64, channel_id: u32,
                            username: &str, e2ee_enabled: bool)
    -> Result<(GroupMeetingSession<'static>, String), InternalError>
{
    let db = sd.db.get().await?;

    // 1. Lupyd decides who may call. Cloudflare never sees our group model.
    if !check_group_membership(&db, group_id, username).await? {
        return Err(InternalError::ForUser(StatusCode::FORBIDDEN,
            "User is not a member of group".into()));
    }
    check_voice_channel_permission(&db, sd, group_id, channel_id, username).await?;

    // 2. One live meeting per voice channel.
    if get_active_meeting_db(&db, group_id, channel_id).await?.is_some() {
        return Err(InternalError::ForUser(StatusCode::CONFLICT,
            "An active meeting session already exists for this channel".into()));
    }

    // 3. Only now do we ask Cloudflare for a room and a token.
    let cf_meeting_id = cf_create_meeting(&sd.realtimekit_config, &title).await?;
    let session = create_meeting_db(&db, group_id, channel_id, username,
                                    &cf_meeting_id, e2ee_enabled).await?;
    add_participant_db(&db, session.session_id, username).await?;
    let participant_token =
        cf_add_participant(&sd.realtimekit_config, &cf_meeting_id, username).await?;

    // 4. Tell every group member's open socket that the channel is live.
    broadcast_meeting_signal(sd, group_id, GroupMeetingSignal {
        group_id, channel_id,
        session_id: session.session_id,
        type_pb: MeetingSignalType::MEETING_SIGNAL_STARTED,
        username: Cow::Owned(username.to_string()),
        cf_meeting_id: Cow::Owned(cf_meeting_id),
    }).await;

    Ok((session, participant_token))
}

Cloudflare never needs to understand our groups, channels or roles. As far as it's concerned, a room called "Group call for group 42 channel 3" exists and some tokens are valid for it. All of the authorization logic stays in our code, where we can test it, audit it and change it.

Cleaning up

Meetings shouldn't outlive their participants. When the last person leaves, the server marks the Cloudflare meeting INACTIVE and broadcasts ENDED to the group:

// firefly-mls/crates/server/src/meeting/mod.rs (leave_meeting)
if let Some(mut info) = sd.active_meetings.get_mut(&(group_id, channel_id)) {
    info.participants.remove(username);
    if info.participants.is_empty() {
        should_end = true;
    }
}

broadcast_meeting_signal(sd, group_id, left_signal).await;

if should_end {
    // PATCH .../meetings/{id} { "status": "INACTIVE" }
    end_meeting_internal(sd, group_id, session_id).await?;
}

The Client Side: Less Code Than You'd Think

On the desktop and Android app (Tauri plus React), joining a group call is "ask Firefly for a token, hand it to RealtimeKit." The one piece of resilience we added: if the microphone fails (permission denied, no device, a webview quirk), we retry in listen-only mode instead of failing the join.

// lupyd-desktop/src/context/meeting-context.tsx
const response = await service.createMeeting(createReq);
const token = response.participantToken;
if (!token) throw new Error("Failed to issue participant token");

let m: any;
try {
  m = await Client.init({
    authToken: token,
    defaults: { video: false, audio: true },
  });
} catch (clientInitErr: any) {
  // Mic blocked or missing? Join anyway, as a listener.
  console.warn("[Meeting] RealtimeKit initialization failed with audio: true. Attempting fallback with audio: false...", clientInitErr);
  m = await Client.init({
    authToken: token,
    defaults: { video: false, audio: false },
  });
}

m.self.on("audioUpdate", ({ audioEnabled }) =>
  setState((prev) => ({ ...prev, isMuted: !audioEnabled })));

m.self.on("screenShareUpdate", ({ screenShareEnabled }) =>
  setState((prev) => ({ ...prev, isScreenSharing: screenShareEnabled })));

And here is the complete screen-share implementation, plus the video grid:

// Screen share in a group call: the whole feature.
const toggleScreenShare = async () => {
  const { cfMeeting, isScreenSharing } = stateRef.current;
  if (!cfMeeting?.self) return;

  if (isScreenSharing) {
    await cfMeeting.self.disableScreenShare();
  } else {
    await cfMeeting.self.enableScreenShare();
  }
};

// ...and the video grid:
<RtkMeeting meeting={state.cfMeeting} mode="fill" />

That's the trade we made. Building simulcast, bandwidth estimation, active-speaker detection and a responsive grid ourselves would have taken months. Here, we wire up events and keep our React state in sync. Our time goes into the Lupyd-specific parts: the floating meeting window, the channel meeting bar, Android foreground-service notifications and local voice-activity indicators.

Where the Secrets Live

For a security-focused product, "where do the credentials live?" is usually the first question people ask. Our answer:

Credential Lives in Client ever sees it?
TURN_TOKEN_ID / TURN_API_TOKEN firefly-mls server env No, only 1-hour TURN credentials
CLOUDFLARE_API_TOKEN firefly-mls server env No, only a per-participant authToken
CLOUDFLARE_ACCOUNT_ID / CLOUDFLARE_REALTIME_APP_ID firefly-mls server env No
Lupyd auth token The user's device Yes. It's what unlocks the two endpoints above

A compromised client can't create rooms, mint tokens for other people or run up a relay bill under someone else's name. The worst it can do is use its own short-lived credentials, and those are tagged with its username.

The Tradeoffs We Accepted

No infrastructure choice is free, and we'd rather be upfront about what this one costs us than leave it for you to find out.

Group media is not end-to-end encrypted yet

WebRTC media is always encrypted in transit with DTLS-SRTP. With an SFU in the middle, though, the SFU terminates that encryption to forward streams. For group calls, that means Cloudflare's SFU handles media in the clear. Lupyd messages are end-to-end encrypted with MLS. Group call media, today, is not.

We've built the groundwork. A patched RTCPeerConnection turns on insertable streams, a worker encrypts each frame with AES-256-GCM through RTCRtpScriptTransform, and Firefly can already export a per-group key from the MLS epoch:

// lupyd-desktop/src/realtimekit/patch.ts (imported first in main.tsx)
const OriginalRTCPeerConnection = window.RTCPeerConnection;

class PatchedRTCPeerConnection extends OriginalRTCPeerConnection {
  constructor(configuration?: RTCConfiguration) {
    if (window.__e2eeEnabled) {
      configuration = configuration ? { ...configuration } : {};
      (configuration as any).encodedInsertableStreams = true;
    }
    super(configuration);
  }
}
window.RTCPeerConnection = PatchedRTCPeerConnection as any;

// firefly/crates/client/src/websocket.rs: the key source we want to use
group.export_secret("meeting-e2ee-v1", &[], 32).await

However, the meeting flow currently ships with e2eeEnabled set to false, and the MLS-derived key isn't wired into the frame encryptor yet. Before we turn it on, we need key rotation on every MLS epoch change and a fail-closed path (no sending unencrypted frames if encryption errors). When it ships, Cloudflare will be forwarding ciphertext it can't read. Because Cloudflare uses standard WebRTC, we can do this with browser APIs rather than waiting for a vendor to add it to their SDK.

Signaling is server-relayed

For 1:1 calls, SDP and ICE candidates travel as protobufs on the authenticated Firefly WebSocket. They aren't sealed inside MLS like chat messages, so our server can see them. The audio and video themselves are DTLS-SRTP encrypted between the two peers, and a TURN relay only forwards those encrypted packets.

Less control at the media layer

With RealtimeKit we don't tune simulcast layers or SFU routing ourselves. Reconnection for group calls is handled by the SDK rather than by our own backoff logic, unlike 1:1 calls, which rebuild the peer connection with exponential backoff capped at 30 seconds. For a product our size, that's a good trade. For a dedicated video company, it might not be.

Vendor dependency

Group calls do depend on Cloudflare. We've limited the damage the same way throughout: the Cloudflare-specific code is confined to a few Rust functions (cf_create_meeting, cf_add_participant, cf_end_meeting, WebrtcConfigGenerator) and one React context. Meeting state, permissions and history live in our database. Swapping providers would be real work, but it wouldn't touch the rest of the app.

Lessons If You're Building This Yourself

  1. Keep vendor credentials on your server. Mint short-lived, per-user credentials and tag them with the user's identity.
  2. Authorize before you allocate. Check membership and permissions before you call the vendor API. It's cheaper, safer, and keeps your access model in your own code.
  3. Add a mock mode from day one. Returning mock-meeting-* IDs when no config is present let us run the full meeting lifecycle in integration tests without network calls or bills.
  4. Stay on standard WebRTC where you can. Our 1:1 path uses no SDK, which keeps it portable and leaves room for insertable streams E2EE.
  5. Degrade gracefully. A broken microphone shouldn't stop someone from joining a meeting. Let them join as a listener.
  6. Clean up on the server. End rooms when the last participant leaves, instead of relying on clients to remember.

Frequently Asked Questions

Does Lupyd use Cloudflare for every call?

Partly. One-to-one calls are peer-to-peer WebRTC and use Cloudflare only for TURN relay credentials when a direct path isn't available. Group voice channels run on Cloudflare RealtimeKit, which uses Cloudflare's SFU.

Can Cloudflare hear my calls?

In 1:1 calls, media is DTLS-SRTP encrypted between the two devices, and a TURN relay only forwards encrypted packets. In group calls, the SFU terminates transport encryption to forward streams, and media end-to-end encryption is not enabled today. We're working on MLS-keyed frame encryption so the SFU only forwards ciphertext.

Why not self-host an SFU like mediasoup or LiveKit?

We could, and the open-source options are excellent. But a good deployment means running media servers and TURN in many regions, 24/7. For our team size, a managed edge network let us ship group calling and spend our engineering time on encryption and product work.

Do Lupyd clients hold Cloudflare API keys?

No. All Cloudflare tokens stay in the Firefly server's environment. Clients receive one-hour TURN credentials or a single participant token for a specific meeting, and only after Lupyd has authenticated them and checked their permissions.

Wrapping Up

Choosing Cloudflare wasn't about finding the "best" video infrastructure in the abstract. It was about where a small team should spend its effort. Cloudflare runs relays and SFUs on a network that already covers our users, behind standard WebRTC. We keep identity, permissions, signaling, history and secrets in our own Rust server.

The result is a small, contained integration that gives Lupyd 1:1 calls, group voice channels, video and screen sharing, with a clear path to end-to-end encrypted group media. If you'd like to see how the messaging side works, read Introducing Firefly or our deep dive on libsignal vs MLS.

Key Takeaways
  • ✓ One-to-one Lupyd calls are standard peer-to-peer WebRTC; Cloudflare only supplies short-lived TURN relay credentials.
  • ✓ Group voice channels run on Cloudflare RealtimeKit, which provides the SFU, participant management, screen share, and React UI components.
  • ✓ Every Cloudflare secret stays in the firefly-mls Rust server; clients receive only one-hour TURN credentials or a per-participant meeting token.
  • ✓ Lupyd checks group membership and voice-channel permissions before calling Cloudflare, so authorization logic never leaves our code.
  • ✓ A mock mode that returns fake meeting IDs lets the full meeting lifecycle run in integration tests without Cloudflare credentials.
  • ✓ Group call media is not end-to-end encrypted yet; MLS-keyed insertable-streams encryption exists in code but is disabled while key rotation is finished.
Open Source

Explore Lupyd on GitHub

Inspect our open-source repositories, cryptography libraries, and client tools on GitHub.

Explore Lupyd on GitHub