Bridging Nextcloud Talk, WhatsApp, and Signal: How To Build a Three-Way Chat Bridge
I help run the communications platform for a community organisation spread across Europe. Some members live in Nextcloud Talk. Others won't leave WhatsApp. A third group insists on Signal for privacy reasons. For months, important messages got lost between the three, and nobody wanted to check yet another app.
So I built a bridge. Messages sent in any one of those platforms now appear in the other two, automatically, with the sender's name attached. It runs on Kubernetes, it's held together by open-source tooling and about 250 lines of custom Go (which we've open-sourced on GitHub).
Here's how it works.
🧩 The Problem
The community had three groups of members, each loyal to a different messaging platform:
- Nextcloud Talk was the "official" platform, hosted on our Junovy infrastructure
- WhatsApp was where the mobile-first members already were
- Signal was the choice for privacy-conscious members
Asking everyone to switch to one platform wasn't going to happen. We needed messages to flow bidirectionally between all three, so nobody had to change their habits.
Nextcloud Talk has a built-in Matterbridge integration, but it only supports around 12 bridge types. WhatsApp and Signal aren't among them. We needed to go custom.
🏗️ Architecture Overview
The solution has three main components: a standalone Matterbridge instance handling Nextcloud Talk and WhatsApp, a Signal REST API service, and a small custom Go relay stitching Signal into the mix.

Why standalone Matterbridge?
Nextcloud Talk's built-in Matterbridge integration is limited to the bridge types compiled into the Nextcloud server image. WhatsApp (via whatsmeow) isn't one of them, and neither is Signal. Running Matterbridge as a standalone service gives us full control over which bridges are compiled in and how they're configured.
Why a custom relay for Signal?
As of 2026, Matterbridge doesn't have native Signal support that works reliably. The signal-cli-rest-api project provides a solid Signal client with a REST and WebSocket interface, but it speaks a different protocol to Matterbridge's API bridge. The Go relay (~250 lines) translates between the two.
📱 The WhatsApp Bridge
WhatsApp bridging uses whatsmeow, a Go library that implements the WhatsApp Web protocol. Getting it working required a few patches on top of the standard Matterbridge build.
Building from source
The upstream pre-built Matterbridge binaries don't include WhatsApp support. We compile from source with the -tags whatsappmulti build flag to enable it.
Keeping up with WhatsApp's servers
WhatsApp updates its server-side protocol regularly. The version of whatsmeow bundled with Matterbridge was from mid-2024 and started returning "client outdated 405" errors against WhatsApp's 2026 servers. We updated the dependency and wrote a small shell script (whatsmeow-compat.sh) that applies mechanical sed patches to handle context.Context API changes between versions. It's not elegant, but it avoids maintaining a full fork.
The JID vs display name gotcha
WhatsApp groups can be joined using their display name, but messages must be sent using JIDs (the internal WhatsApp identifier). We patched Matterbridge's JoinChannel to accept display-name-based group joins, while ensuring sends go through the correct JID. This is one of those things that works in testing and breaks in production if you're not careful.
Pairing
The WhatsApp bridge authenticates via QR code, the same flow you'd use to link WhatsApp Web. On first startup, the QR code appears in the pod logs. Scan it from the phone that'll act as the bridge account, and the session persists on a Kubernetes PVC.
🔒 The Signal Bridge
Signal bridging is a two-part setup: the signal-cli-rest-api handles the Signal protocol, and our custom Go relay translates between it and Matterbridge's API bridge.
Signal registration
Before anything works, the bridge phone number needs to be registered with Signal. The flow is: solve a CAPTCHA on Signal's website, submit it to the REST API along with the phone number, receive an SMS verification code, and confirm. After that, group memberships sync automatically.
One useful detail: you can register the same phone number for both WhatsApp and Signal simultaneously. They're separate protocols with no conflict.
The Go relay
The relay service is the glue between signal-cli-rest-api and Matterbridge. We've open-sourced it at github.com/Junovy-Hosting/matterbridge-signal-relay, so you can grab the code and adapt it for your own setup.
It handles two directions:
Signal to Matterbridge: signal-cli-rest-api pushes incoming messages over a WebSocket connection. The relay picks these up, filters out echo messages (messages the bridge itself sent), and forwards them to Matterbridge's API endpoint.
Matterbridge to Signal: the relay subscribes to Matterbridge's /api/stream HTTP endpoint, which delivers a stream of messages from all connected bridges. When a message arrives for a Signal-mapped group, the relay posts it to signal-cli-rest-api's send endpoint.
The group ID mismatch
This was the trickiest part. signal-cli-rest-api uses a group.XXX format for group identifiers in its REST API, but the WebSocket message envelopes contain a raw base64 internal_id field. These don't look alike at all. The relay fetches the full group list at startup and builds a mapping table between the two formats, so it can route messages correctly regardless of which direction they're flowing.
☸️ Kubernetes Deployment
The whole setup runs on our Junovy Kubernetes cluster, managed via Flux CD GitOps.
Pod architecture
Matterbridge runs as a StatefulSet with a persistent volume for the WhatsApp session data. signal-cli-rest-api is a separate StatefulSet with its own persistent volume for the Signal session. The Go relay runs as a sidecar container on the Matterbridge pod, sharing localhost for API communication.
Secrets management
The Matterbridge configuration file (matterbridge.toml) contains credentials for all connected services. We keep the TOML skeleton in git with placeholder values, and use ExternalSecrets to template in the real credentials from HashiCorp Vault at deploy time.
NetworkPolicy lessons
If you're running strict NetworkPolicies (and you should be), there are a couple of gotchas worth knowing:
- Node-local DNS cache: if your cluster uses a node-local DNS cache (like
169.254.25.10), you need an explicitipBlockrule for it. A pod selector targetingkube-system/kube-dnswon't match traffic that's hitting the node cache instead. - Service mesh conflicts: we run Linkerd, but the bridge pods need Linkerd injection disabled. Strict egress policies block the linkerd-identity handshake, and the bridge's outbound traffic patterns don't play well with the proxy.
- Internal vs external routing: Matterbridge connects to Nextcloud Talk via the public ingress URL (
cloud.junovy.com), not via internal ClusterIP. This is intentional; it avoids TLS certificate mismatches and keeps the configuration portable.
📊 What's Running Today
The bridge currently connects several conversation groups across the three platforms. Each group has a corresponding channel in Talk, WhatsApp, and Signal, and messages flow between all three in near real-time. Text messages work fully in all directions. File and image bridging is currently text-only on the Signal side (you'll see a notification that a file was shared, but the file itself doesn't transfer yet).
🔧 Lessons Learned
Protocol version rot is real. Both whatsmeow and signal-cli need regular updates because WhatsApp and Signal update their server-side protocols frequently. A build that works today might stop working in a few months. Budget for maintenance.
Matterbridge's RemoteNickFormat has a naming quirk. The {LABEL} template variable resolves to the destination bridge label, not the source. If you want to show where a message came from, use {PROTOCOL}, though the names aren't always pretty. We ended up dropping the protocol prefix entirely for a cleaner look.
Group membership matters. Our Nextcloud Talk instance uses Keycloak for authentication, and Keycloak-synced groups strip local members. The bridge user needs to be in a local group with access to the Talk conversations. We created a service-accounts group specifically for this.
Echo suppression is important. Without it, the bridge forwards its own messages back to the source platform, creating an infinite loop. The Go relay checks the sender ID on every incoming Signal message and skips anything from its own account.
📦 Open Source
We've released the Signal relay component as an open-source project: matterbridge-signal-relay. It's the Go sidecar that bridges signal-cli-rest-api and Matterbridge's API gateway. If you're running Matterbridge and want to add Signal support, this should save you the work of writing your own relay from scratch. Issues and PRs are welcome.
🔮 What's Next
A few things on the roadmap:
- Monitoring. We're adding Prometheus rules to track bridge health: pod restarts, WebSocket reconnection frequency, and message delivery lag.
- Image relay for Signal. Currently, photos and files shared via WhatsApp or Talk show up as text-only notifications on the Signal side. We want to bridge the actual files.
- Matrix as a long-term path. If Signal bridging becomes unreliable (signal-cli is community-maintained and can break on Signal protocol changes), we're looking at mautrix-signal and mautrix-whatsapp through a lightweight Matrix homeserver like Conduit. It's the more "maintained" architecture for multi-protocol bridging, but it's also more complex to run.
💬 Get in Touch
If you're running into similar challenges with fragmented messaging across your organisation, we'd be happy to talk through the architecture in more detail. We run this on our Junovy hosting platform, and we're open about how it all fits together.
Check out the matterbridge-signal-relay repo, drop us a line at hello@junovy.com, or visit junovy.com.
This post is part of our technical blog series where we share what we're building at Junovy and the real-world problems we're solving for the organisations we work with.