DEV Community

Cover image for Build a Raspberry Pi Home Intercom with WebRTC and HTTPS
HomeGrid VPN
HomeGrid VPN

Posted on Originally published at homegrid-vpn.com on

Build a Raspberry Pi Home Intercom with WebRTC and HTTPS

A Raspberry Pi with a Camera Module, USB microphone and USB speaker can become a remote video intercom for a family home. HomeGrid VPN provides the remote network path, WebRTC carries video and audio, and Caddy provides browser HTTPS , without port forwarding or DDNS on the family home’s router.

This is an architecture and implementation guide, not a tested, ready-to-run application. The examples explain which files call which functions and where camera, microphone and speaker devices connect to WebRTC.

VPN connections and separate signaling and media paths
Top: outbound VPN connections. Bottom: separate signaling and media paths within that VPN.

The example assumes an existing HomeGrid VPN Grid at your home. Register the Raspberry Pi itself as a Linux Spoke rather than adding a Grid at the family home. Your computer also connects to the VPN and reaches the Pi’s VPN IPv4 through the Cloud Hub. The Cloud Hub is not necessarily the internet exit.

Part 1: Build the Raspberry Pi and WebRTC intercom

1. Hardware and media directions

At the family home, prepare a Raspberry Pi 4 or 5 (at least 1 GB RAM recommended), a compatible Camera Module, a USB microphone, a USB speaker or echo-cancelling USB speakerphone, Raspberry Pi OS with systemd and a network connection. On the viewer’s side, use a Windows PC, Mac or smartphone with a microphone and speaker. The viewer does not need a camera.

Direction Input Output
Family home → computer Pi Camera Module Browser <video>
Family home → computer Pi USB microphone Browser <audio> → computer speaker
Computer → family home Browser microphone Pi USB speaker

The Pi’s microphone can pick up its speaker output and cause feedback. An echo-cancelling device at the family home helps address this.

2. Check the camera and audio devices

Update the OS, check camera detection and capture a still image:

sudo apt update
sudo apt full-upgrade -y
rpicam-hello --list-cameras
rpicam-still -n -o test.jpg
sudo apt install -y alsa-utils
arecord -l
aplay -l

Enter fullscreen mode Exit fullscreen mode

Record the capture and playback device identifiers for selection in audio.py. Choose device numbers and sample rates for your actual hardware.

3. Add a Linux Spoke from the Dashboard

In the HomeGrid VPN Dashboard, in the “Add a Spoke” panel, click “+ Add Spoke (QR Code)”, enter a device name and select “Linux (command)”. The Linux installation command dialog opens.

Click Copy for the lower split (--split) command and run it in the Pi’s terminal. Copy the generated command rather than recreating or modifying it manually. Split mode sends VPN destinations through the tunnel while ordinary internet traffic uses the family home’s ISP. Do not publish the command containing your token.

For the current installation workflow, see the official guide to adding a Linux device.

After setup, check the Pi’s VPN IPv4:

ip -4 addr show dev wg0

Enter fullscreen mode Exit fullscreen mode

The address 100.64.0.5 below is only an example. Replace it with the assigned address. No inbound rule is required on the family home’s router for the outbound VPN connection. Spokes in the same tenant can communicate through the Cloud Hub. Verify on your actual setup that the selected WebRTC ICE candidate pair uses the VPN addresses and that the device firewalls allow the required UDP traffic.

4. Files on the Raspberry Pi

All these files live on the Raspberry Pi. The server delivers static/index.html and static/client.js, but the HTML and JavaScript run in the computer or smartphone browser.

/opt/webrtc-intercom/
├── app.py # aiohttp server and HTTP routing
├── webrtc.py # aiortc sessions and Offer/Answer
├── camera.py # Picamera2 → VideoStreamTrack
├── audio.py # USB audio and AudioStreamTrack
├── config.py # VPN IPv4 and audio device configuration
├── requirements.txt # Python dependencies
└── static/
    ├── index.html # Browser intercom page
    ├── client.js # Browser WebRTC and button handlers
    └── style.css

