Ubuntu 24.04 WebRTC lxc Intro Basics
| Please Select a Licence from the LICENCE_HEADERS page |
And place at top of your page |
If no Licence is Selected/Appended, Default will be CC0 Default Licence IF there is no Licence placed below this notice!
When you edit this page, you agree to release your contribution under the CC0 Licence LICENCE:
More information about the cc0 licence can be found here: You can copy, modify, distribute and perform the work, even for commercial purposes, all without asking permission. Licence: Statement of Purpose The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. 1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; moral rights retained by the original author(s) and/or performer(s); publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; rights protecting the extraction, dissemination, use and reuse of data in a Work; database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. 2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. 3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. 4. Limitations and Disclaimers. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. |
WebRTC Video Chat on Ubuntu 24.04 with LXC — Complete Beginner's Guide
- This walk through is tested on a ubuntu-mate 24.04 host.
- Guide written with help from claude.ai for Ubuntu 24.04 LTS (Noble Numbat). Node.js 20 LTS. Last verified March 2026
This guide walks you through setting up a working browser-based WebRTC video/audio chat application. The signalling server will run inside an LXC container on your Ubuntu 24.04 host machine, and you will connect to it from your browser.
What You Will End Up With
- An LXC container running Ubuntu 24.04
- A Node.js signalling server inside that container
- A static HTML/JS frontend served from the container
- Two browser tabs (or two devices on your network) that can video/audio chat peer-to-peer
How It Works (Plain English)
WebRTC lets two browsers talk directly to each other for audio and video. But before they can do that, they need to find each other and agree on connection details. This is called signalling.
Think of it like two people wanting to call each other — they first need to exchange phone numbers through a third party. The signalling server is that third party. Once the call is set up, the signalling server steps aside and the browsers talk directly.
[Browser A] <--WebSocket--> [Signalling Server in LXC] <--WebSocket--> [Browser B]
|
(only used during setup)
|
[Browser A] <============ WebRTC peer-to-peer audio/video ============> [Browser B]
Part 1: Set Up the LXC Container
Step 1.1 — Install LXC on Your Host
Open a terminal on your Ubuntu 24.04 host and run:
- NOTE: you can also install lxd with snap packages on ubuntu.
sudo apt update sudo apt install lxc lxc-utils -y
Check it installed correctly:
lxc-checkconfig
You should see mostly enabled next to each item. A few missing items are normal and will not affect this guide.
Step 1.2 — Create the Container
Create a new Ubuntu 24.04 container called webrtc:
sudo lxc-create -n webrtc -t download -- -d ubuntu -r noble -a amd64
This downloads the Ubuntu 24.04 (Noble) image. It may take a minute or two.
Step 1.3 — Start the Container
sudo lxc-start -n webrtc
Check it is running:
sudo lxc-ls --fancy
You should see webrtc listed with state RUNNING.
Step 1.4 — Log Into the Container
sudo lxc-attach -n webrtc
Your prompt will change — you are now inside the container. Everything from here until told otherwise runs inside the container.
Step 1.5 — Update the Container
apt update && apt upgrade -y
Part 2: Install Node.js Inside the Container
Step 2.1 — Install Node.js
We will use the official NodeSource repository to get a recent version of Node.js:
apt install -y curl curl -fsSL https://deb.nodesource.com/setup_20.x | bash - apt install -y nodejs
Check the versions installed:
node -v npm -v
You should see something like v20.x.x and 10.x.x. Any version of Node 18 or higher is fine.
Part 3: Create the Signalling Server
Step 3.1 — Create a Project Folder
mkdir /opt/webrtc cd /opt/webrtc
Step 3.2 — Initialise the Node Project
npm init -y
This creates a package.json file. The -y flag just accepts all the defaults.
Step 3.3 — Install Dependencies
We need two packages:
- express — a simple web server to serve the HTML frontend
- socket.io — handles WebSocket connections for signalling
npm install express socket.io
Step 3.4 — Create the Server File
- NOTE:
nanowill need to be installed in the container withapt install nano -y
Create the file:
nano server.js
Paste in the following code exactly. Use Ctrl+Shift+V to paste in the terminal:
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const path = require('path');
const app = express();
const server = http.createServer(app);
const io = new Server(server);
// Serve the frontend HTML file
app.use(express.static(path.join(__dirname, 'public')));
// Keep track of who is in each room
const rooms = {};
io.on('connection', (socket) => {
console.log('A user connected:', socket.id);
// User wants to join a room
socket.on('join', (room) => {
socket.join(room);
if (!rooms[room]) {
rooms[room] = [];
}
// Tell the new user who is already in the room
socket.emit('room-users', rooms[room]);
// Add this user to the room list
rooms[room].push(socket.id);
console.log(`${socket.id} joined room: ${room}`);
});
// Relay a WebRTC offer to a specific user
socket.on('offer', ({ to, offer }) => {
io.to(to).emit('offer', { from: socket.id, offer });
});
// Relay a WebRTC answer to a specific user
socket.on('answer', ({ to, answer }) => {
io.to(to).emit('answer', { from: socket.id, answer });
});
// Relay ICE candidates between peers
socket.on('ice-candidate', ({ to, candidate }) => {
io.to(to).emit('ice-candidate', { from: socket.id, candidate });
});
// Clean up when a user disconnects
socket.on('disconnect', () => {
for (const room in rooms) {
rooms[room] = rooms[room].filter((id) => id !== socket.id);
// Tell others in the room this user left
socket.to(room).emit('user-left', socket.id);
}
console.log('User disconnected:', socket.id);
});
});
const PORT = 3000;
server.listen(PORT, '0.0.0.0', () => {
console.log(`Signalling server running on port ${PORT}`);
});
Save and exit: press Ctrl+X, then Y, then Enter.
Step 3.5 — Create the Frontend
Create a folder for the HTML file:
mkdir public nano public/index.html
Paste in the following:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WebRTC Chat</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: sans-serif; background: #1a1a2e; color: #eee; display: flex; flex-direction: column; align-items: center; padding: 20px; }
h1 { margin-bottom: 20px; color: #e94560; }
#join-area { margin-bottom: 20px; display: flex; gap: 10px; }
input { padding: 10px; border-radius: 6px; border: none; font-size: 1rem; width: 200px; }
button { padding: 10px 20px; background: #e94560; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 1rem; }
button:hover { background: #c73652; }
#videos { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
video { width: 320px; height: 240px; background: #000; border-radius: 8px; border: 2px solid #e94560; }
#status { margin-top: 15px; font-size: 0.9rem; color: #aaa; }
</style>
</head>
<body>
<h1>WebRTC Chat</h1>
<div id="join-area">
<input id="room-input" type="text" placeholder="Enter room name" value="room1" />
<button onclick="joinRoom()">Join Room</button>
</div>
<div id="videos">
<video id="local-video" autoplay muted playsinline></video>
</div>
<p id="status">Enter a room name and click Join.</p>
<!-- Socket.io client is served automatically by the server -->
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io();
let localStream;
const peers = {}; // peerId -> RTCPeerConnection
// STUN server config — Google's free public STUN server
const iceConfig = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
};
async function joinRoom() {
const room = document.getElementById('room-input').value.trim();
if (!room) return alert('Please enter a room name');
document.getElementById('status').textContent = 'Getting camera and microphone...';
try {
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
document.getElementById('local-video').srcObject = localStream;
} catch (err) {
alert('Could not access camera/microphone: ' + err.message);
return;
}
document.getElementById('status').textContent = `Joining room: ${room}`;
socket.emit('join', room);
}
// Server tells us who is already in the room
socket.on('room-users', async (users) => {
document.getElementById('status').textContent = `In room. ${users.length} other(s) here.`;
// Send an offer to each existing user
for (const userId of users) {
await createOffer(userId);
}
});
// A remote peer sent us an offer
socket.on('offer', async ({ from, offer }) => {
const pc = createPeerConnection(from);
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit('answer', { to: from, answer });
});
// A remote peer accepted our offer
socket.on('answer', async ({ from, answer }) => {
const pc = peers[from];
if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer));
});
// ICE candidate from a remote peer
socket.on('ice-candidate', async ({ from, candidate }) => {
const pc = peers[from];
if (pc && candidate) {
try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) {}
}
});
// A user left the room
socket.on('user-left', (userId) => {
if (peers[userId]) {
peers[userId].close();
delete peers[userId];
}
const el = document.getElementById('video-' + userId);
if (el) el.remove();
document.getElementById('status').textContent = 'A user left the room.';
});
function createPeerConnection(peerId) {
const pc = new RTCPeerConnection(iceConfig);
peers[peerId] = pc;
// Add our local tracks so the remote peer gets our video/audio
localStream.getTracks().forEach((track) => pc.addTrack(track, localStream));
// When we get an ICE candidate, send it to the remote peer
pc.onicecandidate = ({ candidate }) => {
if (candidate) socket.emit('ice-candidate', { to: peerId, candidate });
};
// When we receive a remote track, display it
pc.ontrack = ({ streams }) => {
let videoEl = document.getElementById('video-' + peerId);
if (!videoEl) {
videoEl = document.createElement('video');
videoEl.id = 'video-' + peerId;
videoEl.autoplay = true;
videoEl.playsInline = true;
document.getElementById('videos').appendChild(videoEl);
}
videoEl.srcObject = streams[0];
};
return pc;
}
async function createOffer(peerId) {
const pc = createPeerConnection(peerId);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit('offer', { to: peerId, offer });
}
</script>
</body>
</html>
Save and exit: Ctrl+X, Y, Enter.
Part 4: Run the Server
Step 4.1 — Test it Manually First
Inside the container, run:
node /opt/webrtc/server.js
You should see:
Signalling server running on port 3000
Press Ctrl+C to stop it for now. We will set it up to run automatically in the next step.
Step 4.2 — Find the Container's IP Address
You need this to access the server from your host browser. Open a second terminal on your host (not inside the container) and run:
sudo lxc-ls --fancy
Look for your webrtc container and note the IP address in the IPV4 column. It will look something like 10.0.3.15.
Step 4.3 — Set Up Auto-start with systemd
We want the server to start automatically when the container boots. Back inside the container, create a systemd service file:
nano /etc/systemd/system/webrtc.service
Paste in:
[Unit]
Description=WebRTC Signalling Server
After=network.target
[Service]
Type=simple
WorkingDirectory=/opt/webrtc
ExecStart=/usr/bin/node /opt/webrtc/server.js
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Save and exit. Then enable and start it:
systemctl daemon-reload systemctl enable webrtc systemctl start webrtc
Check it is running:
systemctl status webrtc
You should see active (running) in green.
Part 5: Configure the LXC Container to Auto-start
We also want the container itself to start when your host boots.
Exit the container first (type exit or press Ctrl+D to return to your host terminal), then run:
sudo nano /var/lib/lxc/webrtc/config
Add these two lines at the bottom of the file:
lxc.start.auto = 1 lxc.start.delay = 5
Save and exit. Now the container (and the signalling server inside it) will start automatically on boot.
Part 6: Open the App in Your Browser
Step 6.1 — Open the App
On your host machine, open a browser and go to:
http://<container-ip>:3000
Replace <container-ip> with the IP address you noted in Step 4.2. For example:
http://10.0.3.15:3000
You should see the WebRTC Chat page.
Step 6.2 — Test With Two Browser Tabs
- Open the app in Tab 1. Type a room name (e.g.
room1) and click Join Room. Allow camera and microphone access when the browser asks. - Open the app in Tab 2 (you can use a private/incognito window to get a separate camera stream). Type the same room name and click Join Room.
The two tabs should now connect and you will see two video feeds.
Could not access camera/microphone: Cannot read properties of undefined (reading 'getUserMedia') - NOTE: if you see
Could not access camera/microphone: Cannot read properties of undefined (reading 'getUserMedia')Then you need to go to step 7 and setup HTTPS
This is the HTTPS issue mentioned in Part 7. Browsers block camera/microphone access on plain http:// from non-localhost addresses — navigator.mediaDevices is simply undefined on insecure origins.
Since you're accessing via the container's IP (e.g. http://10.0.3.15:3000), the browser sees it as insecure and disables the API entirely.
Quickest fix — use mkcert on your host:While you choose — here's a quick workaround you can do right now with zero config, if you just want to test immediately:
Option: Chrome flag to allow insecure origins
Open Chrome and go to: chrome://flags/#unsafely-treat-insecure-origin-as-secure
Paste your container URL (e.g. http://10.0.3.15:3000) into the text box and enable it
Relaunch Chrome when prompted
This tells Chrome to treat that specific IP as secure, enabling getUserMedia. Only use this for local dev/testing — never on a production machine. Firefox equivalent: go to about:config, search for media.devices.insecure.enabled and set it to true. Once you pick your preferred HTTPS method above I'll give you the exact step-by-step commands to do it properly inside your LXC container.
Step 6.3 — Test From Another Device on Your Network
On any other device connected to the same Wi-Fi or LAN, open a browser and go to the same URL:
http://<container-ip>:3000
Join the same room name and you should connect peer-to-peer with the other browser.
Note: Camera and microphone access in browsers requires either localhost or a secure HTTPS connection. For testing on the same machine, http://localhost works fine. For other devices on your network, you may need to set up HTTPS (see Part 7).
Part 7: (Optional) Enable HTTPS for Other Devices
Browsers block camera/microphone access on plain HTTP from non-localhost addresses. To use this from other devices on your network, you have two options:
Option A — Use a Self-Signed Certificate (Quick)
Inside the container:
apt install -y openssl cd /opt/webrtc openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes -subj "/CN=localhost"
Edit server.js and replace the http section:
const https = require('https');
const fs = require('fs');
const server = https.createServer({
key: fs.readFileSync('/opt/webrtc/key.pem'),
cert: fs.readFileSync('/opt/webrtc/cert.pem'),
}, app);
Appended server.js file:
const express = require('express');
const https = require('https');
const { Server } = require('socket.io');
const path = require('path');
const app = express();
const fs = require('fs');
const server = https.createServer({
key: fs.readFileSync('/opt/webrtc/key.pem'),
cert: fs.readFileSync('/opt/webrtc/cert.pem'),
}, app);
const io = new Server(server);
// Serve the frontend HTML file
app.use(express.static(path.join(__dirname, 'public')));
// Keep track of who is in each room
const rooms = {};
io.on('connection', (socket) => {
console.log('A user connected:', socket.id);
// User wants to join a room
socket.on('join', (room) => {
socket.join(room);
if (!rooms[room]) {
rooms[room] = [];
}
// Tell the new user who is already in the room
socket.emit('room-users', rooms[room]);
// Add this user to the room list
rooms[room].push(socket.id);
console.log(`${socket.id} joined room: ${room}`);
});
// Relay a WebRTC offer to a specific user
socket.on('offer', ({ to, offer }) => {
io.to(to).emit('offer', { from: socket.id, offer });
});
// Relay a WebRTC answer to a specific user
socket.on('answer', ({ to, answer }) => {
io.to(to).emit('answer', { from: socket.id, answer });
});
// Relay ICE candidates between peers
socket.on('ice-candidate', ({ to, candidate }) => {
io.to(to).emit('ice-candidate', { from: socket.id, candidate });
});
// Clean up when a user disconnects
socket.on('disconnect', () => {
for (const room in rooms) {
rooms[room] = rooms[room].filter((id) => id !== socket.id);
// Tell others in the room this user left
socket.to(room).emit('user-left', socket.id);
}
console.log('User disconnected:', socket.id);
});
});
const PORT = 3000;
server.listen(PORT, '0.0.0.0', () => {
console.log(`Signalling server running on port ${PORT}`);
});
Change const http = require('http'); and const server = http.createServer(app); lines accordingly.
Restart the service:
systemctl restart webrtc
Access via:
https://<container-ip>:3000
Your browser will warn about the self-signed certificate — click Advanced and Proceed to continue.
Option B — Use mkcert (Trusted Certificate, Recommended)
mkcert creates locally trusted certificates without browser warnings.
On your host machine:
sudo apt install mkcert mkcert -install mkcert <container-ip>
This creates <container-ip>.pem and <container-ip>-key.pem. Copy them into the container and follow the same server.js edits as Option A.
Part 8: Useful Commands Reference
Managing the Container
| Command | What it does |
|---|---|
sudo lxc-start -n webrtc |
Start the container |
sudo lxc-stop -n webrtc |
Stop the container |
sudo lxc-attach -n webrtc |
Open a shell inside the container |
sudo lxc-ls --fancy |
List containers and their status/IP |
Managing the Server (run inside the container)
| Command | What it does |
|---|---|
systemctl status webrtc |
Check if the server is running |
systemctl restart webrtc |
Restart the server |
systemctl stop webrtc |
Stop the server |
journalctl -u webrtc -f |
Watch live server logs |
Adding a ChatBox
- Added:
- A name input on the join screen so people aren't just "Anonymous"
- A chat panel on the right with message bubbles (your messages on the right, others on the left)
- Automatic link detection — any URL typed becomes a clickable link
- Enter to send, Shift+Enter for a newline
- A 500 character counter that turns red when you're close to the limit
- System messages when users join/leave
- A live peer count header showing how many others are in the room
- The textarea auto-resizes as you type
- Edit
public/index.html
Appended public/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>WebRTC Chat</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0d0f14;
--surface: #161920;
--border: #252932;
--accent: #4ade80;
--accent2: #22d3ee;
--muted: #4b5263;
--text: #e2e8f0;
--subtext: #8892a4;
--self-msg: #1a2e22;
--other-msg:#161d2e;
--danger: #f87171;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'IBM Plex Sans', sans-serif;
background: var(--bg);
color: var(--text);
height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Top bar ── */
header {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 20px;
border-bottom: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
header h1 {
font-family: 'IBM Plex Mono', monospace;
font-size: 1rem;
color: var(--accent);
letter-spacing: 0.05em;
}
#room-badge {
font-family: 'IBM Plex Mono', monospace;
font-size: 0.75rem;
color: var(--subtext);
background: var(--border);
padding: 2px 10px;
border-radius: 999px;
display: none;
}
#status-dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--muted);
margin-left: auto;
flex-shrink: 0;
transition: background 0.3s;
}
#status-dot.live { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
/* ── Main layout ── */
main {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Join screen ── */
#join-screen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
flex: 1;
padding: 40px 20px;
}
#join-screen h2 {
font-family: 'IBM Plex Mono', monospace;
font-size: 1.4rem;
color: var(--accent);
}
#join-screen p { color: var(--subtext); font-size: 0.9rem; text-align: center; max-width: 340px; }
.field-group {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
max-width: 320px;
}
.field-group label { font-size: 0.8rem; color: var(--subtext); font-family: 'IBM Plex Mono', monospace; }
input[type="text"] {
padding: 10px 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-family: 'IBM Plex Sans', sans-serif;
font-size: 0.95rem;
width: 100%;
transition: border-color 0.2s;
outline: none;
}
input[type="text"]:focus { border-color: var(--accent); }
button.primary {
padding: 11px 24px;
background: var(--accent);
color: #0d0f14;
border: none;
border-radius: 6px;
cursor: pointer;
font-family: 'IBM Plex Mono', monospace;
font-weight: 600;
font-size: 0.9rem;
width: 100%;
max-width: 320px;
transition: opacity 0.2s, transform 0.1s;
}
button.primary:hover { opacity: 0.85; }
button.primary:active { transform: scale(0.98); }
/* ── App layout (after join) ── */
#app { display: none; flex: 1; overflow: hidden; }
#app.visible { display: flex; }
/* ── Video panel ── */
#video-panel {
display: flex;
flex-direction: column;
gap: 0;
background: #0a0c10;
border-right: 1px solid var(--border);
overflow-y: auto;
flex-shrink: 0;
width: 340px;
}
@media (max-width: 700px) {
#app.visible { flex-direction: column; }
#video-panel { width: 100%; max-height: 220px; flex-direction: row; overflow-x: auto; overflow-y: hidden; border-right: none; border-bottom: 1px solid var(--border); }
}
.video-wrapper {
position: relative;
background: #000;
}
.video-wrapper video {
width: 100%;
display: block;
aspect-ratio: 4/3;
object-fit: cover;
}
.video-label {
position: absolute;
bottom: 6px; left: 8px;
font-family: 'IBM Plex Mono', monospace;
font-size: 0.7rem;
color: #fff;
background: rgba(0,0,0,0.55);
padding: 2px 7px;
border-radius: 4px;
}
/* ── Chat panel ── */
#chat-panel {
display: flex;
flex-direction: column;
flex: 1;
overflow: hidden;
}
#chat-header {
padding: 10px 16px;
border-bottom: 1px solid var(--border);
font-family: 'IBM Plex Mono', monospace;
font-size: 0.78rem;
color: var(--subtext);
background: var(--surface);
flex-shrink: 0;
}
#messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
scroll-behavior: smooth;
}
#messages::-webkit-scrollbar { width: 4px; }
#messages::-webkit-scrollbar-track { background: transparent; }
#messages::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
/* System messages */
.msg-system {
text-align: center;
font-size: 0.75rem;
color: var(--muted);
font-family: 'IBM Plex Mono', monospace;
padding: 2px 0;
}
/* Chat bubbles */
.msg-bubble {
display: flex;
flex-direction: column;
gap: 3px;
max-width: 85%;
}
.msg-bubble.self { align-self: flex-end; align-items: flex-end; }
.msg-bubble.other { align-self: flex-start; align-items: flex-start; }
.msg-meta {
font-size: 0.7rem;
color: var(--subtext);
font-family: 'IBM Plex Mono', monospace;
padding: 0 4px;
}
.msg-bubble.self .msg-meta { color: var(--accent); }
.msg-text {
padding: 9px 13px;
border-radius: 12px;
font-size: 0.9rem;
line-height: 1.5;
word-break: break-word;
}
.msg-bubble.self .msg-text {
background: var(--self-msg);
border: 1px solid rgba(74,222,128,0.2);
border-bottom-right-radius: 3px;
}
.msg-bubble.other .msg-text {
background: var(--other-msg);
border: 1px solid var(--border);
border-bottom-left-radius: 3px;
}
/* Links inside messages */
.msg-text a {
color: var(--accent2);
text-decoration: underline;
text-underline-offset: 2px;
word-break: break-all;
}
.msg-text a:hover { opacity: 0.8; }
/* ── Input bar ── */
#input-bar {
display: flex;
gap: 8px;
padding: 12px 16px;
border-top: 1px solid var(--border);
background: var(--surface);
flex-shrink: 0;
}
#msg-input {
flex: 1;
padding: 10px 14px;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text);
font-family: 'IBM Plex Sans', sans-serif;
font-size: 0.9rem;
outline: none;
resize: none;
height: 42px;
max-height: 120px;
overflow-y: auto;
transition: border-color 0.2s;
}
#msg-input:focus { border-color: var(--accent); }
#send-btn {
padding: 0 16px;
background: var(--accent);
color: #0d0f14;
border: none;
border-radius: 8px;
cursor: pointer;
font-family: 'IBM Plex Mono', monospace;
font-weight: 600;
font-size: 0.85rem;
flex-shrink: 0;
transition: opacity 0.2s;
}
#send-btn:hover { opacity: 0.85; }
#char-count {
font-size: 0.7rem;
color: var(--muted);
font-family: 'IBM Plex Mono', monospace;
align-self: flex-end;
padding-bottom: 4px;
flex-shrink: 0;
width: 36px;
text-align: right;
}
#char-count.warn { color: var(--danger); }
</style>
</head>
<body>
<header>
<h1>// webrtc</h1>
<span id="room-badge"></span>
<div id="status-dot" title="Connecting..."></div>
</header>
<!-- Join screen -->
<div id="join-screen">
<h2>join a room</h2>
<p>Enter your name and a room name. Anyone with the same room name will connect with you.</p>
<div class="field-group">
<label>YOUR NAME</label>
<input id="name-input" type="text" placeholder="e.g. Alice" maxlength="24" />
</div>
<div class="field-group">
<label>ROOM NAME</label>
<input id="room-input" type="text" placeholder="e.g. room1" value="room1" maxlength="32" />
</div>
<button class="primary" onclick="joinRoom()">Join Room →</button>
</div>
<!-- App (shown after joining) -->
<main>
<div id="app">
<!-- Left: videos -->
<div id="video-panel">
<div class="video-wrapper" id="local-wrapper">
<video id="local-video" autoplay muted playsinline></video>
<div class="video-label" id="local-label">you</div>
</div>
</div>
<!-- Right: chat -->
<div id="chat-panel">
<div id="chat-header">MESSAGES — <span id="peer-count">0 others in room</span></div>
<div id="messages">
<div class="msg-system">Chat is end-to-end via your server. Video is peer-to-peer.</div>
</div>
<div id="input-bar">
<textarea id="msg-input" placeholder="Type a message… (Enter to send, Shift+Enter for newline)" rows="1"></textarea>
<span id="char-count">500</span>
<button id="send-btn" onclick="sendMessage()">Send</button>
</div>
</div>
</div>
</main>
<script src="/socket.io/socket.io.js"></script>
<script>
const socket = io();
let localStream;
let myName = 'Anonymous';
let currentRoom = '';
let peerCount = 0;
const peers = {};
const MAX_MSG = 500;
const iceConfig = {
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
};
// ── Helpers ──────────────────────────────────────────────
// Detect URLs in text and wrap them in <a> tags
function linkify(text) {
const escaped = text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
const urlRegex = /(https?:\/\/[^\s<>"]+)/g;
return escaped.replace(urlRegex, '<a href="$1" target="_blank" rel="noopener noreferrer">$1</a>');
}
function addMessage({ name, message, isSelf, isSystem }) {
const container = document.getElementById('messages');
if (isSystem) {
const el = document.createElement('div');
el.className = 'msg-system';
el.textContent = message;
container.appendChild(el);
} else {
const bubble = document.createElement('div');
bubble.className = 'msg-bubble ' + (isSelf ? 'self' : 'other');
const now = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const meta = document.createElement('div');
meta.className = 'msg-meta';
meta.textContent = (isSelf ? 'you' : name) + ' · ' + now;
const body = document.createElement('div');
body.className = 'msg-text';
// Linkify and preserve newlines
body.innerHTML = linkify(message).replace(/\n/g, '<br>');
bubble.appendChild(meta);
bubble.appendChild(body);
container.appendChild(bubble);
}
// Auto-scroll to bottom
container.scrollTop = container.scrollHeight;
}
function updatePeerCount() {
document.getElementById('peer-count').textContent =
peerCount === 0 ? 'no others in room yet'
: peerCount === 1 ? '1 other in room'
: `${peerCount} others in room`;
}
// ── Join ─────────────────────────────────────────────────
async function joinRoom() {
const nameVal = document.getElementById('name-input').value.trim();
const roomVal = document.getElementById('room-input').value.trim();
if (!roomVal) { alert('Please enter a room name'); return; }
myName = nameVal || 'Anonymous';
currentRoom = roomVal;
try {
localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
document.getElementById('local-video').srcObject = localStream;
document.getElementById('local-label').textContent = myName + ' (you)';
} catch (err) {
alert('Could not access camera/microphone: ' + err.message);
return;
}
document.getElementById('join-screen').style.display = 'none';
document.getElementById('app').classList.add('visible');
document.getElementById('room-badge').textContent = '# ' + currentRoom;
document.getElementById('room-badge').style.display = '';
document.getElementById('status-dot').classList.add('live');
socket.emit('join', currentRoom);
addMessage({ isSystem: true, message: `You joined #${currentRoom} as "${myName}"` });
updatePeerCount();
}
// ── Chat ─────────────────────────────────────────────────
function sendMessage() {
const input = document.getElementById('msg-input');
const text = input.value.trim();
if (!text || !currentRoom) return;
if (text.length > MAX_MSG) { alert(`Max ${MAX_MSG} characters`); return; }
socket.emit('chat-message', { room: currentRoom, message: text, name: myName });
addMessage({ name: myName, message: text, isSelf: true });
input.value = '';
input.style.height = '42px';
document.getElementById('char-count').textContent = MAX_MSG;
document.getElementById('char-count').classList.remove('warn');
}
socket.on('chat-message', ({ name, message }) => {
addMessage({ name, message, isSelf: false });
});
// Enter to send, Shift+Enter for newline
document.getElementById('msg-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// Char counter + auto-resize textarea
document.getElementById('msg-input').addEventListener('input', function () {
const remaining = MAX_MSG - this.value.length;
const counter = document.getElementById('char-count');
counter.textContent = remaining;
counter.classList.toggle('warn', remaining < 50);
this.style.height = '42px';
this.style.height = Math.min(this.scrollHeight, 120) + 'px';
});
// ── WebRTC ───────────────────────────────────────────────
socket.on('room-users', async (users) => {
peerCount = users.length;
updatePeerCount();
for (const userId of users) {
await createOffer(userId);
}
});
socket.on('offer', async ({ from, offer }) => {
const pc = createPeerConnection(from);
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit('answer', { to: from, answer });
});
socket.on('answer', async ({ from, answer }) => {
const pc = peers[from];
if (pc) await pc.setRemoteDescription(new RTCSessionDescription(answer));
});
socket.on('ice-candidate', async ({ from, candidate }) => {
const pc = peers[from];
if (pc && candidate) {
try { await pc.addIceCandidate(new RTCIceCandidate(candidate)); } catch (e) {}
}
});
socket.on('user-left', (userId) => {
if (peers[userId]) { peers[userId].close(); delete peers[userId]; }
const el = document.getElementById('video-' + userId);
if (el) el.closest('.video-wrapper').remove();
peerCount = Math.max(0, peerCount - 1);
updatePeerCount();
addMessage({ isSystem: true, message: 'A user left the room.' });
});
function createPeerConnection(peerId) {
const pc = new RTCPeerConnection(iceConfig);
peers[peerId] = pc;
localStream.getTracks().forEach((track) => pc.addTrack(track, localStream));
pc.onicecandidate = ({ candidate }) => {
if (candidate) socket.emit('ice-candidate', { to: peerId, candidate });
};
pc.ontrack = ({ streams }) => {
let wrapper = document.getElementById('wrapper-' + peerId);
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.className = 'video-wrapper';
wrapper.id = 'wrapper-' + peerId;
const vid = document.createElement('video');
vid.id = 'video-' + peerId;
vid.autoplay = true;
vid.playsInline = true;
const label = document.createElement('div');
label.className = 'video-label';
label.textContent = 'peer';
wrapper.appendChild(vid);
wrapper.appendChild(label);
document.getElementById('video-panel').appendChild(wrapper);
peerCount = Object.keys(peers).length;
updatePeerCount();
}
document.getElementById('video-' + peerId).srcObject = streams[0];
};
return pc;
}
async function createOffer(peerId) {
const pc = createPeerConnection(peerId);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit('offer', { to: peerId, offer });
}
</script>
</body>
</html>
- Edit
server.js
Appended server.js
const express = require('express');
const https = require('https');
const { Server } = require('socket.io');
const path = require('path');
const app = express();
const fs = require('fs');
const server = https.createServer({
key: fs.readFileSync('/opt/webrtc/key.pem'),
cert: fs.readFileSync('/opt/webrtc/cert.pem'),
}, app);
const io = new Server(server);
// Serve the frontend HTML file
app.use(express.static(path.join(__dirname, 'public')));
// Keep track of who is in each room
const rooms = {};
io.on('connection', (socket) => {
console.log('A user connected:', socket.id);
// User wants to join a room
socket.on('join', (room) => {
socket.join(room);
if (!rooms[room]) {
rooms[room] = [];
}
// Tell the new user who is already in the room
socket.emit('room-users', rooms[room]);
// Add this user to the room list
rooms[room].push(socket.id);
console.log(`${socket.id} joined room: ${room}`);
});
// Relay a WebRTC offer to a specific user
socket.on('offer', ({ to, offer }) => {
io.to(to).emit('offer', { from: socket.id, offer });
});
// Relay a WebRTC answer to a specific user
socket.on('answer', ({ to, answer }) => {
io.to(to).emit('answer', { from: socket.id, answer });
});
// Relay ICE candidates between peers
socket.on('ice-candidate', ({ to, candidate }) => {
io.to(to).emit('ice-candidate', { from: socket.id, candidate });
});
// ---- CHAT ----
// Relay a chat message to everyone else in the room
socket.on('chat-message', ({ room, message, name }) => {
// Broadcast to everyone in the room EXCEPT the sender
socket.to(room).emit('chat-message', {
from: socket.id,
name: name || 'Anonymous',
message,
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
});
});
// ---- END CHAT ----
// Clean up when a user disconnects
socket.on('disconnect', () => {
for (const room in rooms) {
rooms[room] = rooms[room].filter((id) => id !== socket.id);
// Tell others in the room this user left
socket.to(room).emit('user-left', socket.id);
}
console.log('User disconnected:', socket.id);
});
});
const PORT = 3000;
server.listen(PORT, '0.0.0.0', () => {
console.log(`Signalling server running on port ${PORT}`);
});
- Restart webrtc:
systemctl restart webrtc
Test - Should be working
Troubleshooting
"Cannot connect to the page"
- Make sure the container is running:
sudo lxc-ls --fancy - Make sure the service is running: attach to the container and run
systemctl status webrtc - Double-check the IP address — it can change after a container restart. Assign a static IP via LXC config if needed.
"Camera/Microphone access denied"
- On non-localhost addresses, browsers require HTTPS. See Part 7.
- Make sure you clicked Allow (not Block) when the browser asked for permissions.
- Try in a different browser, or clear site permissions in browser settings.
Two tabs connect but no video appears
- This usually means ICE negotiation is failing. For local testing this should not happen.
- If testing across networks (not just local LAN), you will need a TURN server (e.g. coturn). This is beyond the scope of this guide.
Port 3000 not reachable
- Check no firewall is blocking it on the host:
sudo ufw status - LXC containers on the default
lxcbr0bridge are reachable from the host by default. No port forwarding should be needed for local access.