"We need to see all our security cameras on one screen" — this is a challenge I actually tackled in a real-world project.
Pull RTSP streams from multiple IP cameras, convert them to HLS with FFmpeg, and display them in a browser-based dashboard. No proprietary surveillance software needed — just open-source tools.
This article covers building a multi-camera surveillance dashboard from architecture design to frontend implementation, all running on a single server.
System Architecture Overview
Let's map out the complete system.
Components:
| Component | Role |
|---|---|
| IP Cameras (multiple) | Deliver H.264/H.265 video via RTSP |
| Node.js Stream Manager | Spawn and manage FFmpeg processes per camera |
| FFmpeg (one per camera) | Convert RTSP to HLS in parallel |
| Nginx | Serve HLS segments as static files |
| Browser Dashboard | Play each camera's HLS stream using hls.js |
The key design decision is running one FFmpeg process per camera. While a single FFmpeg process can handle multiple inputs, if one stream freezes, it can take down the others. Process isolation minimizes the blast radius of failures.
Converting Multiple RTSP Streams to HLS Simultaneously
Let's start with raw FFmpeg commands before adding the Node.js management layer.
Directory Structure
mkdir -p /var/www/hls/{cam01,cam02,cam03}
FFmpeg Commands Per Camera
# Camera 1 (Hikvision)
ffmpeg -rtsp_transport tcp \
-i "rtsp://admin:pass1@192.168.1.64:554/Streaming/Channels/101" \
-c:v copy -c:a aac -b:a 128k \
-f hls -hls_time 2 -hls_list_size 10 \
-hls_flags delete_segments+append_list \
-hls_segment_filename "/var/www/hls/cam01/seg_%03d.ts" \
"/var/www/hls/cam01/index.m3u8" &
# Camera 2 (Dahua)
ffmpeg -rtsp_transport tcp \
-i "rtsp://admin:pass2@192.168.1.108:554/cam/realmonitor?channel=1&subtype=0" \
-c:v copy -c:a aac -b:a 128k \
-f hls -hls_time 2 -hls_list_size 10 \
-hls_flags delete_segments+append_list \
-hls_segment_filename "/var/www/hls/cam02/seg_%03d.ts" \
"/var/www/hls/cam02/index.m3u8" &
# Camera 3 (ONVIF)
ffmpeg -rtsp_transport tcp \
-i "rtsp://admin:pass3@192.168.1.100:554/onvif1" \
-c:v copy -c:a aac -b:a 128k \
-f hls -hls_time 2 -hls_list_size 10 \
-hls_flags delete_segments+append_list \
-hls_segment_filename "/var/www/hls/cam03/seg_%03d.ts" \
"/var/www/hls/cam03/index.m3u8" &
wait
The & runs each command in the background, and wait blocks until all processes finish. This works, but monitoring, restarting, and configuration management are all manual — which is why we wrap it in Node.js for production.
Resource Estimates
With -c:v copy (no re-encoding), per-stream resource usage is minimal.
| Cameras | CPU Usage (approx.) | Memory | Network Bandwidth |
|---|---|---|---|
| 1-4 | 5-10% | ~50MB/process | 2-8 Mbps/camera |
| 5-10 | 10-25% | ~500MB | 10-40 Mbps |
| 10-20 | 20-50% | ~1GB | 20-80 Mbps |
If H.265→H.264 transcoding is required, CPU load increases dramatically. Consider GPU acceleration with NVENC or QSV — see the GPU Encoding Guide for details.
Building a Stream Manager with Node.js
We need a Node.js server to spawn, stop, and monitor FFmpeg processes.
Project Structure
rtsp-hls-dashboard/
├── server/
│ ├── index.mjs # Entry point
│ ├── stream-manager.mjs # FFmpeg process management
│ └── config.mjs # Camera configuration
├── public/
│ └── index.html # Dashboard UI
├── package.json
└── .env # Environment variables (credentials)
Camera Configuration
// server/config.mjs
export const cameras = [
{
id: "cam01",
name: "Entrance",
rtspUrl: process.env.CAM01_RTSP_URL,
hlsDir: "/var/www/hls/cam01",
},
{
id: "cam02",
name: "Server Room",
rtspUrl: process.env.CAM02_RTSP_URL,
hlsDir: "/var/www/hls/cam02",
},
{
id: "cam03",
name: "Parking Lot",
rtspUrl: process.env.CAM03_RTSP_URL,
hlsDir: "/var/www/hls/cam03",
},
];
RTSP URLs are managed via .env. Never hardcode credentials in source code.
# .env
CAM01_RTSP_URL=rtsp://admin:pass1@192.168.1.64:554/Streaming/Channels/101
CAM02_RTSP_URL=rtsp://admin:pass2@192.168.1.108:554/cam/realmonitor?channel=1&subtype=0
CAM03_RTSP_URL=rtsp://admin:pass3@192.168.1.100:554/onvif1
Stream Manager
// server/stream-manager.mjs
import { spawn } from "node:child_process";
import { mkdir } from "node:fs/promises";
import path from "node:path";
export class StreamManager {
#processes = new Map();
#restartTimers = new Map();
async startStream(camera) {
if (this.#processes.has(camera.id)) {
console.log(`[${camera.id}] Already running`);
return;
}
await mkdir(camera.hlsDir, { recursive: true });
const args = [
"-rtsp_transport", "tcp",
"-timeout", "5000000",
"-i", camera.rtspUrl,
"-c:v", "copy",
"-c:a", "aac", "-b:a", "128k",
"-f", "hls",
"-hls_time", "2",
"-hls_list_size", "10",
"-hls_flags", "delete_segments+append_list",
"-hls_segment_filename",
path.join(camera.hlsDir, "seg_%03d.ts"),
path.join(camera.hlsDir, "index.m3u8"),
];
const proc = spawn("ffmpeg", args, {
stdio: ["ignore", "pipe", "pipe"],
});
proc.stderr.on("data", (data) => {
const line = data.toString().trim();
if (line.includes("Error") || line.includes("error")) {
console.error(`[${camera.id}] ${line}`);
}
});
proc.on("exit", (code) => {
console.log(`[${camera.id}] FFmpeg exited with code ${code}`);
this.#processes.delete(camera.id);
this.#scheduleRestart(camera);
});
this.#processes.set(camera.id, proc);
console.log(`[${camera.id}] Started (PID: ${proc.pid})`);
}
stopStream(cameraId) {
const proc = this.#processes.get(cameraId);
if (proc) {
proc.kill("SIGTERM");
this.#processes.delete(cameraId);
}
const timer = this.#restartTimers.get(cameraId);
if (timer) {
clearTimeout(timer);
this.#restartTimers.delete(cameraId);
}
}
#scheduleRestart(camera) {
console.log(`[${camera.id}] Restarting in 5 seconds...`);
const timer = setTimeout(() => {
this.#restartTimers.delete(camera.id);
this.startStream(camera);
}, 5000);
this.#restartTimers.set(camera.id, timer);
}
getStatus() {
const status = {};
for (const [id, proc] of this.#processes) {
status[id] = { pid: proc.pid, running: !proc.killed };
}
return status;
}
stopAll() {
for (const [id] of this.#processes) {
this.stopStream(id);
}
}
}
Three key design choices:
- Process isolation: Each camera gets its own
spawn. One crash doesn't affect others - Auto-restart: The
exitevent schedules a restart after 5 seconds - Clean shutdown:
SIGTERMfor graceful termination
Entry Point
// server/index.mjs
import "dotenv/config";
import http from "node:http";
import { cameras } from "./config.mjs";
import { StreamManager } from "./stream-manager.mjs";
const manager = new StreamManager();
// Start all camera streams
for (const cam of cameras) {
manager.startStream(cam);
}
// Status API
const server = http.createServer((req, res) => {
if (req.url === "/api/status") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({
cameras: cameras.map((cam) => ({
id: cam.id,
name: cam.name,
hlsUrl: `/hls/${cam.id}/index.m3u8`,
...manager.getStatus()[cam.id],
})),
}));
return;
}
res.writeHead(404);
res.end("Not Found");
});
server.listen(3001, () => {
console.log("Stream manager API running on port 3001");
});
// Graceful shutdown
process.on("SIGTERM", () => {
console.log("Shutting down...");
manager.stopAll();
server.close();
process.exit(0);
});
process.on("SIGINT", () => {
console.log("Shutting down...");
manager.stopAll();
server.close();
process.exit(0);
});
A Browser-Based Frontend for Camera Feeds
A simple dashboard using hls.js to display camera feeds in a responsive grid.
<!-- 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>Surveillance Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/hls.js@1"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0a0a;
color: #e0e0e0;
font-family: "SF Mono", "Fira Code", monospace;
}
.header {
padding: 1rem 2rem;
border-bottom: 1px solid #1a1a1a;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 { font-size: 1.2rem; color: #b4f0a0; }
.status { font-size: 0.8rem; color: #666; }
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(480px, 1fr));
gap: 1px;
background: #1a1a1a;
padding: 1px;
}
.camera-cell {
background: #0a0a0a;
position: relative;
}
.camera-cell video {
width: 100%;
display: block;
background: #000;
}
.camera-label {
position: absolute;
top: 8px;
left: 8px;
background: rgba(0, 0, 0, 0.7);
color: #b4f0a0;
padding: 4px 8px;
font-size: 0.75rem;
border: 1px solid #b4f0a033;
}
.camera-status {
position: absolute;
top: 8px;
right: 8px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #4caf50;
}
.camera-status.offline { background: #f44336; }
@media (max-width: 768px) {
.grid { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="header">
<h1>>_ Surveillance Dashboard</h1>
<div class="status" id="clock"></div>
</div>
<div class="grid" id="grid"></div>
<script>
async function init() {
const res = await fetch("/api/status");
const data = await res.json();
const grid = document.getElementById("grid");
for (const cam of data.cameras) {
const cell = document.createElement("div");
cell.className = "camera-cell";
cell.innerHTML = `
<video id="video-${cam.id}" muted autoplay playsinline></video>
<div class="camera-label">${cam.name} [${cam.id}]</div>
<div class="camera-status ${cam.running ? "" : "offline"}"
id="status-${cam.id}"></div>
`;
grid.appendChild(cell);
const video = cell.querySelector("video");
if (Hls.isSupported()) {
const hls = new Hls({
liveSyncDurationCount: 3,
liveMaxLatencyDurationCount: 6,
enableWorker: true,
});
hls.loadSource(cam.hlsUrl);
hls.attachMedia(video);
hls.on(Hls.Events.ERROR, (event, data) => {
if (data.fatal) {
console.error(`[${cam.id}] HLS error:`, data.type);
setTimeout(() => {
hls.loadSource(cam.hlsUrl);
hls.attachMedia(video);
}, 3000);
}
});
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
video.src = cam.hlsUrl;
}
}
setInterval(() => {
document.getElementById("clock").textContent =
new Date().toLocaleString("en-US");
}, 1000);
}
init();
</script>
</body>
</html>
Key features:
- Responsive grid:
grid-template-columns: repeat(auto-fit, minmax(480px, 1fr))adapts to the number of cameras - Low latency: hls.js
liveSyncDurationCount: 3minimizes delay - Error recovery: Auto-reconnect after 3 seconds on HLS errors
- Mobile-friendly: Switches to single column below 768px
Recording and Automatic Cleanup
Beyond live streaming, you often need simultaneous recording. FFmpeg's -f tee lets you create multiple outputs from a single input.
Simultaneous HLS Streaming and Recording
ffmpeg -rtsp_transport tcp \
-i "rtsp://admin:pass1@192.168.1.64:554/Streaming/Channels/101" \
-c:v copy -c:a aac -b:a 128k \
-f tee -map 0:v -map 0:a \
"[f=hls:hls_time=2:hls_list_size=10:hls_flags=delete_segments+append_list:hls_segment_filename=/var/www/hls/cam01/seg_%03d.ts]/var/www/hls/cam01/index.m3u8|[f=segment:segment_time=3600:segment_format=mp4:reset_timestamps=1:strftime=1]/var/recordings/cam01/%Y%m%d_%H%M%S.mp4"
This produces both "live HLS streaming" and "hourly MP4 recordings" from a single FFmpeg process.
Auto-Deleting Old Recordings
Manage disk space with a cron job that deletes old files.
# /etc/cron.daily/cleanup-recordings
#!/bin/bash
# Delete recordings older than 30 days
find /var/recordings/ -name "*.mp4" -mtime +30 -delete
# Log the cleanup
echo "[$(date)] Cleanup completed" >> /var/log/recording-cleanup.log
chmod +x /etc/cron.daily/cleanup-recordings
For more on automating batch operations, see Automating Video Batch Processing with FFmpeg and Python.
Production Considerations — systemd, Logging, Monitoring
systemd Service
Register the Node.js stream manager as a systemd service for automatic recovery on server restarts.
# /etc/systemd/system/surveillance-dashboard.service
[Unit]
Description=Surveillance Dashboard Stream Manager
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=surveillance
Group=surveillance
WorkingDirectory=/opt/rtsp-hls-dashboard
ExecStart=/usr/bin/node server/index.mjs
Restart=always
RestartSec=10
Environment=NODE_ENV=production
EnvironmentFile=/opt/rtsp-hls-dashboard/.env
# Security hardening
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/www/hls /var/recordings
ProtectHome=true
[Install]
WantedBy=multi-user.target
sudo systemctl enable surveillance-dashboard
sudo systemctl start surveillance-dashboard
sudo systemctl status surveillance-dashboard
Log Management
FFmpeg produces verbose output. Use journald for management and filter for errors.
# Stream errors in real time
journalctl -u surveillance-dashboard -f | grep -i error
# Last hour of logs
journalctl -u surveillance-dashboard --since "1 hour ago"
Health Checks
A simple script that verifies each camera's HLS playlist is being updated.
#!/bin/bash
# /opt/rtsp-hls-dashboard/healthcheck.sh
CAMERAS=("cam01" "cam02" "cam03")
ALERT_THRESHOLD=30 # seconds
for cam in "${CAMERAS[@]}"; do
playlist="/var/www/hls/${cam}/index.m3u8"
if [ ! -f "$playlist" ]; then
echo "[ALERT] ${cam}: playlist not found"
continue
fi
age=$(( $(date +%s) - $(stat -c %Y "$playlist") ))
if [ "$age" -gt "$ALERT_THRESHOLD" ]; then
echo "[ALERT] ${cam}: playlist is ${age}s old (threshold: ${ALERT_THRESHOLD}s)"
else
echo "[OK] ${cam}: last updated ${age}s ago"
fi
done
Run this via cron and integrate with email or Slack alerts for production monitoring.
For building a similar setup in the cloud, see Building an FFmpeg Encoding Server on a VPS.
Wrapping Up
Here's a summary of the multi-camera surveillance dashboard built with FFmpeg and Node.js:
- Architecture: One FFmpeg process per camera for fault isolation
- Stream management: Node.js handles spawning, stopping, and auto-restarting FFmpeg
- Frontend: hls.js renders camera feeds in a responsive browser grid
- Recording:
-f teefor simultaneous HLS streaming and MP4 segment recording - Operations: systemd service + health checks for stable production operation
The source code for this system will be available at GitHub: omitsu-dev/rtsp-hls-dashboard.
For securing remote access to this system with a VPN, stay tuned for the upcoming guide. For enterprise deployment inquiries, get in touch.
- How to Receive, Convert, and Stream RTSP Camera Feeds with FFmpeg — RTSP fundamentals
- FFmpeg GPU Acceleration Guide — Handling high camera counts
- How to Stream HLS Video with FFmpeg and a CDN — Advanced HLS configuration