Enter fullscreen mode Exit fullscreen mode

The Pi uses picamera2 for the camera, aiortc for WebRTC, aiohttp for HTTP, av / PyAV for media frames, sounddevice / ALSA for USB audio, and numpy for data processing. Install Picamera2 with attention to compatibility between Raspberry Pi OS Python packages and libcamera.

The web service and WebRTC backend are not separate Python processes. app.py starts one HTTP server and calls webrtc.py in the same process when a request reaches /offer.

5. How the browser loads the intercom page

An example development URL is http://100.64.0.5:8080/. Browser microphone access normally will not work at this HTTP address; Part 2 adds HTTPS.

On the Pi, app.py defines the aiohttp routes:

app.router.add_get("/", index)
app.router.add_post("/offer", handle_offer)
app.router.add_static("/static/", BASE_DIR / "static")

Enter fullscreen mode Exit fullscreen mode

GET / returns static/index.html; GET /static/client.js returns the JavaScript. The HTML provides media output elements:

<video id="remoteVideo" autoplay playsinline></video>
<audio id="remoteAudio" autoplay></audio>
<button id="startCall">Start call</button>
<button id="stopCall">End call</button>
<script src="/static/client.js"></script>

Enter fullscreen mode Exit fullscreen mode

client.js retrieves remoteVideo and remoteAudio using document.getElementById().

6. client.js: connect the microphone and received media

Bind the Start call button to startCall() with addEventListener("click", startCall). The sequence is:

  1. Request only the computer’s microphone with getUserMedia({audio: true, video: false}).
  2. Call createConnection() to create new RTCPeerConnection({iceServers: []}).
  3. Register the microphone for sending with pc.addTrack(micTrack, stream); audio uses bidirectional sendrecv.
  4. Request the Pi’s video with pc.addTransceiver("video", {direction: "recvonly"}).
  5. Register pc.ontrack to display and play received media.
  6. Create the connection description with pc.createOffer() and setLocalDescription().
  7. Send POST /offer to the same HTTP server.
  8. Apply the HTTP response with pc.setRemoteDescription(answer).
  9. Start WebRTC media once ICE establishes a working path.

The main device bindings look like this:

const remoteVideo = document.getElementById("remoteVideo");
const remoteAudio = document.getElementById("remoteAudio");

const stream = await navigator.mediaDevices.getUserMedia({
  audio: true, video: false
});
const pc = new RTCPeerConnection({ iceServers: [] });
stream.getAudioTracks().forEach(t => pc.addTrack(t, stream));
pc.addTransceiver("video", { direction: "recvonly" });

pc.ontrack = event => {
  if (event.track.kind === "video") {
    remoteVideo.srcObject = new MediaStream([event.track]);
  } else if (event.track.kind === "audio") {
    remoteAudio.srcObject = new MediaStream([event.track]);
  }
};

Enter fullscreen mode Exit fullscreen mode

pc.addTrack() registers local media for sending; pc.ontrack receives media from the other peer. Browser audio normally plays through the speaker or headphones selected by the OS. Autoplay restrictions may require a play() call tied to the user’s call-start interaction.

7. From POST /offer to the Pi’s WebRTC session

client.js creates an Offer and sends its SDP and type as JSON through fetch("/offer", {method: "POST", ...}). The implementation must wait for ICE gathering to complete or implement an exchange of additional candidates.

Because app.py registers app.router.add_post("/offer", handle_offer), a POST invokes handle_offer():

# app.py: bridge HTTP and WebRTC
async def handle_offer(request):
    data = await request.json()
    offer = RTCSessionDescription(sdp=data["sdp"], type=data["type"])
    answer = await create_session(offer) # Call webrtc.py
    return web.json_response({"sdp": answer.sdp, "type": answer.type})

Enter fullscreen mode Exit fullscreen mode

RTCSessionDescription comes from aiortc. create_session is an ordinary Python function imported with from webrtc import create_session. There is no second POST to a separate WebRTC web server.

Next, webrtc.py creates an aiortc peer connection, registers the Pi camera and USB microphone, and attaches a handler that sends received browser audio to the speaker:

# Essential wiring in webrtc.py, not a complete implementation
pc = RTCPeerConnection()
pc.addTrack(PiCameraTrack()) # Pi camera → computer
pc.addTrack(MicrophoneTrack()) # Pi microphone → computer

@pc.on("track")
def on_track(track):
    if track.kind == "audio":
        # Pass track to the playback worker in audio.py
        ...

await pc.setRemoteDescription(offer)
answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
# Return pc.localDescription to app.py

Enter fullscreen mode Exit fullscreen mode

PiCameraTrack is a VideoStreamTrack in camera.py that converts Picamera2 images into PyAV VideoFrame objects. MicrophoneTrack is an AudioStreamTrack in audio.py that returns USB microphone samples as AudioFrame objects.

The USB speaker is not a sending track , so do not add it with addTrack(). Pass computer microphone audio received by the Pi’s pc.on("track") to the playback worker in audio.py. Convert channel counts, sample rates and frame formats for the device, and keep synchronous device I/O from blocking the event loop.

8. Media after the Answer

app.py returns the Answer in the HTTP response and the browser calls pc.setRemoteDescription(answer). Once ICE establishes connectivity:

Media Pi side Browser side
Video camera.py → pc.addTrack() pc.ontrack → <video>
Family home’s voice audio.py → pc.addTrack() pc.ontrack → <audio>
Viewer’s voice pc.on("track") → USB speaker getUserMedia() → pc.addTrack()

/offer is an HTTP round trip for connection setup, not an API that continuously receives video and audio POSTs. In this design, WebRTC media uses separate UDP traffic. Keep the Pi’s peer connection alive after returning the Answer, close it at the end of the call or on disconnection, and release camera and audio resources.

For an initial implementation, connect video and bidirectional audio together when Start call is pressed. Adding audio later to an existing video-only session can require renegotiation when tracks are added.

Part 2: Add Caddy HTTPS for browser microphone access

9. Why HTTP inside the VPN is insufficient

navigator.mediaDevices.getUserMedia() generally requires a secure context such as HTTPS or localhost. Opening http://100.64.0.5:8080/ over the VPN does not make it a secure browser context.

Place Caddy in front of the Python server:

  • Browser → HTTPS :443 → Caddy listening on the VPN IP.
  • Caddy → HTTP 127.0.0.1:8080 → app.py.
  • Caddy forwards GET /, static files and POST /offer.
  • WebRTC media travels as UDP between aiortc and the browser , rather than through Caddy.

10. Bind Python to localhost and Caddy to the VPN IP

Change app.py to listen on localhost:

web.run_app(app, host="127.0.0.1", port=8080)

Enter fullscreen mode Exit fullscreen mode

Install Caddy on the Pi:

sudo apt update
sudo apt install -y caddy
sudo nano /etc/caddy/Caddyfile

Enter fullscreen mode Exit fullscreen mode

For the example VPN IPv4 100.64.0.5:

https://100.64.0.5 {
    bind 100.64.0.5
    tls internal
    reverse_proxy 127.0.0.1:8080
}

Enter fullscreen mode Exit fullscreen mode

tls internal issues a server certificate through Caddy’s internal CA. Validate and apply the configuration:

sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl restart caddy
systemctl status caddy

Enter fullscreen mode Exit fullscreen mode

Caddy may fail to bind if the VPN address is not available during startup. Test service ordering and restart behavior.

11. Retrieve Caddy’s public root CA certificate

Mac and Windows do not initially trust the internal CA. With the usual systemd installation, the Pi’s Caddy root certificate is normally at:

/var/lib/caddy/.local/share/caddy/pki/authorities/local/root.crt

Enter fullscreen mode Exit fullscreen mode

root.crt is a PEM-encoded public X.509 certificate. Never distribute the private key, root.key.

Copy only the public certificate to a location readable by your Pi user, then retrieve it on the Mac or Windows computer using SCP or another suitable method. Compare its fingerprint through a separate trusted channel and confirm it belongs to the Caddy instance you manage before installing trust.

12. Trust the CA in macOS Keychain

Import root.crt into the System keychain using Keychain Access and configure its trust. An administrator terminal example is:

sudo security add-trusted-cert \
  -d -r trustRoot \
  -k /Library/Keychains/System.keychain \
  ~/Downloads/caddy-root.crt

Enter fullscreen mode Exit fullscreen mode

Replace the file path with its actual location. Safari and Chrome normally use macOS certificate trust settings; Firefox and other browsers may require additional configuration.

13. Trust the CA in Windows

Copy root.crt to Windows. Choose Install Certificate, select the option to place all certificates in a specified store, and choose Trusted Root Certification Authorities. To install it for the current user with PowerShell:

Import-Certificate -FilePath ".\caddy-root.crt" -CertStoreLocation Cert:\CurrentUser\Root

Enter fullscreen mode Exit fullscreen mode

Installing a root CA means trusting the certificates issued by that CA , not just one intercom page. Do not install a root CA whose origin you cannot verify.

14. Open HTTPS and verify WebRTC

While connected to HomeGrid VPN, open the following on the Mac or Windows computer:

https://100.64.0.5/

Caddy forwards GET / and GET /static/client.js to app.py. Start call asks for browser microphone permission, creates an Offer and sends it to POST /offer on the same HTTPS origin.

Caddy forwards the request to handle_offer() and webrtc.py returns the Answer. Because fetch("/offer") uses a relative URL, adding Caddy does not require changing this URL.

Verify that the selected ICE candidate pair uses the VPN addresses, that Pi video and microphone audio play on the computer, and that the viewer’s voice comes out of the Pi’s USB speaker.

15. Startup, access control and family privacy

Run app.py with systemd and manage Caddy as its own systemd service. webrtc.py, camera.py and audio.py remain modules in the same Python application, rather than independent web servers.

Devices in the same tenant, including invited family members’ devices, have a VPN route to the Pi Spoke. LAN ACL controls access to LANs behind Grids or site gateways; it does not restrict access to the Pi’s own Spoke address. Restrict that access with the Pi’s firewall and application authentication. Without authentication, any device that can reach /offer may invoke it. VPN-only listening and certificate trust do not replace user authentication. Authenticate and restrict callers, control concurrent sessions, release tracks on shutdown, handle failures and address audio feedback. Also, iceServers: [] alone does not confine WebRTC UDP to the VPN: inspect selected candidates and enforce firewall restrictions.

Obtain the household’s consent before installing the camera or capturing audio and video. Agree on the camera’s field of view, call indicators and who may view it. If using a family member’s internet connection, obtain the subscriber’s consent and check the ISP contract conditions.

How the components fit together

  • HomeGrid VPN: registers the Pi as a Linux Spoke and provides a VPN path without port forwarding at the family home.
  • app.py / aiohttp: serves the page and receives POST /offer on that same web server.
  • webrtc.py / aiortc: sends camera and microphone tracks with addTrack() and receives the viewer’s audio with on("track").
  • client.js: sends the computer microphone with addTrack() and attaches received tracks to HTML using ontrack.
  • Caddy: provides HTTPS and an internal CA whose public root certificate is trusted on the viewing computer.

The initial Offer/Answer HTTP exchange prepares the connection. Established WebRTC media then takes a separate path. Understanding that boundary helps when extending the intercom.

References

Originally published at https://www.homegrid-vpn.com/blog/en/raspberry-pi-webrtc-intercom/

Top comments (0)